react-native-iap
Version:
React Native In-App Purchases module for iOS and Android using Nitro
259 lines (247 loc) • 10.8 kB
JavaScript
;
// External dependencies
import { useCallback, useEffect, useState, useRef } from 'react';
import { Platform } from 'react-native';
import { RnIapConsole } from "../utils/debug.js";
// Internal modules
import { initConnection, purchaseErrorListener, purchaseUpdatedListener, promotedProductListenerIOS, getAvailablePurchases, finishTransaction as finishTransactionInternal, requestPurchase as requestPurchaseInternal, fetchProducts, validateReceipt as validateReceiptInternal, verifyPurchase as verifyPurchaseTopLevel, verifyPurchaseWithProvider as verifyPurchaseWithProviderTopLevel, getActiveSubscriptions, hasActiveSubscriptions, restorePurchases as restorePurchasesTopLevel, getPromotedProductIOS, requestPurchaseOnPromotedProductIOS, checkAlternativeBillingAvailabilityAndroid, showAlternativeBillingDialogAndroid, createAlternativeBillingTokenAndroid, userChoiceBillingListenerAndroid } from "../index.js";
// Types
import { ErrorCode } from "../types.js";
import { normalizeErrorCodeFromNative } from "../utils/errorMapping.js";
// Types for event subscriptions
/**
* React Hook for managing In-App Purchases.
* See documentation at https://react-native-iap.hyo.dev/docs/hooks/useIAP
*/
export function useIAP(options) {
const [connected, setConnected] = useState(false);
const [products, setProducts] = useState([]);
const [subscriptions, setSubscriptions] = useState([]);
const [availablePurchases, setAvailablePurchases] = useState([]);
const [promotedProductIOS, setPromotedProductIOS] = useState();
const [activeSubscriptions, setActiveSubscriptions] = useState([]);
const optionsRef = useRef(options);
const connectedRef = useRef(false);
// Helper function to merge arrays with duplicate checking
const mergeWithDuplicateCheck = useCallback((existingItems, newItems, getKey) => {
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({});
const subscriptionsRefState = useRef([]);
useEffect(() => {
subscriptionsRefState.current = subscriptions;
}, [subscriptions]);
const fetchProductsInternal = useCallback(async params => {
if (!connectedRef.current) {
RnIapConsole.warn('[useIAP] fetchProducts called before connection; skipping');
return;
}
try {
const requestType = params.type ?? 'in-app';
RnIapConsole.debug('[useIAP] Calling fetchProducts with:', {
skus: params.skus,
type: requestType
});
const result = await fetchProducts({
skus: params.skus,
type: requestType
});
RnIapConsole.debug('[useIAP] fetchProducts result:', result);
const items = result ?? [];
// fetchProducts already returns properly filtered results based on type
if (requestType === 'subs') {
// All items are already subscriptions
setSubscriptions(prevSubscriptions => mergeWithDuplicateCheck(prevSubscriptions, items, subscription => subscription.id));
return;
}
if (requestType === 'all') {
// fetchProducts already properly separates products and subscriptions
const newProducts = items.filter(item => item.type === 'in-app');
const newSubscriptions = items.filter(item => item.type === 'subs');
setProducts(prevProducts => mergeWithDuplicateCheck(prevProducts, newProducts, product => product.id));
setSubscriptions(prevSubscriptions => mergeWithDuplicateCheck(prevSubscriptions, newSubscriptions, subscription => subscription.id));
return;
}
// For 'in-app' type, all items are already products
setProducts(prevProducts => mergeWithDuplicateCheck(prevProducts, items, product => product.id));
} catch (error) {
RnIapConsole.error('Error fetching products:', error);
}
}, [mergeWithDuplicateCheck]);
const getAvailablePurchasesInternal = useCallback(async _skus => {
try {
const result = await getAvailablePurchases({
alsoPublishToEventListenerIOS: false,
onlyIncludeActiveItemsIOS: true
});
setAvailablePurchases(result);
} catch (error) {
RnIapConsole.error('Error fetching available purchases:', error);
}
}, []);
const getActiveSubscriptionsInternal = useCallback(async subscriptionIds => {
try {
const result = await getActiveSubscriptions(subscriptionIds);
setActiveSubscriptions(result);
return result;
} catch (error) {
RnIapConsole.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 => {
try {
return await hasActiveSubscriptions(subscriptionIds);
} catch (error) {
RnIapConsole.error('Error checking active subscriptions:', error);
return false;
}
}, []);
const finishTransaction = useCallback(async args => {
// Directly delegate to root API finishTransaction without catching errors.
// This allows the root API's error handling logic to work correctly, including:
// - iOS: treating "Transaction not found" as success (already-finished transactions)
// - Proper validation and error messages for required fields
// Users should handle errors in their onPurchaseSuccess callback if needed.
await finishTransactionInternal(args);
}, []);
const requestPurchase = useCallback(requestObj => requestPurchaseInternal(requestObj), []);
// No local restorePurchases; use the top-level helper via returned API
const validateReceipt = useCallback(async options => validateReceiptInternal(options), []);
const verifyPurchase = useCallback(async options => {
return verifyPurchaseTopLevel(options);
}, []);
const verifyPurchaseWithProvider = useCallback(async options => {
return verifyPurchaseWithProviderTopLevel(options);
}, []);
const initIapWithSubscriptions = useCallback(async () => {
// Register listeners BEFORE initConnection to avoid race condition
subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener(async purchase => {
// Always refresh subscription state after a purchase event
try {
await getActiveSubscriptionsInternal();
await getAvailablePurchasesInternal();
} catch (e) {
RnIapConsole.warn('[useIAP] post-purchase refresh failed:', e);
}
if (optionsRef.current?.onPurchaseSuccess) {
optionsRef.current.onPurchaseSuccess(purchase);
}
});
subscriptionsRef.current.purchaseError = purchaseErrorListener(error => {
const mappedError = {
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') {
// iOS promoted products listener
subscriptionsRef.current.promotedProductIOS = promotedProductListenerIOS(product => {
setPromotedProductIOS(product);
if (optionsRef.current?.onPromotedProductIOS) {
optionsRef.current.onPromotedProductIOS(product);
}
});
}
// Add user choice billing listener for Android (if provided)
if (Platform.OS === 'android' && optionsRef.current?.onUserChoiceBillingAndroid) {
subscriptionsRef.current.userChoiceBillingAndroid = userChoiceBillingListenerAndroid(details => {
if (optionsRef.current?.onUserChoiceBillingAndroid) {
optionsRef.current.onUserChoiceBillingAndroid(details);
}
});
}
// Initialize connection with config
// Prefer enableBillingProgramAndroid over deprecated alternativeBillingModeAndroid
let config;
if (Platform.OS === 'android') {
if (optionsRef.current?.enableBillingProgramAndroid) {
config = {
enableBillingProgramAndroid: optionsRef.current.enableBillingProgramAndroid
};
} else if (optionsRef.current?.alternativeBillingModeAndroid) {
// Deprecated: use alternativeBillingModeAndroid for backwards compatibility
config = {
alternativeBillingModeAndroid: optionsRef.current.alternativeBillingModeAndroid
};
}
}
const result = await initConnection(config);
setConnected(result);
if (!result) {
// Clean up some listeners but leave purchaseError for potential retries
subscriptionsRef.current.purchaseUpdate?.remove();
subscriptionsRef.current.purchaseUpdate = undefined;
return;
}
}, [getActiveSubscriptionsInternal, getAvailablePurchasesInternal]);
useEffect(() => {
initIapWithSubscriptions();
const currentSubscriptions = subscriptionsRef.current;
return () => {
currentSubscriptions.purchaseUpdate?.remove();
currentSubscriptions.purchaseError?.remove();
currentSubscriptions.promotedProductIOS?.remove();
currentSubscriptions.userChoiceBillingAndroid?.remove();
// Keep connection alive across screens to avoid race conditions
setConnected(false);
};
}, [initIapWithSubscriptions]);
return {
connected,
products,
subscriptions,
finishTransaction,
availablePurchases,
promotedProductIOS,
activeSubscriptions,
getAvailablePurchases: getAvailablePurchasesInternal,
fetchProducts: fetchProductsInternal,
requestPurchase,
validateReceipt,
verifyPurchase,
verifyPurchaseWithProvider,
restorePurchases: async () => {
try {
await restorePurchasesTopLevel();
await getAvailablePurchasesInternal();
} catch (e) {
RnIapConsole.warn('Failed to restore purchases:', e);
}
},
getPromotedProductIOS,
requestPurchaseOnPromotedProductIOS,
getActiveSubscriptions: getActiveSubscriptionsInternal,
hasActiveSubscriptions: hasActiveSubscriptionsInternal,
// Alternative billing (Android only)
...(Platform.OS === 'android' ? {
checkAlternativeBillingAvailabilityAndroid,
showAlternativeBillingDialogAndroid,
createAlternativeBillingTokenAndroid
} : {})
};
}
//# sourceMappingURL=useIAP.js.map