@umituz/react-native-subscription 2.14.82 → 2.14.83
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/PaywallContainer.tsx +26 -159
- package/src/domains/paywall/components/PaywallFeatures.tsx +25 -0
- package/src/domains/paywall/components/PaywallFooter.tsx +46 -93
- package/src/domains/paywall/components/PaywallModal.tsx +14 -99
- package/src/domains/paywall/hooks/usePaywallActions.ts +63 -0
- package/src/index.ts +23 -461
- package/src/infrastructure/repositories/CreditsRepository.ts +43 -177
- package/src/infrastructure/services/SubscriptionInitializer.ts +32 -186
- package/src/presentation/hooks/index.ts +23 -0
- package/src/presentation/hooks/useDeductCredit.ts +22 -148
- package/src/presentation/hooks/useInitializeCredits.ts +57 -0
- package/src/presentation/hooks/usePremiumWithCredits.ts +1 -1
- package/src/revenuecat/index.ts +12 -0
- package/src/utils/index.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.83",
|
|
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",
|
|
@@ -1,190 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PaywallContainer Component
|
|
3
|
-
* Package-driven paywall with mode-based filtering
|
|
4
|
-
* Mode: credits | subscription | hybrid
|
|
5
3
|
*/
|
|
6
4
|
|
|
7
|
-
import React, {
|
|
8
|
-
import type { PurchasesPackage } from "react-native-purchases";
|
|
5
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
9
6
|
import { usePaywallVisibility } from "../../../presentation/hooks/usePaywallVisibility";
|
|
10
7
|
import { useSubscriptionPackages } from "../../../revenuecat/presentation/hooks/useSubscriptionPackages";
|
|
11
|
-
import { usePurchasePackage } from "../../../revenuecat/presentation/hooks/usePurchasePackage";
|
|
12
|
-
import { useRestorePurchase } from "../../../revenuecat/presentation/hooks/useRestorePurchase";
|
|
13
|
-
import { SubscriptionManager } from "../../../revenuecat/infrastructure/managers/SubscriptionManager";
|
|
14
8
|
import { filterPackagesByMode } from "../../../utils/packageFilter";
|
|
15
9
|
import { createCreditAmountsFromPackages } from "../../../utils/creditMapper";
|
|
16
10
|
import { PaywallModal } from "./PaywallModal";
|
|
11
|
+
import { usePaywallActions } from "../hooks/usePaywallActions";
|
|
17
12
|
import type { PaywallContainerProps } from "./PaywallContainer.types";
|
|
18
13
|
|
|
19
|
-
declare const __DEV__: boolean;
|
|
20
14
|
|
|
21
|
-
export const PaywallContainer: React.FC<PaywallContainerProps> = ({
|
|
22
|
-
userId,
|
|
23
|
-
isAnonymous = false,
|
|
24
|
-
translations,
|
|
25
|
-
mode = "subscription",
|
|
26
|
-
legalUrls,
|
|
27
|
-
features,
|
|
28
|
-
heroImage,
|
|
29
|
-
bestValueIdentifier,
|
|
30
|
-
creditsLabel,
|
|
31
|
-
creditAmounts,
|
|
32
|
-
packageFilterConfig,
|
|
33
|
-
onPurchaseSuccess,
|
|
34
|
-
onPurchaseError,
|
|
35
|
-
onAuthRequired,
|
|
36
|
-
visible,
|
|
37
|
-
onClose,
|
|
38
|
-
}) => {
|
|
39
|
-
const { showPaywall: globalShowPaywall, closePaywall: globalClosePaywall } = usePaywallVisibility();
|
|
40
|
-
|
|
41
|
-
const isVisible = visible !== undefined ? visible : globalShowPaywall;
|
|
42
|
-
const handleClose = onClose !== undefined ? onClose : globalClosePaywall;
|
|
43
15
|
|
|
44
|
-
|
|
45
|
-
const {
|
|
46
|
-
const { mutateAsync: restorePurchases } = useRestorePurchase(userId ?? undefined);
|
|
16
|
+
export const PaywallContainer: React.FC<PaywallContainerProps> = (props) => {
|
|
17
|
+
const { userId, isAnonymous = false, translations, mode = "subscription", legalUrls, features, heroImage, bestValueIdentifier, creditsLabel, creditAmounts, packageFilterConfig, onPurchaseSuccess, onPurchaseError, onAuthRequired, visible, onClose } = props;
|
|
47
18
|
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
19
|
+
const { showPaywall, closePaywall } = usePaywallVisibility();
|
|
20
|
+
const isVisible = visible ?? showPaywall;
|
|
21
|
+
const handleClose = onClose ?? closePaywall;
|
|
51
22
|
|
|
52
|
-
const {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
: creditAmounts;
|
|
57
|
-
|
|
58
|
-
return { filteredPackages: filtered, computedCreditAmounts: computed };
|
|
59
|
-
}, [allPackages, mode, packageFilterConfig, creditAmounts]);
|
|
23
|
+
const { data: allPackages = [], isLoading } = useSubscriptionPackages(userId ?? undefined);
|
|
24
|
+
const { handlePurchase, handleRestore, pendingPackage, setPendingPackage, purchasePackage } = usePaywallActions({
|
|
25
|
+
userId: userId ?? undefined, isAnonymous, onPurchaseSuccess, onPurchaseError, onAuthRequired, onClose: handleClose
|
|
26
|
+
});
|
|
60
27
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
mode,
|
|
67
|
-
isConfigured: SubscriptionManager.isConfigured(),
|
|
68
|
-
isInitialized: SubscriptionManager.isInitialized(),
|
|
69
|
-
allPackagesCount: allPackages.length,
|
|
70
|
-
filteredPackagesCount: filteredPackages.length,
|
|
71
|
-
computedCreditAmounts,
|
|
72
|
-
isLoading,
|
|
73
|
-
isFetching,
|
|
74
|
-
status,
|
|
75
|
-
error: error?.message ?? null,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
}, [isVisible, userId, isAnonymous, mode, allPackages.length, filteredPackages.length, isLoading, isFetching, status, error]);
|
|
28
|
+
const wasAnonymousRef = useRef(isAnonymous);
|
|
29
|
+
const { filteredPackages, computedCreditAmounts } = useMemo(() => ({
|
|
30
|
+
filteredPackages: filterPackagesByMode(allPackages, mode, packageFilterConfig),
|
|
31
|
+
computedCreditAmounts: mode !== "subscription" && !creditAmounts ? createCreditAmountsFromPackages(allPackages) : creditAmounts
|
|
32
|
+
}), [allPackages, mode, packageFilterConfig, creditAmounts]);
|
|
79
33
|
|
|
80
|
-
// Auto-purchase
|
|
34
|
+
// Auto-purchase after auth
|
|
81
35
|
useEffect(() => {
|
|
82
|
-
const
|
|
36
|
+
const wasAuth = wasAnonymousRef.current && !isAnonymous;
|
|
83
37
|
wasAnonymousRef.current = isAnonymous;
|
|
84
38
|
|
|
85
|
-
if (
|
|
86
|
-
console.log("[PaywallContainer] Auth state check:", {
|
|
87
|
-
wasAnonymous,
|
|
88
|
-
isAnonymous,
|
|
89
|
-
hasPendingPackage: !!pendingPackage,
|
|
90
|
-
userId,
|
|
91
|
-
pendingPkgId: pendingPackage?.identifier,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// If user was anonymous, now authenticated, and has pending package
|
|
96
|
-
if (wasAnonymous && !isAnonymous && pendingPackage && userId) {
|
|
97
|
-
if (__DEV__) {
|
|
98
|
-
console.log("[PaywallContainer] User authenticated, auto-purchasing pending package:", pendingPackage.identifier);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Execute the purchase
|
|
39
|
+
if (wasAuth && pendingPackage && userId) {
|
|
102
40
|
(async () => {
|
|
103
41
|
try {
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
if (__DEV__) {
|
|
107
|
-
console.log("[PaywallContainer] Auto-purchase successful");
|
|
108
|
-
}
|
|
42
|
+
const res = await purchasePackage(pendingPackage);
|
|
43
|
+
if (res.success) {
|
|
109
44
|
onPurchaseSuccess?.();
|
|
110
45
|
handleClose();
|
|
111
46
|
}
|
|
112
|
-
} catch (err) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
console.error("[PaywallContainer] Auto-purchase failed:", message);
|
|
116
|
-
}
|
|
117
|
-
onPurchaseError?.(message);
|
|
118
|
-
} finally {
|
|
119
|
-
setPendingPackage(null);
|
|
120
|
-
}
|
|
47
|
+
} catch (err: any) {
|
|
48
|
+
onPurchaseError?.(err.message || String(err));
|
|
49
|
+
} finally { setPendingPackage(null); }
|
|
121
50
|
})();
|
|
122
51
|
}
|
|
123
|
-
}, [isAnonymous, userId, pendingPackage, purchasePackage, onPurchaseSuccess, onPurchaseError, handleClose]);
|
|
124
|
-
|
|
125
|
-
const handlePurchase = useCallback(
|
|
126
|
-
async (pkg: PurchasesPackage) => {
|
|
127
|
-
// Auth gating: require authentication for anonymous users
|
|
128
|
-
if (isAnonymous) {
|
|
129
|
-
if (__DEV__) {
|
|
130
|
-
console.log("[PaywallContainer] Anonymous user, storing package and requiring auth:", pkg.identifier);
|
|
131
|
-
console.log("[PaywallContainer] onAuthRequired is defined:", !!onAuthRequired);
|
|
132
|
-
}
|
|
133
|
-
// Store package for auto-purchase after auth
|
|
134
|
-
setPendingPackage(pkg);
|
|
135
|
-
// Don't close paywall - keep it open so user can purchase after auth
|
|
136
|
-
onAuthRequired?.();
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
try {
|
|
141
|
-
if (__DEV__) {
|
|
142
|
-
console.log("[PaywallContainer] Purchase started:", pkg.identifier);
|
|
143
|
-
}
|
|
144
|
-
const result = await purchasePackage(pkg);
|
|
145
|
-
if (result.success) {
|
|
146
|
-
if (__DEV__) {
|
|
147
|
-
console.log("[PaywallContainer] Purchase successful");
|
|
148
|
-
}
|
|
149
|
-
onPurchaseSuccess?.();
|
|
150
|
-
handleClose();
|
|
151
|
-
}
|
|
152
|
-
} catch (err) {
|
|
153
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
154
|
-
if (__DEV__) {
|
|
155
|
-
console.error("[PaywallContainer] Purchase failed:", message);
|
|
156
|
-
}
|
|
157
|
-
onPurchaseError?.(message);
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
[isAnonymous, purchasePackage, handleClose, onPurchaseSuccess, onPurchaseError, onAuthRequired, setPendingPackage]
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
const handleRestore = useCallback(async () => {
|
|
164
|
-
try {
|
|
165
|
-
if (__DEV__) {
|
|
166
|
-
console.log("[PaywallContainer] Restore started");
|
|
167
|
-
}
|
|
168
|
-
const result = await restorePurchases();
|
|
169
|
-
if (result.success) {
|
|
170
|
-
if (__DEV__) {
|
|
171
|
-
console.log("[PaywallContainer] Restore successful");
|
|
172
|
-
}
|
|
173
|
-
onPurchaseSuccess?.();
|
|
174
|
-
handleClose();
|
|
175
|
-
}
|
|
176
|
-
} catch (err) {
|
|
177
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
178
|
-
if (__DEV__) {
|
|
179
|
-
console.error("[PaywallContainer] Restore failed:", message);
|
|
180
|
-
}
|
|
181
|
-
onPurchaseError?.(message);
|
|
182
|
-
}
|
|
183
|
-
}, [restorePurchases, handleClose, onPurchaseSuccess, onPurchaseError]);
|
|
52
|
+
}, [isAnonymous, userId, pendingPackage, purchasePackage, onPurchaseSuccess, onPurchaseError, handleClose, setPendingPackage]);
|
|
184
53
|
|
|
185
|
-
if (!isVisible)
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
54
|
+
if (!isVisible) return null;
|
|
188
55
|
|
|
189
56
|
return (
|
|
190
57
|
<PaywallModal
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { AtomicText, AtomicIcon, useAppDesignTokens } from "@umituz/react-native-design-system";
|
|
4
|
+
import type { SubscriptionFeature } from "../entities";
|
|
5
|
+
import { paywallModalStyles as styles } from "./PaywallModal.styles";
|
|
6
|
+
|
|
7
|
+
export const PaywallFeatures: React.FC<{ features: SubscriptionFeature[] }> = ({ features }) => {
|
|
8
|
+
const tokens = useAppDesignTokens();
|
|
9
|
+
if (!features.length) return null;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<View style={[styles.features, { backgroundColor: tokens.colors.surfaceSecondary }]}>
|
|
13
|
+
{features.map((feature, idx) => (
|
|
14
|
+
<View key={idx} style={styles.featureRow}>
|
|
15
|
+
<View style={[styles.featureIcon, { backgroundColor: tokens.colors.primaryLight }]}>
|
|
16
|
+
<AtomicIcon name={feature.icon} customSize={16} customColor={tokens.colors.primary} />
|
|
17
|
+
</View>
|
|
18
|
+
<AtomicText type="bodyMedium" style={[styles.featureText, { color: tokens.colors.textPrimary }]}>
|
|
19
|
+
{feature.text}
|
|
20
|
+
</AtomicText>
|
|
21
|
+
</View>
|
|
22
|
+
))}
|
|
23
|
+
</View>
|
|
24
|
+
);
|
|
25
|
+
};
|
|
@@ -1,98 +1,51 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Paywall Footer
|
|
3
|
-
* Action button and legal links
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import React from "react";
|
|
7
|
-
import { View, TouchableOpacity
|
|
8
|
-
import { AtomicText,
|
|
2
|
+
import { View, TouchableOpacity } from "react-native";
|
|
3
|
+
import { AtomicText, useAppDesignTokens } from "@umituz/react-native-design-system";
|
|
4
|
+
import type { PaywallTranslations, PaywallLegalUrls } from "../entities";
|
|
5
|
+
import { paywallModalStyles as styles } from "./PaywallModal.styles";
|
|
9
6
|
|
|
10
7
|
interface PaywallFooterProps {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
privacyText?: string;
|
|
17
|
-
termsText?: string;
|
|
18
|
-
privacyUrl?: string;
|
|
19
|
-
termsUrl?: string;
|
|
20
|
-
onPurchase: () => void;
|
|
21
|
-
onRestore: () => void;
|
|
8
|
+
translations: PaywallTranslations;
|
|
9
|
+
legalUrls: PaywallLegalUrls;
|
|
10
|
+
isProcessing: boolean;
|
|
11
|
+
onRestore?: () => Promise<void | boolean>;
|
|
12
|
+
onLegalClick: (url: string | undefined) => void;
|
|
22
13
|
}
|
|
23
14
|
|
|
24
|
-
export const PaywallFooter: React.FC<PaywallFooterProps> =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
onRestore,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
</TouchableOpacity>
|
|
62
|
-
)}
|
|
63
|
-
|
|
64
|
-
<TouchableOpacity onPress={onRestore}>
|
|
65
|
-
<AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }}>
|
|
66
|
-
{restoreButtonText}
|
|
67
|
-
</AtomicText>
|
|
68
|
-
</TouchableOpacity>
|
|
69
|
-
|
|
70
|
-
{privacyText && privacyUrl && (
|
|
71
|
-
<TouchableOpacity onPress={() => handleOpenUrl(privacyUrl)}>
|
|
72
|
-
<AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }}>
|
|
73
|
-
{privacyText}
|
|
74
|
-
</AtomicText>
|
|
75
|
-
</TouchableOpacity>
|
|
76
|
-
)}
|
|
77
|
-
</View>
|
|
78
|
-
</View>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
PaywallFooter.displayName = "PaywallFooter";
|
|
84
|
-
|
|
85
|
-
const styles = StyleSheet.create({
|
|
86
|
-
container: {
|
|
87
|
-
paddingHorizontal: 24,
|
|
88
|
-
paddingBottom: 32,
|
|
89
|
-
},
|
|
90
|
-
purchaseButton: {
|
|
91
|
-
marginBottom: 16,
|
|
92
|
-
},
|
|
93
|
-
linksRow: {
|
|
94
|
-
flexDirection: "row",
|
|
95
|
-
justifyContent: "space-between",
|
|
96
|
-
paddingHorizontal: 8,
|
|
97
|
-
},
|
|
98
|
-
});
|
|
15
|
+
export const PaywallFooter: React.FC<PaywallFooterProps> = ({
|
|
16
|
+
translations,
|
|
17
|
+
legalUrls,
|
|
18
|
+
isProcessing,
|
|
19
|
+
onRestore,
|
|
20
|
+
onLegalClick,
|
|
21
|
+
}) => {
|
|
22
|
+
const tokens = useAppDesignTokens();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<View style={styles.footer}>
|
|
26
|
+
{onRestore && (
|
|
27
|
+
<TouchableOpacity onPress={onRestore} disabled={isProcessing} style={[styles.restoreButton, isProcessing && styles.restoreButtonDisabled]}>
|
|
28
|
+
<AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
|
|
29
|
+
{isProcessing ? translations.processingText : translations.restoreButtonText}
|
|
30
|
+
</AtomicText>
|
|
31
|
+
</TouchableOpacity>
|
|
32
|
+
)}
|
|
33
|
+
<View style={styles.legalRow}>
|
|
34
|
+
{legalUrls.termsUrl && (
|
|
35
|
+
<TouchableOpacity onPress={() => onLegalClick(legalUrls.termsUrl)}>
|
|
36
|
+
<AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
|
|
37
|
+
{translations.termsOfServiceText}
|
|
38
|
+
</AtomicText>
|
|
39
|
+
</TouchableOpacity>
|
|
40
|
+
)}
|
|
41
|
+
{legalUrls.privacyUrl && (
|
|
42
|
+
<TouchableOpacity onPress={() => onLegalClick(legalUrls.privacyUrl)}>
|
|
43
|
+
<AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
|
|
44
|
+
{translations.privacyText}
|
|
45
|
+
</AtomicText>
|
|
46
|
+
</TouchableOpacity>
|
|
47
|
+
)}
|
|
48
|
+
</View>
|
|
49
|
+
</View>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Paywall Modal
|
|
3
|
-
* Renders packages passed from PaywallContainer
|
|
4
|
-
* Filtering is handled by PaywallContainer based on mode
|
|
5
3
|
*/
|
|
6
4
|
|
|
7
5
|
import React, { useState, useCallback } from "react";
|
|
@@ -12,6 +10,8 @@ import type { PurchasesPackage } from "react-native-purchases";
|
|
|
12
10
|
import { PlanCard } from "./PlanCard";
|
|
13
11
|
import type { SubscriptionFeature, PaywallTranslations, PaywallLegalUrls } from "../entities";
|
|
14
12
|
import { paywallModalStyles as styles } from "./PaywallModal.styles";
|
|
13
|
+
import { PaywallFeatures } from "./PaywallFeatures";
|
|
14
|
+
import { PaywallFooter } from "./PaywallFooter";
|
|
15
15
|
|
|
16
16
|
export interface PaywallModalProps {
|
|
17
17
|
visible: boolean;
|
|
@@ -30,22 +30,7 @@ export interface PaywallModalProps {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
33
|
-
const {
|
|
34
|
-
visible,
|
|
35
|
-
onClose,
|
|
36
|
-
translations,
|
|
37
|
-
packages = [],
|
|
38
|
-
features = [],
|
|
39
|
-
isLoading = false,
|
|
40
|
-
legalUrls = {},
|
|
41
|
-
bestValueIdentifier,
|
|
42
|
-
creditAmounts,
|
|
43
|
-
creditsLabel,
|
|
44
|
-
heroImage,
|
|
45
|
-
onPurchase,
|
|
46
|
-
onRestore,
|
|
47
|
-
} = props;
|
|
48
|
-
|
|
33
|
+
const { visible, onClose, translations, packages = [], features = [], isLoading = false, legalUrls = {}, bestValueIdentifier, creditAmounts, creditsLabel, heroImage, onPurchase, onRestore } = props;
|
|
49
34
|
const tokens = useAppDesignTokens();
|
|
50
35
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
|
51
36
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
@@ -56,41 +41,24 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
|
56
41
|
try {
|
|
57
42
|
const pkg = packages.find((p) => p.product.identifier === selectedPlanId);
|
|
58
43
|
if (pkg) await onPurchase(pkg);
|
|
59
|
-
} finally {
|
|
60
|
-
setIsProcessing(false);
|
|
61
|
-
}
|
|
44
|
+
} finally { setIsProcessing(false); }
|
|
62
45
|
}, [selectedPlanId, packages, onPurchase]);
|
|
63
46
|
|
|
64
47
|
const handleRestore = useCallback(async () => {
|
|
65
48
|
if (!onRestore || isProcessing) return;
|
|
66
49
|
setIsProcessing(true);
|
|
67
|
-
try {
|
|
68
|
-
await onRestore();
|
|
69
|
-
} finally {
|
|
70
|
-
setIsProcessing(false);
|
|
71
|
-
}
|
|
50
|
+
try { await onRestore(); } finally { setIsProcessing(false); }
|
|
72
51
|
}, [onRestore, isProcessing]);
|
|
73
52
|
|
|
74
53
|
const handleLegalUrl = useCallback(async (url: string | undefined) => {
|
|
75
54
|
if (!url) return;
|
|
76
|
-
try {
|
|
77
|
-
const supported = await Linking.canOpenURL(url);
|
|
78
|
-
if (supported) await Linking.openURL(url);
|
|
79
|
-
} catch {
|
|
80
|
-
// Silent fail
|
|
81
|
-
}
|
|
55
|
+
try { if (await Linking.canOpenURL(url)) await Linking.openURL(url); } catch { /* Silent fail */ }
|
|
82
56
|
}, []);
|
|
83
57
|
|
|
84
|
-
const isPurchaseDisabled = !selectedPlanId;
|
|
85
|
-
|
|
86
58
|
return (
|
|
87
59
|
<BaseModal visible={visible} onClose={onClose} contentStyle={styles.modalContent}>
|
|
88
60
|
<View style={[styles.container, { backgroundColor: tokens.colors.surface }]}>
|
|
89
|
-
<TouchableOpacity
|
|
90
|
-
onPress={onClose}
|
|
91
|
-
style={[styles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary }]}
|
|
92
|
-
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
93
|
-
>
|
|
61
|
+
<TouchableOpacity onPress={onClose} style={[styles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary }]} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
|
94
62
|
<AtomicIcon name="close-outline" size="md" customColor={tokens.colors.textPrimary} />
|
|
95
63
|
</TouchableOpacity>
|
|
96
64
|
|
|
@@ -102,35 +70,14 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
|
102
70
|
)}
|
|
103
71
|
|
|
104
72
|
<View style={styles.header}>
|
|
105
|
-
<AtomicText type="headlineMedium" style={[styles.title, { color: tokens.colors.textPrimary }]}>
|
|
106
|
-
|
|
107
|
-
</AtomicText>
|
|
108
|
-
{translations.subtitle && (
|
|
109
|
-
<AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>
|
|
110
|
-
{translations.subtitle}
|
|
111
|
-
</AtomicText>
|
|
112
|
-
)}
|
|
73
|
+
<AtomicText type="headlineMedium" style={[styles.title, { color: tokens.colors.textPrimary }]}>{translations.title}</AtomicText>
|
|
74
|
+
{translations.subtitle && <AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>{translations.subtitle}</AtomicText>}
|
|
113
75
|
</View>
|
|
114
76
|
|
|
115
|
-
{features
|
|
116
|
-
<View style={[styles.features, { backgroundColor: tokens.colors.surfaceSecondary }]}>
|
|
117
|
-
{features.map((feature, idx) => (
|
|
118
|
-
<View key={idx} style={styles.featureRow}>
|
|
119
|
-
<View style={[styles.featureIcon, { backgroundColor: tokens.colors.primaryLight }]}>
|
|
120
|
-
<AtomicIcon name={feature.icon} customSize={16} customColor={tokens.colors.primary} />
|
|
121
|
-
</View>
|
|
122
|
-
<AtomicText type="bodyMedium" style={[styles.featureText, { color: tokens.colors.textPrimary }]}>
|
|
123
|
-
{feature.text}
|
|
124
|
-
</AtomicText>
|
|
125
|
-
</View>
|
|
126
|
-
))}
|
|
127
|
-
</View>
|
|
128
|
-
)}
|
|
77
|
+
<PaywallFeatures features={features} />
|
|
129
78
|
|
|
130
79
|
{isLoading ? (
|
|
131
|
-
<View style={styles.loading}>
|
|
132
|
-
<AtomicSpinner size="lg" color="primary" text={translations.loadingText} />
|
|
133
|
-
</View>
|
|
80
|
+
<View style={styles.loading}><AtomicSpinner size="lg" color="primary" text={translations.loadingText} /></View>
|
|
134
81
|
) : (
|
|
135
82
|
<View style={styles.plans}>
|
|
136
83
|
{packages.map((pkg) => (
|
|
@@ -149,12 +96,8 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
|
149
96
|
|
|
150
97
|
<TouchableOpacity
|
|
151
98
|
onPress={handlePurchase}
|
|
152
|
-
disabled={
|
|
153
|
-
style={[
|
|
154
|
-
styles.cta,
|
|
155
|
-
{ backgroundColor: tokens.colors.primary },
|
|
156
|
-
(isPurchaseDisabled || isProcessing) && styles.ctaDisabled,
|
|
157
|
-
]}
|
|
99
|
+
disabled={!selectedPlanId || isProcessing}
|
|
100
|
+
style={[styles.cta, { backgroundColor: tokens.colors.primary }, (!selectedPlanId || isProcessing) && styles.ctaDisabled]}
|
|
158
101
|
activeOpacity={0.8}
|
|
159
102
|
>
|
|
160
103
|
<AtomicText type="titleLarge" style={[styles.ctaText, { color: tokens.colors.onPrimary }]}>
|
|
@@ -162,35 +105,7 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
|
162
105
|
</AtomicText>
|
|
163
106
|
</TouchableOpacity>
|
|
164
107
|
|
|
165
|
-
<
|
|
166
|
-
{onRestore && (
|
|
167
|
-
<TouchableOpacity
|
|
168
|
-
onPress={handleRestore}
|
|
169
|
-
disabled={isProcessing}
|
|
170
|
-
style={[styles.restoreButton, isProcessing && styles.restoreButtonDisabled]}
|
|
171
|
-
>
|
|
172
|
-
<AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
|
|
173
|
-
{isProcessing ? translations.processingText : translations.restoreButtonText}
|
|
174
|
-
</AtomicText>
|
|
175
|
-
</TouchableOpacity>
|
|
176
|
-
)}
|
|
177
|
-
<View style={styles.legalRow}>
|
|
178
|
-
{legalUrls.termsUrl && (
|
|
179
|
-
<TouchableOpacity onPress={() => handleLegalUrl(legalUrls.termsUrl)}>
|
|
180
|
-
<AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
|
|
181
|
-
{translations.termsOfServiceText}
|
|
182
|
-
</AtomicText>
|
|
183
|
-
</TouchableOpacity>
|
|
184
|
-
)}
|
|
185
|
-
{legalUrls.privacyUrl && (
|
|
186
|
-
<TouchableOpacity onPress={() => handleLegalUrl(legalUrls.privacyUrl)}>
|
|
187
|
-
<AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
|
|
188
|
-
{translations.privacyText}
|
|
189
|
-
</AtomicText>
|
|
190
|
-
</TouchableOpacity>
|
|
191
|
-
)}
|
|
192
|
-
</View>
|
|
193
|
-
</View>
|
|
108
|
+
<PaywallFooter translations={translations} legalUrls={legalUrls} isProcessing={isProcessing} onRestore={onRestore ? handleRestore : undefined} onLegalClick={handleLegalUrl} />
|
|
194
109
|
</ScrollView>
|
|
195
110
|
</View>
|
|
196
111
|
</BaseModal>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
3
|
+
import { usePurchasePackage } from "../../../revenuecat/presentation/hooks/usePurchasePackage";
|
|
4
|
+
import { useRestorePurchase } from "../../../revenuecat/presentation/hooks/useRestorePurchase";
|
|
5
|
+
|
|
6
|
+
declare const __DEV__: boolean;
|
|
7
|
+
|
|
8
|
+
interface UsePaywallActionsProps {
|
|
9
|
+
userId?: string;
|
|
10
|
+
isAnonymous: boolean;
|
|
11
|
+
onPurchaseSuccess?: () => void;
|
|
12
|
+
onPurchaseError?: (error: string) => void;
|
|
13
|
+
onAuthRequired?: () => void;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const usePaywallActions = ({
|
|
18
|
+
userId,
|
|
19
|
+
isAnonymous,
|
|
20
|
+
onPurchaseSuccess,
|
|
21
|
+
onPurchaseError,
|
|
22
|
+
onAuthRequired,
|
|
23
|
+
onClose,
|
|
24
|
+
}: UsePaywallActionsProps) => {
|
|
25
|
+
const { mutateAsync: purchasePackage } = usePurchasePackage(userId);
|
|
26
|
+
const { mutateAsync: restorePurchases } = useRestorePurchase(userId);
|
|
27
|
+
const [pendingPackage, setPendingPackage] = useState<PurchasesPackage | null>(null);
|
|
28
|
+
|
|
29
|
+
const handlePurchase = useCallback(async (pkg: PurchasesPackage) => {
|
|
30
|
+
if (isAnonymous) {
|
|
31
|
+
if (__DEV__) console.log("[PaywallActions] Anonymous user, storing package:", pkg.identifier);
|
|
32
|
+
setPendingPackage(pkg);
|
|
33
|
+
onAuthRequired?.();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (__DEV__) console.log("[PaywallActions] Purchase started:", pkg.identifier);
|
|
39
|
+
const res = await purchasePackage(pkg);
|
|
40
|
+
if (res.success) {
|
|
41
|
+
onPurchaseSuccess?.();
|
|
42
|
+
onClose();
|
|
43
|
+
}
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
onPurchaseError?.(err.message || String(err));
|
|
46
|
+
}
|
|
47
|
+
}, [isAnonymous, purchasePackage, onClose, onPurchaseSuccess, onPurchaseError, onAuthRequired]);
|
|
48
|
+
|
|
49
|
+
const handleRestore = useCallback(async () => {
|
|
50
|
+
try {
|
|
51
|
+
if (__DEV__) console.log("[PaywallActions] Restore started");
|
|
52
|
+
const res = await restorePurchases();
|
|
53
|
+
if (res.success) {
|
|
54
|
+
onPurchaseSuccess?.();
|
|
55
|
+
onClose();
|
|
56
|
+
}
|
|
57
|
+
} catch (err: any) {
|
|
58
|
+
onPurchaseError?.(err.message || String(err));
|
|
59
|
+
}
|
|
60
|
+
}, [restorePurchases, onClose, onPurchaseSuccess, onPurchaseError]);
|
|
61
|
+
|
|
62
|
+
return { handlePurchase, handleRestore, pendingPackage, setPendingPackage, purchasePackage };
|
|
63
|
+
};
|