react-native-iap
Version:
React Native In-App Purchases module for iOS and Android using Nitro
266 lines (258 loc) • 10 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 { ProductQueryType, 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 [currentPurchase, setCurrentPurchase] = useState();
const [promotedProductIOS, setPromotedProductIOS] = useState();
const [currentPurchaseError, setCurrentPurchaseError] = 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 clearCurrentPurchase = useCallback(() => {
setCurrentPurchase(undefined);
}, []);
const clearCurrentPurchaseError = useCallback(() => {
setCurrentPurchaseError(undefined);
}, []);
const getProductsInternal = useCallback(async skus => {
try {
const result = await fetchProducts({
skus,
type: ProductQueryType.InApp
});
setProducts(prevProducts => mergeWithDuplicateCheck(prevProducts, result, product => product.id));
} catch (error) {
console.error('Error fetching products:', error);
}
}, [mergeWithDuplicateCheck]);
const getSubscriptionsInternal = useCallback(async skus => {
try {
const result = await fetchProducts({
skus,
type: ProductQueryType.Subs
});
setSubscriptions(prevSubscriptions => mergeWithDuplicateCheck(prevSubscriptions, result, 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 result = await fetchProducts(params);
if (params.type === ProductQueryType.Subs) {
setSubscriptions(prevSubscriptions => mergeWithDuplicateCheck(prevSubscriptions, result, subscription => subscription.id));
} else {
setProducts(prevProducts => mergeWithDuplicateCheck(prevProducts, result, 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 ({
purchase,
isConsumable
}) => {
try {
return await finishTransactionInternal({
purchase,
isConsumable
});
} catch (err) {
throw err;
} finally {
if (purchase.id === currentPurchase?.id) {
clearCurrentPurchase();
}
if (purchase.id === currentPurchaseError?.productId) {
clearCurrentPurchaseError();
}
}
}, [currentPurchase?.id, currentPurchaseError?.productId, clearCurrentPurchase, clearCurrentPurchaseError]);
const requestPurchaseWithReset = useCallback(async requestObj => {
clearCurrentPurchase();
clearCurrentPurchaseError();
try {
return await requestPurchaseInternal(requestObj);
} catch (error) {
throw error;
}
}, [clearCurrentPurchase, clearCurrentPurchaseError]);
// 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 => {
setCurrentPurchaseError(undefined);
setCurrentPurchase(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;
}
setCurrentPurchase(undefined);
setCurrentPurchaseError(mappedError);
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,
currentPurchase,
currentPurchaseError,
promotedProductIOS,
activeSubscriptions,
clearCurrentPurchase,
clearCurrentPurchaseError,
getAvailablePurchases: getAvailablePurchasesInternal,
fetchProducts: fetchProductsInternal,
requestPurchase: requestPurchaseWithReset,
validateReceipt,
restorePurchases: async () => {
try {
const purchases = await restorePurchasesTopLevel({
alsoPublishToEventListenerIOS: false,
onlyIncludeActiveItemsIOS: true
});
setAvailablePurchases(purchases);
} catch (e) {
console.warn('Failed to restore purchases:', e);
}
},
getProducts: getProductsInternal,
getSubscriptions: getSubscriptionsInternal,
getPromotedProductIOS,
requestPurchaseOnPromotedProductIOS,
getActiveSubscriptions: getActiveSubscriptionsInternal,
hasActiveSubscriptions: hasActiveSubscriptionsInternal
};
}
//# sourceMappingURL=useIAP.js.map
;