From dc62c9af0a677c195bbad539553df4c3a2430ed1 Mon Sep 17 00:00:00 2001 From: Asad Raza Date: Tue, 8 Sep 2026 17:29:49 +0100 Subject: [PATCH] feat(banner): add consent-or-pay login/subscribe callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridges the native SDK's Consent-or-Pay 1st-layer banner support (MSDK-3779): onLoginClicked/onSubscribeClicked events fire when the user taps the subscriber-login link or Reject & Subscribe button, and notifyLoginSuccess/ notifySubscribeSuccess let the host app clear stored TCF consent after a successful login or subscription. Also fixes the sample app's Metro config to resolve a single react-native copy — the SDK's own node_modules/react-native and the sample's were diverging in version, creating two disconnected RCTDeviceEventEmitter singletons so native events emitted through the SDK's copy never reached listeners registered through the sample app's copy. --- .../reactnative/RNUsercentricsModule.kt | 35 +++++++++++++++---- .../reactnative/RNUsercentricsModuleSpec.kt | 6 ++++ .../reactnative/api/UsercentricsProxy.kt | 18 +++++++--- ios/Manager/UsercentricsManager.swift | 19 +++++++++- ios/RNUsercentricsModule.mm | 6 ++++ ios/RNUsercentricsModule.swift | 26 ++++++++++++-- ios/RNUsercentricsModuleSpec.h | 7 ++++ .../Fake/FakeUsercentricsManager.swift | 28 +++++++++++++++ sample/metro.config.js | 18 ++++++++++ sample/src/screens/Home.tsx | 33 ++++++++++++++++- src/NativeUsercentrics.ts | 4 +++ src/Usercentrics.tsx | 26 ++++++++++++++ src/fabric/NativeUsercentricsModule.ts | 4 +++ 13 files changed, 216 insertions(+), 14 deletions(-) diff --git a/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt b/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt index f23b0bb..08760ff 100644 --- a/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt +++ b/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt @@ -1,7 +1,6 @@ package com.usercentrics.reactnative import com.facebook.react.bridge.* -import com.facebook.react.modules.core.DeviceEventManagerModule import com.usercentrics.sdk.UsercentricsDisposableEvent import com.usercentrics.sdk.UsercentricsEvent import com.usercentrics.reactnative.api.UsercentricsProxy @@ -44,7 +43,13 @@ internal class RNUsercentricsModule( val bannerSettings = options?.bannerSettingsFromMap(context) val activity = reactContextProvider.activity()!! - usercentricsProxy.showFirstLayer(activity, bannerSettings, promise) + usercentricsProxy.showFirstLayer( + activity, + bannerSettings, + onLoginClicked = { url -> emitEvent(ON_LOGIN_CLICKED_EVENT, url) }, + onSubscribeClicked = { url -> emitEvent(ON_SUBSCRIBE_CLICKED_EVENT, url) }, + promise, + ) } catch (e: Exception) { promise.reject(e) } @@ -254,6 +259,24 @@ internal class RNUsercentricsModule( }) } + @ReactMethod + override fun notifyLoginSuccess(promise: Promise) { + usercentricsProxy.instance.notifyLoginSuccess({ + promise.resolve(null) + }, { + promise.reject(it) + }) + } + + @ReactMethod + override fun notifySubscribeSuccess(promise: Promise) { + usercentricsProxy.instance.notifySubscribeSuccess({ + promise.resolve(null) + }, { + promise.reject(it) + }) + } + @ReactMethod override fun addListener(eventName: String) { if (eventName != ON_GPP_SECTION_CHANGE_EVENT) return @@ -282,10 +305,8 @@ internal class RNUsercentricsModule( super.invalidate() } - private fun emitEvent(eventName: String, payload: WritableMap) { - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit(eventName, payload) + private fun emitEvent(eventName: String, payload: Any?) { + reactApplicationContext.emitDeviceEvent(eventName, payload) } private fun readableMapValueToAny(map: ReadableMap): Any? { @@ -322,5 +343,7 @@ internal class RNUsercentricsModule( companion object { const val NAME = "RNUsercentricsModule" const val ON_GPP_SECTION_CHANGE_EVENT = "onGppSectionChange" + const val ON_LOGIN_CLICKED_EVENT = "onLoginClicked" + const val ON_SUBSCRIBE_CLICKED_EVENT = "onSubscribeClicked" } } diff --git a/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModuleSpec.kt b/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModuleSpec.kt index 6262d34..8aa52d7 100644 --- a/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModuleSpec.kt +++ b/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModuleSpec.kt @@ -33,6 +33,12 @@ abstract class RNUsercentricsModuleSpec internal constructor(context: ReactAppli @ReactMethod abstract fun clearUserSession(promise: Promise) + @ReactMethod + abstract fun notifyLoginSuccess(promise: Promise) + + @ReactMethod + abstract fun notifySubscribeSuccess(promise: Promise) + @ReactMethod abstract fun getConsents(promise: Promise) diff --git a/android/src/main/java/com/usercentrics/reactnative/api/UsercentricsProxy.kt b/android/src/main/java/com/usercentrics/reactnative/api/UsercentricsProxy.kt index fe0550f..1f21f54 100644 --- a/android/src/main/java/com/usercentrics/reactnative/api/UsercentricsProxy.kt +++ b/android/src/main/java/com/usercentrics/reactnative/api/UsercentricsProxy.kt @@ -14,7 +14,13 @@ interface UsercentricsProxy { fun initialize(context: Context, options: UsercentricsOptions) fun isReady(onSuccess: (UsercentricsReadyStatus) -> Unit, onFailure: (UsercentricsError) -> Unit) - fun showFirstLayer(activity: Activity, bannerSettings: BannerSettings?, promise: Promise) + fun showFirstLayer( + activity: Activity, + bannerSettings: BannerSettings?, + onLoginClicked: (String?) -> Unit, + onSubscribeClicked: (String?) -> Unit, + promise: Promise, + ) fun showSecondLayer(activity: Activity, bannerSettings: BannerSettings?, promise: Promise) } @@ -39,11 +45,15 @@ internal class UsercentricsProxyImpl : UsercentricsProxy { override fun showFirstLayer( activity: Activity, bannerSettings: BannerSettings?, + onLoginClicked: (String?) -> Unit, + onSubscribeClicked: (String?) -> Unit, promise: Promise, ) { - UsercentricsBanner(activity, bannerSettings).showFirstLayer { - promise.resolve(it?.toWritableMap()) - } + UsercentricsBanner(activity, bannerSettings).showFirstLayer( + callback = { promise.resolve(it?.toWritableMap()) }, + onLoginClicked = onLoginClicked, + onSubscribeClicked = onSubscribeClicked, + ) } override fun showSecondLayer( diff --git a/ios/Manager/UsercentricsManager.swift b/ios/Manager/UsercentricsManager.swift index d62a55a..b108a9c 100644 --- a/ios/Manager/UsercentricsManager.swift +++ b/ios/Manager/UsercentricsManager.swift @@ -9,8 +9,13 @@ public protocol UsercentricsManager { func restoreUserSession(controllerId: String, onSuccess: @escaping ((UsercentricsReadyStatus) -> Void), onFailure: @escaping ((Error) -> Void)) func showFirstLayer(bannerSettings: BannerSettings?, + onLoginClicked: @escaping (String?) -> Void, + onSubscribeClicked: @escaping (String?) -> Void, dismissViewHandler: @escaping (UsercentricsConsentUserResponse) -> Void) + func notifyLoginSuccess(onSuccess: @escaping (() -> Void), onError: @escaping ((Error) -> Void)) + func notifySubscribeSuccess(onSuccess: @escaping (() -> Void), onError: @escaping ((Error) -> Void)) + func showSecondLayer(bannerSettings: BannerSettings?, dismissViewHandler: @escaping (UsercentricsConsentUserResponse) -> Void) @@ -60,8 +65,20 @@ final class UsercentricsManagerImplementation: UsercentricsManager { } func showFirstLayer(bannerSettings: BannerSettings?, + onLoginClicked: @escaping (String?) -> Void, + onSubscribeClicked: @escaping (String?) -> Void, dismissViewHandler: @escaping (UsercentricsConsentUserResponse) -> Void) { - UsercentricsBanner(bannerSettings: bannerSettings).showFirstLayer(completionHandler: dismissViewHandler) + UsercentricsBanner(bannerSettings: bannerSettings).showFirstLayer(onLoginClicked: onLoginClicked, + onSubscribeClicked: onSubscribeClicked, + completionHandler: dismissViewHandler) + } + + func notifyLoginSuccess(onSuccess: @escaping (() -> Void), onError: @escaping ((Error) -> Void)) { + UsercentricsCore.shared.notifyLoginSuccess(onSuccess: onSuccess, onError: onError) + } + + func notifySubscribeSuccess(onSuccess: @escaping (() -> Void), onError: @escaping ((Error) -> Void)) { + UsercentricsCore.shared.notifySubscribeSuccess(onSuccess: onSuccess, onError: onError) } func showSecondLayer(bannerSettings: BannerSettings?, diff --git a/ios/RNUsercentricsModule.mm b/ios/RNUsercentricsModule.mm index 8ba4f4d..7487ec7 100644 --- a/ios/RNUsercentricsModule.mm +++ b/ios/RNUsercentricsModule.mm @@ -108,4 +108,10 @@ @interface RCT_EXTERN_MODULE(RNUsercentricsModule, NSObject) RCT_EXTERN_METHOD(clearUserSession:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(notifyLoginSuccess:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(notifySubscribeSuccess:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) @end diff --git a/ios/RNUsercentricsModule.swift b/ios/RNUsercentricsModule.swift index 21f7036..d4783f3 100644 --- a/ios/RNUsercentricsModule.swift +++ b/ios/RNUsercentricsModule.swift @@ -32,7 +32,7 @@ class RNUsercentricsModule: RCTEventEmitter { } override func supportedEvents() -> [String]! { - return [Self.onGppSectionChangeEvent] + return [Self.onGppSectionChangeEvent, Self.onLoginClickedEvent, Self.onSubscribeClickedEvent] } override func startObserving() { @@ -79,7 +79,11 @@ class RNUsercentricsModule: RCTEventEmitter { return } - self.usercentricsManager.showFirstLayer(bannerSettings: BannerSettings(from: dict)) { response in + self.usercentricsManager.showFirstLayer(bannerSettings: BannerSettings(from: dict), onLoginClicked: { [weak self] url in + self?.sendEvent(withName: Self.onLoginClickedEvent, body: url) + }, onSubscribeClicked: { [weak self] url in + self?.sendEvent(withName: Self.onSubscribeClickedEvent, body: url) + }) { response in resolve(response.toDictionary()) } } @@ -270,7 +274,25 @@ class RNUsercentricsModule: RCTEventEmitter { } } + @objc func notifyLoginSuccess(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { + usercentricsManager.notifyLoginSuccess { + resolve(nil) + } onError: { error in + reject("usercentrics_reactNative_notifyLoginSuccess_error", error.localizedDescription, error) + } + } + + @objc func notifySubscribeSuccess(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { + usercentricsManager.notifySubscribeSuccess { + resolve(nil) + } onError: { error in + reject("usercentrics_reactNative_notifySubscribeSuccess_error", error.localizedDescription, error) + } + } + private static let onGppSectionChangeEvent = "onGppSectionChange" + private static let onLoginClickedEvent = "onLoginClicked" + private static let onSubscribeClickedEvent = "onSubscribeClicked" } // MARK: - RCTBridgeModule & TurboModule Conformance diff --git a/ios/RNUsercentricsModuleSpec.h b/ios/RNUsercentricsModuleSpec.h index b3cde89..47f99e4 100644 --- a/ios/RNUsercentricsModuleSpec.h +++ b/ios/RNUsercentricsModuleSpec.h @@ -29,6 +29,13 @@ NS_ASSUME_NONNULL_BEGIN - (void)clearUserSession:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; +// Consent or Pay +- (void)notifyLoginSuccess:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)notifySubscribeSuccess:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + // Data Retrieval - (void)getConsents:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; diff --git a/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift b/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift index 2f1841f..6db7021 100644 --- a/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift +++ b/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift @@ -216,10 +216,20 @@ final class FakeUsercentricsManager: UsercentricsManager { } var showFirstLayerBannerSettings: BannerSettings? + var loginClickedUrl: String? + var subscribeClickedUrl: String? func showFirstLayer(bannerSettings: BannerSettings?, + onLoginClicked: @escaping (String?) -> Void, + onSubscribeClicked: @escaping (String?) -> Void, dismissViewHandler: @escaping (UsercentricsConsentUserResponse) -> Void) { self.showFirstLayerBannerSettings = bannerSettings + if let loginClickedUrl = loginClickedUrl { + onLoginClicked(loginClickedUrl) + } + if let subscribeClickedUrl = subscribeClickedUrl { + onSubscribeClicked(subscribeClickedUrl) + } dismissViewHandler(UsercentricsConsentUserResponse(consents: [], controllerId: "", userInteraction: .acceptAll)) } @@ -244,4 +254,22 @@ final class FakeUsercentricsManager: UsercentricsManager { onError(clearUserSessionError) } } + + var notifyLoginSuccessError: Error? + func notifyLoginSuccess(onSuccess: @escaping (() -> Void), onError: @escaping ((Error) -> Void)) { + if let notifyLoginSuccessError = notifyLoginSuccessError { + onError(notifyLoginSuccessError) + return + } + onSuccess() + } + + var notifySubscribeSuccessError: Error? + func notifySubscribeSuccess(onSuccess: @escaping (() -> Void), onError: @escaping ((Error) -> Void)) { + if let notifySubscribeSuccessError = notifySubscribeSuccessError { + onError(notifySubscribeSuccessError) + return + } + onSuccess() + } } diff --git a/sample/metro.config.js b/sample/metro.config.js index 1a03436..284eada 100644 --- a/sample/metro.config.js +++ b/sample/metro.config.js @@ -8,6 +8,24 @@ config.resolver.extraNodeModules = { "@usercentrics/react-native-sdk": path.resolve(__dirname, "../"), }; +// Force a single react-native copy — the SDK's own node_modules/react-native (0.79.7) is a +// separate install from the sample's (0.81.4), which was creating two disconnected +// RCTDeviceEventEmitter singletons: native events emitted through the SDK's copy never +// reached listeners registered through the sample app's copy. extraNodeModules alone doesn't +// work here since it's only a fallback consulted when normal resolution fails — react-native +// resolves fine in both locations, so we need to intercept resolution directly. +const sampleReactNative = path.resolve(__dirname, "node_modules/react-native"); +config.resolver.resolveRequest = (context, moduleName, platform) => { + if (moduleName === "react-native" || moduleName.startsWith("react-native/")) { + return context.resolveRequest( + context, + path.join(sampleReactNative, moduleName.slice("react-native".length)), + platform + ); + } + return context.resolveRequest(context, moduleName, platform); +}; + // Tell Metro where to resolve modules from — needed so that files inside // the SDK's node_modules can resolve their own transitive dependencies. config.resolver.nodeModulesPaths = [ diff --git a/sample/src/screens/Home.tsx b/sample/src/screens/Home.tsx index 171f858..01ce7b9 100644 --- a/sample/src/screens/Home.tsx +++ b/sample/src/screens/Home.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Button, StyleSheet, View } from 'react-native'; +import { Alert, Button, StyleSheet, View } from 'react-native'; import { BannerSettings, Usercentrics, @@ -42,6 +42,37 @@ export const HomeScreen = ({ navigation }: { navigation: any }) => { .catch(e => console.error('[Usercentrics] status failed:', e)); }, [showFirstLayer]); + React.useEffect(() => { + const loginSubscription = Usercentrics.onLoginClicked(async (url) => { + console.log('[Usercentrics] onLoginClicked:', url); + Alert.alert('onLoginClicked', `url: ${url}`); + try { + await Usercentrics.notifyLoginSuccess(); + console.log('[Usercentrics] notifyLoginSuccess done'); + Alert.alert('notifyLoginSuccess', 'TCF storage cleared'); + } catch (e) { + console.error('[Usercentrics] notifyLoginSuccess failed:', e); + Alert.alert('notifyLoginSuccess failed', String(e)); + } + }); + const subscribeSubscription = Usercentrics.onSubscribeClicked(async (url) => { + console.log('[Usercentrics] onSubscribeClicked:', url); + Alert.alert('onSubscribeClicked', `url: ${url}`); + try { + await Usercentrics.notifySubscribeSuccess(); + console.log('[Usercentrics] notifySubscribeSuccess done'); + Alert.alert('notifySubscribeSuccess', 'TCF storage cleared'); + } catch (e) { + console.error('[Usercentrics] notifySubscribeSuccess failed:', e); + Alert.alert('notifySubscribeSuccess failed', String(e)); + } + }); + return () => { + loginSubscription.remove(); + subscribeSubscription.remove(); + }; + }, []); + async function showSecondLayer() { try { const response = await Usercentrics.showSecondLayer({ diff --git a/src/NativeUsercentrics.ts b/src/NativeUsercentrics.ts index 171ba66..6509d18 100644 --- a/src/NativeUsercentrics.ts +++ b/src/NativeUsercentrics.ts @@ -30,6 +30,10 @@ export interface Spec extends TurboModule { getControllerId(): Promise; clearUserSession(): Promise; + // Consent or Pay + notifyLoginSuccess(): Promise; + notifySubscribeSuccess(): Promise; + // Data Retrieval getConsents(): Promise>; getCMPData(): Promise; diff --git a/src/Usercentrics.tsx b/src/Usercentrics.tsx index 082b6a5..cace532 100644 --- a/src/Usercentrics.tsx +++ b/src/Usercentrics.tsx @@ -171,4 +171,30 @@ export const Usercentrics = { onGppSectionChange: (callback: (payload: GppSectionChangePayload) => void): EmitterSubscription => { return eventEmitter.addListener("onGppSectionChange", callback); }, + + // Fires when the user taps the Consent-or-Pay 1st-layer subscriber-login link. The banner is not + // dismissed automatically — call notifyLoginSuccess once the host app confirms login, then dismiss + // the banner yourself. + onLoginClicked: (callback: (url: string | null) => void): EmitterSubscription => { + return eventEmitter.addListener("onLoginClicked", callback); + }, + + // Fires when the user taps the Consent-or-Pay 1st-layer Reject & Subscribe button. The banner is not + // dismissed automatically — call notifySubscribeSuccess once the host app confirms the subscription, + // then dismiss the banner yourself. + onSubscribeClicked: (callback: (url: string | null) => void): EmitterSubscription => { + return eventEmitter.addListener("onSubscribeClicked", callback); + }, + + // Clears stored TCF consent data after a successful Consent-or-Pay login. + notifyLoginSuccess: async (): Promise => { + await RNUsercentricsModule.isReady(); + return RNUsercentricsModule.notifyLoginSuccess(); + }, + + // Clears stored TCF consent data after a successful Consent-or-Pay subscription. + notifySubscribeSuccess: async (): Promise => { + await RNUsercentricsModule.isReady(); + return RNUsercentricsModule.notifySubscribeSuccess(); + }, } diff --git a/src/fabric/NativeUsercentricsModule.ts b/src/fabric/NativeUsercentricsModule.ts index 52aa87a..d542234 100644 --- a/src/fabric/NativeUsercentricsModule.ts +++ b/src/fabric/NativeUsercentricsModule.ts @@ -15,6 +15,10 @@ export interface Spec extends TurboModule { getControllerId(): Promise; clearUserSession(): Promise; + // Consent or Pay + notifyLoginSuccess(): Promise; + notifySubscribeSuccess(): Promise; + // Data Retrieval getConsents(): Promise>; getCMPData(): Promise;