@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.
- package/package.json +1 -1
- package/src/domains/paywall/components/PaywallModal.tsx +36 -13
- package/src/domains/paywall/components/PlanCard.tsx +16 -3
- package/src/domains/paywall/entities/types.ts +4 -0
- package/src/domains/paywall/hooks/usePaywallTranslations.ts +8 -0
- package/src/presentation/hooks/index.ts +0 -15
- package/src/presentation/hooks/useFeatureGate.ts +41 -116
- package/src/revenuecat/domain/types/RevenueCatTypes.ts +32 -0
- package/src/revenuecat/index.ts +1 -0
- package/src/revenuecat/presentation/hooks/useRevenueCatTrialEligibility.ts +179 -0
- package/src/presentation/hooks/useAuthAwarePurchase.md +0 -92
- package/src/presentation/hooks/useAuthAwarePurchase.ts +0 -138
- package/src/presentation/hooks/useAuthGate.md +0 -89
- package/src/presentation/hooks/useAuthGate.ts +0 -65
- package/src/presentation/hooks/useCreditChecker.md +0 -102
- package/src/presentation/hooks/useCreditChecker.ts +0 -41
- package/src/presentation/hooks/useCreditsGate.md +0 -94
- package/src/presentation/hooks/useCreditsGate.ts +0 -81
- package/src/presentation/hooks/useDevTestCallbacks.md +0 -91
- package/src/presentation/hooks/useDevTestCallbacks.ts +0 -142
- package/src/presentation/hooks/useInitializeCredits.md +0 -92
- package/src/presentation/hooks/useInitializeCredits.ts +0 -57
- package/src/presentation/hooks/usePremiumGate.md +0 -88
- package/src/presentation/hooks/usePremiumGate.ts +0 -116
- package/src/presentation/hooks/usePremiumWithCredits.md +0 -92
- package/src/presentation/hooks/usePremiumWithCredits.ts +0 -48
- package/src/presentation/hooks/useSubscription.md +0 -94
- package/src/presentation/hooks/useSubscription.ts +0 -119
- package/src/presentation/hooks/useSubscriptionDetails.md +0 -93
- package/src/presentation/hooks/useSubscriptionDetails.ts +0 -85
- package/src/presentation/hooks/useSubscriptionGate.md +0 -84
- package/src/presentation/hooks/useSubscriptionGate.ts +0 -67
- package/src/presentation/hooks/useSubscriptionStatus.md +0 -94
- package/src/presentation/hooks/useSubscriptionStatus.ts +0 -64
- package/src/presentation/hooks/useTrialEligibility.ts +0 -66
- package/src/presentation/hooks/useUserTier.md +0 -91
- package/src/presentation/hooks/useUserTier.ts +0 -78
- package/src/presentation/hooks/useUserTierWithRepository.md +0 -92
- 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.
|
|
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.
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
{
|
|
49
|
+
{displayBadge && (
|
|
41
50
|
<View style={styles.badgeContainer}>
|
|
42
|
-
<AtomicBadge
|
|
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
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
}, [
|
|
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;
|
|
85
|
-
const
|
|
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
|
|
80
|
+
console.log("[useFeatureGate] requireFeature", {
|
|
128
81
|
isAuthenticated,
|
|
129
82
|
hasSubscription,
|
|
130
|
-
|
|
131
|
-
|
|
83
|
+
creditBalance: creditBalanceRef.current,
|
|
84
|
+
requiredCredits,
|
|
132
85
|
});
|
|
133
86
|
}
|
|
134
87
|
|
|
135
|
-
|
|
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
|
-
|
|
150
|
-
if (
|
|
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
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
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
|
|
196
|
-
hasSubscription
|
|
197
|
-
hasCredits
|
|
198
|
-
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
|
+
};
|
package/src/revenuecat/index.ts
CHANGED
|
@@ -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
|
+
}
|