Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<IgniteProvider
Expand All @@ -769,6 +769,9 @@ You can configure up to 3 buttons as a custom module. Each button accepts a call
primaryColor: PRIMARY_COLOR
}}
customModules={{
headerView: {
image: require('./assets/my_module_header.png'),
},
button1: {
enabled: true,
title: 'My Button 1',
Expand All @@ -790,6 +793,23 @@ You can configure up to 3 buttons as a custom module. Each button accepts a call
</IgniteProvider>
```

`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
Expand All @@ -816,6 +836,31 @@ Single button example:
| <img src="docs/assets/custom-modules/ios-single-button.png" width="150"> | <img src="docs/assets/custom-modules/android-single-button.png" width="150"> |
| <img src="docs/assets/custom-modules/ios-multi-buttons.png" width="150"> | <img src="docs/assets/custom-modules/android-multi-buttons.png" width="150"> |

#### 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';

<IgniteProvider
options={{
apiKey: API_KEY,
clientName: CLIENT_NAME,
primaryColor: PRIMARY_COLOR
}}
customModules={{
button1: {
enabled: true,
title: 'Visit Ticketmaster',
callback: () => Linking.openURL('https://www.ticketmaster.com'),
},
}}
>
<App />
</IgniteProvider>
```

### 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.
Expand Down
81 changes: 81 additions & 0 deletions __tests__/IgniteProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@
},
}));

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<typeof NativeConfig>;
const mockNativeAccountsSdk = NativeAccountsSdk as jest.Mocked<
typeof NativeAccountsSdk
Expand Down Expand Up @@ -496,7 +506,7 @@
});
});

describe.skip('calls setImage', () => {

Check warning on line 509 in __tests__/IgniteProvider.test.tsx

View workflow job for this annotation

GitHub Actions / lint

Disabled test suite
it('when image provided', () => {
render(
<IgniteProvider
Expand Down Expand Up @@ -820,7 +830,7 @@
});
});

describe.skip('calls setImage', () => {

Check warning on line 833 in __tests__/IgniteProvider.test.tsx

View workflow job for this annotation

GitHub Actions / lint

Disabled test suite
it('when image provided', () => {
render(
<IgniteProvider
Expand All @@ -847,6 +857,77 @@
});
});
});

describe('customModules headerView', () => {
it('sets a color header when color is provided', () => {
render(
<IgniteProvider
options={options}
customModules={{
headerView: { color: '#026cdf' },
}}
>
<View />
</IgniteProvider>
);

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(
<IgniteProvider
options={options}
customModules={{
headerView: { image: require('./testImage.png') },
}}
>
<View />
</IgniteProvider>
);

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', () => {
Expand Down
161 changes: 118 additions & 43 deletions android/src/main/java/com/ticketmasterignite/tickets/TicketsSdkView.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"))
Expand All @@ -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<List<TicketsSDKModule>> {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ const App = () => {
},
}}
customModules={{
headerView: {
image: require('../assets/react_background.png'),
},
button1: {
enabled: true,
title: 'My Button 1',
Expand Down
Loading
Loading