diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 2d2905ee..f8c57cb7 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -1502,4 +1502,41 @@ describe("Purchases", () => { }); }); }); + + describe("trackCustomPaywallImpression", () => { + describe("when Purchases is not configured", () => { + it("it rejects", async () => { + NativeModules.RNPurchases.isConfigured.mockResolvedValueOnce(false); + + try { + await Purchases.trackCustomPaywallImpression(); + fail("expected error"); + } catch (error) { } + + expect(NativeModules.RNPurchases.trackCustomPaywallImpression).toBeCalledTimes(0); + }); + }); + + it("makes right call with no params", async () => { + await Purchases.trackCustomPaywallImpression(); + + expect(NativeModules.RNPurchases.trackCustomPaywallImpression).toBeCalledTimes(1); + expect(NativeModules.RNPurchases.trackCustomPaywallImpression).toBeCalledWith({}); + }); + + it("makes right call with paywallId", async () => { + await Purchases.trackCustomPaywallImpression({ paywallId: "my_paywall" }); + + expect(NativeModules.RNPurchases.trackCustomPaywallImpression).toBeCalledTimes(1); + expect(NativeModules.RNPurchases.trackCustomPaywallImpression).toBeCalledWith({ paywallId: "my_paywall" }); + }); + + it("makes right call with null paywallId", async () => { + await Purchases.trackCustomPaywallImpression({ paywallId: null }); + + expect(NativeModules.RNPurchases.trackCustomPaywallImpression).toBeCalledTimes(1); + expect(NativeModules.RNPurchases.trackCustomPaywallImpression).toBeCalledWith({ paywallId: null }); + }); + + }); }); diff --git a/android/src/main/java/com/revenuecat/purchases/react/RNPurchasesModule.java b/android/src/main/java/com/revenuecat/purchases/react/RNPurchasesModule.java index abc888fc..fd837315 100644 --- a/android/src/main/java/com/revenuecat/purchases/react/RNPurchasesModule.java +++ b/android/src/main/java/com/revenuecat/purchases/react/RNPurchasesModule.java @@ -609,6 +609,11 @@ public void redeemWebPurchase(String urlString, final Promise promise) { CommonKt.redeemWebPurchase(urlString, getOnResult(promise)); } + @ReactMethod + public void trackCustomPaywallImpression(ReadableMap data) { + CommonKt.trackCustomPaywallImpression(data.toHashMap()); + } + // endregion //================================================================================ diff --git a/apitesters/purchases.ts b/apitesters/purchases.ts index d2298741..df25725c 100644 --- a/apitesters/purchases.ts +++ b/apitesters/purchases.ts @@ -35,7 +35,7 @@ import { } from "../dist"; import Purchases from "../dist/purchases"; -import { GoogleProductChangeInfo, SubscriptionOption } from "../src"; +import { GoogleProductChangeInfo, SubscriptionOption, TrackCustomPaywallImpressionOptions } from "../src"; async function checkPurchases(purchases: Purchases) { const productIds: string[] = []; @@ -455,6 +455,15 @@ async function checkOverridePreferredLocale() { await Purchases.overridePreferredLocale(preferredUILocaleOverride); } +async function checkTrackCustomPaywallImpression() { + await Purchases.trackCustomPaywallImpression(); + await Purchases.trackCustomPaywallImpression({}); + await Purchases.trackCustomPaywallImpression({ paywallId: "my_paywall" }); + await Purchases.trackCustomPaywallImpression({ paywallId: null }); + const options: TrackCustomPaywallImpressionOptions = { paywallId: "test" }; + await Purchases.trackCustomPaywallImpression(options); +} + async function checkSyncPurchasesResult() { const syncPurchasesResult: SyncPurchasesResult = await Purchases.syncPurchasesForResult(); const customerInfo: CustomerInfo = syncPurchasesResult.customerInfo; diff --git a/examples/purchaseTesterTypescript/App.tsx b/examples/purchaseTesterTypescript/App.tsx index bc403099..3fe9aabb 100644 --- a/examples/purchaseTesterTypescript/App.tsx +++ b/examples/purchaseTesterTypescript/App.tsx @@ -27,6 +27,7 @@ import VirtualCurrencyScreen from "./app/screens/VirtualCurrencyScreen"; import CustomVariablesScreen from "./app/screens/CustomVariablesScreen"; import { CustomVariablesProvider } from "./app/context/CustomVariablesContext"; import PurchaseLogicPaywallScreen from "./app/screens/PurchaseLogicPaywallScreen"; +import CustomPaywallScreen from "./app/screens/CustomPaywallScreen"; import APIKeys from './app/APIKeys'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -178,6 +179,11 @@ const App = () => { component={PurchaseLogicPaywallScreen} options={{ title: 'PurchaseLogic Paywall' }} /> + diff --git a/examples/purchaseTesterTypescript/app/RootStackParamList.tsx b/examples/purchaseTesterTypescript/app/RootStackParamList.tsx index 3ef3ac95..144da3fe 100644 --- a/examples/purchaseTesterTypescript/app/RootStackParamList.tsx +++ b/examples/purchaseTesterTypescript/app/RootStackParamList.tsx @@ -24,6 +24,7 @@ type RootStackParamList = { CustomerCenterModalWithHeader: { shouldShowCloseButton?: boolean }; CustomVariables: undefined; PurchaseLogicPaywall: { offering: PurchasesOffering | null }; + CustomPaywall: undefined; }; export default RootStackParamList; diff --git a/examples/purchaseTesterTypescript/app/screens/CustomPaywallScreen.tsx b/examples/purchaseTesterTypescript/app/screens/CustomPaywallScreen.tsx new file mode 100644 index 00000000..ec82cf81 --- /dev/null +++ b/examples/purchaseTesterTypescript/app/screens/CustomPaywallScreen.tsx @@ -0,0 +1,165 @@ +import React, {useState} from 'react'; + +import { + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +import Purchases from 'react-native-purchases'; +import {NativeStackScreenProps} from '@react-navigation/native-stack'; +import RootStackParamList from '../RootStackParamList'; + +type Props = NativeStackScreenProps; + +const CustomPaywallScreen: React.FC = ({navigation}) => { + const [status, setStatus] = useState(null); + + const trackImpressionNoId = async () => { + try { + await Purchases.trackCustomPaywallImpression(); + setStatus('trackCustomPaywallImpression (no id) succeeded'); + console.log('[CustomPaywall] Tracked custom paywall impression (no id)'); + } catch (e) { + setStatus(`Error: ${e}`); + } + }; + + const trackImpressionWithId = async () => { + try { + await Purchases.trackCustomPaywallImpression({ + paywallId: 'my-test-paywall', + }); + setStatus('trackCustomPaywallImpression (paywallId) succeeded'); + console.log('[CustomPaywall] Tracked custom paywall impression (paywallId)'); + } catch (e) { + setStatus(`Error: ${e}`); + } + }; + + return ( + + + Custom Paywall Impression + + Use this screen to test tracking custom paywall impressions. + + + + + Track impression (no params) + + + + + + Track impression (paywallId only) + + + + {status && ( + + + {status} + + + )} + + navigation.goBack()}> + Close + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f8f9fa', + }, + content: { + flex: 1, + padding: 24, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 28, + fontWeight: '700', + marginBottom: 8, + color: '#212529', + }, + description: { + fontSize: 14, + color: '#868e96', + marginBottom: 24, + textAlign: 'center', + }, + button: { + width: '100%', + padding: 16, + backgroundColor: '#1971c2', + borderRadius: 12, + alignItems: 'center', + marginBottom: 12, + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + color: '#ffffff', + }, + statusContainer: { + width: '100%', + padding: 16, + borderRadius: 8, + marginTop: 12, + marginBottom: 12, + }, + successContainer: { + backgroundColor: '#d3f9d8', + borderColor: '#69db7c', + borderWidth: 1, + }, + errorContainer: { + backgroundColor: '#ffe3e3', + borderColor: '#ff8787', + borderWidth: 1, + }, + successText: { + color: '#2b8a3e', + fontSize: 14, + }, + errorText: { + color: '#c92a2a', + fontSize: 14, + }, + closeButton: { + padding: 12, + }, + closeButtonText: { + fontSize: 16, + color: '#868e96', + }, +}); + +export default CustomPaywallScreen; diff --git a/examples/purchaseTesterTypescript/app/screens/HomeScreen.tsx b/examples/purchaseTesterTypescript/app/screens/HomeScreen.tsx index d95d1fe0..1f5b3b7f 100644 --- a/examples/purchaseTesterTypescript/app/screens/HomeScreen.tsx +++ b/examples/purchaseTesterTypescript/app/screens/HomeScreen.tsx @@ -570,6 +570,10 @@ const HomeScreen: React.FC = ({navigation}) => { + navigation.navigate('CustomPaywall')}> + Custom Paywall Screen (track impression) + navigation.navigate('WinBackTesting', {})}> Win-Back Offer Testing diff --git a/ios/RNPurchases.m b/ios/RNPurchases.m index 12b05e47..494c18f4 100644 --- a/ios/RNPurchases.m +++ b/ios/RNPurchases.m @@ -627,6 +627,14 @@ static void logUnavailablePresentCodeRedemptionSheet() { } } +RCT_EXPORT_METHOD(trackCustomPaywallImpression:(NSDictionary *)data) { + if (@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)) { + [RCCommonFunctionality trackCustomPaywallImpression:data]; + } else { + NSLog(@"[Purchases] Warning: tried to call trackCustomPaywallImpression, but it's only available on iOS 15.0 or greater."); + } +} + #pragma mark - #pragma mark Delegate Methods - (void)purchases:(RCPurchases *)purchases receivedUpdatedCustomerInfo:(RCCustomerInfo *)customerInfo { diff --git a/scripts/setupJest.js b/scripts/setupJest.js index 3c2e7122..3978d3f4 100644 --- a/scripts/setupJest.js +++ b/scripts/setupJest.js @@ -809,7 +809,8 @@ NativeModules.RNPurchases = { redeemWebPurchase: jest.fn(), getVirtualCurrencies: jest.fn(), invalidateVirtualCurrenciesCache: jest.fn(), - getCachedVirtualCurrencies: jest.fn() + getCachedVirtualCurrencies: jest.fn(), + trackCustomPaywallImpression: jest.fn() }; jest.mock( diff --git a/src/purchases.ts b/src/purchases.ts index ede1d38a..93d6eae1 100644 --- a/src/purchases.ts +++ b/src/purchases.ts @@ -141,6 +141,13 @@ export interface DebugEvent { */ export type DebugEventListener = (event: DebugEvent) => void; +/** + * Options for tracking a custom paywall impression. + */ +export interface TrackCustomPaywallImpressionOptions { + paywallId?: string | null; +} + let debugEventListeners: DebugEventListener[] = []; eventEmitter?.addListener( @@ -1831,6 +1838,21 @@ export default class Purchases { return RNPurchases.isConfigured(); } + /** + * Tracks an impression of a custom (non-RevenueCat) paywall. + * Call this method when your custom paywall is displayed to a user. + * This enables RevenueCat to track paywall impressions for analytics. + * + * @param params - Optional parameters for the impression event. + * @param params.paywallId - Optional identifier for the custom paywall being shown. + */ + public static async trackCustomPaywallImpression( + params?: TrackCustomPaywallImpressionOptions + ): Promise { + await Purchases.throwIfNotConfigured(); + RNPurchases.trackCustomPaywallImpression(params ?? {}); + } + private static async throwIfNotConfigured() { const isConfigured = await Purchases.isConfigured(); if (!isConfigured) {