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 diff --git a/README.md b/README.md index 2ef99f3..2c21d5a 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, a bundled image via `require()`, or a remote image via `{ uri: '...' }`. ```typescript ``` +`headerView` accepts a bundled image, a remote image, or a solid color: + +```typescript +// 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' } +``` + Single button example: ```typescript @@ -816,6 +836,31 @@ 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'), + }, + }} +> + + +``` + ### 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/__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', () => { diff --git a/android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt b/android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt index 2d141d2..f78f47b 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 @@ -240,8 +245,14 @@ 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) if (Config.get("button1") == "true") { moduleBase.setLeftButtonText(Config.get("button1Title")) @@ -261,6 +272,102 @@ 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 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> { @@ -338,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/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..0575431 100644 --- a/ios/TicketsSDK+Extention.swift +++ b/ios/TicketsSDK+Extention.swift @@ -7,6 +7,18 @@ 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 + } +} + +private final class FixedSizeHeaderView: UIView { + override var intrinsicContentSize: CGSize { + return TMTicketsModule.HeaderDisplay.defaultSize + } +} + extension TicketsSDKViewProtocol { func deepLinkToOrder(_ orderId: String) { TMTickets.shared.display(orderOrEventId: orderId) @@ -98,7 +110,7 @@ extension TicketsSDKViewProtocol { let module = TMTicketsModule( identifier: "com.\(Config.shared.get(for: "clientName"))", - headerDisplay: nil, + headerDisplay: customModuleHeaderDisplay(), actionButtons: actionButtons ) @@ -107,6 +119,30 @@ 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") + .trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard let color = UIColor(hexString: hex) else { return nil } + let view = FixedSizeHeaderView( + frame: CGRect(origin: .zero, size: TMTicketsModule.HeaderDisplay.defaultSize) + ) + 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;