From ad07329f530f8f28e4ba87d927da8c103da74586 Mon Sep 17 00:00:00 2001 From: joe goodall Date: Tue, 26 May 2026 15:48:32 +0100 Subject: [PATCH 1/5] feat: add image option to custom modules --- README.md | 15 ++++- .../tickets/TicketsSdkView.kt | 55 +++++++++++++++++++ example/src/App.tsx | 3 + ios/TicketsSDK+Extention.swift | 29 +++++++++- src/IgniteProvider.tsx | 17 +++++- src/types.tsx | 3 + 6 files changed, 119 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2ef99f3..1e641d0 100644 --- a/README.md +++ b/README.md @@ -759,7 +759,7 @@ You can select custom images for `seatUpgradesModule` and `venueConcessionsModul ### Custom Modules -You can configure up to 3 buttons as a custom module. Each button accepts a callback function. Currently a header view above the buttons is not available for configuration in this library. +You can configure up to 3 buttons as a custom module. Each button accepts a callback function. An optional `headerView` can be displayed above the buttons — either a solid color or an image bundled with your app via `require()`. ```typescript ``` +`headerView` accepts either an image or a solid color: + +```typescript +// Image header (use require() — single source, bundled by Metro) +headerView: { image: require('./assets/my_module_header.png') } + +// Solid color header +headerView: { color: '#026cdf' } +``` + Single button example: ```typescript diff --git a/android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt b/android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt index 2d141d2..2b0146f 100644 --- a/android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt +++ b/android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt @@ -1,10 +1,15 @@ package com.ticketmasterignite.tickets import android.content.Context +import android.graphics.BitmapFactory import android.util.Log import android.view.View +import android.view.ViewGroup import android.view.ViewTreeObserver import android.widget.FrameLayout +import android.widget.ImageView +import java.net.URL +import kotlinx.coroutines.withContext import com.facebook.react.uimanager.ThemedReactContext import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -243,6 +248,8 @@ class TicketsSdkView(context: Context) : FrameLayout(context) { private fun getCustomModule(context: Context): ModuleBase { val moduleBase = ModuleBase(context) + applyCustomModuleHeader(context, moduleBase) + if (Config.get("button1") == "true") { moduleBase.setLeftButtonText(Config.get("button1Title")) } @@ -261,6 +268,54 @@ class TicketsSdkView(context: Context) : FrameLayout(context) { return moduleBase } + private fun applyCustomModuleHeader(context: Context, moduleBase: ModuleBase) { + when (Config.get("customModuleHeaderType")) { + "color" -> { + val hex = Config.optionalString("customModuleHeaderColor") ?: return + val color = runCatching { hex.toColorInt() }.getOrNull() ?: return + val view = View(context).apply { + setBackgroundColor(color) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + (resources.displayMetrics.density * 120).toInt() + ) + } + moduleBase.setHeader(view) + } + "image" -> { + val imageUri = Config.getImage("customModuleHeaderImage") ?: return + val imageView = ImageView(context).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + moduleBase.setHeader(imageView) + loadHeaderImage(imageUri, imageView) + } + } + } + + private fun loadHeaderImage(imageUri: String, target: ImageView) { + if (imageUri.startsWith("http")) { + ioScope.launch { + val bitmap = runCatching { + URL(imageUri).openStream().use { BitmapFactory.decodeStream(it) } + }.getOrNull() + if (bitmap != null) { + withContext(Dispatchers.Main) { target.setImageBitmap(bitmap) } + } + } + } else { + val resourceName = imageUri.substringAfterLast('/').substringBeforeLast('.') + val resourceId = context.resources?.getIdentifier(resourceName, "drawable", context.packageName) + if (resourceId != null && resourceId != 0) { + target.setImageResource(resourceId) + } + } + } + private fun setCustomModules() { TicketsSDKSingleton.moduleDelegate = object : TicketsModuleDelegate { override fun getCustomModulesLiveData(order: TicketsModuleDelegate.Order): LiveData> { diff --git a/example/src/App.tsx b/example/src/App.tsx index 8cde945..3faf570 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -59,6 +59,9 @@ const App = () => { }, }} customModules={{ + headerView: { + image: require('../assets/react_background.png'), + }, button1: { enabled: true, title: 'My Button 1', diff --git a/ios/TicketsSDK+Extention.swift b/ios/TicketsSDK+Extention.swift index 6c852a4..f6a58c8 100644 --- a/ios/TicketsSDK+Extention.swift +++ b/ios/TicketsSDK+Extention.swift @@ -7,6 +7,12 @@ protocol TicketsSDKViewProtocol { // Protocol doesn't need to define anything - just marks types that can use these delegates } +private final class FixedSizeImageView: UIImageView { + override var intrinsicContentSize: CGSize { + return TMTicketsModule.HeaderDisplay.defaultSize + } +} + extension TicketsSDKViewProtocol { func deepLinkToOrder(_ orderId: String) { TMTickets.shared.display(orderOrEventId: orderId) @@ -98,7 +104,7 @@ extension TicketsSDKViewProtocol { let module = TMTicketsModule( identifier: "com.\(Config.shared.get(for: "clientName"))", - headerDisplay: nil, + headerDisplay: customModuleHeaderDisplay(), actionButtons: actionButtons ) @@ -107,6 +113,27 @@ extension TicketsSDKViewProtocol { completion(modules) } + private func customModuleHeaderDisplay() -> TMTicketsModule.HeaderDisplay? { + let headerType = Config.shared.get(for: "customModuleHeaderType") + + switch headerType { + case "color": + let hex = Config.shared.get(for: "customModuleHeaderColor") + guard let color = UIColor(hexString: hex) else { return nil } + let view = UIView() + view.backgroundColor = color + return TMTicketsModule.HeaderDisplay(view: view) + case "image": + guard let image = Config.shared.getImage(for: "customModuleHeaderImage") else { return nil } + let imageView = FixedSizeImageView(image: image) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + return TMTicketsModule.HeaderDisplay(view: imageView) + default: + return nil + } + } + public func addPreBuiltModules(event: TMPurchasedEvent) -> [TMTicketsModule] { print(" - Adding Prebuilt Modules") var output: [TMTicketsModule] = [] diff --git a/src/IgniteProvider.tsx b/src/IgniteProvider.tsx index c9a9937..960e890 100644 --- a/src/IgniteProvider.tsx +++ b/src/IgniteProvider.tsx @@ -307,7 +307,9 @@ export const IgniteProvider: React.FC = ({ }); // Custom Modules - Object.entries(customModules).forEach(([moduleName, moduleOptions]) => { + const { headerView, ...buttonModules } = customModules; + Object.entries(buttonModules).forEach(([moduleName, moduleOptions]) => { + if (!moduleOptions) return; const isEnabled = moduleOptions.enabled ? 'true' : 'false'; const dismissTicketView = moduleOptions.dismissTicketViewIos === undefined @@ -320,6 +322,19 @@ export const IgniteProvider: React.FC = ({ dismissTicketView ); }); + + if (headerView && 'color' in headerView) { + NativeConfig.setConfig('customModuleHeaderType', 'color'); + NativeConfig.setConfig('customModuleHeaderColor', headerView.color); + } else if (headerView && 'image' in headerView) { + NativeConfig.setConfig('customModuleHeaderType', 'image'); + const resolvedImage = Image.resolveAssetSource(headerView.image); + if (resolvedImage?.uri) { + NativeConfig.setImage('customModuleHeaderImage', resolvedImage.uri); + } + } else { + NativeConfig.setConfig('customModuleHeaderType', ''); + } }, [customModules, prebuiltModules]); const setTicketDeepLink = useCallback((id: string) => { diff --git a/src/types.tsx b/src/types.tsx index d96e6a7..01aa31c 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -170,7 +170,10 @@ export type SportXrData = { export type MemberInfo = Record | null; +export type CustomModuleHeaderView = { color: string } | { image: any }; + export type CustomModules = { + headerView?: CustomModuleHeaderView; button1?: { enabled: boolean; title: string; From 319411c7d3f665dda360bf9bc97378cbca5666f6 Mon Sep 17 00:00:00 2001 From: joe goodall Date: Tue, 26 May 2026 15:55:11 +0100 Subject: [PATCH 2/5] add test --- __tests__/IgniteProvider.test.tsx | 81 +++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/__tests__/IgniteProvider.test.tsx b/__tests__/IgniteProvider.test.tsx index ce03ae5..8d7aa27 100644 --- a/__tests__/IgniteProvider.test.tsx +++ b/__tests__/IgniteProvider.test.tsx @@ -39,6 +39,16 @@ jest.mock('../src/specs/NativeAccountsSdk', () => ({ }, })); +jest.mock('react-native/Libraries/Image/Image', () => { + const Image = () => null; + Image.resolveAssetSource = jest.fn(); + + return { + __esModule: true, + default: Image, + }; +}); + const mockNativeConfig = NativeConfig as jest.Mocked; const mockNativeAccountsSdk = NativeAccountsSdk as jest.Mocked< typeof NativeAccountsSdk @@ -847,6 +857,77 @@ describe('IgniteProvider', () => { }); }); }); + + describe('customModules headerView', () => { + it('sets a color header when color is provided', () => { + render( + + + + ); + + expect(mockNativeConfig.setConfig).toHaveBeenCalledWith( + 'customModuleHeaderType', + 'color' + ); + expect(mockNativeConfig.setConfig).toHaveBeenCalledWith( + 'customModuleHeaderColor', + '#026cdf' + ); + expect(mockNativeConfig.setImage).not.toHaveBeenCalledWith( + 'customModuleHeaderImage', + expect.any(String) + ); + }); + + it('sets an image header when image is provided', () => { + const headerImageUri = 'mock-header-image-uri'; + const mockResolveAssetSource = jest.requireMock( + 'react-native/Libraries/Image/Image' + ).default.resolveAssetSource; + mockResolveAssetSource.mockReturnValue({ + uri: headerImageUri, + } as any); + + render( + + + + ); + + expect(mockNativeConfig.setConfig).toHaveBeenCalledWith( + 'customModuleHeaderType', + 'image' + ); + expect(mockNativeConfig.setImage).toHaveBeenCalledWith( + 'customModuleHeaderImage', + headerImageUri + ); + }); + + it('clears the header type when no header is provided', () => { + render(component); + + expect(mockNativeConfig.setConfig).toHaveBeenCalledWith( + 'customModuleHeaderType', + '' + ); + expect(mockNativeConfig.setImage).not.toHaveBeenCalledWith( + 'customModuleHeaderImage', + expect.any(String) + ); + }); + }); }); describe('NativeAccountsSdk', () => { From 496f1d1ad5e9c882b2f1df5e5368b3b7bf632bc9 Mon Sep 17 00:00:00 2001 From: joe goodall Date: Tue, 26 May 2026 17:05:08 +0100 Subject: [PATCH 3/5] fix button linking and image --- README.md | 49 +++++++- .../tickets/TicketsSdkView.kt | 106 +++++++++++------- ios/TicketsSDK+Extention.swift | 11 +- 3 files changed, 119 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 1e641d0..9e2f4c8 100644 --- a/README.md +++ b/README.md @@ -759,7 +759,7 @@ You can select custom images for `seatUpgradesModule` and `venueConcessionsModul ### Custom Modules -You can configure up to 3 buttons as a custom module. Each button accepts a callback function. An optional `headerView` can be displayed above the buttons — either a solid color or an image bundled with your app via `require()`. +You can configure up to 3 buttons as a custom module. Each button accepts a callback function. An optional `headerView` can be displayed above the buttons — either a solid color, a bundled image via `require()`, or a remote image via `{ uri: '...' }`. ```typescript ``` -`headerView` accepts either an image or a solid color: +`headerView` accepts a bundled image, a remote image, or a solid color: ```typescript -// Image header (use require() — single source, bundled by Metro) +// Bundled image (use require() — Metro resolves the asset at build time) headerView: { image: require('./assets/my_module_header.png') } +// Remote image (pass an object with a `uri`) +headerView: { + image: { + uri: 'https://www.example.com/path/to/my_module_header.png', + }, +} + // Solid color header headerView: { color: '#026cdf' } ``` @@ -829,6 +836,42 @@ Single button example: | | | | | | +#### Opening a URL from a button + +Use React Native's `Linking` API in the `callback` to open an external URL when a custom button is tapped: + +```typescript +import { Linking } from 'react-native'; + + Linking.openURL('https://www.ticketmaster.com'), + }, + }} +> + + +``` + +For custom URL schemes (e.g. `tel:`, `mailto:`, deep links), guard the call with `Linking.canOpenURL`: + +```typescript +callback: async () => { + const url = 'tel:+1234567890'; + if (await Linking.canOpenURL(url)) { + await Linking.openURL(url); + } +}, +``` + ### Analytics You can send a callback method to `IgniteProvider` to receive Ignite SDK analytics in your app which you can then send off to your chosen analytics service. diff --git a/android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt b/android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt index 2b0146f..f78f47b 100644 --- a/android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt +++ b/android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt @@ -245,8 +245,12 @@ class TicketsSdkView(context: Context) : FrameLayout(context) { } } + private fun getCustomModuleId(): String { + return "com.${Config.get("clientName")}" + } + private fun getCustomModule(context: Context): ModuleBase { - val moduleBase = ModuleBase(context) + val moduleBase = ModuleBase(context, getCustomModuleId()) applyCustomModuleHeader(context, moduleBase) @@ -316,6 +320,54 @@ class TicketsSdkView(context: Context) : FrameLayout(context) { } } + private fun customModuleEventName( + moduleId: String?, + buttonTitle: String?, + callbackValue: String? + ): String? { + val isCustomModule = moduleId == getCustomModuleId() + val isLegacyModulePress = moduleId == null + + if (!isCustomModule && !isLegacyModulePress) { + return null + } + + return when { + (isCustomModule && callbackValue == "LeftClick") || + buttonTitle == Config.get("button1Title") -> "ticketsSdkCustomModuleButton1" + (isCustomModule && callbackValue == "MiddleButton") || + buttonTitle == Config.get("button2Title") -> "ticketsSdkCustomModuleButton2" + (isCustomModule && callbackValue == "RightClick") || + buttonTitle == Config.get("button3Title") -> "ticketsSdkCustomModuleButton3" + else -> null + } + } + + private fun emitTicketsSdkEvent(eventName: String, eventOrders: EventOrders?) { + val params: WritableMap = Arguments.createMap() + val paramValues: WritableMap = Arguments.createMap().apply { + putString("eventOrderInfo", eventOrders.toString()) + } + params.putMap(eventName, paramValues) + GlobalEventEmitter.sendEvent("igniteAnalytics", params) + } + + private fun handleActionButtonPress( + moduleId: String?, + buttonTitle: String?, + callbackValue: String?, + eventOrders: EventOrders? + ) { + val eventName = customModuleEventName(moduleId, buttonTitle, callbackValue) + ?: when (buttonTitle) { + "Order" -> "ticketsSdkVenueConcessionsOrderFor" + "Wallet" -> "ticketsSdkVenueConcessionsWalletFor" + else -> null + } + + eventName?.let { emitTicketsSdkEvent(it, eventOrders) } + } + private fun setCustomModules() { TicketsSDKSingleton.moduleDelegate = object : TicketsModuleDelegate { override fun getCustomModulesLiveData(order: TicketsModuleDelegate.Order): LiveData> { @@ -393,48 +445,16 @@ class TicketsSdkView(context: Context) : FrameLayout(context) { callbackValue: String?, eventOrders: EventOrders? ) { - when (buttonTitle) { - Config.get("button1Title") -> { - val params: WritableMap = Arguments.createMap() - val paramValues: WritableMap = Arguments.createMap().apply { - putString("eventOrderInfo", eventOrders.toString()) - } - params.putMap("ticketsSdkCustomModuleButton1", paramValues) - GlobalEventEmitter.sendEvent("igniteAnalytics", params) - } - Config.get("button2Title") -> { - val params: WritableMap = Arguments.createMap() - val paramValues: WritableMap = Arguments.createMap().apply { - putString("eventOrderInfo", eventOrders.toString()) - } - params.putMap("ticketsSdkCustomModuleButton2", paramValues) - GlobalEventEmitter.sendEvent("igniteAnalytics", params) - } - Config.get("button3Title") -> { - val params: WritableMap = Arguments.createMap() - val paramValues: WritableMap = Arguments.createMap().apply { - putString("eventOrderInfo", eventOrders.toString()) - } - params.putMap("ticketsSdkCustomModuleButton3", paramValues) - GlobalEventEmitter.sendEvent("igniteAnalytics", params) - } - "Order" -> { - val params: WritableMap = Arguments.createMap() - val paramValues: WritableMap = Arguments.createMap().apply { - putString("eventOrderInfo", eventOrders.toString()) - } - params.putMap("ticketsSdkVenueConcessionsOrderFor", paramValues) - GlobalEventEmitter.sendEvent("igniteAnalytics", params) - } - "Wallet" -> { - val params: WritableMap = Arguments.createMap() - val paramValues: WritableMap = Arguments.createMap().apply { - putString("eventOrderInfo", eventOrders.toString()) - } - params.putMap("ticketsSdkVenueConcessionsWalletFor", paramValues) - GlobalEventEmitter.sendEvent("igniteAnalytics", params) - } - } + handleActionButtonPress(null, buttonTitle, callbackValue, eventOrders) + } + + override fun userDidPressActionButton( + moduleId: String?, + buttonTitle: String?, + callbackValue: String?, + eventOrders: EventOrders? + ) { + handleActionButtonPress(moduleId, buttonTitle, callbackValue, eventOrders) } } } diff --git a/ios/TicketsSDK+Extention.swift b/ios/TicketsSDK+Extention.swift index f6a58c8..0575431 100644 --- a/ios/TicketsSDK+Extention.swift +++ b/ios/TicketsSDK+Extention.swift @@ -13,6 +13,12 @@ private final class FixedSizeImageView: UIImageView { } } +private final class FixedSizeHeaderView: UIView { + override var intrinsicContentSize: CGSize { + return TMTicketsModule.HeaderDisplay.defaultSize + } +} + extension TicketsSDKViewProtocol { func deepLinkToOrder(_ orderId: String) { TMTickets.shared.display(orderOrEventId: orderId) @@ -119,8 +125,11 @@ extension TicketsSDKViewProtocol { switch headerType { case "color": let hex = Config.shared.get(for: "customModuleHeaderColor") + .trimmingCharacters(in: CharacterSet(charactersIn: "#")) guard let color = UIColor(hexString: hex) else { return nil } - let view = UIView() + let view = FixedSizeHeaderView( + frame: CGRect(origin: .zero, size: TMTicketsModule.HeaderDisplay.defaultSize) + ) view.backgroundColor = color return TMTicketsModule.HeaderDisplay(view: view) case "image": From f50fd367b3295e2567e810cb76583124e084f725 Mon Sep 17 00:00:00 2001 From: joe goodall Date: Wed, 27 May 2026 09:27:48 +0100 Subject: [PATCH 4/5] add missing platform bundle --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b7e397..5cfcc65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,7 +99,10 @@ jobs: uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: '26.1.1' - + + - name: Install iOS platform + run: sudo xcodebuild -downloadPlatform iOS + - name: Checkout uses: actions/checkout@v3 From f4d49ccb8370d9b8cb51809a0eb0e4509485d618 Mon Sep 17 00:00:00 2001 From: joe goodall Date: Wed, 27 May 2026 09:58:49 +0100 Subject: [PATCH 5/5] edit readme --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index 9e2f4c8..2c21d5a 100644 --- a/README.md +++ b/README.md @@ -861,17 +861,6 @@ import { Linking } from 'react-native'; ``` -For custom URL schemes (e.g. `tel:`, `mailto:`, deep links), guard the call with `Linking.canOpenURL`: - -```typescript -callback: async () => { - const url = 'tel:+1234567890'; - if (await Linking.canOpenURL(url)) { - await Linking.openURL(url); - } -}, -``` - ### Analytics You can send a callback method to `IgniteProvider` to receive Ignite SDK analytics in your app which you can then send off to your chosen analytics service.