react-native-iap
Version:
React Native In-App Purchases module for iOS and Android using Nitro
245 lines (237 loc) • 9.59 kB
JavaScript
// 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 "../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 [promotedProductsIOS] = useState([]);
const [subscriptions, setSubscriptions] = useState([]);
const [availablePurchases, setAvailablePurchases] = useState([]);
const [promotedProductIOS, setPromotedProductIOS] = useState();
const [promotedProductIdIOS] = 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 getProductsInternal = useCallback(async skus => {
try {
const result = await fetchProducts({
skus,
type: 'in-app'
});
const newProducts = (result ?? []).filter(item => item.type === 'in-app');
setProducts(prevProducts => mergeWithDuplicateCheck(prevProducts, newProducts, product => product.id));
} catch (error) {
console.error('Error fetching products:', error);
}
}, [mergeWithDuplicateCheck]);
const getSubscriptionsInternal = useCallback(async skus => {
try {
const result = await fetchProducts({
skus,
type: 'subs'
});
const newSubscriptions = (result ?? []).filter(item => item.type === 'subs');
setSubscriptions(prevSubscriptions => mergeWithDuplicateCheck(prevSubscriptions, newSubscriptions, subscription => subscription.id));
} catch (error) {
console.error('Error fetching subscriptions:', error);
}
}, [mergeWithDuplicateCheck]);
const fetchProductsInternal = useCallback(async params => {
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 ?? [];
if (requestType === 'subs') {
const newSubscriptions = items.filter(item => item.type === 'subs');
setSubscriptions(prevSubscriptions => mergeWithDuplicateCheck(prevSubscriptions, newSubscriptions, subscription => subscription.id));
return;
}
if (requestType === 'all') {
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;
}
const newProducts = items.filter(item => item.type === 'in-app');
setProducts(prevProducts => mergeWithDuplicateCheck(prevProducts, newProducts, product => product.id));
} catch (error) {
console.error('Error fetching products:', error);
}
}, [mergeWithDuplicateCheck]);
const getAvailablePurchasesInternal = useCallback(async _skus => {
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 => {
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 => {
try {
return await hasActiveSubscriptions(subscriptionIds);
} catch (error) {
console.error('Error checking active subscriptions:', error);
return false;
}
}, []);
const finishTransaction = useCallback(async args => {
try {
await finishTransactionInternal(args);
} catch (err) {
throw err;
}
}, []);
const requestPurchase = useCallback(requestObj => requestPurchaseInternal(requestObj), []);
// No local restorePurchases; use the top-level helper via returned API
const validateReceipt = useCallback(async (sku, androidOptions) => {
return validateReceiptInternal({
sku,
androidOptions
});
}, []);
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) {
console.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') {
subscriptionsRef.current.promotedProductsIOS = promotedProductListenerIOS(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
};
}
//# sourceMappingURL=useIAP.js.map
;