@umituz/react-native-subscription 2.26.13 → 2.26.15

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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/domains/paywall/components/PaywallModal.tsx +36 -13
  3. package/src/domains/paywall/components/PlanCard.tsx +16 -3
  4. package/src/domains/paywall/entities/types.ts +4 -0
  5. package/src/domains/paywall/hooks/usePaywallTranslations.ts +8 -0
  6. package/src/presentation/hooks/index.ts +0 -15
  7. package/src/presentation/hooks/useFeatureGate.ts +41 -116
  8. package/src/revenuecat/domain/types/RevenueCatTypes.ts +32 -0
  9. package/src/revenuecat/index.ts +1 -0
  10. package/src/revenuecat/presentation/hooks/useRevenueCatTrialEligibility.ts +179 -0
  11. package/src/presentation/hooks/useAuthAwarePurchase.md +0 -92
  12. package/src/presentation/hooks/useAuthAwarePurchase.ts +0 -138
  13. package/src/presentation/hooks/useAuthGate.md +0 -89
  14. package/src/presentation/hooks/useAuthGate.ts +0 -65
  15. package/src/presentation/hooks/useCreditChecker.md +0 -102
  16. package/src/presentation/hooks/useCreditChecker.ts +0 -41
  17. package/src/presentation/hooks/useCreditsGate.md +0 -94
  18. package/src/presentation/hooks/useCreditsGate.ts +0 -81
  19. package/src/presentation/hooks/useDevTestCallbacks.md +0 -91
  20. package/src/presentation/hooks/useDevTestCallbacks.ts +0 -142
  21. package/src/presentation/hooks/useInitializeCredits.md +0 -92
  22. package/src/presentation/hooks/useInitializeCredits.ts +0 -57
  23. package/src/presentation/hooks/usePremiumGate.md +0 -88
  24. package/src/presentation/hooks/usePremiumGate.ts +0 -116
  25. package/src/presentation/hooks/usePremiumWithCredits.md +0 -92
  26. package/src/presentation/hooks/usePremiumWithCredits.ts +0 -48
  27. package/src/presentation/hooks/useSubscription.md +0 -94
  28. package/src/presentation/hooks/useSubscription.ts +0 -119
  29. package/src/presentation/hooks/useSubscriptionDetails.md +0 -93
  30. package/src/presentation/hooks/useSubscriptionDetails.ts +0 -85
  31. package/src/presentation/hooks/useSubscriptionGate.md +0 -84
  32. package/src/presentation/hooks/useSubscriptionGate.ts +0 -67
  33. package/src/presentation/hooks/useSubscriptionStatus.md +0 -94
  34. package/src/presentation/hooks/useSubscriptionStatus.ts +0 -64
  35. package/src/presentation/hooks/useTrialEligibility.ts +0 -66
  36. package/src/presentation/hooks/useUserTier.md +0 -91
  37. package/src/presentation/hooks/useUserTier.ts +0 -78
  38. package/src/presentation/hooks/useUserTierWithRepository.md +0 -92
  39. package/src/presentation/hooks/useUserTierWithRepository.ts +0 -151
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.26.13",
3
+ "version": "2.26.15",
4
4
  "description": "Complete subscription management with RevenueCat, paywall UI, and credits system for React Native apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -16,6 +16,14 @@ import { usePurchaseLoadingStore, selectIsPurchasing } from "../../../presentati
16
16
 
17
17
  declare const __DEV__: boolean;
18
18
 
19
+ /** Trial eligibility info per product */
20
+ export interface TrialEligibilityInfo {
21
+ /** Whether eligible for trial */
22
+ eligible: boolean;
23
+ /** Trial duration in days */
24
+ durationDays?: number;
25
+ }
26
+
19
27
  export interface PaywallModalProps {
20
28
  visible: boolean;
21
29
  onClose: () => void;
@@ -30,10 +38,12 @@ export interface PaywallModalProps {
30
38
  heroImage?: ImageSourcePropType;
31
39
  onPurchase?: (pkg: PurchasesPackage) => Promise<void | boolean>;
32
40
  onRestore?: () => Promise<void | boolean>;
41
+ /** Trial eligibility map per product ID */
42
+ trialEligibility?: Record<string, TrialEligibilityInfo>;
33
43
  }
34
44
 
35
45
  export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
36
- const { visible, onClose, translations, packages = [], features = [], isLoading = false, legalUrls = {}, bestValueIdentifier, creditAmounts, creditsLabel, heroImage, onPurchase, onRestore } = props;
46
+ const { visible, onClose, translations, packages = [], features = [], isLoading = false, legalUrls = {}, bestValueIdentifier, creditAmounts, creditsLabel, heroImage, onPurchase, onRestore, trialEligibility = {} } = props;
37
47
  const tokens = useAppDesignTokens();
38
48
  const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
39
49
  const [isLocalProcessing, setIsLocalProcessing] = useState(false);
@@ -114,7 +124,11 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
114
124
 
115
125
  <View style={styles.header}>
116
126
  <AtomicText type="headlineMedium" style={[styles.title, { color: tokens.colors.textPrimary }]}>{translations.title}</AtomicText>
117
- {translations.subtitle && <AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>{translations.subtitle}</AtomicText>}
127
+ {(translations.trialSubtitle || translations.subtitle) && (
128
+ <AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>
129
+ {translations.trialSubtitle ?? translations.subtitle}
130
+ </AtomicText>
131
+ )}
118
132
  </View>
119
133
 
120
134
  <PaywallFeatures features={features} />
@@ -123,17 +137,26 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
123
137
  <View style={styles.loading}><AtomicSpinner size="lg" color="primary" text={translations.loadingText} /></View>
124
138
  ) : (
125
139
  <View style={styles.plans}>
126
- {packages.map((pkg) => (
127
- <PlanCard
128
- key={pkg.product.identifier}
129
- pkg={pkg}
130
- isSelected={selectedPlanId === pkg.product.identifier}
131
- onSelect={() => setSelectedPlanId(pkg.product.identifier)}
132
- badge={pkg.product.identifier === bestValueIdentifier ? translations.bestValueBadgeText : undefined}
133
- creditAmount={creditAmounts?.[pkg.product.identifier]}
134
- creditsLabel={creditsLabel}
135
- />
136
- ))}
140
+ {packages.map((pkg) => {
141
+ const productId = pkg.product.identifier;
142
+ const eligibility = trialEligibility[productId];
143
+ const hasFreeTrial = eligibility?.eligible ?? false;
144
+
145
+ return (
146
+ <PlanCard
147
+ key={productId}
148
+ pkg={pkg}
149
+ isSelected={selectedPlanId === productId}
150
+ onSelect={() => setSelectedPlanId(productId)}
151
+ badge={productId === bestValueIdentifier ? translations.bestValueBadgeText : undefined}
152
+ creditAmount={creditAmounts?.[productId]}
153
+ creditsLabel={creditsLabel}
154
+ hasFreeTrial={hasFreeTrial}
155
+ trialDurationDays={eligibility?.durationDays}
156
+ trialBadgeText={hasFreeTrial ? translations.trialBadgeText : undefined}
157
+ />
158
+ );
159
+ })}
137
160
  </View>
138
161
  )}
139
162
 
@@ -17,14 +17,23 @@ interface PlanCardProps {
17
17
  badge?: string;
18
18
  creditAmount?: number;
19
19
  creditsLabel?: string;
20
+ /** Whether this plan has a free trial */
21
+ hasFreeTrial?: boolean;
22
+ /** Trial duration in days */
23
+ trialDurationDays?: number;
24
+ /** Trial badge text (e.g., "7 days free") */
25
+ trialBadgeText?: string;
20
26
  }
21
27
 
22
28
  export const PlanCard: React.FC<PlanCardProps> = React.memo(
23
- ({ pkg, isSelected, onSelect, badge, creditAmount, creditsLabel }) => {
29
+ ({ pkg, isSelected, onSelect, badge, creditAmount, creditsLabel, hasFreeTrial, trialBadgeText }) => {
24
30
  const tokens = useAppDesignTokens();
25
31
  const title = pkg.product.title;
26
32
  const price = formatPrice(pkg.product.price, pkg.product.currencyCode);
27
33
 
34
+ // Determine which badge to show (trial badge takes priority if eligible)
35
+ const displayBadge = hasFreeTrial && trialBadgeText ? trialBadgeText : badge;
36
+
28
37
  return (
29
38
  <TouchableOpacity onPress={onSelect} activeOpacity={0.7} style={styles.touchable}>
30
39
  <View
@@ -37,9 +46,13 @@ export const PlanCard: React.FC<PlanCardProps> = React.memo(
37
46
  },
38
47
  ]}
39
48
  >
40
- {badge && (
49
+ {displayBadge && (
41
50
  <View style={styles.badgeContainer}>
42
- <AtomicBadge text={badge} variant="primary" size="sm" />
51
+ <AtomicBadge
52
+ text={displayBadge}
53
+ variant={hasFreeTrial ? "success" : "primary"}
54
+ size="sm"
55
+ />
43
56
  </View>
44
57
  )}
45
58
 
@@ -41,6 +41,10 @@ export interface PaywallTranslations {
41
41
  privacyText?: string;
42
42
  termsOfServiceText?: string;
43
43
  bestValueBadgeText?: string;
44
+ /** Trial-related translations */
45
+ trialBadgeText?: string;
46
+ /** Trial subtitle (e.g., "Try free for 7 days, then $X/year") */
47
+ trialSubtitle?: string;
44
48
  }
45
49
 
46
50
  export interface PaywallLegalUrls {
@@ -13,6 +13,10 @@ interface PaywallTranslationKeys {
13
13
  processingText: string;
14
14
  privacyText: string;
15
15
  termsOfServiceText: string;
16
+ /** Trial badge text key */
17
+ trialBadgeText?: string;
18
+ /** Trial subtitle key */
19
+ trialSubtitle?: string;
16
20
  }
17
21
 
18
22
  interface UsePaywallTranslationsParams {
@@ -38,6 +42,8 @@ const DEFAULT_KEYS: PaywallTranslationKeys = {
38
42
  processingText: "paywall.processing",
39
43
  privacyText: "auth.privacyPolicy",
40
44
  termsOfServiceText: "auth.termsOfService",
45
+ trialBadgeText: "paywall.trial.badge",
46
+ trialSubtitle: "paywall.trial.subtitle",
41
47
  };
42
48
 
43
49
  export const usePaywallTranslations = ({
@@ -62,6 +68,8 @@ export const usePaywallTranslations = ({
62
68
  processingText: t(mergedKeys.processingText),
63
69
  privacyText: t(mergedKeys.privacyText),
64
70
  termsOfServiceText: t(mergedKeys.termsOfServiceText),
71
+ trialBadgeText: mergedKeys.trialBadgeText ? t(mergedKeys.trialBadgeText) : undefined,
72
+ trialSubtitle: mergedKeys.trialSubtitle ? t(mergedKeys.trialSubtitle) : undefined,
65
73
  }),
66
74
  [t, mergedKeys],
67
75
  );
@@ -1,25 +1,10 @@
1
- export * from "./useAuthAwarePurchase";
2
- export * from "./useAuthGate";
3
1
  export * from "./useAuthSubscriptionSync";
4
2
  export * from "./useSavedPurchaseAutoExecution";
5
- export * from "./useCreditChecker";
6
3
  export * from "./useCredits";
7
- export * from "./useCreditsGate";
8
4
  export * from "./useDeductCredit";
9
- export * from "./useInitializeCredits";
10
- export * from "./useDevTestCallbacks";
11
5
  export * from "./useFeatureGate";
12
6
  export * from "./usePaywallVisibility";
13
7
  export * from "./usePremium";
14
- export * from "./usePremiumGate";
15
- export * from "./usePremiumWithCredits";
16
- export * from "./useSubscription";
17
- export * from "./useSubscriptionDetails";
18
- export * from "./useSubscriptionGate";
19
8
  export * from "./useSubscriptionSettingsConfig";
20
- export * from "./useSubscriptionStatus";
21
- export * from "./useUserTier";
22
- export * from "./useUserTierWithRepository";
23
- export * from "./useTrialEligibility";
24
9
  export * from "./feedback/usePaywallFeedback";
25
10
  export * from "./feedback/useFeedbackSubmit";
@@ -1,75 +1,52 @@
1
1
  /**
2
2
  * useFeatureGate Hook
3
- * Combines auth, subscription, and credits gates into a unified feature gate.
3
+ * Unified feature gate: Auth Subscription Credits
4
+ * Uses ref pattern to avoid stale closure issues.
4
5
  */
5
6
 
6
7
  import { useCallback, useRef, useEffect } from "react";
7
- import { useAuthGate } from "./useAuthGate";
8
- import { useSubscriptionGate } from "./useSubscriptionGate";
9
- import { useCreditsGate } from "./useCreditsGate";
10
8
 
11
9
  declare const __DEV__: boolean;
12
10
 
13
-
14
-
15
11
  export interface UseFeatureGateParams {
16
- /** Whether user is authenticated (not guest/anonymous) */
17
- isAuthenticated: boolean;
18
- /** Callback to show auth modal with pending action */
19
- onShowAuthModal: (pendingCallback: () => void | Promise<void>) => void;
20
- /** Whether user has active subscription (optional, defaults to false) */
21
- hasSubscription?: boolean;
22
- /** Whether user has enough credits for the action */
23
- hasCredits: boolean;
24
- /** Current credit balance */
25
- creditBalance: number;
26
- /** Credits required for this action (optional, for paywall display) */
27
- requiredCredits?: number;
28
- /** Callback to show paywall - receives required credits */
29
- onShowPaywall: (requiredCredits?: number) => void;
12
+ readonly isAuthenticated: boolean;
13
+ readonly onShowAuthModal: (pendingCallback: () => void | Promise<void>) => void;
14
+ readonly hasSubscription?: boolean;
15
+ readonly creditBalance: number;
16
+ readonly requiredCredits?: number;
17
+ readonly onShowPaywall: (requiredCredits?: number) => void;
30
18
  }
31
19
 
32
20
  export interface UseFeatureGateResult {
33
- /** Gate a feature - checks auth, subscription, then credits */
34
- requireFeature: (action: () => void | Promise<void>) => void;
35
- /** Whether user is authenticated */
36
- isAuthenticated: boolean;
37
- /** Whether user has active subscription */
38
- hasSubscription: boolean;
39
- /** Whether user has enough credits */
40
- hasCredits: boolean;
41
- /** Current credit balance */
42
- creditBalance: number;
43
- /** Whether feature access is allowed */
44
- canAccess: boolean;
21
+ readonly requireFeature: (action: () => void | Promise<void>) => void;
22
+ readonly isAuthenticated: boolean;
23
+ readonly hasSubscription: boolean;
24
+ readonly hasCredits: boolean;
25
+ readonly creditBalance: number;
26
+ readonly canAccess: boolean;
45
27
  }
46
28
 
47
- export function useFeatureGate(
48
- params: UseFeatureGateParams
49
- ): UseFeatureGateResult {
29
+ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResult {
50
30
  const {
51
31
  isAuthenticated,
52
32
  onShowAuthModal,
53
33
  hasSubscription = false,
54
- hasCredits,
55
34
  creditBalance,
56
- requiredCredits,
35
+ requiredCredits = 1,
57
36
  onShowPaywall,
58
37
  } = params;
59
38
 
60
- // Store pending action for execution after purchase
61
39
  const pendingActionRef = useRef<(() => void | Promise<void>) | null>(null);
62
40
  const prevCreditBalanceRef = useRef(creditBalance);
63
41
  const isWaitingForPurchaseRef = useRef(false);
64
42
 
65
- // Refs to always get current values in closures
66
- const hasCreditsRef = useRef(hasCredits);
43
+ const creditBalanceRef = useRef(creditBalance);
67
44
  const hasSubscriptionRef = useRef(hasSubscription);
68
45
  const onShowPaywallRef = useRef(onShowPaywall);
69
46
 
70
47
  useEffect(() => {
71
- hasCreditsRef.current = hasCredits;
72
- }, [hasCredits]);
48
+ creditBalanceRef.current = creditBalance;
49
+ }, [creditBalance]);
73
50
 
74
51
  useEffect(() => {
75
52
  hasSubscriptionRef.current = hasSubscription;
@@ -79,11 +56,9 @@ export function useFeatureGate(
79
56
  onShowPaywallRef.current = onShowPaywall;
80
57
  }, [onShowPaywall]);
81
58
 
82
- // Execute pending action when credits increase after purchase
83
59
  useEffect(() => {
84
- const prevBalance = prevCreditBalanceRef.current ?? 0; // ← FIX: Default to 0 if undefined
85
- const currentBalance = creditBalance;
86
- const creditsIncreased = currentBalance > prevBalance;
60
+ const prevBalance = prevCreditBalanceRef.current ?? 0;
61
+ const creditsIncreased = creditBalance > prevBalance;
87
62
 
88
63
  if (isWaitingForPurchaseRef.current && creditsIncreased && pendingActionRef.current) {
89
64
  const action = pendingActionRef.current;
@@ -91,11 +66,7 @@ export function useFeatureGate(
91
66
  isWaitingForPurchaseRef.current = false;
92
67
 
93
68
  if (typeof __DEV__ !== "undefined" && __DEV__) {
94
- console.log("[useFeatureGate] Credits increased, executing pending action", {
95
- prevBalance,
96
- currentBalance,
97
- creditsIncreased,
98
- });
69
+ console.log("[useFeatureGate] Credits increased, executing pending action");
99
70
  }
100
71
  action();
101
72
  }
@@ -103,113 +74,67 @@ export function useFeatureGate(
103
74
  prevCreditBalanceRef.current = creditBalance;
104
75
  }, [creditBalance]);
105
76
 
106
- // Compose individual gates
107
- const authGate = useAuthGate({
108
- isAuthenticated,
109
- onAuthRequired: onShowAuthModal,
110
- });
111
-
112
- const subscriptionGate = useSubscriptionGate({
113
- hasSubscription,
114
- onSubscriptionRequired: () => onShowPaywall(requiredCredits),
115
- });
116
-
117
- const creditsGate = useCreditsGate({
118
- hasCredits,
119
- creditBalance,
120
- requiredCredits,
121
- onCreditsRequired: onShowPaywall,
122
- });
123
-
124
77
  const requireFeature = useCallback(
125
78
  (action: () => void | Promise<void>) => {
126
79
  if (typeof __DEV__ !== "undefined" && __DEV__) {
127
- console.log("[useFeatureGate] requireFeature called", {
80
+ console.log("[useFeatureGate] requireFeature", {
128
81
  isAuthenticated,
129
82
  hasSubscription,
130
- hasCredits,
131
- creditBalance,
83
+ creditBalance: creditBalanceRef.current,
84
+ requiredCredits,
132
85
  });
133
86
  }
134
87
 
135
- // Step 1: Auth check
136
- if (!authGate.requireAuth(() => {})) {
137
- // Wrap action to re-check credits after auth succeeds
138
- // Using refs to get current values when callback executes
88
+ if (!isAuthenticated) {
139
89
  const postAuthAction = () => {
140
- // Subscription check (bypasses credits if subscribed)
141
90
  if (hasSubscriptionRef.current) {
142
- if (typeof __DEV__ !== "undefined" && __DEV__) {
143
- console.log("[useFeatureGate] User has subscription, executing action");
144
- }
145
91
  action();
146
92
  return;
147
93
  }
148
94
 
149
- // Credits check
150
- if (!hasCreditsRef.current) {
95
+ const currentBalance = creditBalanceRef.current;
96
+ if (currentBalance < requiredCredits) {
151
97
  pendingActionRef.current = action;
152
98
  isWaitingForPurchaseRef.current = true;
153
99
  onShowPaywallRef.current(requiredCredits);
154
100
  return;
155
101
  }
156
102
 
157
- // All checks passed
158
- if (typeof __DEV__ !== "undefined" && __DEV__) {
159
- console.log("[useFeatureGate] User has credits, executing action");
160
- }
161
103
  action();
162
104
  };
163
105
  onShowAuthModal(postAuthAction);
164
106
  return;
165
107
  }
166
108
 
167
- // Step 2: Subscription check (bypasses credits if subscribed)
168
109
  if (hasSubscription) {
169
110
  action();
170
111
  return;
171
112
  }
172
113
 
173
- // Step 3: Credits check
174
- if (!creditsGate.requireCredits(() => {})) {
175
- // Store pending action for execution after purchase
114
+ const currentBalance = creditBalanceRef.current;
115
+ if (currentBalance < requiredCredits) {
116
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
117
+ console.log("[useFeatureGate] No credits, showing paywall");
118
+ }
176
119
  pendingActionRef.current = action;
177
120
  isWaitingForPurchaseRef.current = true;
121
+ onShowPaywall(requiredCredits);
178
122
  return;
179
123
  }
180
124
 
181
- // Step 4: All checks passed, execute action
182
125
  action();
183
126
  },
184
- [
185
- authGate,
186
- creditsGate,
187
- hasSubscription,
188
- requiredCredits,
189
- onShowAuthModal,
190
- ]
127
+ [isAuthenticated, hasSubscription, requiredCredits, onShowAuthModal, onShowPaywall]
191
128
  );
192
129
 
130
+ const hasCredits = creditBalance >= requiredCredits;
131
+
193
132
  return {
194
133
  requireFeature,
195
- isAuthenticated: authGate.isAuthenticated,
196
- hasSubscription: subscriptionGate.hasSubscription,
197
- hasCredits: creditsGate.hasCredits,
198
- creditBalance: creditsGate.creditBalance,
134
+ isAuthenticated,
135
+ hasSubscription,
136
+ hasCredits,
137
+ creditBalance,
199
138
  canAccess: isAuthenticated && (hasSubscription || hasCredits),
200
139
  };
201
140
  }
202
-
203
- export { useAuthGate, useSubscriptionGate, useCreditsGate };
204
- export type {
205
- UseAuthGateParams,
206
- UseAuthGateResult,
207
- } from "./useAuthGate";
208
- export type {
209
- UseSubscriptionGateParams,
210
- UseSubscriptionGateResult,
211
- } from "./useSubscriptionGate";
212
- export type {
213
- UseCreditsGateParams,
214
- UseCreditsGateResult,
215
- } from "./useCreditsGate";
@@ -75,3 +75,35 @@ export function getErrorMessage(error: unknown, fallback: string): string {
75
75
  }
76
76
  return fallback;
77
77
  }
78
+
79
+ /**
80
+ * Trial Eligibility Types
81
+ * For RevenueCat introductory offer (free trial) support
82
+ */
83
+
84
+ /** Trial info for a subscription product */
85
+ export interface TrialInfo {
86
+ /** Whether user is eligible for trial */
87
+ eligible: boolean;
88
+ /** Trial duration in days */
89
+ durationDays: number;
90
+ /** Product identifier */
91
+ productId: string;
92
+ }
93
+
94
+ /** Configuration for trial display */
95
+ export interface TrialDisplayConfig {
96
+ /** Product IDs that have trial offers */
97
+ trialProductIds: string[];
98
+ /** Default trial duration in days */
99
+ defaultTrialDays: number;
100
+ /** Whether to show trial badge */
101
+ showTrialBadge: boolean;
102
+ }
103
+
104
+ /** Default trial configuration */
105
+ export const DEFAULT_TRIAL_CONFIG: TrialDisplayConfig = {
106
+ trialProductIds: [],
107
+ defaultTrialDays: 7,
108
+ showTrialBadge: true,
109
+ };
@@ -10,3 +10,4 @@ export * from "./presentation/hooks/useRevenueCat";
10
10
  export * from "./presentation/hooks/useCustomerInfo";
11
11
  export * from "./presentation/hooks/usePaywallFlow";
12
12
  export * from "./presentation/hooks/useSubscriptionQueries";
13
+ export * from "./presentation/hooks/useRevenueCatTrialEligibility";
@@ -0,0 +1,179 @@
1
+ /**
2
+ * useRevenueCatTrialEligibility Hook
3
+ * Checks if user is eligible for introductory offers via RevenueCat
4
+ * Uses Apple's native mechanism for trial eligibility
5
+ */
6
+
7
+ import { useState, useEffect, useCallback, useRef } from "react";
8
+ import Purchases, {
9
+ type IntroEligibility,
10
+ INTRO_ELIGIBILITY_STATUS,
11
+ } from "react-native-purchases";
12
+ import { getRevenueCatService } from "../../infrastructure/services/RevenueCatService";
13
+
14
+ declare const __DEV__: boolean;
15
+
16
+ /** Trial eligibility info for a single product */
17
+ export interface ProductTrialEligibility {
18
+ /** Product identifier */
19
+ productId: string;
20
+ /** Whether eligible for introductory offer (free trial) */
21
+ eligible: boolean;
22
+ /** Trial duration in days (if available from product) */
23
+ trialDurationDays?: number;
24
+ }
25
+
26
+ /** Map of product ID to eligibility */
27
+ export type TrialEligibilityMap = Record<string, ProductTrialEligibility>;
28
+
29
+ export interface UseRevenueCatTrialEligibilityResult {
30
+ /** Map of product IDs to their trial eligibility */
31
+ eligibilityMap: TrialEligibilityMap;
32
+ /** Whether eligibility check is in progress */
33
+ isLoading: boolean;
34
+ /** Whether any product has an eligible trial */
35
+ hasEligibleTrial: boolean;
36
+ /** Check eligibility for specific product IDs */
37
+ checkEligibility: (productIds: string[]) => Promise<void>;
38
+ /** Get eligibility for a specific product */
39
+ getProductEligibility: (productId: string) => ProductTrialEligibility | null;
40
+ }
41
+
42
+ /** Cache duration in milliseconds (5 minutes) */
43
+ const CACHE_DURATION_MS = 5 * 60 * 1000;
44
+
45
+ /** Cached eligibility result */
46
+ interface CachedEligibility {
47
+ data: TrialEligibilityMap;
48
+ timestamp: number;
49
+ }
50
+
51
+ let eligibilityCache: CachedEligibility | null = null;
52
+
53
+ /**
54
+ * Hook to check trial eligibility via RevenueCat
55
+ * Uses Apple's introductory offer eligibility system
56
+ */
57
+ export function useRevenueCatTrialEligibility(): UseRevenueCatTrialEligibilityResult {
58
+ const [eligibilityMap, setEligibilityMap] = useState<TrialEligibilityMap>({});
59
+ const [isLoading, setIsLoading] = useState(false);
60
+ const isMountedRef = useRef(true);
61
+
62
+ useEffect(() => {
63
+ isMountedRef.current = true;
64
+ return () => {
65
+ isMountedRef.current = false;
66
+ };
67
+ }, []);
68
+
69
+ const checkEligibility = useCallback(async (productIds: string[]) => {
70
+ if (productIds.length === 0) {
71
+ return;
72
+ }
73
+
74
+ // Check cache validity
75
+ if (
76
+ eligibilityCache &&
77
+ Date.now() - eligibilityCache.timestamp < CACHE_DURATION_MS
78
+ ) {
79
+ const allCached = productIds.every(
80
+ (id) => eligibilityCache?.data[id] !== undefined
81
+ );
82
+ if (allCached && isMountedRef.current) {
83
+ setEligibilityMap(eligibilityCache.data);
84
+ return;
85
+ }
86
+ }
87
+
88
+ const service = getRevenueCatService();
89
+ if (!service || !service.isInitialized()) {
90
+ if (__DEV__) {
91
+ console.log("[TrialEligibility] RevenueCat not initialized");
92
+ }
93
+ return;
94
+ }
95
+
96
+ setIsLoading(true);
97
+
98
+ try {
99
+ const eligibilities: Record<string, IntroEligibility> =
100
+ await Purchases.checkTrialOrIntroDiscountEligibility(productIds);
101
+
102
+ const newMap: TrialEligibilityMap = {};
103
+
104
+ for (const productId of productIds) {
105
+ const eligibility = eligibilities[productId];
106
+ const isEligible =
107
+ eligibility?.status === INTRO_ELIGIBILITY_STATUS.INTRO_ELIGIBILITY_STATUS_ELIGIBLE;
108
+
109
+ newMap[productId] = {
110
+ productId,
111
+ eligible: isEligible,
112
+ trialDurationDays: 7, // Default to 7 days as configured in App Store Connect
113
+ };
114
+
115
+ if (__DEV__) {
116
+ console.log(
117
+ `[TrialEligibility] ${productId}: ${isEligible ? "ELIGIBLE" : "NOT_ELIGIBLE"}`
118
+ );
119
+ }
120
+ }
121
+
122
+ // Update cache
123
+ eligibilityCache = {
124
+ data: { ...eligibilityCache?.data, ...newMap },
125
+ timestamp: Date.now(),
126
+ };
127
+
128
+ if (isMountedRef.current) {
129
+ setEligibilityMap((prev) => ({ ...prev, ...newMap }));
130
+ }
131
+ } catch (error) {
132
+ if (__DEV__) {
133
+ console.log("[TrialEligibility] Error checking eligibility:", error);
134
+ }
135
+ // On error, default to eligible (better UX)
136
+ const fallbackMap: TrialEligibilityMap = {};
137
+ for (const productId of productIds) {
138
+ fallbackMap[productId] = {
139
+ productId,
140
+ eligible: true,
141
+ trialDurationDays: 7,
142
+ };
143
+ }
144
+ if (isMountedRef.current) {
145
+ setEligibilityMap((prev) => ({ ...prev, ...fallbackMap }));
146
+ }
147
+ } finally {
148
+ if (isMountedRef.current) {
149
+ setIsLoading(false);
150
+ }
151
+ }
152
+ }, []);
153
+
154
+ const getProductEligibility = useCallback(
155
+ (productId: string): ProductTrialEligibility | null => {
156
+ return eligibilityMap[productId] ?? null;
157
+ },
158
+ [eligibilityMap]
159
+ );
160
+
161
+ const hasEligibleTrial = Object.values(eligibilityMap).some(
162
+ (e) => e.eligible
163
+ );
164
+
165
+ return {
166
+ eligibilityMap,
167
+ isLoading,
168
+ hasEligibleTrial,
169
+ checkEligibility,
170
+ getProductEligibility,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Clear eligibility cache (useful for testing)
176
+ */
177
+ export function clearTrialEligibilityCache(): void {
178
+ eligibilityCache = null;
179
+ }