UNPKG

react-native-iap

Version:

React Native In-App Purchases module for iOS and Android using Nitro

464 lines (429 loc) 14.3 kB
// External dependencies import {useCallback, useEffect, useState, useRef} from 'react'; import {Platform} from 'react-native'; // Internal modules import { initConnection, purchaseErrorListener, purchaseUpdatedListener, promotedProductListenerIOS, getAvailablePurchases, finishTransaction as finishTransactionInternal, requestPurchase as requestPurchaseInternal, fetchProducts, validateReceipt as validateReceiptInternal, getActiveSubscriptions, hasActiveSubscriptions, restorePurchases as restorePurchasesTopLevel, getPromotedProductIOS, requestPurchaseOnPromotedProductIOS, } from '../'; // Types import {ErrorCode} from '../types'; import type { ProductQueryType, RequestPurchaseProps, RequestPurchaseResult, } from '../types'; import type { ActiveSubscription, Product, Purchase, PurchaseError, ProductSubscription, } from '../types'; import type {MutationFinishTransactionArgs} from '../types'; import {normalizeErrorCodeFromNative} from '../utils/errorMapping'; // Types for event subscriptions interface EventSubscription { remove(): void; } type UseIap = { connected: boolean; products: Product[]; promotedProductsIOS: Purchase[]; promotedProductIdIOS?: string; subscriptions: ProductSubscription[]; availablePurchases: Purchase[]; promotedProductIOS?: Product; activeSubscriptions: ActiveSubscription[]; finishTransaction: (args: MutationFinishTransactionArgs) => Promise<void>; getAvailablePurchases: (skus?: string[]) => Promise<void>; fetchProducts: (params: { skus: string[]; type?: ProductQueryType | null; }) => Promise<void>; /** * @deprecated Use fetchProducts({ skus, type: 'in-app' }) instead. This method will be removed in version 3.0.0. * Note: This method internally uses fetchProducts, so no deprecation warning is shown. */ getProducts: (skus: string[]) => Promise<void>; /** * @deprecated Use fetchProducts({ skus, type: 'subs' }) instead. This method will be removed in version 3.0.0. * Note: This method internally uses fetchProducts, so no deprecation warning is shown. */ getSubscriptions: (skus: string[]) => Promise<void>; requestPurchase: ( params: RequestPurchaseProps, ) => Promise<RequestPurchaseResult | null>; validateReceipt: ( sku: string, androidOptions?: { packageName: string; productToken: string; accessToken: string; isSub?: boolean; }, ) => Promise<any>; restorePurchases: () => Promise<void>; getPromotedProductIOS: () => Promise<Product | null>; requestPurchaseOnPromotedProductIOS: () => Promise<boolean>; getActiveSubscriptions: ( subscriptionIds?: string[], ) => Promise<ActiveSubscription[]>; hasActiveSubscriptions: (subscriptionIds?: string[]) => Promise<boolean>; }; export interface UseIapOptions { onPurchaseSuccess?: (purchase: Purchase) => void; onPurchaseError?: (error: PurchaseError) => void; onSyncError?: (error: Error) => void; shouldAutoSyncPurchases?: boolean; // New option to control auto-syncing onPromotedProductIOS?: (product: Product) => void; } /** * React Hook for managing In-App Purchases. * See documentation at https://react-native-iap.hyo.dev/docs/hooks/useIAP */ export function useIAP(options?: UseIapOptions): UseIap { const [connected, setConnected] = useState<boolean>(false); const [products, setProducts] = useState<Product[]>([]); const [promotedProductsIOS] = useState<Purchase[]>([]); const [subscriptions, setSubscriptions] = useState<ProductSubscription[]>([]); const [availablePurchases, setAvailablePurchases] = useState<Purchase[]>([]); const [promotedProductIOS, setPromotedProductIOS] = useState<Product>(); const [promotedProductIdIOS] = useState<string>(); const [activeSubscriptions, setActiveSubscriptions] = useState< ActiveSubscription[] >([]); const optionsRef = useRef<UseIapOptions | undefined>(options); const connectedRef = useRef<boolean>(false); // Helper function to merge arrays with duplicate checking const mergeWithDuplicateCheck = useCallback( <T>( existingItems: T[], newItems: T[], getKey: (item: T) => string, ): T[] => { const merged = [...existingItems]; newItems.forEach((newItem) => { const isDuplicate = merged.some( (existingItem) => getKey(existingItem) === getKey(newItem), ); if (!isDuplicate) { merged.push(newItem); } }); return merged; }, [], ); useEffect(() => { optionsRef.current = options; }, [options]); useEffect(() => { connectedRef.current = connected; }, [connected]); const subscriptionsRef = useRef<{ purchaseUpdate?: EventSubscription; purchaseError?: EventSubscription; promotedProductsIOS?: EventSubscription; promotedProductIOS?: EventSubscription; }>({}); const subscriptionsRefState = useRef<ProductSubscription[]>([]); useEffect(() => { subscriptionsRefState.current = subscriptions; }, [subscriptions]); const getProductsInternal = useCallback( async (skus: string[]): Promise<void> => { try { const result = await fetchProducts({ skus, type: 'in-app', }); const newProducts = (result ?? []).filter( (item): item is Product => item.type === 'in-app', ); setProducts((prevProducts: Product[]) => mergeWithDuplicateCheck( prevProducts, newProducts, (product: Product) => product.id, ), ); } catch (error) { console.error('Error fetching products:', error); } }, [mergeWithDuplicateCheck], ); const getSubscriptionsInternal = useCallback( async (skus: string[]): Promise<void> => { try { const result = await fetchProducts({ skus, type: 'subs', }); const newSubscriptions = (result ?? []).filter( (item): item is ProductSubscription => item.type === 'subs', ); setSubscriptions((prevSubscriptions: ProductSubscription[]) => mergeWithDuplicateCheck( prevSubscriptions, newSubscriptions, (subscription: ProductSubscription) => subscription.id, ), ); } catch (error) { console.error('Error fetching subscriptions:', error); } }, [mergeWithDuplicateCheck], ); const fetchProductsInternal = useCallback( async (params: { skus: string[]; type?: ProductQueryType | null; }): Promise<void> => { if (!connectedRef.current) { console.warn( '[useIAP] fetchProducts called before connection; skipping', ); return; } try { const requestType = params.type ?? 'in-app'; const result = await fetchProducts({ skus: params.skus, type: requestType, }); const items = (result ?? []) as (Product | ProductSubscription)[]; if (requestType === 'subs') { const newSubscriptions = items.filter( (item): item is ProductSubscription => item.type === 'subs', ); setSubscriptions((prevSubscriptions: ProductSubscription[]) => mergeWithDuplicateCheck( prevSubscriptions, newSubscriptions, (subscription: ProductSubscription) => subscription.id, ), ); return; } if (requestType === 'all') { const newProducts = items.filter( (item): item is Product => item.type === 'in-app', ); const newSubscriptions = items.filter( (item): item is ProductSubscription => item.type === 'subs', ); setProducts((prevProducts: Product[]) => mergeWithDuplicateCheck( prevProducts, newProducts, (product: Product) => product.id, ), ); setSubscriptions((prevSubscriptions: ProductSubscription[]) => mergeWithDuplicateCheck( prevSubscriptions, newSubscriptions, (subscription: ProductSubscription) => subscription.id, ), ); return; } const newProducts = items.filter( (item): item is Product => item.type === 'in-app', ); setProducts((prevProducts: Product[]) => mergeWithDuplicateCheck( prevProducts, newProducts, (product: Product) => product.id, ), ); } catch (error) { console.error('Error fetching products:', error); } }, [mergeWithDuplicateCheck], ); const getAvailablePurchasesInternal = useCallback( async (_skus?: string[]): Promise<void> => { try { const result = await getAvailablePurchases({ alsoPublishToEventListenerIOS: false, onlyIncludeActiveItemsIOS: true, }); setAvailablePurchases(result); } catch (error) { console.error('Error fetching available purchases:', error); } }, [], ); const getActiveSubscriptionsInternal = useCallback( async (subscriptionIds?: string[]): Promise<ActiveSubscription[]> => { try { const result = await getActiveSubscriptions(subscriptionIds); setActiveSubscriptions(result); return result; } catch (error) { console.error('Error getting active subscriptions:', error); // Don't clear existing activeSubscriptions on error - preserve current state // This prevents the UI from showing empty state when there are temporary network issues return []; } }, [], ); const hasActiveSubscriptionsInternal = useCallback( async (subscriptionIds?: string[]): Promise<boolean> => { try { return await hasActiveSubscriptions(subscriptionIds); } catch (error) { console.error('Error checking active subscriptions:', error); return false; } }, [], ); const finishTransaction = useCallback( async (args: MutationFinishTransactionArgs): Promise<void> => { try { await finishTransactionInternal(args); } catch (err) { throw err; } }, [], ); const requestPurchase = useCallback( (requestObj: RequestPurchaseProps) => requestPurchaseInternal(requestObj), [], ); // No local restorePurchases; use the top-level helper via returned API const validateReceipt = useCallback( async ( sku: string, androidOptions?: { packageName: string; productToken: string; accessToken: string; isSub?: boolean; }, ) => { return validateReceiptInternal({ sku, androidOptions, }); }, [], ); const initIapWithSubscriptions = useCallback(async (): Promise<void> => { // Register listeners BEFORE initConnection to avoid race condition subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener( async (purchase: Purchase) => { // Always refresh subscription state after a purchase event try { await getActiveSubscriptionsInternal(); await getAvailablePurchasesInternal(); } catch (e) { console.warn('[useIAP] post-purchase refresh failed:', e); } if (optionsRef.current?.onPurchaseSuccess) { optionsRef.current.onPurchaseSuccess(purchase); } }, ); subscriptionsRef.current.purchaseError = purchaseErrorListener((error) => { const mappedError: PurchaseError = { code: normalizeErrorCodeFromNative(error.code), message: error.message, productId: undefined, }; // Ignore init error until connected if ( mappedError.code === ErrorCode.InitConnection && !connectedRef.current ) { return; } if (optionsRef.current?.onPurchaseError) { optionsRef.current.onPurchaseError(mappedError); } }); if (Platform.OS === 'ios') { subscriptionsRef.current.promotedProductsIOS = promotedProductListenerIOS( (product: Product) => { setPromotedProductIOS(product); if (optionsRef.current?.onPromotedProductIOS) { optionsRef.current.onPromotedProductIOS(product); } }, ); } const result = await initConnection(); setConnected(result); if (!result) { // Clean up some listeners but leave purchaseError for potential retries subscriptionsRef.current.purchaseUpdate?.remove(); subscriptionsRef.current.promotedProductsIOS?.remove(); subscriptionsRef.current.purchaseUpdate = undefined; subscriptionsRef.current.promotedProductsIOS = undefined; return; } }, [getActiveSubscriptionsInternal, getAvailablePurchasesInternal]); useEffect(() => { initIapWithSubscriptions(); const currentSubscriptions = subscriptionsRef.current; return () => { currentSubscriptions.purchaseUpdate?.remove(); currentSubscriptions.purchaseError?.remove(); currentSubscriptions.promotedProductsIOS?.remove(); currentSubscriptions.promotedProductIOS?.remove(); // Keep connection alive across screens to avoid race conditions setConnected(false); }; }, [initIapWithSubscriptions]); return { connected, products, promotedProductsIOS, promotedProductIdIOS, subscriptions, finishTransaction, availablePurchases, promotedProductIOS, activeSubscriptions, getAvailablePurchases: getAvailablePurchasesInternal, fetchProducts: fetchProductsInternal, requestPurchase, validateReceipt, restorePurchases: async () => { try { await restorePurchasesTopLevel(); await getAvailablePurchasesInternal(); } catch (e) { console.warn('Failed to restore purchases:', e); } }, getProducts: getProductsInternal, getSubscriptions: getSubscriptionsInternal, getPromotedProductIOS, requestPurchaseOnPromotedProductIOS, getActiveSubscriptions: getActiveSubscriptionsInternal, hasActiveSubscriptions: hasActiveSubscriptionsInternal, }; }