diff --git a/examples/adsTester/App.tsx b/examples/adsTester/App.tsx index f6b5ba523..6144d1e6c 100644 --- a/examples/adsTester/App.tsx +++ b/examples/adsTester/App.tsx @@ -1,7 +1,8 @@ -import 'react-native-get-random-values'; import React, {useCallback, useEffect, useState} from 'react'; import { + Platform, SafeAreaView, + ScrollView, StyleSheet, Text, TouchableOpacity, @@ -13,19 +14,31 @@ import Purchases, { } from 'react-native-purchases'; import mobileAds, { AdEventType, + BannerAd, + BannerAdSize, RewardedAdEventType, - RewardedInterstitialAd, TestIds, } from 'react-native-google-mobile-ads'; -import {v4 as uuidv4} from 'uuid'; +import { + RevenueCatAdMob, + TrackableAd, + useTrackingBannerAdProps, +} from 'react-native-purchases-admob'; -// Your RevenueCat public SDK key (a Test Store key works while developing). +/** + * Configuration. Everything ships with placeholder / Google test values that + * are safe to commit. For a real reward grant, set your RevenueCat key, an + * AdMob unit whose server-side verification URL points at RevenueCat, and your + * AdMob app id (`Info.plist` + `app.json`). + */ const API_KEY = 'YOUR_REVENUECAT_API_KEY'; - -// Google's official test rewarded-interstitial ad unit (per-platform) — safe -// to commit and always fills. Swap for your own AdMob unit (with its -// server-side verification URL pointed at RevenueCat) to grant a real reward. -const AD_UNIT_ID = TestIds.REWARDED_INTERSTITIAL; +const AD_UNITS = { + interstitial: TestIds.INTERSTITIAL, + rewarded: TestIds.REWARDED, + rewardedInterstitial: TestIds.REWARDED_INTERSTITIAL, + appOpen: TestIds.APP_OPEN, + banner: TestIds.BANNER, +}; function describeReward(reward: VerifiedReward): string { switch (reward.type) { @@ -40,127 +53,191 @@ function describeReward(reward: VerifiedReward): string { } } -export default function App() { - const [status, setStatus] = useState('Configuring…'); - const [impressionId, setImpressionId] = useState(null); - const [result, setResult] = useState(null); - const [ready, setReady] = useState(false); +function describeResult(result: RewardVerificationResult): string { + if (result.failed || !result.reward) { + return '❌ verification failed'; + } + const extra = + result.moreRewards.length > 0 ? ` (+${result.moreRewards.length} more)` : ''; + return `✅ ${describeReward(result.reward)}${extra}`; +} - useEffect(() => { - (async () => { - Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG); - Purchases.configure({apiKey: API_KEY}); - await mobileAds().initialize(); - setStatus('Ready. Tap to load a rewarded ad.'); - setReady(true); - })(); - }, []); +type FullScreenAd = TrackableAd; - const loadAndShow = useCallback(async () => { - setReady(false); - setResult(null); - setStatus('Generating verification token…'); +interface FormatProps { + title: string; + create: () => Promise | FullScreenAd; + show: (ad: FullScreenAd, onResult: (text: string) => void) => Promise; +} - // react-native-google-mobile-ads doesn't expose AdMob's response id before - // the ad loads (SSV options must be set at request time), so assign your own - // unique impression ID. Reuse it for your RevenueCat ad-tracking calls to - // correlate the reward with the impression. - const id = uuidv4(); - setImpressionId(id); +/** One load/show card per full-screen format. */ +function FormatCard({title, create, show}: FormatProps) { + const [ad, setAd] = useState(null); + const [status, setStatus] = useState('No ad loaded'); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); - let token; + const load = useCallback(async () => { + setBusy(true); + setResult(null); + setStatus('Loading…'); + let created: FullScreenAd; try { - token = await Purchases.generateRewardVerificationToken(id); + created = await create(); } catch (e) { - setStatus(`❌ Failed to generate verification token: ${e}`); - setReady(true); + setBusy(false); + setStatus(`Error: ${(e as Error).message}`); return; } - - // Forward the token to AdMob's server-side verification options. - const ad = RewardedInterstitialAd.createForAdRequest(AD_UNIT_ID, { - serverSideVerificationOptions: { - userId: token.appUserID, - customData: token.customData, - }, + created.addAdEventsListener(({type, payload}) => { + switch (type) { + case AdEventType.LOADED: + case RewardedAdEventType.LOADED: + setAd(created); + setBusy(false); + setStatus(`Ad ready (${created.responseInfo?.responseId ?? '?'})`); + break; + case AdEventType.IMPRESSION: + setStatus('Impression recorded'); + break; + case AdEventType.CLOSED: + setAd(null); + setBusy(false); + setStatus('Dismissed'); + break; + case AdEventType.ERROR: + setAd(null); + setBusy(false); + setStatus(`Error: ${(payload as Error).message}`); + break; + } }); + created.load(); + }, [create]); - const unsubLoaded = ad.addAdEventListener( - RewardedAdEventType.LOADED, - () => { - setStatus('Ad loaded. Showing…'); - ad.show(); - }, - ); - - let finished = false; - - const cleanup = () => { - unsubLoaded(); - unsubEarned(); - unsubError(); - unsubClosed(); - }; - - // When the user earns the reward, poll RevenueCat for the verified result. - const unsubEarned = ad.addAdEventListener( - RewardedAdEventType.EARNED_REWARD, - async () => { - setStatus('Reward earned. Verifying…'); - try { - const res: RewardVerificationResult = - await Purchases.pollRewardVerification(token.clientTransactionId); - if (res.failed || !res.reward) { - setResult('❌ verification failed'); - } else { - const extra = - res.moreRewards.length > 0 - ? ` (+${res.moreRewards.length} more)` - : ''; - setResult(`✅ ${describeReward(res.reward)}${extra}`); - } + const onPress = useCallback(async () => { + if (ad) { + setBusy(true); + try { + await show(ad, text => { setStatus('Done'); - } finally { - finished = true; - setReady(true); - } - }, - ); + setResult(text); + }); + } catch (e) { + // show() rejects without an ERROR event when the ad is not loaded. + setAd(null); + setBusy(false); + setStatus(`Error: ${(e as Error).message}`); + } + } else { + await load(); + } + }, [ad, load, show]); - const unsubError = ad.addAdEventListener(AdEventType.ERROR, error => { - setStatus(`❌ Ad error: ${error.message}`); - finished = true; - setReady(true); - cleanup(); - }); + return ( + + {title} + {status} + {result != null && {result}} + + {ad ? `Show ${title}` : `Load ${title}`} + + + ); +} - const unsubClosed = ad.addAdEventListener(AdEventType.CLOSED, () => { - if (!finished) { - finished = true; - setReady(true); - } - cleanup(); - }); +const showPlain = (ad: FullScreenAd) => + RevenueCatAdMob.showAndTrack(ad, {placement: 'shown_from_card'}); + +const showVerified = ( + ad: FullScreenAd, + onResult: (text: string) => void, +) => + RevenueCatAdMob.showWithRewardVerification(ad, { + placement: 'shown_from_card', + onRewardVerificationStarted: () => onResult('Verifying reward…'), + onRewardVerificationCompleted: result => onResult(describeResult(result)), + }); - setStatus('Loading ad…'); - ad.load(); +export default function App() { + const [configured, setConfigured] = useState(false); + const [bannerStatus, setBannerStatus] = useState('Banner not loaded'); + + useEffect(() => { + (async () => { + Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG); + Purchases.configure({apiKey: API_KEY}); + await mobileAds().initialize(); + setConfigured(true); + })(); }, []); + const bannerProps = useTrackingBannerAdProps({ + unitId: AD_UNITS.banner, + size: BannerAdSize.BANNER, + placement: 'banner_bottom', + onAdLoaded: ({responseInfo}) => + setBannerStatus(`Banner loaded (${responseInfo?.responseId ?? '?'})`), + onPaid: paid => setBannerStatus(`Banner paid: ${paid.value} ${paid.currency}`), + }); + + if (!configured) { + return ( + + Configuring… + + ); + } + return ( - - Rewarded Ad Verification - {status} - {impressionId != null && ( - impressionId: {impressionId} - )} - {result != null && {result}} - - Load & show rewarded ad - + + RevenueCat AdMob adapter ({Platform.OS}) + + RevenueCatAdMob.createInterstitialAd(AD_UNITS.interstitial, { + placement: 'interstitial_card', + }) + } + show={showPlain} + /> + + RevenueCatAdMob.createRewardedAd(AD_UNITS.rewarded, { + placement: 'rewarded_card', + enableRewardVerification: true, + }) + } + show={showVerified} + /> + + RevenueCatAdMob.createRewardedInterstitialAd( + AD_UNITS.rewardedInterstitial, + {placement: 'rewarded_interstitial_card', enableRewardVerification: true}, + ) + } + show={showVerified} + /> + + RevenueCatAdMob.createAppOpenAd(AD_UNITS.appOpen, { + placement: 'app_open_card', + }) + } + show={showPlain} + /> + {bannerStatus} + + + ); @@ -168,17 +245,25 @@ export default function App() { const styles = StyleSheet.create({ container: {flex: 1, backgroundColor: '#fff'}, - content: {flex: 1, justifyContent: 'center', padding: 24, gap: 16}, - title: {fontSize: 22, fontWeight: '600', textAlign: 'center'}, - status: {fontSize: 16, textAlign: 'center', color: '#333'}, - impression: {fontSize: 13, textAlign: 'center', color: '#888'}, - result: {fontSize: 18, textAlign: 'center', fontWeight: '600'}, + content: {padding: 16, gap: 12}, + header: {fontSize: 20, fontWeight: '600', textAlign: 'center', marginBottom: 8}, + card: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 12, + padding: 12, + gap: 8, + }, + title: {fontSize: 16, fontWeight: '600'}, + status: {fontSize: 14, color: '#333', textAlign: 'center'}, + result: {fontSize: 16, fontWeight: '600', textAlign: 'center'}, button: { backgroundColor: '#f2545b', - paddingVertical: 16, - borderRadius: 12, + paddingVertical: 12, + borderRadius: 10, alignItems: 'center', }, buttonDisabled: {opacity: 0.4}, - buttonText: {color: '#fff', fontSize: 16, fontWeight: '600'}, + buttonText: {color: '#fff', fontSize: 15, fontWeight: '600'}, + banner: {alignItems: 'center', paddingVertical: 8}, }); diff --git a/examples/adsTester/babel.config.js b/examples/adsTester/babel.config.js index 237f8ba3d..5a0dd4b2d 100644 --- a/examples/adsTester/babel.config.js +++ b/examples/adsTester/babel.config.js @@ -14,6 +14,10 @@ module.exports = { extensions: ['.tsx', '.ts', '.js', '.json'], alias: { [pak.name]: path.join(__dirname, '../..', pak.source), + 'react-native-purchases-admob': path.join( + __dirname, + '../../react-native-purchases-admob/src/index', + ), }, }, ], diff --git a/examples/adsTester/metro.config.js b/examples/adsTester/metro.config.js index cdc5eaec5..98c319252 100644 --- a/examples/adsTester/metro.config.js +++ b/examples/adsTester/metro.config.js @@ -6,9 +6,13 @@ const pak = require('../../package.json'); const root = path.resolve(__dirname, '../..'); -const modules = Object.keys({ - ...pak.peerDependencies, -}); +// react-native-purchases-admob is aliased to its source (babel.config.js) and +// lists react-native-google-mobile-ads as a dev dependency, so force every +// import of it onto this app's single copy (the one with the native module). +const modules = [ + ...Object.keys({...pak.peerDependencies}), + 'react-native-google-mobile-ads', +]; const config = { watchFolders: [root], @@ -19,6 +23,11 @@ const config = { m => new RegExp(`^${escape(path.join(root, 'node_modules', m))}\\/.*$`), ), + // The adapter workspace keeps its own dev dependencies (react-native, + // react-native-google-mobile-ads, ...); never let Metro pick those up. + new RegExp( + `^${escape(path.join(root, 'react-native-purchases-admob', 'node_modules'))}\\/.*$`, + ), ]), extraNodeModules: modules.reduce((acc, name) => { diff --git a/examples/adsTester/package.json b/examples/adsTester/package.json index c3c25ab7d..ddd77fd22 100644 --- a/examples/adsTester/package.json +++ b/examples/adsTester/package.json @@ -13,9 +13,7 @@ "dependencies": { "react": "19.2.6", "react-native": "0.86.0", - "react-native-get-random-values": "^2.0.0", - "react-native-google-mobile-ads": "^15.0.0", - "uuid": "^14.0.2" + "react-native-google-mobile-ads": "^17.0.0" }, "devDependencies": { "@babel/core": "^7.28.6", diff --git a/examples/adsTester/tsconfig.json b/examples/adsTester/tsconfig.json index 420f6664d..6415c7f92 100644 --- a/examples/adsTester/tsconfig.json +++ b/examples/adsTester/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "react-native-purchases": ["../../src/index"] + "react-native-purchases": ["../../src/index"], + "react-native-purchases-admob": ["../../react-native-purchases-admob/src/index"] }, "isolatedModules": false, "types": [] diff --git a/examples/adsTester/yarn.lock b/examples/adsTester/yarn.lock index a120f142b..c519648e8 100644 --- a/examples/adsTester/yarn.lock +++ b/examples/adsTester/yarn.lock @@ -1420,7 +1420,7 @@ __metadata: languageName: node linkType: hard -"@iabtcf/core@npm:^1.5.3": +"@iabtcf/core@npm:^1.5.6": version: 1.5.6 resolution: "@iabtcf/core@npm:1.5.6" checksum: 561626b626ee476dea5dd3a6ed0ebc26caac2475fc9509583f0e66c92ad67c3a2c2816b3516a4f043904b40607c9a4d44cbacbf09a5897740fd273e057c02c2c @@ -2278,10 +2278,8 @@ __metadata: prettier: 2.8.8 react: 19.2.6 react-native: 0.86.0 - react-native-get-random-values: ^2.0.0 - react-native-google-mobile-ads: ^15.0.0 + react-native-google-mobile-ads: ^17.0.0 typescript: ^5.8.3 - uuid: ^14.0.2 languageName: unknown linkType: soft @@ -3786,13 +3784,6 @@ __metadata: languageName: node linkType: hard -"fast-base64-decode@npm:^1.0.0": - version: 1.0.0 - resolution: "fast-base64-decode@npm:1.0.0" - checksum: 4c59eb1775a7f132333f296c5082476fdcc8f58d023c42ed6d378d2e2da4c328c7a71562f271181a725dd17cdaa8f2805346cc330cdbad3b8e4b9751508bd0a3 - languageName: node - linkType: hard - "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -6093,29 +6084,20 @@ __metadata: languageName: node linkType: hard -"react-native-get-random-values@npm:^2.0.0": - version: 2.0.0 - resolution: "react-native-get-random-values@npm:2.0.0" +"react-native-google-mobile-ads@npm:^17.0.0": + version: 17.2.0 + resolution: "react-native-google-mobile-ads@npm:17.2.0" dependencies: - fast-base64-decode: ^1.0.0 - peerDependencies: - react-native: ">=0.81" - checksum: 57e80fa836e61d5ff08d31cfb3df45c00a6616651b3f7521af2d4fd6456aefd05e17eb21a269d458ada0dd58bc8c25eef6b43097eefc542ab6186072f6b653a3 - languageName: node - linkType: hard - -"react-native-google-mobile-ads@npm:^15.0.0": - version: 15.8.3 - resolution: "react-native-google-mobile-ads@npm:15.8.3" - dependencies: - "@iabtcf/core": ^1.5.3 + "@iabtcf/core": ^1.5.6 use-deep-compare-effect: ^1.8.1 peerDependencies: expo: ">=47.0.0" + react: "*" + react-native: ">=0.86.0" peerDependenciesMeta: expo: optional: true - checksum: 00fcbbf680c27ad80020305a5040fdbb09a7b0ad353725479a993caff06ab5e6e2728f36aa0448ab60e38ed7c2b0591edeb55eef435839c656b22e58036dd84f + checksum: 62e676f0d139320198b44d38796b98ec29aa56fdfa206b1fc372af34e5f947b2fd281d9740ab6cfea8231d78e43dbf1af2eed68de9a25f9c51fdd0e1c58051e7 languageName: node linkType: hard @@ -7189,15 +7171,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^14.0.2": - version: 14.0.2 - resolution: "uuid@npm:14.0.2" - bin: - uuid: dist-node/bin/uuid - checksum: e6f906b70714a39b687eadd443493eb4fd8b7998fa289a3754af215e84418bbfeadfbc3d72da833556a9c2d6fd902d9d769e9bb6c874cf4d583f7ccd03b2456e - languageName: node - linkType: hard - "vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2"