From 6bb19851736857c8f2672d71ebfadbd9631efc7d Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Thu, 27 Nov 2025 15:13:14 +0100 Subject: [PATCH 1/2] Add presentPaywall support for Expo Go using a WebView that loads purchases-js --- examples/purchaseTesterExpo/app/_layout.tsx | 15 +- examples/purchaseTesterExpo/package.json | 3 +- react-native-purchases-ui/package.json | 6 +- react-native-purchases-ui/src/index.tsx | 23 + .../src/preview/WebViewPaywall.tsx | 442 ++++++++++++++++++ .../src/preview/WebViewPaywallPresenter.tsx | 290 ++++++++++++ .../src/preview/nativeModules.ts | 68 +++ .../src/preview/previewComponents.tsx | 142 +++++- .../src/utils/environment.ts | 6 +- src/browser/nativeModule.ts | 15 + src/index.ts | 3 + 11 files changed, 999 insertions(+), 14 deletions(-) create mode 100644 react-native-purchases-ui/src/preview/WebViewPaywall.tsx create mode 100644 react-native-purchases-ui/src/preview/WebViewPaywallPresenter.tsx diff --git a/examples/purchaseTesterExpo/app/_layout.tsx b/examples/purchaseTesterExpo/app/_layout.tsx index 063da6071..3417e6dda 100644 --- a/examples/purchaseTesterExpo/app/_layout.tsx +++ b/examples/purchaseTesterExpo/app/_layout.tsx @@ -9,6 +9,7 @@ import { Platform } from 'react-native'; import { useColorScheme } from '@/components/useColorScheme'; import Purchases from 'react-native-purchases'; +import { WebViewPaywallProvider } from 'react-native-purchases-ui'; import APIKeys from '@/constants/APIKeys'; export { @@ -92,11 +93,13 @@ function RootLayoutNav() { const colorScheme = useColorScheme(); return ( - - - - - - + + + + + + + + ); } diff --git a/examples/purchaseTesterExpo/package.json b/examples/purchaseTesterExpo/package.json index 2972d695b..3613a564b 100644 --- a/examples/purchaseTesterExpo/package.json +++ b/examples/purchaseTesterExpo/package.json @@ -33,7 +33,8 @@ "react-native-reanimated": "~3.17.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", - "react-native-web": "~0.20.0" + "react-native-web": "~0.20.0", + "react-native-webview": "~13.16.0" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/react-native-purchases-ui/package.json b/react-native-purchases-ui/package.json index 75e03cac4..2cad3ead3 100644 --- a/react-native-purchases-ui/package.json +++ b/react-native-purchases-ui/package.json @@ -79,11 +79,15 @@ "react": "*", "react-native": ">= 0.73.0", "react-native-purchases": "9.6.8", - "react-native-web": "*" + "react-native-web": "*", + "react-native-webview": "*" }, "peerDependenciesMeta": { "react-native-web": { "optional": true + }, + "react-native-webview": { + "optional": true } }, "packageManager": "yarn@3.6.1", diff --git a/react-native-purchases-ui/src/index.tsx b/react-native-purchases-ui/src/index.tsx index 05c199075..e88caf6f2 100644 --- a/react-native-purchases-ui/src/index.tsx +++ b/react-native-purchases-ui/src/index.tsx @@ -22,6 +22,7 @@ import { previewNativeModuleRNCustomerCenter, previewNativeModuleRNPaywalls } fr import { PreviewCustomerCenter, PreviewPaywall } from "./preview/previewComponents"; export { PAYWALL_RESULT } from "@revenuecat/purchases-typescript-internal"; +export { WebViewPaywallProvider, WebViewPaywallModal } from "./preview/WebViewPaywallPresenter"; const LINKING_ERROR = `The package 'react-native-purchases-ui' doesn't seem to be linked. Make sure: \n\n` + @@ -714,6 +715,28 @@ export default class RevenueCatUI { public static PaywallFooterContainerView: React.FC = RevenueCatUI.OriginalTemplatePaywallFooterContainerView; + /** + * Modal component for WebView-based paywall presentation in Expo Go. + * + * Add this component anywhere in your app to enable paywall presentation + * in environments without native module support (like Expo Go). + * + * Usage: + * ```tsx + * import RevenueCatUI from 'react-native-purchases-ui'; + * + * export default function App() { + * return ( + * <> + * + * + * + * ); + * } + * ``` + */ + public static WebViewPaywallModal: React.FC = require("./preview/WebViewPaywallPresenter").WebViewPaywallModal; + private static logWarningIfPreviewAPIMode(methodName: string) { if (usingPreviewAPIMode) { // tslint:disable-next-line:no-console diff --git a/react-native-purchases-ui/src/preview/WebViewPaywall.tsx b/react-native-purchases-ui/src/preview/WebViewPaywall.tsx new file mode 100644 index 000000000..f78b446b2 --- /dev/null +++ b/react-native-purchases-ui/src/preview/WebViewPaywall.tsx @@ -0,0 +1,442 @@ +import React, { useRef, useCallback, useEffect } from 'react'; +import { Modal, View, StyleSheet, ActivityIndicator, Text } from 'react-native'; + +// Type definitions for react-native-webview (optional peer dependency) +interface WebViewMessageEvent { + nativeEvent: { + data: string; + }; +} + +interface WebViewErrorEvent { + nativeEvent: { + description?: string; + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type WebViewComponentType = React.ComponentType; + +// WebView ref type +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface WebViewRef { + postMessage: (message: string) => void; +} + +// Message protocol types for RN <-> WebView communication + +/** Messages sent from React Native to WebView */ +export type RNToWebViewMessage = { + type: 'configure'; + apiKey: string; + appUserId: string; + offeringIdentifier?: string; + presentedOfferingContext?: Record; + customerEmail?: string; + locale?: string; +}; + +/** Messages sent from WebView to React Native */ +export type WebViewToRNMessage = + | { type: 'ready' } + | { type: 'purchased'; customerInfo: Record } + | { type: 'cancelled' } + | { type: 'error'; message: string; code?: string }; + +export interface WebViewPaywallProps { + visible: boolean; + apiKey: string; + appUserId: string; + offeringIdentifier?: string; + presentedOfferingContext?: Record; + customerEmail?: string; + onPurchased?: (customerInfo: Record) => void; + onCancelled?: () => void; + onError?: (error: { message: string; code?: string }) => void; + onDismiss: () => void; +} + +/** + * WebView-based paywall component for Expo Go environments. + * Loads purchases-js in a WebView to provide a real browser context + * where DOM APIs are available. + */ +export const WebViewPaywall: React.FC = ({ + visible, + apiKey, + appUserId, + offeringIdentifier, + presentedOfferingContext, + customerEmail, + onPurchased, + onCancelled, + onError, + onDismiss, +}) => { + const webViewRef = useRef(null); + const [isLoading, setIsLoading] = React.useState(true); + const [loadError, setLoadError] = React.useState(null); + + // Try to import WebView dynamically to handle cases where it's not installed + const [WebView, setWebView] = React.useState(null); + + useEffect(() => { + // Dynamically import react-native-webview using require to avoid bundler issues + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const webViewModule = require('react-native-webview'); + setWebView(() => webViewModule.default || webViewModule.WebView); + } catch { + setLoadError( + 'react-native-webview is required for paywall support in Expo Go. ' + + 'Please install it with: npx expo install react-native-webview' + ); + } + }, []); + + // Send configuration to WebView once it's ready + const sendConfiguration = useCallback(() => { + if (!webViewRef.current) return; + + const config: RNToWebViewMessage = { + type: 'configure', + apiKey, + appUserId, + offeringIdentifier, + presentedOfferingContext, + customerEmail, + }; + + webViewRef.current.postMessage(JSON.stringify(config)); + }, [apiKey, appUserId, offeringIdentifier, presentedOfferingContext, customerEmail]); + + // Handle messages from WebView + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + try { + const message: WebViewToRNMessage = JSON.parse(event.nativeEvent.data); + + switch (message.type) { + case 'ready': + setIsLoading(false); + sendConfiguration(); + break; + + case 'purchased': + onPurchased?.(message.customerInfo); + onDismiss(); + break; + + case 'cancelled': + onCancelled?.(); + onDismiss(); + break; + + case 'error': + onError?.({ message: message.message, code: message.code }); + onDismiss(); + break; + } + } catch (e) { + console.error('[WebViewPaywall] Failed to parse message:', e); + } + }, + [sendConfiguration, onPurchased, onCancelled, onError, onDismiss] + ); + + // Generate the HTML content for the WebView + const htmlContent = generatePaywallHTML(); + + if (!visible) { + return null; + } + + // Show error if WebView couldn't be loaded + if (loadError) { + return ( + + + + WebView Required + {loadError} + + Dismiss + + + + + ); + } + + // Show loading while WebView component is being imported + if (!WebView) { + return ( + + + + Loading... + + + ); + } + + return ( + + + {isLoading && ( + + + Loading paywall... + + )} + { + const { nativeEvent } = syntheticEvent; + console.error('[WebViewPaywall] WebView error:', nativeEvent); + onError?.({ message: nativeEvent.description || 'WebView failed to load' }); + onDismiss(); + }} + javaScriptEnabled={true} + domStorageEnabled={true} + originWhitelist={['*']} + style={styles.webView} + // Allow mixed content for CDN loading + mixedContentMode="compatibility" + // iOS specific + allowsInlineMediaPlayback={true} + // Android specific + setSupportMultipleWindows={false} + /> + + + ); +}; + +/** + * Generates the HTML content that will run in the WebView. + * This HTML loads purchases-js from CDN and sets up the paywall. + */ +function generatePaywallHTML(): string { + return ` + + + + + + RevenueCat Paywall + + + +
+
+
+ Initializing... +
+
+ + + + +`; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + webView: { + flex: 1, + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1, + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: '#666', + }, + errorContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + errorTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + errorMessage: { + fontSize: 14, + color: '#666', + textAlign: 'center', + marginBottom: 20, + }, + dismissButton: { + fontSize: 16, + color: '#007AFF', + fontWeight: '600', + }, +}); + +export default WebViewPaywall; + diff --git a/react-native-purchases-ui/src/preview/WebViewPaywallPresenter.tsx b/react-native-purchases-ui/src/preview/WebViewPaywallPresenter.tsx new file mode 100644 index 000000000..0176abde6 --- /dev/null +++ b/react-native-purchases-ui/src/preview/WebViewPaywallPresenter.tsx @@ -0,0 +1,290 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { PAYWALL_RESULT } from '@revenuecat/purchases-typescript-internal'; + +export interface PresentWebViewPaywallParams { + apiKey: string; + appUserId: string; + offeringIdentifier?: string; + presentedOfferingContext?: Record; + customerEmail?: string; +} + +type PaywallResolver = { + resolve: (result: PAYWALL_RESULT) => void; + reject: (error: Error) => void; + params: PresentWebViewPaywallParams; +}; + +// Shared state that components can subscribe to +type PaywallState = { + pendingPaywall: PaywallResolver | null; + listeners: Set<() => void>; +}; + +const paywallState: PaywallState = { + pendingPaywall: null, + listeners: new Set(), +}; + +// Debug: Log when this module is loaded +console.log("[WebViewPaywallPresenter] Module loaded, paywallState created"); + +function notifyListeners() { + console.log("[WebViewPaywallPresenter] Notifying", paywallState.listeners.size, "listeners"); + paywallState.listeners.forEach(listener => listener()); +} + +function subscribe(listener: () => void): () => void { + paywallState.listeners.add(listener); + console.log("[WebViewPaywallPresenter] Listener subscribed, total:", paywallState.listeners.size); + return () => { + paywallState.listeners.delete(listener); + console.log("[WebViewPaywallPresenter] Listener unsubscribed, total:", paywallState.listeners.size); + }; +} + +function getPendingPaywall(): PaywallResolver | null { + return paywallState.pendingPaywall; +} + +function setPendingPaywall(paywall: PaywallResolver | null) { + paywallState.pendingPaywall = paywall; + notifyListeners(); +} + +/** + * Present a paywall using WebView. Returns a promise that resolves + * with the paywall result. + */ +export async function presentWebViewPaywall( + params: PresentWebViewPaywallParams +): Promise { + console.log("[WebViewPaywall] Presenting paywall..."); + console.log("[WebViewPaywall] Number of listeners:", paywallState.listeners.size); + + if (paywallState.listeners.size === 0) { + console.error("[WebViewPaywall] ERROR: No listeners registered! Make sure wraps your app."); + return PAYWALL_RESULT.ERROR; + } + + if (paywallState.pendingPaywall) { + // Already presenting a paywall, reject the pending one + paywallState.pendingPaywall.reject(new Error('Another paywall is being presented')); + } + + return new Promise((resolve, reject) => { + setPendingPaywall({ resolve, reject, params }); + }); +} + +/** + * Called when the paywall completes (purchased, cancelled, or error). + */ +function handlePaywallComplete(result: PAYWALL_RESULT): void { + const pending = paywallState.pendingPaywall; + if (pending) { + pending.resolve(result); + setPendingPaywall(null); + } +} + +/** + * Dismiss the current paywall if any. + */ +function dismissPaywall(): void { + if (paywallState.pendingPaywall) { + handlePaywallComplete(PAYWALL_RESULT.CANCELLED); + } +} + +/** + * Provider component that must be rendered at the root of the app + * to enable WebView paywall presentation in Expo Go. + * + * Usage: + * ```tsx + * // In your App.tsx + * import { WebViewPaywallProvider } from 'react-native-purchases-ui'; + * + * export default function App() { + * return ( + * + * + * + * ); + * } + * ``` + */ +export const WebViewPaywallProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [, forceUpdate] = useState({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [WebViewComponent, setWebViewComponent] = useState | null>(null); + const mountedRef = useRef(true); + + console.log("[WebViewPaywallProvider] Rendering, WebViewComponent loaded:", !!WebViewComponent); + + // Load the WebViewPaywall component dynamically + useEffect(() => { + mountedRef.current = true; + console.log("[WebViewPaywallProvider] useEffect: Loading WebViewPaywall component"); + + import('./WebViewPaywall') + .then((module) => { + if (mountedRef.current) { + console.log("[WebViewPaywallProvider] WebViewPaywall component loaded successfully"); + setWebViewComponent(() => module.WebViewPaywall); + } + }) + .catch((error) => { + console.warn('[WebViewPaywallProvider] Failed to load WebViewPaywall:', error); + }); + + return () => { + mountedRef.current = false; + }; + }, []); + + // Subscribe to paywall state changes + useEffect(() => { + console.log("[WebViewPaywallProvider] useEffect: Subscribing to paywall state"); + const unsubscribe = subscribe(() => { + console.log("[WebViewPaywallProvider] State changed, pending:", !!getPendingPaywall()); + forceUpdate({}); + }); + return () => { + console.log("[WebViewPaywallProvider] useEffect cleanup: Unsubscribing"); + unsubscribe(); + }; + }, []); + + const pendingPaywall = getPendingPaywall(); + console.log("[WebViewPaywallProvider] Current state - pendingPaywall:", !!pendingPaywall, "WebViewComponent:", !!WebViewComponent); + + const handlePurchased = useCallback((_customerInfo: Record) => { + console.log("[WebViewPaywallProvider] Purchase completed"); + handlePaywallComplete(PAYWALL_RESULT.PURCHASED); + }, []); + + const handleCancelled = useCallback(() => { + console.log("[WebViewPaywallProvider] Paywall cancelled"); + handlePaywallComplete(PAYWALL_RESULT.CANCELLED); + }, []); + + const handleError = useCallback((error: { message: string; code?: string }) => { + console.error('[WebViewPaywallProvider] Paywall error:', error); + handlePaywallComplete(PAYWALL_RESULT.ERROR); + }, []); + + const handleDismiss = useCallback(() => { + console.log("[WebViewPaywallProvider] Paywall dismissed"); + dismissPaywall(); + }, []); + + return ( + <> + {children} + {WebViewComponent && pendingPaywall && (() => { + console.log("[WebViewPaywallProvider] Rendering WebView paywall modal"); + return ( + + ); + })()} + + ); +}; + +/** + * Self-contained modal component for WebView paywall presentation. + * Add this component anywhere in your app as an alternative to WebViewPaywallProvider. + */ +export const WebViewPaywallModal: React.FC = () => { + const [, forceUpdate] = useState({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [WebViewComponent, setWebViewComponent] = useState | null>(null); + const mountedRef = useRef(true); + + // Load the WebViewPaywall component dynamically + useEffect(() => { + mountedRef.current = true; + + import('./WebViewPaywall') + .then((module) => { + if (mountedRef.current) { + console.log("[WebViewPaywallModal] WebViewPaywall component loaded"); + setWebViewComponent(() => module.WebViewPaywall); + } + }) + .catch((error) => { + console.warn('[WebViewPaywallModal] Failed to load WebViewPaywall:', error); + }); + + return () => { + mountedRef.current = false; + }; + }, []); + + // Subscribe to paywall state changes + useEffect(() => { + console.log("[WebViewPaywallModal] Subscribing to paywall state"); + const unsubscribe = subscribe(() => { + console.log("[WebViewPaywallModal] State changed, pending:", !!getPendingPaywall()); + forceUpdate({}); + }); + return unsubscribe; + }, []); + + const pendingPaywall = getPendingPaywall(); + + const handlePurchased = useCallback((_customerInfo: Record) => { + console.log("[WebViewPaywallModal] Purchase completed"); + handlePaywallComplete(PAYWALL_RESULT.PURCHASED); + }, []); + + const handleCancelled = useCallback(() => { + console.log("[WebViewPaywallModal] Paywall cancelled"); + handlePaywallComplete(PAYWALL_RESULT.CANCELLED); + }, []); + + const handleError = useCallback((error: { message: string; code?: string }) => { + console.error('[WebViewPaywallModal] Paywall error:', error); + handlePaywallComplete(PAYWALL_RESULT.ERROR); + }, []); + + const handleDismiss = useCallback(() => { + console.log("[WebViewPaywallModal] Paywall dismissed"); + dismissPaywall(); + }, []); + + // Don't render anything if no paywall is pending or component not loaded + if (!WebViewComponent || !pendingPaywall) { + return null; + } + + console.log("[WebViewPaywallModal] Rendering WebView paywall"); + + return ( + + ); +}; diff --git a/react-native-purchases-ui/src/preview/nativeModules.ts b/react-native-purchases-ui/src/preview/nativeModules.ts index 52b9cf470..9eb8bb12d 100644 --- a/react-native-purchases-ui/src/preview/nativeModules.ts +++ b/react-native-purchases-ui/src/preview/nativeModules.ts @@ -1,5 +1,27 @@ import { PurchasesCommon } from "@revenuecat/purchases-js-hybrid-mappings" import { PAYWALL_RESULT } from "@revenuecat/purchases-typescript-internal" +import { isExpoGo, isRorkSandbox, isWebPlatform } from "../utils/environment" +import { presentWebViewPaywall } from "./WebViewPaywallPresenter" + +// Import getStoredApiKey from react-native-purchases +// This is a peer dependency, so it should be available at runtime +let getStoredApiKey: (() => string | null) | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const rnPurchases = require("react-native-purchases"); + getStoredApiKey = rnPurchases.getStoredApiKey; +} catch (e) { + console.warn('[RevenueCatUI] Could not import from react-native-purchases'); +} + +/** + * Determines if the WebView-based paywall should be used. + * WebView is needed for Expo Go and Rork sandbox where DOM APIs are not available. + * Web platform has DOM available, so it can use PurchasesCommon.presentPaywall() directly. + */ +function shouldUseWebViewPaywall(): boolean { + return (isExpoGo() || isRorkSandbox()) && !isWebPlatform(); +} /** * Preview implementation of the native module for Preview API mode, i.e. for environments where native modules are not available @@ -13,6 +35,25 @@ export const previewNativeModuleRNPaywalls = { _displayCloseButton?: boolean, _fontFamily?: string | null, ): Promise => { + // Use WebView-based paywall for Expo Go/Rork (no DOM available) + // Use PurchasesCommon.presentPaywall() for web (DOM available) + console.log("PRESENTING PAYWALL IN WEB VIEW:", shouldUseWebViewPaywall()); + if (shouldUseWebViewPaywall()) { + const apiKey = getStoredApiKey?.(); + if (!apiKey) { + console.error('[RevenueCatUI] API key not found. Make sure Purchases is configured.'); + return PAYWALL_RESULT.ERROR; + } + console.log("PRESENTING PAYWALL IN WEB VIEW:", apiKey); + return await presentWebViewPaywall({ + apiKey, + appUserId: PurchasesCommon.getInstance().getAppUserId(), + offeringIdentifier, + presentedOfferingContext, + }); + } + + // Web platform - DOM is available, use direct approach return await PurchasesCommon.getInstance().presentPaywall({ offeringIdentifier, presentedOfferingContext, @@ -26,6 +67,33 @@ export const previewNativeModuleRNPaywalls = { _displayCloseButton?: boolean, _fontFamily?: string | null, ) => { + // Check entitlement first + if (requiredEntitlementIdentifier) { + const customerInfo = await PurchasesCommon.getInstance().getCustomerInfo(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const activeEntitlements = (customerInfo as any)?.entitlements?.active ?? {}; + if (activeEntitlements[requiredEntitlementIdentifier]) { + return PAYWALL_RESULT.NOT_PRESENTED; + } + } + + // Use WebView-based paywall for Expo Go/Rork (no DOM available) + // Use PurchasesCommon.presentPaywall() for web (DOM available) + if (shouldUseWebViewPaywall()) { + const apiKey = getStoredApiKey?.(); + if (!apiKey) { + console.error('[RevenueCatUI] API key not found. Make sure Purchases is configured.'); + return PAYWALL_RESULT.ERROR; + } + return await presentWebViewPaywall({ + apiKey, + appUserId: PurchasesCommon.getInstance().getAppUserId(), + offeringIdentifier, + presentedOfferingContext, + }); + } + + // Web platform - DOM is available, use direct approach return await PurchasesCommon.getInstance().presentPaywall({ requiredEntitlementIdentifier, offeringIdentifier, diff --git a/react-native-purchases-ui/src/preview/previewComponents.tsx b/react-native-purchases-ui/src/preview/previewComponents.tsx index 1bd934637..08489a866 100644 --- a/react-native-purchases-ui/src/preview/previewComponents.tsx +++ b/react-native-purchases-ui/src/preview/previewComponents.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, type ViewStyle, type StyleProp } from 'react-native'; import { type CustomerInfo, @@ -7,6 +7,23 @@ import { type PurchasesPackage, type PurchasesStoreTransaction, } from "@revenuecat/purchases-typescript-internal"; +import { isExpoGo, isRorkSandbox } from "../utils/environment"; +import { PurchasesCommon } from "@revenuecat/purchases-js-hybrid-mappings"; + +// Import getStoredApiKey from react-native-purchases +// This is a peer dependency, so it should be available at runtime +let getStoredApiKey: (() => string | null) | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const rnPurchases = require("react-native-purchases"); + getStoredApiKey = rnPurchases.getStoredApiKey; +} catch (e) { + console.warn('[PreviewPaywall] Could not import from react-native-purchases'); +} + +// Lazy load WebViewPaywall to avoid issues when react-native-webview is not installed +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let WebViewPaywall: React.ComponentType | null = null; export interface PreviewPaywallProps { offering?: PurchasesOffering | null; @@ -25,17 +42,136 @@ export interface PreviewPaywallProps { onDismiss?: () => void; } +/** + * Determines if WebView-based paywall should be used. + * WebView is needed for Expo Go and Rork sandbox where DOM APIs are not available. + */ +function shouldUseWebViewPaywall(): boolean { + return (isExpoGo() || isRorkSandbox()); +} + export const PreviewPaywall: React.FC = ({ + offering, displayCloseButton = true, fontFamily, + onPurchaseCompleted, + onPurchaseError, + onPurchaseCancelled, onDismiss, }) => { - const handleClose = () => { + const [WebViewComponent, setWebViewComponent] = useState | null>(null); + const [loadError, setLoadError] = useState(null); + const useWebView = shouldUseWebViewPaywall(); + + // Load WebViewPaywall component dynamically for Expo Go + useEffect(() => { + if (useWebView && !WebViewPaywall) { + import('./WebViewPaywall') + .then((module) => { + WebViewPaywall = module.WebViewPaywall; + setWebViewComponent(() => module.WebViewPaywall); + }) + .catch((error) => { + console.warn('[PreviewPaywall] Failed to load WebViewPaywall:', error); + setLoadError('Failed to load WebView paywall component'); + }); + } else if (useWebView && WebViewPaywall) { + setWebViewComponent(() => WebViewPaywall); + } + }, [useWebView]); + + const handleClose = useCallback(() => { + onPurchaseCancelled?.(); onDismiss?.(); - }; + }, [onPurchaseCancelled, onDismiss]); + + const handlePurchased = useCallback((customerInfo: Record) => { + onPurchaseCompleted?.({ + customerInfo: customerInfo as unknown as CustomerInfo, + storeTransaction: {} as PurchasesStoreTransaction, + }); + onDismiss?.(); + }, [onPurchaseCompleted, onDismiss]); + + const handleError = useCallback((error: { message: string; code?: string }) => { + onPurchaseError?.({ + error: { message: error.message, code: error.code } as unknown as PurchasesError, + }); + onDismiss?.(); + }, [onPurchaseError, onDismiss]); const textStyle = fontFamily ? { fontFamily } : undefined; + // For Expo Go/Rork: Use WebView-based paywall + if (useWebView) { + const apiKey = getStoredApiKey?.(); + + if (!apiKey) { + return ( + + + + Configuration Error + + + Purchases must be configured before presenting a paywall. + + + Close + + + + ); + } + + if (loadError) { + return ( + + + + {loadError} + + + Close + + + + ); + } + + if (!WebViewComponent) { + return ( + + + Loading paywall... + + + ); + } + + // Get app user ID from PurchasesCommon + let appUserId = ''; + try { + appUserId = PurchasesCommon.getInstance().getAppUserId(); + } catch (e) { + console.error('[PreviewPaywall] Failed to get app user ID:', e); + } + + return ( + + ); + } + + // For Web: Show placeholder (web uses PurchasesCommon.presentPaywall() directly via imperative API) return ( diff --git a/react-native-purchases-ui/src/utils/environment.ts b/react-native-purchases-ui/src/utils/environment.ts index 5605bf28d..bc819b8a2 100644 --- a/react-native-purchases-ui/src/utils/environment.ts +++ b/react-native-purchases-ui/src/utils/environment.ts @@ -33,7 +33,7 @@ declare global { /** * Detects if the app is running in Expo Go */ -function isExpoGo(): boolean { +export function isExpoGo(): boolean { if (!!NativeModules.RNPaywalls && !!NativeModules.RNCustomerCenter) { return false; } @@ -44,13 +44,13 @@ function isExpoGo(): boolean { /** * Detects if the app is running in the Rork app */ -function isRorkSandbox(): boolean { +export function isRorkSandbox(): boolean { return !!NativeModules.RorkSandbox; } /** * Detects if the app is running on web platform */ -function isWebPlatform(): boolean { +export function isWebPlatform(): boolean { return Platform.OS === 'web'; } \ No newline at end of file diff --git a/src/browser/nativeModule.ts b/src/browser/nativeModule.ts index d514d90a8..482cf6a97 100644 --- a/src/browser/nativeModule.ts +++ b/src/browser/nativeModule.ts @@ -11,6 +11,18 @@ import { purchaseSimulatedPackage } from './simulatedstore/purchaseSimulatedPack const packageVersion = '9.1.0'; +// Store configuration for use by WebView paywall in Expo Go +let storedApiKey: string | null = null; + +/** + * Get the API key that was used to configure Purchases. + * Used by WebView paywall in Expo Go environments. + * @internal + */ +export function getStoredApiKey(): string | null { + return storedApiKey; +} + /** * Browser implementation of the native module. This will be used in the browser and Expo Go. */ @@ -40,6 +52,9 @@ export const browserNativeModuleRNPurchases = { ); } + // Store API key for use by WebView paywall in Expo Go + storedApiKey = apiKey; + PurchasesCommon.configure({ apiKey, appUserId: appUserID || undefined, diff --git a/src/index.ts b/src/index.ts index 947501548..64894dbec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,6 @@ export * from './errors'; export * from './customerInfo'; export * from './purchases'; export * from './offerings'; + +// Export for internal use by react-native-purchases-ui +export { getStoredApiKey } from './browser/nativeModule'; From d741cab18477b2d5b2f553725da3e0f8ac4efdae Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Fri, 5 Dec 2025 17:08:40 +0100 Subject: [PATCH 2/2] Try to remove need of parent wrapper --- examples/purchaseTesterExpo/app/_layout.tsx | 15 ++-- react-native-purchases-ui/src/index.tsx | 4 +- .../src/preview/PaywallModalRoot.tsx | 90 +++++++++++++++++++ .../src/preview/WebViewPaywallPresenter.tsx | 33 +++---- .../src/preview/nativeModules.ts | 37 ++++++++ 5 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 react-native-purchases-ui/src/preview/PaywallModalRoot.tsx diff --git a/examples/purchaseTesterExpo/app/_layout.tsx b/examples/purchaseTesterExpo/app/_layout.tsx index 3417e6dda..063da6071 100644 --- a/examples/purchaseTesterExpo/app/_layout.tsx +++ b/examples/purchaseTesterExpo/app/_layout.tsx @@ -9,7 +9,6 @@ import { Platform } from 'react-native'; import { useColorScheme } from '@/components/useColorScheme'; import Purchases from 'react-native-purchases'; -import { WebViewPaywallProvider } from 'react-native-purchases-ui'; import APIKeys from '@/constants/APIKeys'; export { @@ -93,13 +92,11 @@ function RootLayoutNav() { const colorScheme = useColorScheme(); return ( - - - - - - - - + + + + + + ); } diff --git a/react-native-purchases-ui/src/index.tsx b/react-native-purchases-ui/src/index.tsx index e88caf6f2..2955a1b43 100644 --- a/react-native-purchases-ui/src/index.tsx +++ b/react-native-purchases-ui/src/index.tsx @@ -22,7 +22,9 @@ import { previewNativeModuleRNCustomerCenter, previewNativeModuleRNPaywalls } fr import { PreviewCustomerCenter, PreviewPaywall } from "./preview/previewComponents"; export { PAYWALL_RESULT } from "@revenuecat/purchases-typescript-internal"; -export { WebViewPaywallProvider, WebViewPaywallModal } from "./preview/WebViewPaywallPresenter"; +// WebViewPaywallProvider is no longer exported - the SDK now automatically handles modal presentation in Expo Go +// WebViewPaywallModal is kept for backward compatibility but is also deprecated +export { WebViewPaywallModal } from "./preview/WebViewPaywallPresenter"; const LINKING_ERROR = `The package 'react-native-purchases-ui' doesn't seem to be linked. Make sure: \n\n` + diff --git a/react-native-purchases-ui/src/preview/PaywallModalRoot.tsx b/react-native-purchases-ui/src/preview/PaywallModalRoot.tsx new file mode 100644 index 000000000..c366e2a73 --- /dev/null +++ b/react-native-purchases-ui/src/preview/PaywallModalRoot.tsx @@ -0,0 +1,90 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { subscribe, getPendingPaywall, handlePaywallComplete, dismissPaywall } from './WebViewPaywallPresenter'; +import { PAYWALL_RESULT } from '@revenuecat/purchases-typescript-internal'; + +/** + * Internal component that automatically renders WebView paywall modals. + * This component is auto-mounted in Expo Go environments and should not be used directly by consumers. + * @internal + */ +export const PaywallModalRoot: React.FC = () => { + const [, forceUpdate] = useState({}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [WebViewComponent, setWebViewComponent] = useState | null>(null); + const mountedRef = useRef(true); + + // Load the WebViewPaywall component dynamically + useEffect(() => { + mountedRef.current = true; + + import('./WebViewPaywall') + .then((module) => { + if (mountedRef.current) { + console.log("[PaywallModalRoot] WebViewPaywall component loaded"); + setWebViewComponent(() => module.WebViewPaywall); + } + }) + .catch((error) => { + console.warn('[PaywallModalRoot] Failed to load WebViewPaywall:', error); + }); + + return () => { + mountedRef.current = false; + }; + }, []); + + // Subscribe to paywall state changes + useEffect(() => { + console.log("[PaywallModalRoot] Subscribing to paywall state"); + const unsubscribe = subscribe(() => { + console.log("[PaywallModalRoot] State changed, pending:", !!getPendingPaywall()); + forceUpdate({}); + }); + return unsubscribe; + }, []); + + const pendingPaywall = getPendingPaywall(); + + const handlePurchased = useCallback((_customerInfo: Record) => { + console.log("[PaywallModalRoot] Purchase completed"); + handlePaywallComplete(PAYWALL_RESULT.PURCHASED); + }, []); + + const handleCancelled = useCallback(() => { + console.log("[PaywallModalRoot] Paywall cancelled"); + handlePaywallComplete(PAYWALL_RESULT.CANCELLED); + }, []); + + const handleError = useCallback((error: { message: string; code?: string }) => { + console.error('[PaywallModalRoot] Paywall error:', error); + handlePaywallComplete(PAYWALL_RESULT.ERROR); + }, []); + + const handleDismiss = useCallback(() => { + console.log("[PaywallModalRoot] Paywall dismissed"); + dismissPaywall(); + }, []); + + // Don't render anything if no paywall is pending or component not loaded + if (!WebViewComponent || !pendingPaywall) { + return null; + } + + console.log("[PaywallModalRoot] Rendering WebView paywall"); + + return ( + + ); +}; + diff --git a/react-native-purchases-ui/src/preview/WebViewPaywallPresenter.tsx b/react-native-purchases-ui/src/preview/WebViewPaywallPresenter.tsx index 0176abde6..e0cba2cc5 100644 --- a/react-native-purchases-ui/src/preview/WebViewPaywallPresenter.tsx +++ b/react-native-purchases-ui/src/preview/WebViewPaywallPresenter.tsx @@ -34,7 +34,7 @@ function notifyListeners() { paywallState.listeners.forEach(listener => listener()); } -function subscribe(listener: () => void): () => void { +export function subscribe(listener: () => void): () => void { paywallState.listeners.add(listener); console.log("[WebViewPaywallPresenter] Listener subscribed, total:", paywallState.listeners.size); return () => { @@ -43,7 +43,7 @@ function subscribe(listener: () => void): () => void { }; } -function getPendingPaywall(): PaywallResolver | null { +export function getPendingPaywall(): PaywallResolver | null { return paywallState.pendingPaywall; } @@ -62,10 +62,8 @@ export async function presentWebViewPaywall( console.log("[WebViewPaywall] Presenting paywall..."); console.log("[WebViewPaywall] Number of listeners:", paywallState.listeners.size); - if (paywallState.listeners.size === 0) { - console.error("[WebViewPaywall] ERROR: No listeners registered! Make sure wraps your app."); - return PAYWALL_RESULT.ERROR; - } + // Note: Listeners are no longer required. The auto-mounted PaywallModalRoot will handle rendering. + // This check is removed to allow presentPaywall to work without WebViewPaywallProvider. if (paywallState.pendingPaywall) { // Already presenting a paywall, reject the pending one @@ -80,7 +78,7 @@ export async function presentWebViewPaywall( /** * Called when the paywall completes (purchased, cancelled, or error). */ -function handlePaywallComplete(result: PAYWALL_RESULT): void { +export function handlePaywallComplete(result: PAYWALL_RESULT): void { const pending = paywallState.pendingPaywall; if (pending) { pending.resolve(result); @@ -91,29 +89,20 @@ function handlePaywallComplete(result: PAYWALL_RESULT): void { /** * Dismiss the current paywall if any. */ -function dismissPaywall(): void { +export function dismissPaywall(): void { if (paywallState.pendingPaywall) { handlePaywallComplete(PAYWALL_RESULT.CANCELLED); } } /** - * Provider component that must be rendered at the root of the app - * to enable WebView paywall presentation in Expo Go. + * @deprecated This provider is no longer required. The SDK now automatically handles modal presentation in Expo Go. + * This component is kept internally for backward compatibility but should not be used in new code. * - * Usage: - * ```tsx - * // In your App.tsx - * import { WebViewPaywallProvider } from 'react-native-purchases-ui'; + * Provider component that was previously required to be rendered at the root of the app + * to enable WebView paywall presentation in Expo Go. * - * export default function App() { - * return ( - * - * - * - * ); - * } - * ``` + * @internal */ export const WebViewPaywallProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [, forceUpdate] = useState({}); diff --git a/react-native-purchases-ui/src/preview/nativeModules.ts b/react-native-purchases-ui/src/preview/nativeModules.ts index 9eb8bb12d..6d86798ce 100644 --- a/react-native-purchases-ui/src/preview/nativeModules.ts +++ b/react-native-purchases-ui/src/preview/nativeModules.ts @@ -2,6 +2,8 @@ import { PurchasesCommon } from "@revenuecat/purchases-js-hybrid-mappings" import { PAYWALL_RESULT } from "@revenuecat/purchases-typescript-internal" import { isExpoGo, isRorkSandbox, isWebPlatform } from "../utils/environment" import { presentWebViewPaywall } from "./WebViewPaywallPresenter" +import { AppRegistry } from "react-native" +import React from "react" // Import getStoredApiKey from react-native-purchases // This is a peer dependency, so it should be available at runtime @@ -109,4 +111,39 @@ export const previewNativeModuleRNCustomerCenter = { return null } +} + +// Auto-mount PaywallModalRoot in Expo Go environments +// This allows presentPaywall to work without requiring WebViewPaywallProvider +if (shouldUseWebViewPaywall()) { + try { + // Dynamically import PaywallModalRoot to avoid loading it in non-Expo Go environments + import('./PaywallModalRoot').then((module) => { + const { PaywallModalRoot } = module; + + // Use AppRegistry.setWrapperComponentProvider to wrap the root component + // This ensures PaywallModalRoot is always mounted in the React tree + // Note: This API may not be available in all React Native versions + if (AppRegistry.setWrapperComponentProvider) { + AppRegistry.setWrapperComponentProvider(() => { + return (props: { children: React.ReactNode }) => { + return ( + <> + {props.children} + + + ); + }; + }); + + console.log('[RevenueCatUI] Auto-mounted PaywallModalRoot for Expo Go'); + } else { + console.warn('[RevenueCatUI] AppRegistry.setWrapperComponentProvider is not available. PaywallModalRoot will not be auto-mounted.'); + } + }).catch((error) => { + console.warn('[RevenueCatUI] Failed to auto-mount PaywallModalRoot:', error); + }); + } catch (error) { + console.warn('[RevenueCatUI] Failed to set up auto-mounting for PaywallModalRoot:', error); + } } \ No newline at end of file