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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions __tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1502,4 +1502,40 @@ 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 });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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

//================================================================================
Expand Down
11 changes: 10 additions & 1 deletion apitesters/purchases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions examples/purchaseTesterTypescript/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -178,6 +179,11 @@ const App = () => {
component={PurchaseLogicPaywallScreen}
options={{ title: 'PurchaseLogic Paywall' }}
/>
<Stack.Screen
name="CustomPaywall"
component={CustomPaywallScreen}
options={{ title: 'Custom Paywall' }}
/>
</Stack.Navigator>
</NavigationContainer>
</CustomVariablesProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type RootStackParamList = {
CustomerCenterModalWithHeader: { shouldShowCloseButton?: boolean };
CustomVariables: undefined;
PurchaseLogicPaywall: { offering: PurchasesOffering | null };
CustomPaywall: undefined;
};

export default RootStackParamList;
Original file line number Diff line number Diff line change
@@ -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<RootStackParamList, 'CustomPaywall'>;

const CustomPaywallScreen: React.FC<Props> = ({navigation}) => {
const [status, setStatus] = useState<string | null>(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 (with id) succeeded');
console.log('[CustomPaywall] Tracked custom paywall impression (with id)');
} catch (e) {
setStatus(`Error: ${e}`);
}
};

return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Custom Paywall Impression</Text>
<Text style={styles.description}>
Use this screen to test tracking custom paywall impressions.
</Text>

<TouchableOpacity
style={styles.button}
onPress={trackImpressionNoId}>
<Text style={styles.buttonText}>
Track custom paywall impression (no id)
</Text>
</TouchableOpacity>

<TouchableOpacity
style={styles.button}
onPress={trackImpressionWithId}>
<Text style={styles.buttonText}>
Track custom paywall impression (with id)
</Text>
</TouchableOpacity>

{status && (
<View
style={[
styles.statusContainer,
status.startsWith('Error')
? styles.errorContainer
: styles.successContainer,
]}>
<Text
style={
status.startsWith('Error')
? styles.errorText
: styles.successText
}>
{status}
</Text>
</View>
)}

<TouchableOpacity
style={styles.closeButton}
onPress={() => navigation.goBack()}>
<Text style={styles.closeButtonText}>Close</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
};

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;
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,10 @@ const HomeScreen: React.FC<Props> = ({navigation}) => {

<Divider />
<View>
<TouchableOpacity
onPress={() => navigation.navigate('CustomPaywall')}>
<Text style={styles.otherActions}>Custom Paywall Screen (track impression)</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => navigation.navigate('WinBackTesting', {})}>
<Text style={styles.otherActions}>Win-Back Offer Testing</Text>
Expand Down
8 changes: 8 additions & 0 deletions ios/RNPurchases.m
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Comment on lines +631 to +635
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember we added isCustomPaywallTrackingAPIAvailable checker in PHC here

But it's true that the trackCustomPaywallImpression still requires iOS 15, etc.

I wonder how we could use the checker. At a minimum, we should probably log something if the method is not available

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good pint, added the logging here as well :)

I don't think using isCustomPaywallTrackingAPIAvailable adds much value here since we have to do this @available check anyways. But please LMK if you disagree :)

}

#pragma mark -
#pragma mark Delegate Methods
- (void)purchases:(RCPurchases *)purchases receivedUpdatedCustomerInfo:(RCCustomerInfo *)customerInfo {
Expand Down
3 changes: 2 additions & 1 deletion scripts/setupJest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
23 changes: 23 additions & 0 deletions src/purchases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -1831,6 +1838,22 @@ 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.
*
* @experimental This API is experimental and may change in future releases.
* @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<void> {
await Purchases.throwIfNotConfigured();
RNPurchases.trackCustomPaywallImpression(params ?? {});
}

private static async throwIfNotConfigured() {
const isConfigured = await Purchases.isConfigured();
if (!isConfigured) {
Expand Down