Activation SDK — iOS Code Snippets
This is a companion to ios-react-native-integration.md. It contains the complete source for files referenced by the main guide that are either too long to inline or required verbatim for the integration to compile.
Each snippet starts with > Create file: <path> so you know where to put it. All paths are relative to your project root, with the local Expo module living under modules/blink-engage/.
You should not need to author any of this code from scratch — it's drop-in. The main guide explains why each piece exists and how the pieces talk to each other.
Table of contents
- Config plugin —
modules/blink-engage/plugin/src/index.ts - Native —
modules/blink-engage/ios/JSBackedTheme.swift(full key tables) - Native —
modules/blink-engage/ios/BRScanResultsSerializer.swift - Native —
UIColor(hex:)extension - TypeScript —
modules/blink-engage/src/types.ts - TypeScript —
modules/blink-engage/src/theme.ts(theme key tables + types) - TypeScript —
modules/blink-engage/src/themeValidation.ts - TypeScript —
modules/blink-engage/src/setTheme.ts - TypeScript —
modules/blink-engage/src/index.ts(public API)
1. Config plugin
Create file:
modules/blink-engage/plugin/src/index.ts
The full production config plugin. Runs at expo prebuild and modifies the generated native projects:
- iOS: adds the
BlinkEngageSwift Package to the Xcode project (six pbxproj sections), setsGADApplicationIdentifierinInfo.plist, injects the SDK initialization block intoAppDelegate.swift, and createsxcassetsimagesets for anythemeIconslisted inapp.config.js. - Android: writes meta-data entries into
AndroidManifest.xml, generatessdk-versions.propertiesfor the module'sbuild.gradle, and copies theme icons intodrawable/. (Android details live in the Android guide.)
import {
ConfigPlugin,
IOSConfig,
withAndroidManifest,
withAppDelegate,
withDangerousMod,
withInfoPlist,
withXcodeProject,
} from 'expo/config-plugins';
import * as fs from 'fs';
import * as path from 'path';
// ==========================================================================
// MARK: - Plugin props
// ==========================================================================
interface BlinkEngagePluginProps {
ios: {
licenseKey: string;
prodIntelKey?: string;
/** SPM exact-version pin for the BlinkEngage Swift Package (e.g. "1.5.1"). */
sdkVersion?: string;
/** Passed to `BlinkEngageSDK.start(debugMode:)` (required since 1.4.0). Leave off in production. */
debugModeEnabled?: boolean;
};
android: {
licenseKey: string;
prodIntelKey?: string;
activationVersion?: string;
blinkReceiptVersion?: string;
engageSdkAutoInit?: boolean;
testOptions?: string[];
};
// Shared
gadAppId: string;
rewardCurrencyName?: string;
rewardCurrencyPerDollar?: number;
userPayoutPercentage?: number;
/** Returned for ScanFinished from the AppDelegate rewardCallback. */
baseReward?: number;
themeIcons?: {
offerRewardIcon?: string;
offerWallFloatingButtonIcon?: string;
missedEarningsNavigationEditButtonIcon?: string;
missedEarningsFieldEditIcon?: string;
postScanReceiptButtonIcon?: string;
postScanBoostDefaultIcon?: string;
postScanSuccessIcon?: string;
ugcBarcodeDetectedIcon?: string;
ugcToastMessageWarningIcon?: string;
};
}
// ==========================================================================
// MARK: - iOS asset name maps
// ==========================================================================
const assetKeyToXcodeNameMap: Record<string, string> = {
offerRewardIcon: 'OfferRewardIcon',
offerWallFloatingButtonIcon: 'OfferWallFloatingButtonIcon',
missedEarningsNavigationEditButtonIcon:
'MissedEarningsNavigationEditButtonIcon',
missedEarningsFieldEditIcon: 'MissedEarningsFieldEditIcon',
postScanReceiptButtonIcon: 'PostScanReceiptButtonIcon',
postScanBoostDefaultIcon: 'PostScanBoostDefaultIcon',
postScanSuccessIcon: 'PostScanSuccessIcon',
ugcBarcodeDetectedIcon: 'UgcBarcodeDetectedIcon',
ugcToastMessageWarningIcon: 'UgcToastMessageWarningIcon',
};
const assetKeyToAndroidNameMap: Record<string, string> = {
offerRewardIcon: 'offer_reward_icon',
offerWallFloatingButtonIcon: 'offer_wall_floating_button_icon',
missedEarningsNavigationEditButtonIcon:
'missed_earnings_navigation_edit_button_icon',
missedEarningsFieldEditIcon: 'missed_earnings_field_edit_icon',
postScanReceiptButtonIcon: 'post_scan_receipt_button_icon',
postScanBoostDefaultIcon: 'post_scan_boost_default_icon',
postScanSuccessIcon: 'post_scan_success_icon',
ugcBarcodeDetectedIcon: 'ugc_barcode_detected_icon',
ugcToastMessageWarningIcon: 'ugc_toast_message_warning_icon',
};
// ==========================================================================
// MARK: - iOS xcassets imageset generation
// ==========================================================================
function createImageset(
xcassetsPath: string,
assetName: string,
sourceImagePath: string,
): void {
const imagesetPath = path.join(xcassetsPath, `${assetName}.imageset`);
if (!fs.existsSync(imagesetPath)) {
fs.mkdirSync(imagesetPath, { recursive: true });
}
const ext = path.extname(sourceImagePath);
const baseName = assetName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
const destImagePath = path.join(imagesetPath, `${baseName}${ext}`);
fs.copyFileSync(sourceImagePath, destImagePath);
// Single source image used at all scales — provide @2x / @3x assets if you have them.
const contentsJson = {
images: [
{ filename: `${baseName}${ext}`, idiom: 'universal', scale: '1x' },
{ filename: `${baseName}${ext}`, idiom: 'universal', scale: '2x' },
{ filename: `${baseName}${ext}`, idiom: 'universal', scale: '3x' },
],
info: { author: 'expo', version: 1 },
};
fs.writeFileSync(
path.join(imagesetPath, 'Contents.json'),
JSON.stringify(contentsJson, null, 2),
);
}
const withBlinkEngageIOSAssets: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withDangerousMod(config, [
'ios',
async (config) => {
const customAssets: Record<string, string> = {};
if (props.themeIcons) {
for (const [key, value] of Object.entries(props.themeIcons)) {
if (value) customAssets[key] = value;
}
}
if (Object.keys(customAssets).length === 0) return config;
const projectRoot = config.modRequest.projectRoot;
const projectName =
config.modRequest.projectName ||
IOSConfig.XcodeUtils.sanitizedName(config.name);
const xcassetsPath = path.join(
projectRoot,
'ios',
projectName,
'Images.xcassets',
);
for (const [assetKey, sourcePath] of Object.entries(customAssets)) {
const sourceImagePath = path.resolve(projectRoot, sourcePath);
if (!fs.existsSync(sourceImagePath)) {
console.warn(
`[BlinkEngage] Image not found at ${sourceImagePath} (${assetKey})`,
);
continue;
}
const assetName = assetKeyToXcodeNameMap[assetKey];
if (!assetName) continue;
createImageset(xcassetsPath, assetName, sourceImagePath);
}
return config;
},
]);
};
// ==========================================================================
// MARK: - SPM package injection
// ==========================================================================
//
// Adds the BlinkEngage Swift Package as a dependency of the main app target.
// Writes six pbxproj sections that together let Xcode resolve, build, link,
// and order the SPM product correctly. Each step is idempotent — re-running
// prebuild does not duplicate entries.
const withBlinkEngageSPM: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withXcodeProject(config, (config) => {
const xcodeProject = config.modResults;
const objects = xcodeProject.hash.project.objects;
const productName = 'BlinkEngage';
const repositoryUrl = 'https://github.com/BlinkReceipt/blinkengage-ios';
const repoName = 'blinkengage-ios';
const requirement: Record<string, string> = {
kind: 'exactVersion',
version: props.ios.sdkVersion || '1.5.1',
};
// --- Step 1: XCRemoteSwiftPackageReference ---
// Declares the package URL + version requirement.
// Equivalent to entering the URL in Xcode's "Add Package" dialog.
const refSection = 'XCRemoteSwiftPackageReference';
if (!objects[refSection]) objects[refSection] = {};
let packageRefUUID: string | null = null;
for (const key of Object.keys(objects[refSection])) {
if (key.includes(repoName)) {
packageRefUUID = key.split(' ')[0];
break;
}
}
const refComment = `${refSection} "${repoName}"`;
if (!packageRefUUID) {
packageRefUUID = xcodeProject.generateUuid();
objects[refSection][`${packageRefUUID} /* ${refComment} */`] = {
isa: refSection,
repositoryURL: repositoryUrl,
requirement,
};
}
// --- Step 2: XCSwiftPackageProductDependency ---
// Selects which product from the package we want.
// (A single SPM package can expose multiple products.)
if (!objects['XCSwiftPackageProductDependency']) {
objects['XCSwiftPackageProductDependency'] = {};
}
let productUUID: string | null = null;
for (const key of Object.keys(
objects['XCSwiftPackageProductDependency'],
)) {
const value = objects['XCSwiftPackageProductDependency'][key];
if (
typeof value === 'object' &&
value.productName === productName
) {
productUUID = key.split(' ')[0];
break;
}
}
if (!productUUID) {
productUUID = xcodeProject.generateUuid();
objects['XCSwiftPackageProductDependency'][
`${productUUID} /* ${productName} */`
] = {
isa: 'XCSwiftPackageProductDependency',
package: `${packageRefUUID} /* ${refComment} */`,
productName,
};
}
// --- Step 3: PBXProject.packageReferences ---
// Registers the package reference at the project level.
// This is what triggers Xcode's "Resolving Package Graph..." step.
const projectId = Object.keys(objects['PBXProject']).find(
(k) => !k.includes('_comment'),
);
if (projectId) {
const proj = objects['PBXProject'][projectId];
if (!proj.packageReferences) proj.packageReferences = [];
const refEntry = `${packageRefUUID} /* ${refComment} */`;
if (
!proj.packageReferences.some((r: string) =>
r.includes(packageRefUUID!),
)
) {
proj.packageReferences.push(refEntry);
}
}
// --- Step 4: PBXBuildFile ---
// Creates a build file entry that bridges the product into a build phase.
let frameworkUUID: string | null = null;
for (const key of Object.keys(objects['PBXBuildFile'])) {
const value = objects['PBXBuildFile'][key];
if (
typeof value === 'object' &&
value.productRef_comment === productName
) {
frameworkUUID = key;
break;
}
}
if (!frameworkUUID) {
frameworkUUID = xcodeProject.generateUuid();
objects['PBXBuildFile'][`${frameworkUUID}_comment`] =
`${productName} in Frameworks`;
objects['PBXBuildFile'][frameworkUUID] = {
isa: 'PBXBuildFile',
productRef: productUUID,
productRef_comment: productName,
};
}
// --- Steps 5 & 6: Main app target only ---
// Expo projects can have multiple native targets (e.g. notification
// service extensions). We must scope linking to the main app target,
// identified by productType === "com.apple.product-type.application".
for (const key of Object.keys(objects['PBXNativeTarget'])) {
if (key.includes('_comment')) continue;
const target = objects['PBXNativeTarget'][key];
if (target.productType !== '"com.apple.product-type.application"') {
continue;
}
// --- Step 5: PBXFrameworksBuildPhase.files ---
// Adds the framework to "Link Binary With Libraries".
if (target.buildPhases) {
for (const phaseRef of target.buildPhases) {
const phaseUUID =
typeof phaseRef === 'string'
? phaseRef.split(' ')[0]
: phaseRef.value;
if (objects['PBXFrameworksBuildPhase'][phaseUUID]) {
const phase =
objects['PBXFrameworksBuildPhase'][phaseUUID];
if (!phase.files) phase.files = [];
const fileEntry = `${frameworkUUID} /* ${productName} in Frameworks */`;
if (
!phase.files.some((f: string) =>
f.includes(frameworkUUID!),
)
) {
phase.files.push(fileEntry);
}
break;
}
}
}
// --- Step 6: PBXNativeTarget.packageProductDependencies ---
// Tells Xcode the target depends on the SPM product, ensuring
// build order: package built before app compiles.
if (!target.packageProductDependencies) {
target.packageProductDependencies = [];
}
const entry = `${productUUID} /* ${productName} */`;
if (
!target.packageProductDependencies.some((p: string) =>
p.includes(productUUID!),
)
) {
target.packageProductDependencies.push(entry);
}
break;
}
return config;
});
};
// ==========================================================================
// MARK: - AppDelegate injection
// ==========================================================================
//
// Locates application(_:didFinishLaunchingWithOptions:) and injects:
// 1. SDK imports (BlinkReceipt, BlinkEngage, GoogleMobileAds)
// 2. BlinkEngageSDK.start(debugMode:) (required since 1.4.0; one-shot guarded)
// 3. BRScanManager licensing + enableBlinkEngage = true
// 4. BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(...)
// consolidating currency/payout/icon + rewardCallback (the callback posts
// BlinkEngageReward notifications observed by BlinkEngageModule.swift)
// 5. MobileAds.shared.start(...)
const withBlinkEngageAppDelegate: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withAppDelegate(config, (config) => {
let contents = config.modResults.contents;
const imports = `import BlinkReceipt
import BlinkEngage
import GoogleMobileAds`;
if (!contents.includes('import BlinkEngage')) {
const lastImportMatch = contents.match(/^import .+$/gm);
if (lastImportMatch) {
const lastImport = lastImportMatch[lastImportMatch.length - 1];
contents = contents.replace(
lastImport,
`${lastImport}\n\n${imports}`,
);
}
}
if (!contents.includes('BRScanManager.shared()')) {
// userPayoutPercentage on the SDK is a fraction in [0.4, 1.0] (default 0.6),
// not a percent. The plugin prop has historically been expressed as a
// percent (0..100), so divide by 100 here for backwards-compat.
const payoutFraction = (props.userPayoutPercentage ?? 100) / 100;
const sdkInitCode = `
// ===== BlinkEngage SDK Configuration =====
// 1.4.0 mandates BlinkEngageSDK.start(debugMode:) is called exactly once
// before any access to BlinkEngageSDK.shared. The one-shot guard prevents
// a precondition crash on RN hot reloads / re-entry into didFinishLaunching.
enum BlinkEngageBootstrap { static var didStart = false }
if !BlinkEngageBootstrap.didStart {
BlinkEngageBootstrap.didStart = true
BlinkEngageSDK.start(debugMode: ${props.ios.debugModeEnabled ?? false})
}
BRScanManager.shared().licenseKey = "${props.ios.licenseKey}"
BRScanManager.shared().prodIntelKey = "${props.ios.prodIntelKey ?? ''}"
BRScanManager.shared().enableBlinkEngage = true
// Theme is installed from BlinkEngageModule.swift (OnCreate) using a
// JS-backed theme driven by setTheme(...) / <BlinkEngageThemeProvider>.
// 1.4.0: reward currency settings + rewardCallback are consolidated into
// an immutable BlinkEngageRewardConfig assigned to BlinkEngageSDK.shared.rewardConfig.
// The reward icon (formerly AppearanceIconKey.offerRewardIcon, removed in 1.4.0)
// is now passed here as currencyImage.
BlinkEngageSDK.shared.rewardConfig = BlinkEngageRewardConfig(
currencyName: "${props.rewardCurrencyName || 'points'}",
currencyPerDollar: ${props.rewardCurrencyPerDollar ?? 100.0},
userPayoutPercentage: ${payoutFraction},
currencyImage: UIImage(named: "OfferRewardIcon"),
currencyImageLocations: .all,
rewardCallback: { context, rewardAmount, blinkReceiptId in
// 1.4.0 callback signature: (String, NSNumber?, String?) -> NSNumber?
// The old `scanResults` parameter was removed in 1.4.0; the module's
// observer uses `blinkReceiptId` to dedup ScanFinished events that the
// SDK can re-fire for the same receipt (retries / reconnects).
var userInfo: [String: Any] = ["context": context]
if let receiptId = blinkReceiptId { userInfo["blinkReceiptId"] = receiptId }
switch context {
case "ScanFinished":
let amount = ${props.baseReward ?? 10}.0
userInfo["amount"] = amount
NotificationCenter.default.post(
name: Notification.Name("BlinkEngageReward"), object: nil, userInfo: userInfo
)
return NSNumber(value: amount)
case "Promo", "Boost", "BarcodeCollection":
let amount = rewardAmount?.doubleValue ?? 0
userInfo["amount"] = amount
NotificationCenter.default.post(
name: Notification.Name("BlinkEngageReward"), object: nil, userInfo: userInfo
)
return nil
default:
return nil
}
}
)
MobileAds.shared.start(completionHandler: nil as GADInitializationCompletionHandler?)
// ===== End BlinkEngage SDK Configuration =====
`;
const didFinishReturnStatementRegex =
/return super\.application\(application, didFinishLaunchingWithOptions: launchOptions\)/;
const match = contents.match(didFinishReturnStatementRegex);
if (match) {
contents = contents.replace(
match[0],
`${sdkInitCode}\n${match[0]}`,
);
}
}
config.modResults.contents = contents;
return config;
});
};
// ==========================================================================
// MARK: - Info.plist
// ==========================================================================
const withBlinkEngageInfoPlist: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withInfoPlist(config, (config) => {
if (props.gadAppId) {
config.modResults.GADApplicationIdentifier = props.gadAppId;
}
return config;
});
};
// ==========================================================================
// MARK: - Android manifest
// ==========================================================================
const withBlinkEngageAndroidManifest: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withAndroidManifest(config, (config) => {
const mainApplication = config.modResults.manifest.application?.[0];
if (!mainApplication) return config;
if (!mainApplication['meta-data']) mainApplication['meta-data'] = [];
const metaData = mainApplication['meta-data'];
const addMetaData = (name: string, value: string) => {
const existing = metaData.find(
(item: any) => item.$?.['android:name'] === name,
);
if (!existing) {
metaData.push({
$: { 'android:name': name, 'android:value': value },
});
}
};
if (props.android.licenseKey) {
addMetaData('com.microblink.LicenseKey', props.android.licenseKey);
}
if (props.android.prodIntelKey) {
addMetaData(
'com.microblink.ProductIntelligence',
props.android.prodIntelKey,
);
}
if (props.gadAppId) {
addMetaData(
'com.google.android.gms.ads.APPLICATION_ID',
props.gadAppId,
);
}
addMetaData(
'com.blinkengage.REWARD_CURRENCY_NAME',
props.rewardCurrencyName || 'points',
);
addMetaData(
'com.blinkengage.REWARD_CURRENCY_PER_DOLLAR',
String(props.rewardCurrencyPerDollar ?? 100),
);
addMetaData(
'com.blinkengage.USER_PAYOUT_PERCENTAGE',
String(props.userPayoutPercentage ?? 100),
);
addMetaData(
'com.blinkengage.BASE_REWARD',
String(props.baseReward ?? 10),
);
if (props.android.testOptions?.length) {
addMetaData(
'com.blinkengage.TEST_OPTIONS',
props.android.testOptions.join(','),
);
}
return config;
});
};
// ==========================================================================
// MARK: - Android assets (drawables)
// ==========================================================================
const withBlinkEngageAndroidAssets: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withDangerousMod(config, [
'android',
async (config) => {
const customAssets: Record<string, string> = {};
if (props.themeIcons) {
for (const [key, value] of Object.entries(props.themeIcons)) {
if (value) customAssets[key] = value;
}
}
if (Object.keys(customAssets).length === 0) return config;
const projectRoot = config.modRequest.projectRoot;
const drawablePath = path.join(
projectRoot,
'android',
'app',
'src',
'main',
'res',
'drawable',
);
if (!fs.existsSync(drawablePath)) {
fs.mkdirSync(drawablePath, { recursive: true });
}
for (const [assetKey, sourcePath] of Object.entries(customAssets)) {
const sourceImagePath = path.resolve(projectRoot, sourcePath);
if (!fs.existsSync(sourceImagePath)) continue;
const drawableName = assetKeyToAndroidNameMap[assetKey];
if (!drawableName) continue;
const ext = path.extname(sourceImagePath);
const destImagePath = path.join(
drawablePath,
`${drawableName}${ext}`,
);
fs.copyFileSync(sourceImagePath, destImagePath);
}
return config;
},
]);
};
// ==========================================================================
// MARK: - Android sdk-versions.properties
// ==========================================================================
//
// The Android module's build.gradle reads this file to resolve the
// Activation / BlinkReceipt artifact versions. Fallback defaults inside
// build.gradle let Android Studio sync without prebuild first.
const withBlinkEngageAndroidVersions: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
return withDangerousMod(config, [
'android',
async (config) => {
const projectRoot = config.modRequest.projectRoot;
const propsFilePath = path.join(
projectRoot,
'modules',
'blink-engage',
'android',
'sdk-versions.properties',
);
const activationVer =
props.android.activationVersion || '1.0.0-beta01';
const blinkReceiptVer =
props.android.blinkReceiptVersion || '2.1.0-beta01';
const content = [
'# Auto-generated by blink-engage config plugin. Do not edit manually.',
`activation_version=${activationVer}`,
`blinkreceipt_version=${blinkReceiptVer}`,
'',
].join('\n');
fs.writeFileSync(propsFilePath, content);
return config;
},
]);
};
// ==========================================================================
// MARK: - Main plugin
// ==========================================================================
const withBlinkEngage: ConfigPlugin<BlinkEngagePluginProps> = (
config,
props,
) => {
if (!props?.ios?.licenseKey || !props?.android?.licenseKey) {
throw new Error(
'[BlinkEngage] licenseKey is required for both ios and android. Set IOS_BLINK_LICENSE_KEY and ANDROID_BLINK_LICENSE_KEY in your .env file.',
);
}
// iOS
config = withBlinkEngageSPM(config, props);
config = withBlinkEngageInfoPlist(config, props);
config = withBlinkEngageIOSAssets(config, props);
config = withBlinkEngageAppDelegate(config, props);
// Android
config = withBlinkEngageAndroidManifest(config, props);
config = withBlinkEngageAndroidAssets(config, props);
config = withBlinkEngageAndroidVersions(config, props);
return config;
};
export default withBlinkEngage;
2. JSBackedTheme.swift
Create file:
modules/blink-engage/ios/JSBackedTheme.swift
The Theme-conforming singleton driven by JS via setTheme(...). The main guide explains the architecture; the value of the file is the complete key tables below — these mirror the SDK's AppearanceColorKey, AppearanceFontNameKey, and AppearanceIconKey enums and must stay in lockstep with both the SDK and the JS-side tables in src/theme.ts.
import UIKit
import BlinkEngage
// ==========================================================================
// MARK: - JSBackedTheme
// ==========================================================================
//
// A Theme implementation whose color/font/icon lookups are driven by
// JavaScript via BlinkEngageModule.setTheme(...). Stores three mutable
// dictionaries that are swapped atomically on every apply(...) call.
//
// Assignment to BlinkEngageSDK.shared.appearance happens exactly once in
// the module's OnCreate; subsequent theme updates just mutate the internal
// dictionaries, so the SDK's next lookup returns the new value.
//
// Thread safety: SDK UI may call color(forKey:) etc. from arbitrary threads.
// JS bridge calls land on the module's dispatch queue. We protect both
// with a concurrent queue using reader/writer semantics (barrier writes).
//
// Unset keys return nil so the SDK can apply its own internal defaults.
// Branding defaults live in JS (see modules/blink-engage/src/theme.ts), not
// here — this class is reusable across clients.
final class JSBackedTheme: NSObject, Theme {
static let shared = JSBackedTheme()
private override init() { super.init() }
// MARK: - Theme protocol static flags
var isRewardIconEnabled: Bool { true }
var isMerchantIconEnabled: Bool { true }
var globalFontMatrix: NSDictionary? { nil }
// MARK: - Thread-safe storage
private let queue = DispatchQueue(
label: "com.blinkengage.theme.store",
attributes: .concurrent
)
private var _colors: [AppearanceColorKey: UIColor] = [:]
private var _fontNames: [AppearanceFontNameKey: String] = [:]
private var _icons: [AppearanceIconKey: UIImage] = [:]
// MARK: - Theme protocol lookups
func color(forKey key: AppearanceColorKey) -> UIColor? {
queue.sync { _colors[key] }
}
func fontName(forKey key: AppearanceFontNameKey) -> String? {
queue.sync { _fontNames[key] }
}
func image(forKey key: AppearanceIconKey) -> UIImage? {
queue.sync { _icons[key] }
}
// MARK: - Apply (called from the module's setTheme bridge)
/// Atomically replaces all three dictionaries.
/// Unknown keys / malformed values are silently dropped — the JS layer
/// has already warned the developer in dev builds.
func apply(
colors rawColors: [String: String],
fontNames rawFonts: [String: String],
icons rawIcons: [String: String]
) {
var parsedColors: [AppearanceColorKey: UIColor] = [:]
for (keyString, hex) in rawColors {
guard
let key = Self.colorKey(from: keyString),
let color = UIColor(hex: hex)
else { continue }
parsedColors[key] = color
}
var parsedFonts: [AppearanceFontNameKey: String] = [:]
for (keyString, value) in rawFonts {
guard let key = Self.fontKey(from: keyString) else { continue }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { continue }
parsedFonts[key] = trimmed
}
var parsedIcons: [AppearanceIconKey: UIImage] = [:]
for (keyString, path) in rawIcons {
guard
let key = Self.iconKey(from: keyString),
let image = Self.loadImage(fromPath: path)
else { continue }
parsedIcons[key] = image
}
queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self._colors = parsedColors
self._fontNames = parsedFonts
self._icons = parsedIcons
}
}
// MARK: - Image loader
//
// expo-asset hands us either a plain path or a file:// URL. Both
// need stripping down to a filesystem path before UIImage can load.
private static func loadImage(fromPath path: String) -> UIImage? {
if path.hasPrefix("file://") {
if let url = URL(string: path) {
return UIImage(contentsOfFile: url.path)
}
return nil
}
return UIImage(contentsOfFile: path)
}
// MARK: - String → AppearanceColorKey
private static func colorKey(from s: String) -> AppearanceColorKey? {
switch s {
// Offer Wall Header
case "offerWallHeaderBackground": return .offerWallHeaderBackground
case "offerWallHeaderTitleLabel": return .offerWallHeaderTitleLabel
case "offerWallHeaderSubtitleLabel": return .offerWallHeaderSubtitleLabel
case "offerWallHeaderBackButtonIcon": return .offerWallHeaderBackButtonIcon
// Offer Wall
case "offerWallBackground": return .offerWallBackground
case "offerWallSectionHeaderLabel": return .offerWallSectionHeaderLabel
case "offerWallSectionHeaderShowMoreIcon": return .offerWallSectionHeaderShowMoreIcon
case "offerWallSectionHeaderShowMoreBackground": return .offerWallSectionHeaderShowMoreBackground
case "offerWallFloatingButtonBackground": return .offerWallFloatingButtonBackground
case "offerWallFloatingButtonLabel": return .offerWallFloatingButtonLabel
case "offerWallMoreMerchantsIcon": return .offerWallMoreMerchantsIcon
// Offer Cards
case "offerRewardPointsLabel": return .offerRewardPointsLabel
case "offerTagLabel": return .offerTagLabel
case "offerTagBackground": return .offerTagBackground
case "offerBackground": return .offerBackground
case "offerBrandLabel": return .offerBrandLabel
case "offerDescriptionLabel": return .offerDescriptionLabel
case "offerEligibleMerchantsLabel": return .offerEligibleMerchantsLabel
// Offer Details
case "offerDetailsExpirationLabel": return .offerDetailsExpirationLabel
case "offerDetailsClipLabel": return .offerDetailsClipLabel
case "offerClipButtonIcon": return .offerClipButtonIcon
case "offerClipButtonBackground": return .offerClipButtonBackground
case "offerClippedButtonIcon": return .offerClippedButtonIcon
case "offerClippedButtonBackground": return .offerClippedButtonBackground
case "offerClippedToastMessageLabel": return .offerClippedToastMessageLabel
case "offerClippedToastMessageBackground": return .offerClippedToastMessageBackground
case "offerDetailsClipRequiredLabel": return .offerDetailsClipRequiredLabel
case "offerDetailsClipRequiredBackground": return .offerDetailsClipRequiredBackground
case "offerDetailsSectionHeaderTitleLabel": return .offerDetailsSectionHeaderTitleLabel
case "offerDetailsSectionHeaderToggleLabel": return .offerDetailsSectionHeaderToggleLabel
case "offerDetailsSectionBodyLabel": return .offerDetailsSectionBodyLabel
case "offerDetailsShortDescription": return .offerDetailsShortDescription
case "offerDetailsTitleLabel": return .offerDetailsTitleLabel
case "offerDetailsEarnRewardLabel": return .offerDetailsEarnRewardLabel
case "offerDetailsFinePrintLabel": return .offerDetailsFinePrintLabel
case "offerDetailsBuyOptionLabel": return .offerDetailsBuyOptionLabel
case "offerDetailsBuyOptionBackground": return .offerDetailsBuyOptionBackground
case "offerDetailsTagChipLabel": return .offerDetailsTagChipLabel
case "offerDetailsTagChipBorder": return .offerDetailsTagChipBorder
// SDK 1.5.0: numbered-list bullets inside Offer Details body sections.
case "offerDetailsSectionNumberedListBadgeLabel": return .offerDetailsSectionNumberedListBadgeLabel
case "offerDetailsSectionNumberedListBadgeBackground": return .offerDetailsSectionNumberedListBadgeBackground
// Ad Loading
case "adLoadingLoadingBarLabel": return .adLoadingLoadingBarLabel
case "adLoadingLoadingBarBackground": return .adLoadingLoadingBarBackground
case "adLoadingLoadingBarProgress": return .adLoadingLoadingBarProgress
case "adLoadingDefaultTitleLabel": return .adLoadingDefaultTitleLabel
case "adLoadingDefaultDescriptionLabel": return .adLoadingDefaultDescriptionLabel
// Error Modal
case "errorModalIconBackground": return .errorModalIconBackground
case "errorModalTitleLabel": return .errorModalTitleLabel
case "errorModalDescriptionLabel": return .errorModalDescriptionLabel
case "errorModalBackButtonLabel": return .errorModalBackButtonLabel
case "errorModalBackground": return .errorModalBackground
// Post Scan
case "postScanHeaderBackground": return .postScanHeaderBackground
case "postScanTotalPointsBackground": return .postScanTotalPointsBackground
case "postScanTotalPointsLabel": return .postScanTotalPointsLabel
case "postScanReceiptButtonIcon": return .postScanReceiptButtonIcon
case "postScanReceiptButtonBackground": return .postScanReceiptButtonBackground
case "postScanFooterButtonTitle": return .postScanFooterButtonTitle
case "postScanFooterBackground": return .postScanFooterBackground
case "postScanMerchantNameLabel": return .postScanMerchantNameLabel
case "postScanTripInfoLabel": return .postScanTripInfoLabel
case "postScanNoBoostsLabel": return .postScanNoBoostsLabel
case "postScanSectionHeaderTitleLabel": return .postScanSectionHeaderTitleLabel
case "postScanSuccessTitleLabel": return .postScanSuccessTitleLabel
case "postScanSuccessDescriptionLabel": return .postScanSuccessDescriptionLabel
case "postScanBoostTitleLabel": return .postScanBoostTitleLabel
case "postScanBoostDescriptionLabel": return .postScanBoostDescriptionLabel
case "postScanBoostSkipButtonLabel": return .postScanBoostSkipButtonLabel
case "postScanBoostClaimButtonLabel": return .postScanBoostClaimButtonLabel
case "postScanBoostClaimButtonIcon": return .postScanBoostClaimButtonIcon
case "postScanBoostClaimButtonBackground": return .postScanBoostClaimButtonBackground
case "postScanPurchasePointsLabel": return .postScanPurchasePointsLabel
case "postScanPurchaseBackground": return .postScanPurchaseBackground
case "postScanQualifiedPurchaseBackground": return .postScanQualifiedPurchaseBackground
case "postScanPurchaseInfoIcon": return .postScanPurchaseInfoIcon
// SDK 1.5.0: `postScanUGCPurchaseBackground` was removed; replaced by the
// dedicated `postScanInlineProductTask*` slots below for the new
// "scan & earn" / "watch & earn" inline product tasks.
case "postScanInlineProductTaskBackground": return .postScanInlineProductTaskBackground
case "postScanInlineProductTaskScanAndEarnBackground": return .postScanInlineProductTaskScanAndEarnBackground
case "postScanInlineProductTaskWatchAndEarnBackground": return .postScanInlineProductTaskWatchAndEarnBackground
case "postScanInlineProductTaskScanAndEarnLabel": return .postScanInlineProductTaskScanAndEarnLabel
case "postScanInlineProductTaskWatchAndEarnLabel": return .postScanInlineProductTaskWatchAndEarnLabel
case "postScanInlineProductTaskPointsLabel": return .postScanInlineProductTaskPointsLabel
case "purchaseRowLabelColor": return .purchaseRowLabelColor
case "purchaseRowMetadataLabelColor": return .purchaseRowMetadataLabelColor
// Stores
case "storesHeaderBackground": return .storesHeaderBackground
case "storesHeaderTitleLabel": return .storesHeaderTitleLabel
case "storesListSectionHeaderLabel": return .storesListSectionHeaderLabel
case "storesListBackground": return .storesListBackground
case "storesListItemBackground": return .storesListItemBackground
case "storesListItemDefaultIcon": return .storesListItemDefaultIcon
case "storesListItemTitleLabel": return .storesListItemTitleLabel
case "storesListItemSubtitleLabel": return .storesListItemSubtitleLabel
// Missed Earnings
case "missedEarningsNavigationTitleLabel": return .missedEarningsNavigationTitleLabel
case "missedEarningsNavigationDescriptionLabel": return .missedEarningsNavigationDescriptionLabel
case "missedEarningsNavigationEditButtonIcon": return .missedEarningsNavigationEditButtonIcon
case "missedEarningsNavigationEditButtonBackground": return .missedEarningsNavigationEditButtonBackground
case "missedEarningsNavigationSaveButtonIcon": return .missedEarningsNavigationSaveButtonIcon
case "missedEarningsNavigationSaveButtonBackground": return .missedEarningsNavigationSaveButtonBackground
case "missedEarningsFieldEditIcon": return .missedEarningsFieldEditIcon
case "missedEarningsAddNewFieldLabel": return .missedEarningsAddNewFieldLabel
case "missedEarningsModifiedFieldBackground": return .missedEarningsModifiedFieldBackground
case "missedEarningsListSectionTitleLabel": return .missedEarningsListSectionTitleLabel
case "missedEarningsTripItemLabel": return .missedEarningsTripItemLabel
case "missedEarningsEditModalTitleLabel": return .missedEarningsEditModalTitleLabel
case "missedEarningsEditModalSubtitleLabel": return .missedEarningsEditModalSubtitleLabel
case "missedEarningsEditModalInputLabel": return .missedEarningsEditModalInputLabel
case "missedEarningsEditModalInputPlaceholderLabel": return .missedEarningsEditModalInputPlaceholderLabel
case "missedEarningsEditModalInputValueLabel": return .missedEarningsEditModalInputValueLabel
case "missedEarningsEditModalCancelButtonLabel": return .missedEarningsEditModalCancelButtonLabel
case "missedEarningsEditModalSaveButtonLabel": return .missedEarningsEditModalSaveButtonLabel
case "missedEarningsEditModalSaveButtonBackground": return .missedEarningsEditModalSaveButtonBackground
case "missedEarningsEditModalBackground": return .missedEarningsEditModalBackground
case "missedEarningsEditModalDatePicker": return .missedEarningsEditModalDatePicker
case "missedEarningsAlertTitleLabel": return .missedEarningsAlertTitleLabel
case "missedEarningsAlertMessageLabel": return .missedEarningsAlertMessageLabel
// UGC
case "ugcBarcodeDetectedBorder": return .ugcBarcodeDetectedBorder
case "ugcBarcodeDetectedIcon": return .ugcBarcodeDetectedIcon
case "ugcNavigationButtonIcon": return .ugcNavigationButtonIcon
case "ugcNavigationButtonBackground": return .ugcNavigationButtonBackground
case "ugcProductInfoBackground": return .ugcProductInfoBackground
case "ugcProductInfoLabel": return .ugcProductInfoLabel
case "ugcToastMessageWarningIcon": return .ugcToastMessageWarningIcon
case "ugcRetakeButtonLabel": return .ugcRetakeButtonLabel
case "ugcRetakeButtonBackground": return .ugcRetakeButtonBackground
case "ugcSubmitButtonLabel": return .ugcSubmitButtonLabel
case "ugcSubmitButtonBackground": return .ugcSubmitButtonBackground
default: return nil
}
}
// MARK: - String → AppearanceFontNameKey
private static func fontKey(from s: String) -> AppearanceFontNameKey? {
switch s {
// Offer Wall Header
case "offerWallHeaderTitleLabel": return .offerWallHeaderTitleLabel
case "offerWallHeaderSubtitleLabel": return .offerWallHeaderSubtitleLabel
// Offer Wall
case "offerWallSectionHeaderLabel": return .offerWallSectionHeaderLabel
case "offerWallFloatingButtonLabel": return .offerWallFloatingButtonLabel
// Offer Cards
case "offerRewardPointsLabel": return .offerRewardPointsLabel
case "offerTagLabel": return .offerTagLabel
case "offerBrandLabel": return .offerBrandLabel
case "offerDescriptionLabel": return .offerDescriptionLabel
case "offerEligibleMerchantsLabel": return .offerEligibleMerchantsLabel
case "offerDetailsClipRequiredLabel": return .offerDetailsClipRequiredLabel
// Offer Details
case "offerDetailsExpirationLabel": return .offerDetailsExpirationLabel
case "offerDetailsClipLabel": return .offerDetailsClipLabel
case "offerClippedToastMessageLabel": return .offerClippedToastMessageLabel
case "offerDetailsSectionHeaderTitleLabel": return .offerDetailsSectionHeaderTitleLabel
case "offerDetailsSectionHeaderToggleLabel": return .offerDetailsSectionHeaderToggleLabel
case "offerDetailsSectionBodyLabel": return .offerDetailsSectionBodyLabel
case "offerDetailsShortDescription": return .offerDetailsShortDescription
case "offerDetailsTitleLabel": return .offerDetailsTitleLabel
case "offerDetailsEarnRewardLabel": return .offerDetailsEarnRewardLabel
case "offerDetailsFinePrintLabel": return .offerDetailsFinePrintLabel
case "offerDetailsBuyOptionLabel": return .offerDetailsBuyOptionLabel
case "offerDetailsTagChipLabel": return .offerDetailsTagChipLabel
// Ad Loading
case "adLoadingLoadingBarLabel": return .adLoadingLoadingBarLabel
case "adLoadingDefaultTitleLabel": return .adLoadingDefaultTitleLabel
case "adLoadingDefaultDescriptionLabel": return .adLoadingDefaultDescriptionLabel
// Error Modal
case "errorModalTitleLabel": return .errorModalTitleLabel
case "errorModalDescriptionLabel": return .errorModalDescriptionLabel
case "errorModalBackButtonLabel": return .errorModalBackButtonLabel
// Post Scan
case "postScanTotalPointsLabel": return .postScanTotalPointsLabel
case "postScanFooterButtonTitle": return .postScanFooterButtonTitle
case "postScanMerchantNameLabel": return .postScanMerchantNameLabel
case "postScanTripInfoLabel": return .postScanTripInfoLabel
case "postScanNoBoostsLabel": return .postScanNoBoostsLabel
case "postScanSectionHeaderTitleLabel": return .postScanSectionHeaderTitleLabel
case "postScanSuccessTitleLabel": return .postScanSuccessTitleLabel
case "postScanSuccessDescriptionLabel": return .postScanSuccessDescriptionLabel
case "postScanBoostTitleLabel": return .postScanBoostTitleLabel
case "postScanBoostDescriptionLabel": return .postScanBoostDescriptionLabel
case "postScanBoostSkipButtonLabel": return .postScanBoostSkipButtonLabel
case "postScanBoostClaimButtonLabel": return .postScanBoostClaimButtonLabel
case "postScanPurchasePointsLabel": return .postScanPurchasePointsLabel
// SDK 1.5.0: inline product task font slots.
case "postScanInlineProductTaskPointsLabel": return .postScanInlineProductTaskPointsLabel
case "postScanInlineProductTaskScanAndEarnLabel": return .postScanInlineProductTaskScanAndEarnLabel
case "postScanInlineProductTaskWatchAndEarnLabel": return .postScanInlineProductTaskWatchAndEarnLabel
case "purchaseRowLabelFont": return .purchaseRowLabelFont
case "purchaseRowMetadataLabelFont": return .purchaseRowMetadataLabelFont
// Stores
case "storesHeaderTitleLabel": return .storesHeaderTitleLabel
case "storesListSectionHeaderLabel": return .storesListSectionHeaderLabel
case "storesListItemTitleLabel": return .storesListItemTitleLabel
case "storesListItemSubtitleLabel": return .storesListItemSubtitleLabel
// Missed Earnings
case "missedEarningsNavigationTitleLabel": return .missedEarningsNavigationTitleLabel
case "missedEarningsNavigationDescriptionLabel": return .missedEarningsNavigationDescriptionLabel
case "missedEarningsListSectionTitleLabel": return .missedEarningsListSectionTitleLabel
case "missedEarningsTripItemLabel": return .missedEarningsTripItemLabel
case "missedEarningsEditModalTitleLabel": return .missedEarningsEditModalTitleLabel
case "missedEarningsEditModalSubtitleLabel": return .missedEarningsEditModalSubtitleLabel
case "missedEarningsEditModalInputLabel": return .missedEarningsEditModalInputLabel
case "missedEarningsEditModalInputPlaceholderLabel": return .missedEarningsEditModalInputPlaceholderLabel
case "missedEarningsEditModalInputValueLabel": return .missedEarningsEditModalInputValueLabel
case "missedEarningsEditModalCancelButtonLabel": return .missedEarningsEditModalCancelButtonLabel
case "missedEarningsEditModalSaveButtonLabel": return .missedEarningsEditModalSaveButtonLabel
case "missedEarningsAlertTitleLabel": return .missedEarningsAlertTitleLabel
case "missedEarningsAlertMessageLabel": return .missedEarningsAlertMessageLabel
// UGC
case "ugcProductInfoLabel": return .ugcProductInfoLabel
case "ugcRetakeButtonLabel": return .ugcRetakeButtonLabel
case "ugcSubmitButtonLabel": return .ugcSubmitButtonLabel
default: return nil
}
}
// MARK: - String → AppearanceIconKey
private static func iconKey(from s: String) -> AppearanceIconKey? {
switch s {
// SDK 1.4.0: `.offerRewardIcon` was removed from AppearanceIconKey. The
// reward / currency icon now ships via `BlinkEngageRewardConfig.currencyImage`,
// wired up at prebuild time from `themeIcons.offerRewardIcon` in app.config.js.
// It is intentionally NOT a runtime theme slot.
case "offerWallFloatingButtonIcon": return .offerWallFloatingButtonIcon
case "missedEarningsNavigationEditButtonIcon": return .missedEarningsNavigationEditButtonIcon
case "missedEarningsFieldEditIcon": return .missedEarningsFieldEditIcon
case "postScanReceiptButtonIcon": return .postScanReceiptButtonIcon
case "postScanBoostDefaultIcon": return .postScanBoostDefaultIcon
case "postScanSuccessIcon": return .postScanSuccessIcon
case "ugcBarcodeDetectedIcon": return .ugcBarcodeDetectedIcon
case "ugcToastMessageWarningIcon": return .ugcToastMessageWarningIcon
default: return nil
}
}
}
3. BRScanResultsSerializer.swift
Create file:
modules/blink-engage/ios/BRScanResultsSerializer.swift
Pure helper that turns a BRScanResults instance into [String: Any] for JS. Mostly mechanical — every confidence-wrapped field (results.merchantName?.value, results.total?.value) is unwrapped and copied onto a flat dictionary. The TS-side ScanSuccess interface in src/types.ts (snippet 5) mirrors this shape one-for-one.
import BlinkReceipt
// BRScanResults reference: https://blinkreceipt.github.io/blinkreceipt-ios/Classes/BRScanResults.html
enum BRScanResultsSerializer {
static func serialize(_ results: BRScanResults) -> [String: Any] {
var dict: [String: Any] = [:]
// Identity
dict["blinkReceiptId"] = results.blinkReceiptId
dict["retailerId"] = results.retailerId.rawValue
// Merchant / Store
dict["merchantName"] = results.merchantName?.value
dict["clientMerchantName"] = results.clientMerchantName?.value
dict["merchantGuess"] = results.merchantGuess
dict["merchantSources"] = results.merchantSources?.map { $0.intValue }
dict["storeNumber"] = results.storeNumber?.value
dict["storeAddress"] = results.storeAddress?.value
dict["storeCity"] = results.storeCity?.value
dict["storeState"] = results.storeState?.value
dict["storeZip"] = results.storeZip?.value
dict["storePhone"] = results.storePhone?.value
dict["storeCountry"] = nil // not available in SDK
dict["mallName"] = results.mallName?.value
dict["channel"] = results.channel?.value
// Totals
dict["total"] = results.total?.value
dict["subtotal"] = results.subtotal?.value
dict["tax"] = results.taxes?.value
dict["tip"] = results.tip?.value
dict["cashback"] = results.cashback
dict["subtotalMatches"] = results.subtotalMatches
dict["currencyCode"] = results.currencyCode
// Date / Time
dict["date"] = results.receiptDate?.value
dict["time"] = results.receiptTime?.value
// Products
dict["products"] = results.products?.map { serializeProduct($0) }
dict["productCount"] = results.products?.count ?? 0
dict["productsPendingLookup"] = results.productsPendingLookup
// Coupons
dict["coupons"] = results.coupons?.map { serializeCoupon($0) }
// Transaction / Payment
dict["transactionId"] = results.transactionId?.value
dict["longTransactionId"] = results.longTransactionId?.value
dict["paymentTransactionId"] = results.paymentTransactionId?.value
dict["paymentTerminalId"] = results.paymentTerminalId?.value
dict["paymentMethods"] = results.paymentMethods?.map { serializePaymentMethod($0) }
dict["last4cc"] = results.last4CC?.value
dict["cashierId"] = results.cashierId?.value
dict["registerId"] = results.registerId?.value
dict["taxId"] = results.taxId?.value
dict["barcode"] = results.barcode
// OCR / Quality
dict["ocrConfidence"] = results.ocrConfidence
dict["foundTopEdge"] = results.foundTopEdge
dict["foundBottomEdge"] = results.foundBottomEdge
dict["serverLookupsCompleted"] = results.serverLookupsCompleted
dict["combinedRawText"] = results.combinedRawText
// Duplicate / Fraud
dict["isDuplicate"] = results.isDuplicate
dict["isFraudulent"] = results.isFraudulent
dict["duplicateBlinkReceiptIds"] = results.duplicateBlinkReceiptIds
// Loyalty / Other
dict["loyaltyProgram"] = results.loyaltyProgram
dict["isInstacartShopper"] = results.isInstacartShopper
dict["memberNumber"] = results.memberNumber
dict["purchaseType"] = results.purchaseType
dict["extendedFields"] = results.extendedFields as? [String: String]
return dict
}
private static func serializeProduct(_ product: BRProduct) -> [String: Any] {
var dict: [String: Any] = [:]
// Receipt line item fields
dict["description"] = product.productDescription?.value
dict["productNumber"] = product.productNumber?.value
dict["quantity"] = product.quantity?.value
dict["unitPrice"] = product.unitPrice?.value
dict["totalPrice"] = product.totalPrice?.value
dict["fullPrice"] = product.fullPrice?.value
dict["priceAfterCoupons"] = product.priceAfterCoupons?.value
dict["unitOfMeasure"] = product.unitOfMeasure?.value
dict["isVoided"] = product.isVoided
// Product Intelligence fields (requires prodIntelKey)
dict["productName"] = product.productName
dict["brand"] = product.brand
dict["category"] = product.category
dict["sector"] = product.sector
dict["department"] = product.department
dict["majorCategory"] = product.majorCategory
dict["subCategory"] = product.subCategory
dict["size"] = product.size
dict["upc"] = product.upc
dict["itemType"] = product.itemType
dict["imageUrl"] = product.imgUrl
dict["probability"] = product.probability
// E-receipt / misc
dict["shippingStatus"] = product.shippingStatus
dict["fuelType"] = product.fuelType
dict["seller"] = product.seller
dict["condition"] = product.condition
dict["productUrl"] = product.productUrl
dict["currencyCode"] = product.currencyCode
dict["isSensitive"] = product.isSensitive
dict["extendedFields"] = product.extendedFields as? [String: String]
if let subs = product.subProducts, !subs.isEmpty {
dict["subProducts"] = subs.map { serializeProduct($0) }
}
return dict
}
private static func serializeCoupon(_ coupon: BRCoupon) -> [String: Any] {
var dict: [String: Any] = [:]
switch coupon.couponType {
case .store: dict["type"] = "store"
case .mfgr: dict["type"] = "manufacturer"
default: dict["type"] = "unknown"
}
dict["amount"] = coupon.couponAmount?.value
dict["description"] = coupon.couponDesc?.value
dict["sku"] = coupon.couponSku?.value
if coupon.relatedProductIndex >= 0 {
dict["relatedProductIndex"] = coupon.relatedProductIndex
}
return dict
}
private static func serializePaymentMethod(_ pm: BRPaymentMethod) -> [String: Any] {
var dict: [String: Any] = [:]
dict["method"] = pm.method?.value
dict["cardType"] = pm.cardType?.value
dict["cardIssuer"] = pm.cardIssuer?.value
dict["amount"] = pm.amount?.value
return dict
}
}
4. UIColor(hex:) extension
Add to: the bottom of
modules/blink-engage/ios/BlinkEngageModule.swift(or a separateUIColor+Hex.swiftfile in the same target)
JSBackedTheme.apply(...) uses UIColor(hex:) to parse "#RRGGBB" and "#RRGGBBAA" strings. Standard UIKit doesn't ship one, so add this small extension:
import UIKit
extension UIColor {
convenience init?(hex: String) {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
var rgb: UInt64 = 0
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
let r, g, b, a: CGFloat
switch hexSanitized.count {
case 6:
r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
b = CGFloat(rgb & 0x0000FF) / 255.0
a = 1.0
case 8:
r = CGFloat((rgb & 0xFF000000) >> 24) / 255.0
g = CGFloat((rgb & 0x00FF0000) >> 16) / 255.0
b = CGFloat((rgb & 0x0000FF00) >> 8) / 255.0
a = CGFloat( rgb & 0x000000FF) / 255.0
default:
return nil
}
self.init(red: r, green: g, blue: b, alpha: a)
}
}
The native theme bridge accepts
#RGB,#RGBA,#RRGGBB, and#RRGGBBAA. The above extension handles only the 6- and 8-digit forms — the JS-side validator (themeValidation.ts) accepts all four lengths and the bridge silently drops anything that fails to parse, so 3- and 4-digit colors silently fall through to SDK defaults. If you want to support short hex on iOS, expand theswitchaccordingly.
5. src/types.ts
Create file:
modules/blink-engage/src/types.ts
Public TypeScript types your app code consumes. The discriminated ReceiptScanResult union mirrors what BRScanResultsSerializer produces; the RewardEvent shape mirrors what BlinkEngageModule.swift emits via sendEvent("onReward", ...).
import { EventSubscription } from 'react-native';
import type { BlinkEngageTheme } from './theme';
// ==========================================================================
// Value types (mirroring BRStringValue/BRFloatValue confidence wrappers)
// ==========================================================================
/**
* Individual product extracted from a scanned receipt.
*/
export interface ScannedProduct {
// Receipt line item fields
description?: string;
productNumber?: string;
quantity?: number;
unitPrice?: number;
totalPrice?: number;
fullPrice?: number;
priceAfterCoupons?: number;
unitOfMeasure?: string;
isVoided?: boolean;
// Product Intelligence fields (requires prodIntelKey)
productName?: string;
brand?: string;
category?: string;
sector?: string;
department?: string;
majorCategory?: string;
subCategory?: string;
size?: string;
upc?: string;
itemType?: string;
imageUrl?: string;
probability?: number;
// E-receipt / misc
shippingStatus?: string;
fuelType?: string;
seller?: string;
condition?: string;
productUrl?: string;
currencyCode?: string;
isSensitive?: boolean;
extendedFields?: Record<string, string>;
subProducts?: ScannedProduct[];
}
export interface ScannedCoupon {
type?: 'unknown' | 'store' | 'manufacturer';
amount?: number;
description?: string;
sku?: string;
relatedProductIndex?: number;
}
export interface ScannedPaymentMethod {
method?: string;
cardType?: string;
cardIssuer?: string;
amount?: number;
}
// ==========================================================================
// Scan result: discriminated union
// ==========================================================================
export interface ScanResultBase {
/** Unique receipt ID from BlinkReceipt */
blinkReceiptId?: string;
}
/**
* Successful scan with full receipt data. All receipt fields are available
* — no need for optional chaining once `success` is narrowed.
*/
export interface ScanSuccess extends ScanResultBase {
success: true;
cancelled: false;
// Merchant / Store
retailerId?: number;
merchantName?: string;
clientMerchantName?: string;
merchantGuess?: string;
merchantSources?: number[];
storeNumber?: string;
storeAddress?: string;
storeCity?: string;
storeState?: string;
storeZip?: string;
storePhone?: string;
storeCountry?: string;
mallName?: string;
channel?: string;
// Totals
total?: number;
subtotal?: number;
tax?: number;
tip?: number;
cashback?: number;
subtotalMatches?: boolean;
currencyCode?: string;
// Date / Time
date?: string;
time?: string;
// Products
products?: ScannedProduct[];
productCount?: number;
productsPendingLookup?: number;
coupons?: ScannedCoupon[];
// Transaction / Payment
transactionId?: string;
longTransactionId?: string;
paymentTransactionId?: string;
paymentTerminalId?: string;
paymentMethods?: ScannedPaymentMethod[];
last4cc?: string;
cashierId?: string;
registerId?: string;
taxId?: string;
barcode?: string;
// OCR / Quality
ocrConfidence?: number;
foundTopEdge?: boolean;
foundBottomEdge?: boolean;
serverLookupsCompleted?: boolean;
combinedRawText?: string;
// Duplicate / Fraud
isDuplicate?: boolean;
isFraudulent?: boolean;
duplicateBlinkReceiptIds?: string[];
// Loyalty / Other
loyaltyProgram?: boolean;
isInstacartShopper?: boolean;
memberNumber?: string;
purchaseType?: string;
extendedFields?: Record<string, string>;
}
export interface ScanCancelled extends ScanResultBase {
success: false;
cancelled: true;
}
export interface ScanError extends ScanResultBase {
success: false;
cancelled: false;
error: string;
}
/**
* Result returned from a STANDALONE receipt scan. Discriminated union of all
* possible scan outcomes. Use `result.success` and `result.cancelled` to narrow.
*/
export type ReceiptScanResult = ScanSuccess | ScanCancelled | ScanError;
// ==========================================================================
// User identity
// ==========================================================================
/**
* User parameters for BlinkEngage SDK.
* At least one of email or phone must be provided.
*/
export interface BlinkEngageUserParams {
/** User's email address (will be hashed with SHA-256) */
email?: string;
/** User's phone number (will be hashed with SHA-256) */
phone?: string;
/** Client-specific user identifier (not hashed) */
clientUserId?: string;
}
// ==========================================================================
// Scan options
// ==========================================================================
/**
* Options for configuring the standalone receipt scanner. Only honored on
* iOS — Android currently ignores these and uses defaults.
*/
export interface ReceiptScanOptions {
/** Whether to detect duplicate receipts. Default: true */
detectDuplicates?: boolean;
/** Country code for receipt parsing (e.g., "US", "UK"). Default: auto-detect */
countryCode?: string;
/** Camera type to use. Default: "enhanced" */
cameraType?: 'standard' | 'enhanced';
}
// ==========================================================================
// Reward events
// ==========================================================================
/**
* Event emitted when a user earns a reward.
*
* - `ScanFinished` fires for every successful receipt scan.
* - `Promo` fires when a scanned receipt matches a qualified promotion.
* - `Boost` fires when the user redeems a boost (rewarded ad / CPA).
* - `BarcodeCollection` fires when a barcode collection reward is triggered.
*
* Since SDK 1.4.0, the on-device reward event does NOT carry parsed receipt
* fields (the `rewardCallback` no longer receives `scanResults`). Use
* `blinkReceiptId` to correlate with either the result of `startReceiptScan()`
* or your `ReceiptProcessed` webhook payload.
*/
export interface RewardEvent {
context: 'ScanFinished' | 'Promo' | 'Boost' | 'BarcodeCollection';
amount: number;
/**
* BlinkReceipt id of the originating receipt, when one applies.
* Always set for `ScanFinished`. Set on `Promo` / `Boost` /
* `BarcodeCollection` when they originate from a specific receipt.
*/
blinkReceiptId?: string;
}
// ==========================================================================
// Offer Wall
// ==========================================================================
/**
* Options for configuring the modal Offer Wall on iOS.
*/
export interface OfferWallOptions {
/** Whether to show the floating "Scan Receipt" button. Default: true */
showFloatingAction?: boolean;
}
// ==========================================================================
// Public interface
// ==========================================================================
export interface BlinkEngageInterface {
setUser(params: BlinkEngageUserParams): Promise<void>;
clearUser(): void;
showOfferWall(options?: OfferWallOptions): Promise<boolean>;
dismissOfferWall(): Promise<boolean>;
startReceiptScan(options?: ReceiptScanOptions): Promise<ReceiptScanResult>;
startMonetizedScan(): Promise<boolean>;
addRewardListener(
listener: (event: RewardEvent) => void,
): EventSubscription;
setTheme(theme: BlinkEngageTheme): Promise<void>;
}
6. src/theme.ts
Create file:
modules/blink-engage/src/theme.ts
Single source of truth for which theme keys the SDK accepts. The as const arrays back both the TypeScript type unions (autocomplete) and the runtime validators in themeValidation.ts. Keep these in lockstep with the Swift enums in JSBackedTheme.swift — adding a key on one side without the other means it'll be silently dropped at runtime.
import type { ImageSourcePropType } from 'react-native';
// ==========================================================================
// Theme key arrays (single source of truth)
// ==========================================================================
/** All color slots the SDK exposes. */
export const BLINK_ENGAGE_COLOR_KEYS = [
// Offer Wall Header
'offerWallHeaderBackground',
'offerWallHeaderTitleLabel',
'offerWallHeaderSubtitleLabel',
'offerWallHeaderBackButtonIcon',
// Offer Wall
'offerWallBackground',
'offerWallSectionHeaderLabel',
'offerWallSectionHeaderShowMoreIcon',
'offerWallSectionHeaderShowMoreBackground',
'offerWallFloatingButtonBackground',
'offerWallFloatingButtonLabel',
'offerWallMoreMerchantsIcon',
// Offer Cards
'offerRewardPointsLabel',
'offerTagLabel',
'offerTagBackground',
'offerBackground',
'offerBrandLabel',
'offerDescriptionLabel',
'offerEligibleMerchantsLabel',
// Offer Details
'offerDetailsExpirationLabel',
'offerDetailsClipLabel',
'offerClipButtonIcon',
'offerClipButtonBackground',
'offerClippedButtonIcon',
'offerClippedButtonBackground',
'offerClippedToastMessageLabel',
'offerClippedToastMessageBackground',
'offerDetailsClipRequiredLabel',
'offerDetailsClipRequiredBackground',
'offerDetailsSectionHeaderTitleLabel',
'offerDetailsSectionHeaderToggleLabel',
'offerDetailsSectionBodyLabel',
'offerDetailsShortDescription',
'offerDetailsTitleLabel',
'offerDetailsEarnRewardLabel',
'offerDetailsFinePrintLabel',
'offerDetailsBuyOptionLabel',
'offerDetailsBuyOptionBackground',
'offerDetailsTagChipLabel',
'offerDetailsTagChipBorder',
// SDK 1.5.0: numbered-list bullets inside Offer Details body sections.
'offerDetailsSectionNumberedListBadgeLabel',
'offerDetailsSectionNumberedListBadgeBackground',
// Ad Loading
'adLoadingLoadingBarLabel',
'adLoadingLoadingBarBackground',
'adLoadingLoadingBarProgress',
'adLoadingDefaultTitleLabel',
'adLoadingDefaultDescriptionLabel',
// Error Modal
'errorModalIconBackground',
'errorModalTitleLabel',
'errorModalDescriptionLabel',
'errorModalBackButtonLabel',
'errorModalBackground',
// Post Scan
'postScanHeaderBackground',
'postScanTotalPointsBackground',
'postScanTotalPointsLabel',
'postScanReceiptButtonIcon',
'postScanReceiptButtonBackground',
'postScanFooterButtonTitle',
'postScanFooterBackground',
'postScanMerchantNameLabel',
'postScanTripInfoLabel',
'postScanNoBoostsLabel',
'postScanSectionHeaderTitleLabel',
'postScanSuccessTitleLabel',
'postScanSuccessDescriptionLabel',
'postScanBoostTitleLabel',
'postScanBoostDescriptionLabel',
'postScanBoostSkipButtonLabel',
'postScanBoostClaimButtonLabel',
'postScanBoostClaimButtonIcon',
'postScanBoostClaimButtonBackground',
'postScanPurchasePointsLabel',
'postScanPurchaseBackground',
'postScanQualifiedPurchaseBackground',
'postScanPurchaseInfoIcon',
// SDK 1.5.0: `postScanUGCPurchaseBackground` was removed; replaced by the
// dedicated `postScanInlineProductTask*` slots below for the new
// "scan & earn" / "watch & earn" inline product tasks.
'postScanInlineProductTaskBackground',
'postScanInlineProductTaskScanAndEarnBackground',
'postScanInlineProductTaskWatchAndEarnBackground',
'postScanInlineProductTaskScanAndEarnLabel',
'postScanInlineProductTaskWatchAndEarnLabel',
'postScanInlineProductTaskPointsLabel',
'purchaseRowLabelColor',
'purchaseRowMetadataLabelColor',
// Stores
'storesHeaderBackground',
'storesHeaderTitleLabel',
'storesListSectionHeaderLabel',
'storesListBackground',
'storesListItemBackground',
'storesListItemDefaultIcon',
'storesListItemTitleLabel',
'storesListItemSubtitleLabel',
// Missed Earnings
'missedEarningsNavigationTitleLabel',
'missedEarningsNavigationDescriptionLabel',
'missedEarningsNavigationEditButtonIcon',
'missedEarningsNavigationEditButtonBackground',
'missedEarningsNavigationSaveButtonIcon',
'missedEarningsNavigationSaveButtonBackground',
'missedEarningsFieldEditIcon',
'missedEarningsAddNewFieldLabel',
'missedEarningsModifiedFieldBackground',
'missedEarningsListSectionTitleLabel',
'missedEarningsTripItemLabel',
'missedEarningsEditModalTitleLabel',
'missedEarningsEditModalSubtitleLabel',
'missedEarningsEditModalInputLabel',
'missedEarningsEditModalInputPlaceholderLabel',
'missedEarningsEditModalInputValueLabel',
'missedEarningsEditModalCancelButtonLabel',
'missedEarningsEditModalSaveButtonLabel',
'missedEarningsEditModalSaveButtonBackground',
'missedEarningsEditModalBackground',
'missedEarningsEditModalDatePicker',
'missedEarningsAlertTitleLabel',
'missedEarningsAlertMessageLabel',
// UGC
'ugcBarcodeDetectedBorder',
'ugcBarcodeDetectedIcon',
'ugcNavigationButtonIcon',
'ugcNavigationButtonBackground',
'ugcProductInfoBackground',
'ugcProductInfoLabel',
'ugcToastMessageWarningIcon',
'ugcRetakeButtonLabel',
'ugcRetakeButtonBackground',
'ugcSubmitButtonLabel',
'ugcSubmitButtonBackground',
] as const;
/** All font slots. */
export const BLINK_ENGAGE_FONT_KEYS = [
'offerWallHeaderTitleLabel',
'offerWallHeaderSubtitleLabel',
'offerWallSectionHeaderLabel',
'offerWallFloatingButtonLabel',
'offerRewardPointsLabel',
'offerTagLabel',
'offerBrandLabel',
'offerDescriptionLabel',
'offerEligibleMerchantsLabel',
'offerDetailsClipRequiredLabel',
'offerDetailsExpirationLabel',
'offerDetailsClipLabel',
'offerClippedToastMessageLabel',
'offerDetailsSectionHeaderTitleLabel',
'offerDetailsSectionHeaderToggleLabel',
'offerDetailsSectionBodyLabel',
'offerDetailsShortDescription',
'offerDetailsTitleLabel',
'offerDetailsEarnRewardLabel',
'offerDetailsFinePrintLabel',
'offerDetailsBuyOptionLabel',
'offerDetailsTagChipLabel',
'adLoadingLoadingBarLabel',
'adLoadingDefaultTitleLabel',
'adLoadingDefaultDescriptionLabel',
'errorModalTitleLabel',
'errorModalDescriptionLabel',
'errorModalBackButtonLabel',
'postScanTotalPointsLabel',
'postScanFooterButtonTitle',
'postScanMerchantNameLabel',
'postScanTripInfoLabel',
'postScanNoBoostsLabel',
'postScanSectionHeaderTitleLabel',
'postScanSuccessTitleLabel',
'postScanSuccessDescriptionLabel',
'postScanBoostTitleLabel',
'postScanBoostDescriptionLabel',
'postScanBoostSkipButtonLabel',
'postScanBoostClaimButtonLabel',
'postScanPurchasePointsLabel',
// SDK 1.5.0: inline product task font slots.
'postScanInlineProductTaskPointsLabel',
'postScanInlineProductTaskScanAndEarnLabel',
'postScanInlineProductTaskWatchAndEarnLabel',
'purchaseRowLabelFont',
'purchaseRowMetadataLabelFont',
'storesHeaderTitleLabel',
'storesListSectionHeaderLabel',
'storesListItemTitleLabel',
'storesListItemSubtitleLabel',
'missedEarningsNavigationTitleLabel',
'missedEarningsNavigationDescriptionLabel',
'missedEarningsListSectionTitleLabel',
'missedEarningsTripItemLabel',
'missedEarningsEditModalTitleLabel',
'missedEarningsEditModalSubtitleLabel',
'missedEarningsEditModalInputLabel',
'missedEarningsEditModalInputPlaceholderLabel',
'missedEarningsEditModalInputValueLabel',
'missedEarningsEditModalCancelButtonLabel',
'missedEarningsEditModalSaveButtonLabel',
'missedEarningsAlertTitleLabel',
'missedEarningsAlertMessageLabel',
'ugcProductInfoLabel',
'ugcRetakeButtonLabel',
'ugcSubmitButtonLabel',
] as const;
/** All icon slots. */
//
// NOTE: `offerRewardIcon` was removed from the SDK's `AppearanceIconKey` in
// 1.4.0. The reward / currency icon is now delivered via
// `BlinkEngageRewardConfig.currencyImage` (set up at prebuild time from
// `themeIcons.offerRewardIcon` in `app.config.js`), not through the runtime
// `setTheme(...)` icons map. Do not list it here — it is intentionally not a
// runtime theme slot.
export const BLINK_ENGAGE_ICON_KEYS = [
'offerWallFloatingButtonIcon',
'missedEarningsNavigationEditButtonIcon',
'missedEarningsFieldEditIcon',
'postScanReceiptButtonIcon',
'postScanBoostDefaultIcon',
'postScanSuccessIcon',
'ugcBarcodeDetectedIcon',
'ugcToastMessageWarningIcon',
] as const;
// ==========================================================================
// Theme key type unions (derived)
// ==========================================================================
export type BlinkEngageColorKey = (typeof BLINK_ENGAGE_COLOR_KEYS)[number];
export type BlinkEngageFontKey = (typeof BLINK_ENGAGE_FONT_KEYS)[number];
export type BlinkEngageIconKey = (typeof BLINK_ENGAGE_ICON_KEYS)[number];
// ==========================================================================
// Theme object
// ==========================================================================
/**
* A JS-driven appearance configuration for the BlinkEngage SDK.
*
* - `colors` — hex strings (`"#RRGGBB"` or `"#RRGGBBAA"`) per color slot.
* - `fontNames` — registered font family names per text slot.
* - `icons` — RN image sources per icon slot.
*
* Any category or key left unset falls back to the SDK's baked-in defaults.
* `setTheme` replaces the entire theme atomically; to tweak one value, spread
* your previous theme:
*
* ```ts
* setTheme({
* ...appTheme,
* colors: { ...appTheme.colors, offerWallHeaderTitleLabel: '#FFFFFF' },
* });
* ```
*/
export interface BlinkEngageTheme {
colors?: Partial<Record<BlinkEngageColorKey, string>>;
fontNames?: Partial<Record<BlinkEngageFontKey, string>>;
icons?: Partial<Record<BlinkEngageIconKey, ImageSourcePropType>>;
}
/**
* Identity helper for authoring a theme with full type inference.
*
* Using this instead of `const x: BlinkEngageTheme = {...}` gives you
* accurate autocomplete on the keys you've actually set.
*/
export const createTheme = <T extends BlinkEngageTheme>(theme: T): T => theme;
7. src/themeValidation.ts
Create file:
modules/blink-engage/src/themeValidation.ts
Validates and sanitizes theme payloads before they cross the bridge. Mirrors the native silent-skip behavior of JSBackedTheme.apply(...), but also emits console.warn in __DEV__ so developers notice typos.
import type { ImageSourcePropType } from 'react-native';
import type { BlinkEngageTheme } from './theme';
import {
BLINK_ENGAGE_COLOR_KEYS,
BLINK_ENGAGE_FONT_KEYS,
BLINK_ENGAGE_ICON_KEYS,
} from './theme';
const LOG_PREFIX = '[BlinkEngage][theme]';
// `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA`
const HEX_COLOR_REGEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
const COLOR_KEY_SET: ReadonlySet<string> = new Set(BLINK_ENGAGE_COLOR_KEYS);
const FONT_KEY_SET: ReadonlySet<string> = new Set(BLINK_ENGAGE_FONT_KEYS);
const ICON_KEY_SET: ReadonlySet<string> = new Set(BLINK_ENGAGE_ICON_KEYS);
/**
* Returns the subset of `colors` that is safe to forward to native:
* known keys + valid hex strings only. Warns in __DEV__ for each dropped entry.
*/
export function validateColors(
colors: BlinkEngageTheme['colors'],
): Record<string, string> {
const safe: Record<string, string> = {};
if (!colors) return safe;
for (const [key, value] of Object.entries(colors)) {
if (!COLOR_KEY_SET.has(key)) {
if (__DEV__) {
console.warn(
`${LOG_PREFIX} Unknown color key "${key}". It will be ignored by the native SDK.`,
);
}
continue;
}
if (typeof value !== 'string') {
if (__DEV__) {
console.warn(
`${LOG_PREFIX} colors.${key}: expected hex string, got ${typeof value}.`,
);
}
continue;
}
if (!HEX_COLOR_REGEX.test(value)) {
if (__DEV__) {
console.warn(
`${LOG_PREFIX} colors.${key}: "${value}" is not a valid hex color. Expected "#RGB", "#RRGGBB", or "#RRGGBBAA".`,
);
}
continue;
}
safe[key] = value;
}
return safe;
}
/**
* Returns the subset of `fontNames` that is safe to forward to native:
* known keys + non-empty strings only. Warns in __DEV__ for each dropped entry.
*/
export function validateFontNames(
fontNames: BlinkEngageTheme['fontNames'],
): Record<string, string> {
const safe: Record<string, string> = {};
if (!fontNames) return safe;
for (const [key, value] of Object.entries(fontNames)) {
if (!FONT_KEY_SET.has(key)) {
if (__DEV__) {
console.warn(
`${LOG_PREFIX} Unknown font key "${key}". It will be ignored by the native SDK.`,
);
}
continue;
}
if (typeof value !== 'string' || value.trim() === '') {
if (__DEV__) {
console.warn(
`${LOG_PREFIX} fontNames.${key}: expected a non-empty font family name.`,
);
}
continue;
}
safe[key] = value.trim();
}
return safe;
}
/**
* Returns the subset of `icons` whose keys are known and whose sources are
* non-null. The actual resolution to a filesystem path happens in setTheme
* (via expo-asset). Warns in __DEV__ for each dropped entry.
*/
export function validateIcons(
icons: BlinkEngageTheme['icons'],
): Record<string, ImageSourcePropType> {
const safe: Record<string, ImageSourcePropType> = {};
if (!icons) return safe;
for (const [key, value] of Object.entries(icons)) {
if (!ICON_KEY_SET.has(key)) {
if (__DEV__) {
console.warn(
`${LOG_PREFIX} Unknown icon key "${key}". It will be ignored by the native SDK.`,
);
}
continue;
}
if (value == null) {
if (__DEV__) {
console.warn(
`${LOG_PREFIX} icons.${key}: source is null/undefined — did you forget a require()?`,
);
}
continue;
}
safe[key] = value;
}
return safe;
}
8. src/setTheme.ts
Create file:
modules/blink-engage/src/setTheme.ts
The setTheme function exposed from the package's public API. Three responsibilities:
- Drop unknown keys / invalid values via
themeValidation.ts. - Resolve every icon source (
require('./icon.png'),{ uri: '...' }, etc.) to a filesystem path viaexpo-asset. Native receives plain paths, never RN module IDs. - Cross the bridge with the sanitized payload.
import { Asset } from 'expo-asset';
import BlinkEngageModule from './BlinkEngageModule';
import type { BlinkEngageTheme } from './theme';
import {
validateColors,
validateFontNames,
validateIcons,
} from './themeValidation';
/**
* Applies a theme to the BlinkEngage SDK UI.
*
* Replaces the entire theme atomically: any key not included falls back to
* the SDK's baked-in defaults. To tweak a single value, spread the previous
* theme:
*
* ```ts
* setTheme({
* ...appTheme,
* colors: { ...appTheme.colors, offerWallHeaderTitleLabel: '#FFFFFF' },
* });
* ```
*
* On Android this is currently a no-op (the native SDK does not yet expose
* a theming API). The JS contract is stable so call sites stay cross-platform.
*
* @example
* ```ts
* import { createTheme, setTheme } from 'blink-engage';
*
* const appTheme = createTheme({
* colors: { offerWallHeaderBackground: '#FFFFFF' },
* icons: { offerRewardIcon: require('./assets/coin.png') },
* });
*
* await setTheme(appTheme);
* ```
*/
export async function setTheme(theme: BlinkEngageTheme): Promise<void> {
// Validation doubles as sanitization: unknown keys and malformed values
// are dropped (with __DEV__ warnings) before we cross the bridge. This
// keeps a single bad entry from torpedoing the whole call via Expo's
// strict arg-type coercion.
const safeColors = validateColors(theme.colors);
const safeFontNames = validateFontNames(theme.fontNames);
const safeIcons = validateIcons(theme.icons);
const icons: Record<string, string> = {};
for (const [key, source] of Object.entries(safeIcons)) {
try {
const asset = Asset.fromModule(
source as Parameters<typeof Asset.fromModule>[0],
);
if (!asset.localUri) {
await asset.downloadAsync();
}
if (asset.localUri) {
icons[key] = asset.localUri;
} else if (__DEV__) {
console.warn(
`[BlinkEngage][theme] icons.${key}: asset resolved but produced no localUri; dropping.`,
);
}
} catch (error) {
if (__DEV__) {
console.warn(
`[BlinkEngage][theme] icons.${key}: failed to resolve source, dropping.`,
error,
);
}
}
}
await BlinkEngageModule.setTheme({
colors: safeColors,
fontNames: safeFontNames,
icons,
});
}
9. src/index.ts
Create file:
modules/blink-engage/src/index.ts
The package's public entry point. Wraps the native module with a clean TypeScript API: SHA-256 hashing of email/phone happens here so plain text never crosses the bridge, and platform shims are kept out of host app code.
import * as Crypto from 'expo-crypto';
import { type EventSubscription } from 'expo-modules-core';
import { Platform } from 'react-native';
import BlinkEngageModule from './BlinkEngageModule';
import { setTheme } from './setTheme';
import type {
BlinkEngageInterface,
BlinkEngageUserParams,
OfferWallOptions,
ReceiptScanOptions,
ReceiptScanResult,
RewardEvent,
} from './types';
// Re-export types
export type { EventSubscription };
export type {
BlinkEngageInterface,
BlinkEngageUserParams,
OfferWallOptions,
ReceiptScanOptions,
ReceiptScanResult,
RewardEvent,
ScannedProduct,
ScannedCoupon,
ScannedPaymentMethod,
ScanSuccess,
ScanCancelled,
ScanError,
} from './types';
// Re-export theme types + helpers
export type {
BlinkEngageTheme,
BlinkEngageColorKey,
BlinkEngageFontKey,
BlinkEngageIconKey,
} from './theme';
export { createTheme } from './theme';
// Re-export the native view component
export {
OfferWallView,
type OfferWallViewProps,
type OfferWallViewRef,
} from './OfferWallView';
// Re-export the theme bridge
export { setTheme } from './setTheme';
// ==========================================================================
// MARK: - Helpers
// ==========================================================================
async function hashSHA256(value: string): Promise<string> {
return await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
value,
);
}
// ==========================================================================
// MARK: - User identity
// ==========================================================================
/**
* Sets user identification data. Email and phone are hashed with SHA-256 on
* the JS side — plain text never crosses the bridge.
*
* Call this after the user logs in. At least one of `email` or `phone` must
* be set before showing the offer wall, otherwise the SDK has no user
* identity to load promotions for.
*/
export async function setUser(params: BlinkEngageUserParams): Promise<void> {
const nativeParams: Record<string, string> = {};
if (params.email) {
nativeParams.emailHash = await hashSHA256(params.email.toLowerCase());
}
if (params.phone) {
nativeParams.phoneHash = await hashSHA256(params.phone);
}
if (params.clientUserId) {
nativeParams.clientUserId = params.clientUserId;
}
BlinkEngageModule.setUser(nativeParams);
}
/** Clears user identification data. Call on logout. */
export function clearUser(): void {
BlinkEngageModule.clearUser();
}
// ==========================================================================
// MARK: - Offer Wall
// ==========================================================================
/**
* Presents the Offer Wall as a full-screen modal (iOS) or a no-op (Android).
* On Android, render `<OfferWallView style={{ flex: 1 }} />` instead.
*/
export function showOfferWall(options?: OfferWallOptions): Promise<boolean> {
if (Platform.OS === 'ios') {
return BlinkEngageModule.showOfferWall(options);
}
return BlinkEngageModule.showOfferWall();
}
/** Dismisses the modal Offer Wall. No-op on Android. */
export function dismissOfferWall(): Promise<boolean> {
return BlinkEngageModule.dismissOfferWall();
}
// ==========================================================================
// MARK: - Standalone Receipt Scans
// ==========================================================================
/**
* Starts a standalone receipt scan with no monetization (no ads, no
* promotion matching, no rewards). Returns the parsed scan results to JS.
*
* Use for "Scan Receipt" features that live outside the offer wall.
*/
export function startReceiptScan(
options?: ReceiptScanOptions,
): Promise<ReceiptScanResult> {
if (Platform.OS === 'android') {
// Android currently ignores options; falls back to defaults.
return BlinkEngageModule.startReceiptScan();
}
return BlinkEngageModule.startReceiptScan(options);
}
/**
* Starts a standalone monetized receipt scan — runs the BlinkEngage post-scan
* flow (ads, promotion matching, reward emission) without opening the offer
* wall first. Rewards arrive via the `onReward` event in exactly the same way
* as a scan launched from inside the offer wall.
*/
export function startMonetizedScan(): Promise<boolean> {
return BlinkEngageModule.startMonetizedScan();
}
// ==========================================================================
// MARK: - Reward events
// ==========================================================================
/**
* Subscribes to reward events. Fires for every `ScanFinished`, `Promo`,
* `Boost`, and `BarcodeCollection` event the SDK emits.
*
* @example
* ```tsx
* useEffect(() => {
* const sub = addRewardListener((event) => {
* console.log(`Earned ${event.amount} from ${event.context}`);
* setCoinBalance(prev => prev + event.amount);
* });
* return () => sub.remove();
* }, []);
* ```
*/
export function addRewardListener(
listener: (event: RewardEvent) => void,
): EventSubscription {
return BlinkEngageModule.addListener('onReward', listener);
}
// ==========================================================================
// MARK: - Default export
// ==========================================================================
export default {
setUser,
clearUser,
showOfferWall,
dismissOfferWall,
startReceiptScan,
startMonetizedScan,
addRewardListener,
setTheme,
} as BlinkEngageInterface;
That's everything you need to fill in the gaps from the main guide. If anything cross-references "see code snippets §N" in ios-react-native-integration.md, it points here. If you spot a mismatch between something in the main guide and a snippet here, the snippet is the source of truth — copy it verbatim.