@umituz/react-native-subscription 2.27.112 → 2.27.114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/domains/credits/application/CreditsInitializer.ts +28 -125
- package/src/domains/credits/application/credit-strategies/{CreditAllocationContext.ts → CreditAllocationOrchestrator.ts} +4 -9
- package/src/domains/credits/application/creditDocumentHelpers.ts +58 -0
- package/src/domains/credits/application/creditOperationUtils.ts +154 -0
- package/src/domains/credits/core/CreditsMapper.ts +8 -13
- package/src/domains/credits/infrastructure/{CreditsRepositoryProvider.ts → CreditsRepositoryManager.ts} +2 -2
- package/src/domains/credits/presentation/useCredits.ts +2 -3
- package/src/domains/credits/presentation/useDeductCredit.ts +4 -4
- package/src/domains/paywall/components/PaywallContainer.types.ts +1 -1
- package/src/domains/paywall/components/PaywallModal.tsx +28 -52
- package/src/domains/paywall/hooks/usePaywallActions.ts +77 -33
- package/src/domains/subscription/application/SubscriptionInitializer.ts +1 -1
- package/src/domains/subscription/application/SubscriptionSyncService.ts +17 -21
- package/src/domains/subscription/core/RevenueCatError.ts +40 -31
- package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +0 -1
- package/src/domains/subscription/infrastructure/hooks/useRevenueCatTrialEligibility.ts +19 -85
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.ts +33 -75
- package/src/domains/subscription/infrastructure/managers/subscriptionManagerUtils.ts +57 -0
- package/src/domains/subscription/infrastructure/services/CustomerInfoListenerManager.ts +3 -12
- package/src/domains/subscription/infrastructure/services/PurchaseHandler.ts +0 -2
- package/src/domains/subscription/infrastructure/services/RevenueCatInitializer.ts +2 -4
- package/src/domains/subscription/infrastructure/services/RevenueCatService.ts +1 -5
- package/src/domains/subscription/infrastructure/utils/PremiumStatusSyncer.ts +3 -12
- package/src/domains/subscription/infrastructure/utils/authPurchaseState.ts +69 -0
- package/src/domains/subscription/infrastructure/utils/trialEligibilityUtils.ts +77 -0
- package/src/domains/subscription/presentation/components/feedback/FeedbackOption.tsx +139 -0
- package/src/domains/subscription/presentation/components/feedback/PaywallFeedbackModal.tsx +15 -70
- package/src/domains/subscription/presentation/components/feedback/paywallFeedbackStyles.ts +0 -92
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +1 -1
- package/src/domains/subscription/presentation/stores/purchaseLoadingStore.ts +1 -18
- package/src/domains/subscription/presentation/useAuthAwarePurchase.ts +19 -69
- package/src/domains/subscription/presentation/usePaywallVisibility.ts +1 -1
- package/src/domains/subscription/presentation/usePremium.ts +2 -11
- package/src/domains/subscription/presentation/useSubscriptionStatus.ts +1 -6
- package/src/domains/trial/application/TrialService.ts +4 -8
- package/src/domains/wallet/index.ts +0 -6
- package/src/domains/wallet/infrastructure/repositories/TransactionRepository.ts +1 -1
- package/src/domains/wallet/infrastructure/services/ProductMetadataService.ts +0 -13
- package/src/domains/wallet/presentation/hooks/useProductMetadata.ts +0 -10
- package/src/domains/wallet/presentation/hooks/useTransactionHistory.ts +0 -8
- package/src/domains/wallet/presentation/screens/WalletScreen.tsx +57 -43
- package/src/index.ts +1 -1
- package/src/init/createSubscriptionInitModule.ts +1 -4
- package/src/presentation/hooks/feedback/useFeedbackSubmit.ts +0 -14
- package/src/shared/application/ActivationHandler.ts +6 -6
- package/src/shared/application/FeedbackService.ts +0 -21
- package/src/shared/infrastructure/SubscriptionEventBus.ts +1 -2
- package/src/shared/presentation/index.ts +1 -0
- package/src/shared/presentation/layouts/ScreenLayout.tsx +79 -0
- package/src/shared/types/CommonTypes.ts +65 -0
- package/src/shared/utils/BaseError.ts +26 -0
- package/src/shared/utils/Logger.ts +15 -46
- package/src/shared/utils/Result.ts +16 -0
- package/src/shared/utils/SubscriptionConfig.ts +1 -1
- package/src/shared/utils/SubscriptionError.ts +20 -30
- package/src/utils/appUtils.ts +34 -0
- package/src/utils/dateUtils.ts +32 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/packageTypeDetector.ts +0 -4
- package/src/domains/wallet/presentation/screens/WalletScreenContainer.tsx +0 -88
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Paywall Modal
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import React, {
|
|
6
|
-
import { View,
|
|
7
|
-
import { BaseModal, useAppDesignTokens, AtomicText, AtomicIcon, AtomicSpinner } from "@umituz/react-native-design-system";
|
|
5
|
+
import React, { useCallback, useEffect } from "react";
|
|
6
|
+
import { View, TouchableOpacity, Linking, type ImageSourcePropType } from "react-native";
|
|
7
|
+
import { BaseModal, useAppDesignTokens, AtomicText, AtomicIcon, AtomicSpinner, useSafeAreaInsets } from "@umituz/react-native-design-system";
|
|
8
|
+
import { ScreenLayout } from "../../../shared/presentation";
|
|
8
9
|
import { Image } from "expo-image";
|
|
9
10
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
10
11
|
import { PlanCard } from "./PlanCard";
|
|
@@ -12,7 +13,7 @@ import type { SubscriptionFeature, PaywallTranslations, PaywallLegalUrls } from
|
|
|
12
13
|
import { paywallModalStyles as styles } from "./PaywallModal.styles";
|
|
13
14
|
import { PaywallFeatures } from "./PaywallFeatures";
|
|
14
15
|
import { PaywallFooter } from "./PaywallFooter";
|
|
15
|
-
import {
|
|
16
|
+
import { usePaywallActions } from "../hooks/usePaywallActions";
|
|
16
17
|
|
|
17
18
|
/** Trial eligibility info per product */
|
|
18
19
|
export interface TrialEligibilityInfo {
|
|
@@ -47,60 +48,28 @@ export interface PaywallModalProps {
|
|
|
47
48
|
export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
48
49
|
const { visible, onClose, translations, packages = [], features = [], isLoading = false, legalUrls = {}, bestValueIdentifier, creditAmounts, creditsLabel, heroImage, onPurchase, onRestore, trialEligibility = {}, trialSubtitleText } = props;
|
|
49
50
|
const tokens = useAppDesignTokens();
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
const insets = useSafeAreaInsets();
|
|
52
|
+
|
|
53
|
+
const {
|
|
54
|
+
selectedPlanId,
|
|
55
|
+
setSelectedPlanId,
|
|
56
|
+
isProcessing,
|
|
57
|
+
handlePurchase,
|
|
58
|
+
handleRestore,
|
|
59
|
+
resetState,
|
|
60
|
+
} = usePaywallActions({ packages, onPurchase, onRestore });
|
|
56
61
|
|
|
57
62
|
// Reset selected plan when packages change
|
|
58
63
|
useEffect(() => {
|
|
59
64
|
setSelectedPlanId(null);
|
|
60
|
-
}, [packages]);
|
|
65
|
+
}, [packages, setSelectedPlanId]);
|
|
61
66
|
|
|
62
67
|
// Cleanup state when modal closes to prevent stale state
|
|
63
68
|
useEffect(() => {
|
|
64
69
|
if (!visible) {
|
|
65
|
-
|
|
66
|
-
setIsLocalProcessing(false);
|
|
67
|
-
}
|
|
68
|
-
}, [visible]);
|
|
69
|
-
|
|
70
|
-
// Combined processing state
|
|
71
|
-
const isProcessing = isLocalProcessing || isGlobalPurchasing;
|
|
72
|
-
|
|
73
|
-
const handlePurchase = useCallback(async () => {
|
|
74
|
-
if (!selectedPlanId || !onPurchase) return;
|
|
75
|
-
|
|
76
|
-
setIsLocalProcessing(true);
|
|
77
|
-
startPurchase(selectedPlanId, "manual");
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
const pkg = packages.find((p) => p.product.identifier === selectedPlanId);
|
|
81
|
-
if (pkg) {
|
|
82
|
-
await onPurchase(pkg);
|
|
83
|
-
}
|
|
84
|
-
} catch (error) {
|
|
85
|
-
if (__DEV__) {
|
|
86
|
-
console.error("[PaywallModal] Purchase failed:", error);
|
|
87
|
-
}
|
|
88
|
-
} finally {
|
|
89
|
-
setIsLocalProcessing(false);
|
|
90
|
-
endPurchase();
|
|
91
|
-
}
|
|
92
|
-
}, [selectedPlanId, packages, onPurchase, startPurchase, endPurchase]);
|
|
93
|
-
|
|
94
|
-
const handleRestore = useCallback(async () => {
|
|
95
|
-
if (!onRestore || isProcessing) return;
|
|
96
|
-
|
|
97
|
-
setIsLocalProcessing(true);
|
|
98
|
-
try {
|
|
99
|
-
await onRestore();
|
|
100
|
-
} finally {
|
|
101
|
-
setIsLocalProcessing(false);
|
|
70
|
+
resetState();
|
|
102
71
|
}
|
|
103
|
-
}, [
|
|
72
|
+
}, [visible, resetState]);
|
|
104
73
|
|
|
105
74
|
const handleLegalUrl = useCallback(async (url: string | undefined) => {
|
|
106
75
|
if (!url) return;
|
|
@@ -110,11 +79,18 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
|
110
79
|
return (
|
|
111
80
|
<BaseModal visible={visible} onClose={onClose} contentStyle={styles.modalContent}>
|
|
112
81
|
<View style={[styles.container, { backgroundColor: tokens.colors.surface }]}>
|
|
113
|
-
<TouchableOpacity
|
|
82
|
+
<TouchableOpacity
|
|
83
|
+
onPress={onClose}
|
|
84
|
+
style={[
|
|
85
|
+
styles.closeBtn,
|
|
86
|
+
{ backgroundColor: tokens.colors.surfaceSecondary, top: Math.max(insets.top, 12) }
|
|
87
|
+
]}
|
|
88
|
+
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
89
|
+
>
|
|
114
90
|
<AtomicIcon name="close-outline" size="md" customColor={tokens.colors.textPrimary} />
|
|
115
91
|
</TouchableOpacity>
|
|
116
92
|
|
|
117
|
-
<
|
|
93
|
+
<ScreenLayout scrollable={true} edges={["bottom"]} backgroundColor="transparent" contentContainerStyle={styles.scroll}>
|
|
118
94
|
{heroImage && (
|
|
119
95
|
<View style={styles.heroContainer}>
|
|
120
96
|
<Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={0} />
|
|
@@ -171,7 +147,7 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
|
171
147
|
</TouchableOpacity>
|
|
172
148
|
|
|
173
149
|
<PaywallFooter translations={translations} legalUrls={legalUrls} isProcessing={isProcessing} onRestore={onRestore ? handleRestore : undefined} onLegalClick={handleLegalUrl} />
|
|
174
|
-
</
|
|
150
|
+
</ScreenLayout>
|
|
175
151
|
</View>
|
|
176
152
|
</BaseModal>
|
|
177
153
|
);
|
|
@@ -1,55 +1,99 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* usePaywallActions Hook
|
|
3
|
+
* Encapsulates purchase and restore flow for the paywall.
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useCallback } from "react";
|
|
2
6
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
3
|
-
import {
|
|
4
|
-
import { useAuthAwarePurchase } from "../../subscription/presentation/useAuthAwarePurchase";
|
|
7
|
+
import { usePurchaseLoadingStore } from "../../subscription/presentation/stores";
|
|
5
8
|
import type { PurchaseSource } from "../../subscription/core/SubscriptionConstants";
|
|
6
9
|
|
|
7
|
-
interface
|
|
10
|
+
export interface UsePaywallActionsParams {
|
|
11
|
+
packages?: PurchasesPackage[];
|
|
12
|
+
onPurchase?: (pkg: PurchasesPackage) => Promise<void | boolean>;
|
|
13
|
+
onRestore?: () => Promise<void | boolean>;
|
|
8
14
|
source?: PurchaseSource;
|
|
9
15
|
onPurchaseSuccess?: () => void;
|
|
10
|
-
onPurchaseError?: (error: string) => void;
|
|
16
|
+
onPurchaseError?: (error: Error | string) => void;
|
|
11
17
|
onAuthRequired?: () => void;
|
|
12
|
-
onClose
|
|
18
|
+
onClose?: () => void;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
|
-
export
|
|
16
|
-
|
|
21
|
+
export function usePaywallActions({
|
|
22
|
+
packages = [],
|
|
23
|
+
onPurchase,
|
|
24
|
+
onRestore,
|
|
17
25
|
onPurchaseSuccess,
|
|
18
26
|
onPurchaseError,
|
|
19
|
-
onAuthRequired
|
|
27
|
+
onAuthRequired,
|
|
20
28
|
onClose,
|
|
21
|
-
}:
|
|
22
|
-
const
|
|
23
|
-
const
|
|
29
|
+
}: UsePaywallActionsParams) {
|
|
30
|
+
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
|
31
|
+
const [isLocalProcessing, setIsLocalProcessing] = useState(false);
|
|
32
|
+
|
|
33
|
+
const { startPurchase, endPurchase } = usePurchaseLoadingStore();
|
|
34
|
+
const isGlobalPurchasing = usePurchaseLoadingStore((state) => state.isPurchasing);
|
|
35
|
+
|
|
36
|
+
const isProcessing = isLocalProcessing || isGlobalPurchasing;
|
|
37
|
+
|
|
38
|
+
const handlePurchase = useCallback(async () => {
|
|
39
|
+
// If no plan selected, use the first available one as fallback or return
|
|
40
|
+
const planId = selectedPlanId || (packages.length > 0 ? packages[0]?.product.identifier : null);
|
|
41
|
+
|
|
42
|
+
if (!planId || !onPurchase || isProcessing) {
|
|
43
|
+
if (!planId && onAuthRequired) onAuthRequired();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setIsLocalProcessing(true);
|
|
48
|
+
// Map PurchaseSource to store's expected "manual" | "auto-execution"
|
|
49
|
+
startPurchase(planId, "manual");
|
|
24
50
|
|
|
25
|
-
const handlePurchase = useCallback(async (pkg: PurchasesPackage) => {
|
|
26
51
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
52
|
+
const pkg = packages.find((p) => p.product.identifier === planId);
|
|
53
|
+
if (pkg) {
|
|
54
|
+
const success = await onPurchase(pkg);
|
|
55
|
+
if (success !== false) {
|
|
56
|
+
onPurchaseSuccess?.();
|
|
57
|
+
onClose?.(); // Close on success if provided
|
|
58
|
+
}
|
|
29
59
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
37
|
-
onPurchaseError?.(message);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
62
|
+
onPurchaseError?.(err);
|
|
63
|
+
} finally {
|
|
64
|
+
setIsLocalProcessing(false);
|
|
65
|
+
endPurchase();
|
|
38
66
|
}
|
|
39
|
-
}, [
|
|
67
|
+
}, [selectedPlanId, packages, onPurchase, isProcessing, startPurchase, endPurchase, onPurchaseSuccess, onPurchaseError, onAuthRequired, onClose]);
|
|
40
68
|
|
|
41
69
|
const handleRestore = useCallback(async () => {
|
|
70
|
+
if (!onRestore || isProcessing) return;
|
|
71
|
+
|
|
72
|
+
setIsLocalProcessing(true);
|
|
42
73
|
try {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
74
|
+
const success = await onRestore();
|
|
75
|
+
if (success !== false) {
|
|
45
76
|
onPurchaseSuccess?.();
|
|
46
|
-
onClose();
|
|
47
77
|
}
|
|
48
|
-
} catch (
|
|
49
|
-
const
|
|
50
|
-
onPurchaseError?.(
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
80
|
+
onPurchaseError?.(err);
|
|
81
|
+
} finally {
|
|
82
|
+
setIsLocalProcessing(false);
|
|
51
83
|
}
|
|
52
|
-
}, [
|
|
84
|
+
}, [onRestore, isProcessing, onPurchaseSuccess, onPurchaseError]);
|
|
53
85
|
|
|
54
|
-
|
|
55
|
-
|
|
86
|
+
const resetState = useCallback(() => {
|
|
87
|
+
setSelectedPlanId(null);
|
|
88
|
+
setIsLocalProcessing(false);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
selectedPlanId,
|
|
93
|
+
setSelectedPlanId,
|
|
94
|
+
isProcessing,
|
|
95
|
+
handlePurchase,
|
|
96
|
+
handleRestore,
|
|
97
|
+
resetState,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { Platform } from "react-native";
|
|
10
|
-
import { configureCreditsRepository } from "../../credits/infrastructure/
|
|
10
|
+
import { configureCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
11
11
|
import { SubscriptionManager } from "../infrastructure/managers/SubscriptionManager";
|
|
12
12
|
import { configureAuthProvider } from "../presentation/useAuthAwarePurchase";
|
|
13
13
|
import { SubscriptionSyncService } from "./SubscriptionSyncService";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CustomerInfo } from "react-native-purchases";
|
|
2
2
|
import type { RevenueCatData } from "../core/RevenueCatData";
|
|
3
3
|
import { type PeriodType, type PurchaseSource, PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
|
|
4
|
-
import { getCreditsRepository } from "../../credits/infrastructure/
|
|
4
|
+
import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
5
5
|
import { extractRevenueCatData } from "./SubscriptionSyncUtils";
|
|
6
6
|
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
7
7
|
|
|
@@ -15,24 +15,22 @@ export class SubscriptionSyncService {
|
|
|
15
15
|
async handlePurchase(userId: string, productId: string, customerInfo: CustomerInfo, source?: PurchaseSource) {
|
|
16
16
|
try {
|
|
17
17
|
const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
|
|
18
|
-
const purchaseId = revenueCatData.originalTransactionId
|
|
18
|
+
const purchaseId = revenueCatData.originalTransactionId
|
|
19
19
|
? `purchase_${revenueCatData.originalTransactionId}`
|
|
20
20
|
: `purchase_${productId}_${Date.now()}`;
|
|
21
21
|
|
|
22
22
|
await getCreditsRepository().initializeCredits(
|
|
23
|
-
userId,
|
|
24
|
-
purchaseId,
|
|
25
|
-
productId,
|
|
26
|
-
source ?? PURCHASE_SOURCE.SETTINGS,
|
|
23
|
+
userId,
|
|
24
|
+
purchaseId,
|
|
25
|
+
productId,
|
|
26
|
+
source ?? PURCHASE_SOURCE.SETTINGS,
|
|
27
27
|
revenueCatData,
|
|
28
|
-
PURCHASE_TYPE.INITIAL
|
|
28
|
+
PURCHASE_TYPE.INITIAL
|
|
29
29
|
);
|
|
30
|
-
|
|
31
|
-
// Notify listeners via Event Bus
|
|
30
|
+
|
|
32
31
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
33
32
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PURCHASE_COMPLETED, { userId, productId });
|
|
34
|
-
} catch
|
|
35
|
-
if (__DEV__) console.error('[SubscriptionSyncService] Credits init failed:', error);
|
|
33
|
+
} catch {
|
|
36
34
|
}
|
|
37
35
|
}
|
|
38
36
|
|
|
@@ -40,23 +38,22 @@ export class SubscriptionSyncService {
|
|
|
40
38
|
try {
|
|
41
39
|
const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
|
|
42
40
|
revenueCatData.expirationDate = newExpirationDate || revenueCatData.expirationDate;
|
|
43
|
-
const purchaseId = revenueCatData.originalTransactionId
|
|
41
|
+
const purchaseId = revenueCatData.originalTransactionId
|
|
44
42
|
? `renewal_${revenueCatData.originalTransactionId}_${newExpirationDate}`
|
|
45
43
|
: `renewal_${productId}_${Date.now()}`;
|
|
46
44
|
|
|
47
45
|
await getCreditsRepository().initializeCredits(
|
|
48
|
-
userId,
|
|
49
|
-
purchaseId,
|
|
50
|
-
productId,
|
|
51
|
-
PURCHASE_SOURCE.RENEWAL,
|
|
46
|
+
userId,
|
|
47
|
+
purchaseId,
|
|
48
|
+
productId,
|
|
49
|
+
PURCHASE_SOURCE.RENEWAL,
|
|
52
50
|
revenueCatData,
|
|
53
51
|
PURCHASE_TYPE.RENEWAL
|
|
54
52
|
);
|
|
55
|
-
|
|
53
|
+
|
|
56
54
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
57
55
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.RENEWAL_DETECTED, { userId, productId });
|
|
58
|
-
} catch
|
|
59
|
-
if (__DEV__) console.error('[SubscriptionSyncService] Renewal credits init failed:', error);
|
|
56
|
+
} catch {
|
|
60
57
|
}
|
|
61
58
|
}
|
|
62
59
|
|
|
@@ -123,8 +120,7 @@ export class SubscriptionSyncService {
|
|
|
123
120
|
|
|
124
121
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
125
122
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED, { userId, isPremium });
|
|
126
|
-
} catch
|
|
127
|
-
if (__DEV__) console.error('[SubscriptionSyncService] Premium status sync failed:', error);
|
|
123
|
+
} catch {
|
|
128
124
|
}
|
|
129
125
|
}
|
|
130
126
|
}
|
|
@@ -3,54 +3,63 @@
|
|
|
3
3
|
* Domain-specific error types for RevenueCat operations
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
import { BaseError } from "../../../shared/utils/BaseError";
|
|
7
|
+
|
|
8
|
+
export class RevenueCatError extends BaseError {
|
|
9
|
+
constructor(message: string, code: string = 'REVENUE_CAT_ERROR', cause?: Error) {
|
|
10
|
+
super(message, code, cause);
|
|
11
|
+
this.name = "RevenueCatError";
|
|
12
|
+
}
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export class RevenueCatInitializationError extends RevenueCatError {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
constructor(message = "RevenueCat service is not initialized", cause?: Error) {
|
|
17
|
+
super(message, 'REVENUE_CAT_NOT_INITIALIZED', cause);
|
|
18
|
+
this.name = "RevenueCatInitializationError";
|
|
19
|
+
}
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export class RevenueCatConfigurationError extends RevenueCatError {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
constructor(message = "RevenueCat configuration is invalid", cause?: Error) {
|
|
24
|
+
super(message, 'REVENUE_CAT_CONFIGURATION_ERROR', cause);
|
|
25
|
+
this.name = "RevenueCatConfigurationError";
|
|
26
|
+
}
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export class RevenueCatPurchaseError extends RevenueCatError {
|
|
28
|
-
|
|
30
|
+
public readonly productId: string | undefined;
|
|
31
|
+
|
|
32
|
+
constructor(message: string, productId?: string, cause?: Error) {
|
|
33
|
+
super(message, 'REVENUE_CAT_PURCHASE_ERROR', cause);
|
|
34
|
+
this.name = "RevenueCatPurchaseError";
|
|
35
|
+
this.productId = productId;
|
|
36
|
+
}
|
|
29
37
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
override toJSON() {
|
|
39
|
+
return {
|
|
40
|
+
...super.toJSON(),
|
|
41
|
+
productId: this.productId,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
export class RevenueCatRestoreError extends RevenueCatError {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
constructor(message = "Failed to restore purchases", cause?: Error) {
|
|
48
|
+
super(message, 'REVENUE_CAT_RESTORE_ERROR', cause);
|
|
49
|
+
this.name = "RevenueCatRestoreError";
|
|
50
|
+
}
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
export class RevenueCatNetworkError extends RevenueCatError {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
constructor(message = "Network error during RevenueCat operation", cause?: Error) {
|
|
55
|
+
super(message, 'REVENUE_CAT_NETWORK_ERROR', cause);
|
|
56
|
+
this.name = "RevenueCatNetworkError";
|
|
57
|
+
}
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
export class RevenueCatExpoGoError extends RevenueCatError {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
constructor(message = "RevenueCat is not available in Expo Go. Use a development build or test store.", cause?: Error) {
|
|
62
|
+
super(message, 'REVENUE_CAT_EXPO_GO_ERROR', cause);
|
|
63
|
+
this.name = "RevenueCatExpoGoError";
|
|
64
|
+
}
|
|
56
65
|
}
|
|
@@ -5,24 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
import { getRevenueCatService } from "../services/RevenueCatService";
|
|
9
|
+
import {
|
|
10
|
+
checkTrialEligibility,
|
|
11
|
+
createFallbackEligibilityMap,
|
|
12
|
+
hasAnyEligibleTrial,
|
|
13
|
+
type ProductTrialEligibility,
|
|
14
|
+
type TrialEligibilityMap,
|
|
15
|
+
} from "../utils/trialEligibilityUtils";
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
export interface ProductTrialEligibility {
|
|
16
|
-
/** Product identifier */
|
|
17
|
-
productId: string;
|
|
18
|
-
/** Whether eligible for introductory offer (free trial) */
|
|
19
|
-
eligible: boolean;
|
|
20
|
-
/** Trial duration in days (if available from product) */
|
|
21
|
-
trialDurationDays?: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Map of product ID to eligibility */
|
|
25
|
-
export type TrialEligibilityMap = Record<string, ProductTrialEligibility>;
|
|
17
|
+
export type { ProductTrialEligibility, TrialEligibilityMap };
|
|
26
18
|
|
|
27
19
|
export interface UseRevenueCatTrialEligibilityResult {
|
|
28
20
|
/** Map of product IDs to their trial eligibility */
|
|
@@ -37,17 +29,6 @@ export interface UseRevenueCatTrialEligibilityResult {
|
|
|
37
29
|
getProductEligibility: (productId: string) => ProductTrialEligibility | null;
|
|
38
30
|
}
|
|
39
31
|
|
|
40
|
-
/** Cache duration in milliseconds (5 minutes) */
|
|
41
|
-
const CACHE_DURATION_MS = 5 * 60 * 1000;
|
|
42
|
-
|
|
43
|
-
/** Cached eligibility result */
|
|
44
|
-
interface CachedEligibility {
|
|
45
|
-
data: TrialEligibilityMap;
|
|
46
|
-
timestamp: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
let eligibilityCache: CachedEligibility | null = null;
|
|
50
|
-
|
|
51
32
|
/**
|
|
52
33
|
* Hook to check trial eligibility via RevenueCat
|
|
53
34
|
* Uses Apple's introductory offer eligibility system
|
|
@@ -56,6 +37,7 @@ export function useRevenueCatTrialEligibility(): UseRevenueCatTrialEligibilityRe
|
|
|
56
37
|
const [eligibilityMap, setEligibilityMap] = useState<TrialEligibilityMap>({});
|
|
57
38
|
const [isLoading, setIsLoading] = useState(false);
|
|
58
39
|
const isMountedRef = useRef(true);
|
|
40
|
+
const currentRequestRef = useRef<number | null>(null);
|
|
59
41
|
|
|
60
42
|
useEffect(() => {
|
|
61
43
|
isMountedRef.current = true;
|
|
@@ -69,69 +51,29 @@ export function useRevenueCatTrialEligibility(): UseRevenueCatTrialEligibilityRe
|
|
|
69
51
|
return;
|
|
70
52
|
}
|
|
71
53
|
|
|
72
|
-
// Check cache validity
|
|
73
|
-
if (
|
|
74
|
-
eligibilityCache &&
|
|
75
|
-
Date.now() - eligibilityCache.timestamp < CACHE_DURATION_MS
|
|
76
|
-
) {
|
|
77
|
-
const allCached = productIds.every(
|
|
78
|
-
(id) => eligibilityCache?.data[id] !== undefined
|
|
79
|
-
);
|
|
80
|
-
if (allCached && isMountedRef.current) {
|
|
81
|
-
setEligibilityMap(eligibilityCache.data);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
54
|
const service = getRevenueCatService();
|
|
87
55
|
if (!service || !service.isInitialized()) {
|
|
88
56
|
return;
|
|
89
57
|
}
|
|
90
58
|
|
|
59
|
+
const requestId = Date.now();
|
|
60
|
+
currentRequestRef.current = requestId;
|
|
91
61
|
setIsLoading(true);
|
|
92
62
|
|
|
93
63
|
try {
|
|
94
|
-
const
|
|
95
|
-
await Purchases.checkTrialOrIntroductoryPriceEligibility(productIds);
|
|
96
|
-
|
|
97
|
-
const newMap: TrialEligibilityMap = {};
|
|
64
|
+
const newMap = await checkTrialEligibility(productIds);
|
|
98
65
|
|
|
99
|
-
|
|
100
|
-
const eligibility = eligibilities[productId];
|
|
101
|
-
const isEligible =
|
|
102
|
-
eligibility?.status === INTRO_ELIGIBILITY_STATUS.INTRO_ELIGIBILITY_STATUS_ELIGIBLE;
|
|
103
|
-
|
|
104
|
-
newMap[productId] = {
|
|
105
|
-
productId,
|
|
106
|
-
eligible: isEligible,
|
|
107
|
-
trialDurationDays: 7, // Default to 7 days as configured in App Store Connect
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Update cache
|
|
112
|
-
eligibilityCache = {
|
|
113
|
-
data: { ...eligibilityCache?.data, ...newMap },
|
|
114
|
-
timestamp: Date.now(),
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
if (isMountedRef.current) {
|
|
66
|
+
if (isMountedRef.current && currentRequestRef.current === requestId) {
|
|
118
67
|
setEligibilityMap((prev) => ({ ...prev, ...newMap }));
|
|
119
68
|
}
|
|
120
69
|
} catch {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
fallbackMap[productId] = {
|
|
125
|
-
productId,
|
|
126
|
-
eligible: true,
|
|
127
|
-
trialDurationDays: 7,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
if (isMountedRef.current) {
|
|
70
|
+
const fallbackMap = createFallbackEligibilityMap(productIds);
|
|
71
|
+
|
|
72
|
+
if (isMountedRef.current && currentRequestRef.current === requestId) {
|
|
131
73
|
setEligibilityMap((prev) => ({ ...prev, ...fallbackMap }));
|
|
132
74
|
}
|
|
133
75
|
} finally {
|
|
134
|
-
if (isMountedRef.current) {
|
|
76
|
+
if (isMountedRef.current && currentRequestRef.current === requestId) {
|
|
135
77
|
setIsLoading(false);
|
|
136
78
|
}
|
|
137
79
|
}
|
|
@@ -144,9 +86,7 @@ export function useRevenueCatTrialEligibility(): UseRevenueCatTrialEligibilityRe
|
|
|
144
86
|
[eligibilityMap]
|
|
145
87
|
);
|
|
146
88
|
|
|
147
|
-
const hasEligibleTrial =
|
|
148
|
-
(e) => e.eligible
|
|
149
|
-
);
|
|
89
|
+
const hasEligibleTrial = hasAnyEligibleTrial(eligibilityMap);
|
|
150
90
|
|
|
151
91
|
return {
|
|
152
92
|
eligibilityMap,
|
|
@@ -157,9 +97,3 @@ export function useRevenueCatTrialEligibility(): UseRevenueCatTrialEligibilityRe
|
|
|
157
97
|
};
|
|
158
98
|
}
|
|
159
99
|
|
|
160
|
-
/**
|
|
161
|
-
* Clear eligibility cache (useful for testing)
|
|
162
|
-
*/
|
|
163
|
-
export function clearTrialEligibilityCache(): void {
|
|
164
|
-
eligibilityCache = null;
|
|
165
|
-
}
|