@umituz/react-native-subscription 2.27.123 → 2.27.125
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/creditOperationUtils.ts +65 -144
- package/src/domains/credits/application/creditOperationUtils.types.ts +19 -0
- package/src/domains/credits/presentation/useCredits.ts +1 -11
- package/src/domains/paywall/components/PaywallContainer.tsx +3 -1
- package/src/domains/paywall/components/PaywallModal.tsx +19 -107
- package/src/domains/paywall/components/PaywallModal.types.ts +26 -0
- package/src/domains/paywall/components/PlanCard.tsx +45 -148
- package/src/domains/paywall/components/PlanCard.types.ts +12 -0
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +116 -0
- package/src/domains/subscription/application/SubscriptionSyncService.ts +17 -99
- package/src/domains/subscription/application/SubscriptionSyncUtils.ts +0 -2
- package/src/domains/subscription/core/SubscriptionConstants.ts +1 -13
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.ts +7 -13
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.types.ts +15 -0
- package/src/domains/subscription/infrastructure/managers/subscriptionManagerUtils.ts +2 -1
- package/src/domains/subscription/infrastructure/services/RevenueCatInitializer.ts +20 -5
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +13 -92
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.types.ts +47 -0
- package/src/domains/subscription/presentation/screens/components/UpgradePrompt.tsx +34 -126
- package/src/domains/subscription/presentation/screens/components/UpgradePrompt.types.ts +12 -0
- package/src/domains/subscription/presentation/usePremium.ts +3 -22
- package/src/domains/subscription/presentation/usePremium.types.ts +16 -0
- package/src/domains/subscription/presentation/useSubscriptionStatus.ts +30 -22
- package/src/domains/subscription/presentation/useSubscriptionStatus.types.ts +7 -0
- package/src/domains/wallet/index.ts +6 -2
- package/src/domains/wallet/infrastructure/config/walletConfig.ts +2 -1
- package/src/domains/wallet/presentation/hooks/useProductMetadata.ts +3 -16
- package/src/domains/wallet/presentation/hooks/useTransactionHistory.ts +1 -13
- package/src/domains/wallet/presentation/screens/WalletScreen.tsx +25 -112
- package/src/domains/wallet/presentation/screens/WalletScreen.types.ts +15 -0
- package/src/shared/utils/appValidators.ts +38 -0
- package/src/shared/utils/validators.ts +4 -122
- package/src/domains/paywall/components/README.md +0 -41
- package/src/domains/subscription/presentation/screens/README.md +0 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.27.
|
|
3
|
+
"version": "2.27.125",
|
|
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,154 +1,75 @@
|
|
|
1
|
-
|
|
2
|
-
* Credit Operation Utilities
|
|
3
|
-
* Business logic for credit calculations and data building
|
|
4
|
-
*/
|
|
5
|
-
|
|
1
|
+
import { Timestamp, serverTimestamp } from "firebase/firestore";
|
|
6
2
|
import { resolveSubscriptionStatus } from "../../subscription/core/SubscriptionStatus";
|
|
7
3
|
import { creditAllocationOrchestrator } from "./credit-strategies/CreditAllocationOrchestrator";
|
|
8
4
|
import { isPast } from "../../../utils";
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
5
|
+
import {
|
|
6
|
+
CalculateCreditsParams,
|
|
7
|
+
BuildCreditsDataParams
|
|
8
|
+
} from "./creditOperationUtils.types";
|
|
9
|
+
|
|
10
|
+
export function calculateNewCredits({ metadata, existingData, creditLimit, purchaseId }: CalculateCreditsParams): number {
|
|
11
|
+
const isPremium = metadata.isPremium;
|
|
12
|
+
const isExpired = metadata.expirationDate ? isPast(metadata.expirationDate) : false;
|
|
13
|
+
const status = resolveSubscriptionStatus({
|
|
14
|
+
isPremium,
|
|
15
|
+
willRenew: metadata.willRenew ?? false,
|
|
16
|
+
isExpired,
|
|
17
|
+
periodType: metadata.periodType ?? undefined,
|
|
18
|
+
});
|
|
19
|
+
const isStatusSync = purchaseId.startsWith("status_sync_");
|
|
20
|
+
return creditAllocationOrchestrator.allocate({
|
|
21
|
+
status,
|
|
22
|
+
isStatusSync,
|
|
23
|
+
existingData,
|
|
24
|
+
creditLimit,
|
|
25
|
+
isSubscriptionActive: isPremium && !isExpired,
|
|
26
|
+
productId: metadata.productId,
|
|
27
|
+
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
* Build credits data object for Firestore update
|
|
61
|
-
*/
|
|
62
|
-
export function buildCreditsData(params: BuildCreditsDataParams): Record<string, any> {
|
|
63
|
-
const {
|
|
64
|
-
existingData,
|
|
65
|
-
newCredits,
|
|
66
|
-
creditLimit,
|
|
67
|
-
purchaseId,
|
|
68
|
-
metadata,
|
|
69
|
-
purchaseHistory,
|
|
70
|
-
platform,
|
|
71
|
-
} = params;
|
|
72
|
-
|
|
73
|
-
const isPremium = metadata.isPremium;
|
|
74
|
-
const isExpired = metadata.expirationDate ? isPast(metadata.expirationDate) : false;
|
|
75
|
-
|
|
76
|
-
const status = resolveSubscriptionStatus({
|
|
77
|
-
isPremium,
|
|
78
|
-
willRenew: metadata.willRenew ?? false,
|
|
79
|
-
isExpired,
|
|
80
|
-
periodType: metadata.periodType ?? undefined,
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const newProcessedPurchases = addProcessedPurchase(existingData.processedPurchases, purchaseId);
|
|
84
|
-
|
|
85
|
-
const creditsData: Record<string, any> = {
|
|
86
|
-
isPremium,
|
|
87
|
-
status,
|
|
88
|
-
credits: newCredits,
|
|
89
|
-
creditLimit,
|
|
90
|
-
lastUpdatedAt: serverTimestamp(),
|
|
91
|
-
processedPurchases: newProcessedPurchases,
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
if (purchaseHistory.length > 0) {
|
|
95
|
-
creditsData.purchaseHistory = purchaseHistory;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const isNewPurchaseOrRenewal = purchaseId.startsWith("purchase_") || purchaseId.startsWith("renewal_");
|
|
99
|
-
if (isNewPurchaseOrRenewal) {
|
|
100
|
-
creditsData.lastPurchaseAt = serverTimestamp();
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (metadata.expirationDate) {
|
|
104
|
-
creditsData.expirationDate = serverTimestamp();
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (metadata.willRenew !== undefined) {
|
|
108
|
-
creditsData.willRenew = metadata.willRenew;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (metadata.originalTransactionId) {
|
|
112
|
-
creditsData.originalTransactionId = metadata.originalTransactionId;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
creditsData.productId = metadata.productId;
|
|
116
|
-
creditsData.platform = platform;
|
|
117
|
-
|
|
118
|
-
return creditsData;
|
|
30
|
+
export function buildCreditsData({
|
|
31
|
+
existingData, newCredits, creditLimit, purchaseId, metadata, purchaseHistory, platform
|
|
32
|
+
}: BuildCreditsDataParams): Record<string, any> {
|
|
33
|
+
const isPremium = metadata.isPremium;
|
|
34
|
+
const isExpired = metadata.expirationDate ? isPast(metadata.expirationDate) : false;
|
|
35
|
+
const status = resolveSubscriptionStatus({
|
|
36
|
+
isPremium,
|
|
37
|
+
willRenew: metadata.willRenew ?? false,
|
|
38
|
+
isExpired,
|
|
39
|
+
periodType: metadata.periodType ?? undefined,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const creditsData: Record<string, any> = {
|
|
43
|
+
isPremium,
|
|
44
|
+
status,
|
|
45
|
+
credits: newCredits,
|
|
46
|
+
creditLimit,
|
|
47
|
+
lastUpdatedAt: serverTimestamp(),
|
|
48
|
+
processedPurchases: [...(existingData?.processedPurchases ?? []), purchaseId].slice(-50),
|
|
49
|
+
productId: metadata.productId,
|
|
50
|
+
platform,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (purchaseHistory.length > 0) creditsData.purchaseHistory = purchaseHistory;
|
|
54
|
+
if (purchaseId.startsWith("purchase_") || purchaseId.startsWith("renewal_")) creditsData.lastPurchaseAt = serverTimestamp();
|
|
55
|
+
if (metadata.expirationDate) creditsData.expirationDate = Timestamp.fromDate(new Date(metadata.expirationDate));
|
|
56
|
+
if (metadata.willRenew !== undefined) creditsData.willRenew = metadata.willRenew;
|
|
57
|
+
if (metadata.originalTransactionId) creditsData.originalTransactionId = metadata.originalTransactionId;
|
|
58
|
+
|
|
59
|
+
return creditsData;
|
|
119
60
|
}
|
|
120
61
|
|
|
121
|
-
/**
|
|
122
|
-
* Check if status sync write should be skipped (no changes)
|
|
123
|
-
*/
|
|
124
62
|
export function shouldSkipStatusSyncWrite(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
63
|
+
purchaseId: string,
|
|
64
|
+
existingData: any,
|
|
65
|
+
newCreditsData: Record<string, any>
|
|
128
66
|
): boolean {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
existingData.status !== newCreditsData.status ||
|
|
138
|
-
existingData.credits !== newCreditsData.credits ||
|
|
139
|
-
existingData.creditLimit !== newCreditsData.creditLimit ||
|
|
140
|
-
existingData.productId !== newCreditsData.productId;
|
|
141
|
-
|
|
142
|
-
return !hasChanged;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Add purchase ID to processed purchases list
|
|
147
|
-
*/
|
|
148
|
-
function addProcessedPurchase(
|
|
149
|
-
existing: string[],
|
|
150
|
-
purchaseId: string,
|
|
151
|
-
limit: number = 50
|
|
152
|
-
): string[] {
|
|
153
|
-
return [...existing, purchaseId].slice(-limit);
|
|
67
|
+
if (!purchaseId.startsWith("status_sync_")) return false;
|
|
68
|
+
return (
|
|
69
|
+
existingData.isPremium === newCreditsData.isPremium &&
|
|
70
|
+
existingData.status === newCreditsData.status &&
|
|
71
|
+
existingData.credits === newCreditsData.credits &&
|
|
72
|
+
existingData.creditLimit === newCreditsData.creditLimit &&
|
|
73
|
+
existingData.productId === newCreditsData.productId
|
|
74
|
+
);
|
|
154
75
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { UserCreditsDocumentRead } from "../core/UserCreditsDocument";
|
|
2
|
+
import type { InitializeCreditsMetadata } from "../../subscription/application/SubscriptionInitializerTypes";
|
|
3
|
+
|
|
4
|
+
export interface CalculateCreditsParams {
|
|
5
|
+
metadata: InitializeCreditsMetadata;
|
|
6
|
+
existingData: UserCreditsDocumentRead;
|
|
7
|
+
creditLimit: number;
|
|
8
|
+
purchaseId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BuildCreditsDataParams {
|
|
12
|
+
existingData: UserCreditsDocumentRead;
|
|
13
|
+
newCredits: number;
|
|
14
|
+
creditLimit: number;
|
|
15
|
+
purchaseId: string;
|
|
16
|
+
metadata: InitializeCreditsMetadata;
|
|
17
|
+
purchaseHistory: any[];
|
|
18
|
+
platform: "ios" | "android";
|
|
19
|
+
}
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useCredits Hook
|
|
3
|
-
*
|
|
4
|
-
* Fetches user credits with TanStack Query best practices.
|
|
5
|
-
* Uses status-based state management for reliable loading detection.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
import { useQuery, useQueryClient } from "@umituz/react-native-design-system";
|
|
9
2
|
import { useCallback, useMemo, useEffect } from "react";
|
|
10
3
|
import { useAuthStore, selectUserId } from "@umituz/react-native-auth";
|
|
@@ -50,7 +43,6 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
50
43
|
const userId = useAuthStore(selectUserId);
|
|
51
44
|
const isConfigured = isCreditsRepositoryConfigured();
|
|
52
45
|
|
|
53
|
-
// Only access config if configured to avoid throwing errors
|
|
54
46
|
const config = isConfigured ? getCreditsConfig() : null;
|
|
55
47
|
const queryEnabled = !!userId && isConfigured;
|
|
56
48
|
|
|
@@ -69,8 +61,6 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
69
61
|
return result.data || null;
|
|
70
62
|
},
|
|
71
63
|
enabled: queryEnabled,
|
|
72
|
-
staleTime: 30 * 1000, // 30 seconds - data considered fresh
|
|
73
|
-
gcTime: 5 * 60 * 1000, // 5 minutes - keep in cache after unmount
|
|
74
64
|
refetchOnMount: "always",
|
|
75
65
|
refetchOnWindowFocus: true,
|
|
76
66
|
refetchOnReconnect: true,
|
|
@@ -78,7 +68,6 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
78
68
|
|
|
79
69
|
const queryClient = useQueryClient();
|
|
80
70
|
|
|
81
|
-
// Observer Pattern: Listen for credit updates
|
|
82
71
|
useEffect(() => {
|
|
83
72
|
if (!userId) return;
|
|
84
73
|
|
|
@@ -126,3 +115,4 @@ export const useHasCredits = (): boolean => {
|
|
|
126
115
|
const { hasCredits } = useCredits();
|
|
127
116
|
return hasCredits;
|
|
128
117
|
};
|
|
118
|
+
|
|
@@ -9,7 +9,9 @@ import { usePaywallVisibility } from "../../subscription/presentation/usePaywall
|
|
|
9
9
|
import { useSubscriptionPackages } from "../../subscription/infrastructure/hooks/useSubscriptionPackages";
|
|
10
10
|
import { useRevenueCatTrialEligibility } from "../../subscription/infrastructure/hooks/useRevenueCatTrialEligibility";
|
|
11
11
|
import { createCreditAmountsFromPackages } from "../../../utils/creditMapper";
|
|
12
|
-
import { PaywallModal
|
|
12
|
+
import { PaywallModal } from "./PaywallModal";
|
|
13
|
+
import type { TrialEligibilityInfo } from "./PaywallModal.types";
|
|
14
|
+
|
|
13
15
|
import { usePaywallActions } from "../hooks/usePaywallActions";
|
|
14
16
|
import { useAuthAwarePurchase } from "../../subscription/presentation/useAuthAwarePurchase";
|
|
15
17
|
import type { PaywallContainerProps } from "./PaywallContainer.types";
|
|
@@ -1,151 +1,63 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Paywall Modal
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
1
|
import React, { useCallback, useEffect } from "react";
|
|
6
|
-
import { View, TouchableOpacity, Linking
|
|
2
|
+
import { View, TouchableOpacity, Linking } from "react-native";
|
|
7
3
|
import { BaseModal, useAppDesignTokens, AtomicText, AtomicIcon, AtomicSpinner, useSafeAreaInsets } from "@umituz/react-native-design-system";
|
|
8
4
|
import { ScreenLayout } from "../../../shared/presentation";
|
|
9
5
|
import { Image } from "expo-image";
|
|
10
|
-
import type { PurchasesPackage } from "react-native-purchases";
|
|
11
6
|
import { PlanCard } from "./PlanCard";
|
|
12
|
-
import type { SubscriptionFeature, PaywallTranslations, PaywallLegalUrls } from "../entities/types";
|
|
13
7
|
import { paywallModalStyles as styles } from "./PaywallModal.styles";
|
|
14
8
|
import { PaywallFeatures } from "./PaywallFeatures";
|
|
15
9
|
import { PaywallFooter } from "./PaywallFooter";
|
|
16
10
|
import { usePaywallActions } from "../hooks/usePaywallActions";
|
|
17
|
-
|
|
18
|
-
/** Trial eligibility info per product */
|
|
19
|
-
export interface TrialEligibilityInfo {
|
|
20
|
-
/** Whether eligible for trial */
|
|
21
|
-
eligible: boolean;
|
|
22
|
-
/** Trial duration in days */
|
|
23
|
-
durationDays?: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface PaywallModalProps {
|
|
27
|
-
visible: boolean;
|
|
28
|
-
onClose: () => void;
|
|
29
|
-
translations: PaywallTranslations;
|
|
30
|
-
packages?: PurchasesPackage[];
|
|
31
|
-
features?: SubscriptionFeature[];
|
|
32
|
-
isLoading?: boolean;
|
|
33
|
-
legalUrls?: PaywallLegalUrls;
|
|
34
|
-
bestValueIdentifier?: string;
|
|
35
|
-
/** Credit amounts per product identifier */
|
|
36
|
-
creditAmounts?: Record<string, number>;
|
|
37
|
-
/** Credits label text (e.g., "credits") */
|
|
38
|
-
creditsLabel?: string;
|
|
39
|
-
heroImage?: ImageSourcePropType;
|
|
40
|
-
onPurchase?: (pkg: PurchasesPackage) => Promise<void | boolean>;
|
|
41
|
-
onRestore?: () => Promise<void | boolean>;
|
|
42
|
-
/** Trial eligibility map per product ID */
|
|
43
|
-
trialEligibility?: Record<string, TrialEligibilityInfo>;
|
|
44
|
-
/** Trial subtitle text for PlanCard (e.g., "7 days free, then billed") */
|
|
45
|
-
trialSubtitleText?: string;
|
|
46
|
-
}
|
|
11
|
+
import { PaywallModalProps } from "./PaywallModal.types";
|
|
47
12
|
|
|
48
13
|
export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
49
14
|
const { visible, onClose, translations, packages = [], features = [], isLoading = false, legalUrls = {}, bestValueIdentifier, creditAmounts, creditsLabel, heroImage, onPurchase, onRestore, trialEligibility = {}, trialSubtitleText } = props;
|
|
50
15
|
const tokens = useAppDesignTokens();
|
|
51
16
|
const insets = useSafeAreaInsets();
|
|
52
|
-
|
|
53
|
-
const {
|
|
54
|
-
selectedPlanId,
|
|
55
|
-
setSelectedPlanId,
|
|
56
|
-
isProcessing,
|
|
57
|
-
handlePurchase,
|
|
58
|
-
handleRestore,
|
|
59
|
-
resetState,
|
|
60
|
-
} = usePaywallActions({ packages, onPurchase, onRestore });
|
|
61
|
-
|
|
62
|
-
// Reset selected plan when packages change
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
setSelectedPlanId(null);
|
|
65
|
-
}, [packages, setSelectedPlanId]);
|
|
17
|
+
const { selectedPlanId, setSelectedPlanId, isProcessing, handlePurchase, handleRestore, resetState } = usePaywallActions({ packages, onPurchase, onRestore });
|
|
66
18
|
|
|
67
|
-
|
|
68
|
-
useEffect(() => {
|
|
69
|
-
if (!visible) {
|
|
70
|
-
resetState();
|
|
71
|
-
}
|
|
72
|
-
}, [visible, resetState]);
|
|
19
|
+
useEffect(() => { setSelectedPlanId(null); }, [packages, setSelectedPlanId]);
|
|
20
|
+
useEffect(() => { if (!visible) resetState(); }, [visible, resetState]);
|
|
73
21
|
|
|
74
22
|
const handleLegalUrl = useCallback(async (url: string | undefined) => {
|
|
75
23
|
if (!url) return;
|
|
76
|
-
try {
|
|
24
|
+
try {
|
|
25
|
+
if (await Linking.canOpenURL(url)) await Linking.openURL(url);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error("[PaywallModal] Legal link error:", err);
|
|
28
|
+
}
|
|
77
29
|
}, []);
|
|
78
30
|
|
|
31
|
+
|
|
79
32
|
return (
|
|
80
33
|
<BaseModal visible={visible} onClose={onClose} contentStyle={styles.modalContent}>
|
|
81
34
|
<View style={[styles.container, { backgroundColor: tokens.colors.surface }]}>
|
|
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
|
-
>
|
|
35
|
+
<TouchableOpacity onPress={onClose} style={[styles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary, top: Math.max(insets.top, 12) }]} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
|
90
36
|
<AtomicIcon name="close-outline" size="md" customColor={tokens.colors.textPrimary} />
|
|
91
37
|
</TouchableOpacity>
|
|
92
|
-
|
|
93
38
|
<ScreenLayout scrollable={true} edges={["bottom"]} backgroundColor="transparent" contentContainerStyle={styles.scroll}>
|
|
94
|
-
{heroImage &&
|
|
95
|
-
<View style={styles.heroContainer}>
|
|
96
|
-
<Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={0} />
|
|
97
|
-
</View>
|
|
98
|
-
)}
|
|
99
|
-
|
|
39
|
+
{heroImage && <View style={styles.heroContainer}><Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={0} /></View>}
|
|
100
40
|
<View style={styles.header}>
|
|
101
41
|
<AtomicText type="headlineMedium" style={[styles.title, { color: tokens.colors.textPrimary }]}>{translations.title}</AtomicText>
|
|
102
|
-
{
|
|
103
|
-
{translations.subtitle && (
|
|
104
|
-
<AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>
|
|
105
|
-
{translations.subtitle}
|
|
106
|
-
</AtomicText>
|
|
107
|
-
)}
|
|
42
|
+
{translations.subtitle && <AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>{translations.subtitle}</AtomicText>}
|
|
108
43
|
</View>
|
|
109
|
-
|
|
110
44
|
<PaywallFeatures features={features} />
|
|
111
|
-
|
|
112
45
|
{isLoading ? (
|
|
113
46
|
<View style={styles.loading}><AtomicSpinner size="lg" color="primary" text={translations.loadingText} /></View>
|
|
114
47
|
) : (
|
|
115
48
|
<View style={styles.plans}>
|
|
116
49
|
{packages.map((pkg) => {
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const hasFreeTrial = eligibility?.eligible ?? false;
|
|
120
|
-
|
|
50
|
+
const pid = pkg.product.identifier;
|
|
51
|
+
const hasTrial = trialEligibility[pid]?.eligible ?? false;
|
|
121
52
|
return (
|
|
122
|
-
<PlanCard
|
|
123
|
-
key={productId}
|
|
124
|
-
pkg={pkg}
|
|
125
|
-
isSelected={selectedPlanId === productId}
|
|
126
|
-
onSelect={() => setSelectedPlanId(productId)}
|
|
127
|
-
badge={productId === bestValueIdentifier ? translations.bestValueBadgeText : undefined}
|
|
128
|
-
creditAmount={creditAmounts?.[productId]}
|
|
129
|
-
creditsLabel={creditsLabel}
|
|
130
|
-
hasFreeTrial={hasFreeTrial}
|
|
131
|
-
trialSubtitleText={hasFreeTrial ? trialSubtitleText : undefined}
|
|
132
|
-
/>
|
|
53
|
+
<PlanCard key={pid} pkg={pkg} isSelected={selectedPlanId === pid} onSelect={() => setSelectedPlanId(pid)} badge={pid === bestValueIdentifier ? translations.bestValueBadgeText : undefined} creditAmount={creditAmounts?.[pid]} creditsLabel={creditsLabel} hasFreeTrial={hasTrial} trialSubtitleText={hasTrial ? trialSubtitleText : undefined} />
|
|
133
54
|
);
|
|
134
55
|
})}
|
|
135
56
|
</View>
|
|
136
57
|
)}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
onPress={handlePurchase}
|
|
140
|
-
disabled={!selectedPlanId || isProcessing}
|
|
141
|
-
style={[styles.cta, { backgroundColor: tokens.colors.primary }, (!selectedPlanId || isProcessing) && styles.ctaDisabled]}
|
|
142
|
-
activeOpacity={0.8}
|
|
143
|
-
>
|
|
144
|
-
<AtomicText type="titleLarge" style={[styles.ctaText, { color: tokens.colors.onPrimary }]}>
|
|
145
|
-
{isProcessing ? translations.processingText : translations.purchaseButtonText}
|
|
146
|
-
</AtomicText>
|
|
58
|
+
<TouchableOpacity onPress={handlePurchase} disabled={!selectedPlanId || isProcessing} style={[styles.cta, { backgroundColor: tokens.colors.primary }, (!selectedPlanId || isProcessing) && styles.ctaDisabled]} activeOpacity={0.8}>
|
|
59
|
+
<AtomicText type="titleLarge" style={[styles.ctaText, { color: tokens.colors.onPrimary }]}>{isProcessing ? translations.processingText : translations.purchaseButtonText}</AtomicText>
|
|
147
60
|
</TouchableOpacity>
|
|
148
|
-
|
|
149
61
|
<PaywallFooter translations={translations} legalUrls={legalUrls} isProcessing={isProcessing} onRestore={onRestore ? handleRestore : undefined} onLegalClick={handleLegalUrl} />
|
|
150
62
|
</ScreenLayout>
|
|
151
63
|
</View>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ImageSourcePropType } from "react-native";
|
|
2
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
3
|
+
import type { SubscriptionFeature, PaywallTranslations, PaywallLegalUrls } from "../entities/types";
|
|
4
|
+
|
|
5
|
+
export interface TrialEligibilityInfo {
|
|
6
|
+
eligible: boolean;
|
|
7
|
+
durationDays?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PaywallModalProps {
|
|
11
|
+
visible: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
translations: PaywallTranslations;
|
|
14
|
+
packages?: PurchasesPackage[];
|
|
15
|
+
features?: SubscriptionFeature[];
|
|
16
|
+
isLoading?: boolean;
|
|
17
|
+
legalUrls?: PaywallLegalUrls;
|
|
18
|
+
bestValueIdentifier?: string;
|
|
19
|
+
creditAmounts?: Record<string, number>;
|
|
20
|
+
creditsLabel?: string;
|
|
21
|
+
heroImage?: ImageSourcePropType;
|
|
22
|
+
onPurchase?: (pkg: PurchasesPackage) => Promise<void | boolean>;
|
|
23
|
+
onRestore?: () => Promise<void | boolean>;
|
|
24
|
+
trialEligibility?: Record<string, TrialEligibilityInfo>;
|
|
25
|
+
trialSubtitleText?: string;
|
|
26
|
+
}
|