@umituz/react-native-subscription 2.39.8 → 2.39.10
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/CreditLimitCalculator.ts +6 -17
- package/src/domains/credits/core/UserCreditsDocument.ts +1 -1
- package/src/domains/credits/infrastructure/CreditsRepository.ts +3 -3
- package/src/domains/credits/infrastructure/operations/CreditsInitializer.ts +1 -1
- package/src/domains/credits/infrastructure/operations/CreditsWriter.ts +1 -1
- package/src/domains/paywall/components/PaywallFeatures.tsx +1 -1
- package/src/domains/paywall/components/PaywallFooter.tsx +1 -1
- package/src/domains/paywall/components/PaywallScreen.styles.ts +116 -44
- package/src/domains/paywall/components/PaywallScreen.tsx +184 -138
- package/src/domains/paywall/entities/types.ts +2 -0
- package/src/domains/paywall/hooks/usePaywallActions.ts +32 -40
- package/src/domains/paywall/utils/paywallLayoutUtils.ts +55 -0
- package/src/domains/revenuecat/core/types/RevenueCatData.ts +1 -1
- package/src/domains/revenuecat/core/types/RevenueCatTypes.ts +2 -2
- package/src/domains/revenuecat/infrastructure/services/RevenueCatInitializer.types.ts +1 -1
- package/src/domains/revenuecat/infrastructure/services/userSwitchHandler.ts +1 -1
- package/src/domains/subscription/application/SubscriptionInitializerTypes.ts +1 -1
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +5 -22
- package/src/domains/subscription/application/SubscriptionSyncUtils.ts +1 -1
- package/src/domains/subscription/application/featureGate/featureGateBusinessRules.ts +27 -10
- package/src/domains/subscription/application/initializer/BackgroundInitializer.ts +42 -41
- package/src/domains/subscription/core/SubscriptionEvents.ts +1 -1
- package/src/domains/subscription/core/SubscriptionStatusHandlers.ts +1 -5
- package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +4 -6
- package/src/domains/subscription/infrastructure/handlers/PurchaseStatusResolver.ts +2 -2
- package/src/domains/subscription/infrastructure/handlers/package-operations/PackageRestorer.ts +1 -1
- package/src/domains/subscription/infrastructure/hooks/usePurchasePackage.ts +1 -1
- package/src/domains/subscription/infrastructure/hooks/useRestorePurchase.ts +1 -1
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.types.ts +2 -2
- package/src/domains/subscription/infrastructure/managers/initializationHandler.ts +1 -1
- package/src/domains/subscription/infrastructure/services/CustomerInfoListenerManager.ts +1 -1
- package/src/domains/subscription/infrastructure/services/PurchaseHandler.ts +2 -2
- package/src/domains/subscription/infrastructure/services/RestoreHandler.ts +4 -4
- package/src/domains/subscription/infrastructure/services/RevenueCatService.types.ts +1 -1
- package/src/domains/subscription/infrastructure/services/ServiceStateManager.ts +1 -1
- package/src/domains/subscription/infrastructure/services/listeners/CustomerInfoHandler.ts +4 -2
- package/src/domains/subscription/infrastructure/services/listeners/ListenerState.ts +1 -1
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseErrorHandler.ts +3 -3
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseExecutor.ts +2 -1
- package/src/domains/subscription/infrastructure/services/purchase/PurchaseValidator.ts +1 -1
- package/src/domains/subscription/infrastructure/services/revenueCatServiceInstance.ts +1 -1
- package/src/domains/subscription/infrastructure/utils/InitializationCache.ts +35 -42
- package/src/domains/subscription/infrastructure/utils/PremiumStatusSyncer.ts +3 -3
- package/src/domains/subscription/presentation/components/details/PremiumDetailsCardTypes.ts +1 -1
- package/src/domains/subscription/presentation/components/sections/SubscriptionSection.types.ts +1 -1
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +1 -1
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.types.ts +1 -1
- package/src/domains/subscription/presentation/stores/purchaseLoadingStore.ts +11 -8
- package/src/domains/subscription/presentation/useSubscriptionStatus.types.ts +1 -1
- package/src/domains/subscription/utils/featureGateUtils.ts +37 -0
- package/src/domains/subscription/utils/packageTypeFormatter.ts +1 -1
- package/src/domains/wallet/infrastructure/repositories/transaction/CollectionBuilder.ts +1 -1
- package/src/domains/wallet/infrastructure/repositories/transaction/TransactionFetcher.ts +2 -1
- package/src/domains/wallet/infrastructure/repositories/transaction/TransactionWriter.ts +2 -1
- package/src/domains/wallet/presentation/screens/WalletScreen.tsx +1 -1
- package/src/index.ts +5 -2
- package/src/init/createSubscriptionInitModule.ts +2 -1
- package/src/shared/infrastructure/SubscriptionEventBus.ts +24 -18
- package/src/domains/revenuecat/core/errors/index.ts +0 -3
- package/src/domains/revenuecat/core/types/index.ts +0 -3
- package/src/domains/subscription/application/initializer/index.ts +0 -2
- package/src/domains/subscription/core/types/index.ts +0 -3
- package/src/domains/subscription/infrastructure/handlers/package-operations/index.ts +0 -4
- package/src/domains/subscription/infrastructure/utils/renewal/index.ts +0 -3
- package/src/shared/infrastructure/firestore/index.ts +0 -2
- package/src/shared/presentation/index.ts +0 -1
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Paywall Screen Component
|
|
3
3
|
*
|
|
4
|
-
* Full-screen paywall
|
|
5
|
-
* to be a standalone screen instead of a modal overlay.
|
|
4
|
+
* Full-screen paywall with optimized FlatList for performance and modern design.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
|
-
import React, { useCallback, useEffect } from "react";
|
|
9
|
-
import {
|
|
7
|
+
import React, { useCallback, useEffect, useMemo } from "react";
|
|
8
|
+
import {
|
|
9
|
+
View,
|
|
10
|
+
TouchableOpacity,
|
|
11
|
+
Linking,
|
|
12
|
+
FlatList,
|
|
13
|
+
ListRenderItem,
|
|
14
|
+
StatusBar,
|
|
15
|
+
} from "react-native";
|
|
10
16
|
import { AtomicText, AtomicIcon, AtomicSpinner } from "@umituz/react-native-design-system/atoms";
|
|
11
17
|
import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
|
|
12
18
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
13
|
-
import { ScreenLayout } from "@umituz/react-native-design-system/layouts";
|
|
14
19
|
import { Image } from "expo-image";
|
|
15
20
|
import { PlanCard } from "./PlanCard";
|
|
16
21
|
import { paywallScreenStyles as styles } from "./PaywallScreen.styles";
|
|
17
|
-
import { PaywallFeatures } from "./PaywallFeatures";
|
|
18
22
|
import { PaywallFooter } from "./PaywallFooter";
|
|
19
23
|
import { usePaywallActions } from "../hooks/usePaywallActions";
|
|
20
24
|
import { PaywallScreenProps } from "./PaywallScreen.types";
|
|
25
|
+
import {
|
|
26
|
+
calculatePaywallItemLayout,
|
|
27
|
+
type PaywallListItem
|
|
28
|
+
} from "../utils/paywallLayoutUtils";
|
|
21
29
|
|
|
22
30
|
export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) => {
|
|
23
31
|
const {
|
|
@@ -42,14 +50,6 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
42
50
|
const tokens = useAppDesignTokens();
|
|
43
51
|
const insets = useSafeAreaInsets();
|
|
44
52
|
|
|
45
|
-
if (__DEV__) {
|
|
46
|
-
console.log("[PaywallScreen] Render:", {
|
|
47
|
-
packagesCount: packages.length,
|
|
48
|
-
isLoadingPackages,
|
|
49
|
-
source,
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
53
|
const { selectedPlanId, setSelectedPlanId, isProcessing, handlePurchase, handleRestore } = usePaywallActions({
|
|
54
54
|
packages,
|
|
55
55
|
onPurchase,
|
|
@@ -61,7 +61,7 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
61
61
|
onClose
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
// Auto-select first package
|
|
64
|
+
// Auto-select first package
|
|
65
65
|
useEffect(() => {
|
|
66
66
|
if (packages.length > 0 && !selectedPlanId) {
|
|
67
67
|
setSelectedPlanId(packages[0].product.identifier);
|
|
@@ -73,125 +73,197 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
73
73
|
try {
|
|
74
74
|
if (await Linking.canOpenURL(url)) await Linking.openURL(url);
|
|
75
75
|
} catch (error) {
|
|
76
|
-
console.error('[PaywallScreen] Failed to open URL:', error
|
|
76
|
+
console.error('[PaywallScreen] Failed to open URL:', error);
|
|
77
77
|
}
|
|
78
78
|
}, []);
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
style={[screenStyles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary }]}
|
|
87
|
-
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
88
|
-
>
|
|
89
|
-
<AtomicIcon name="close-outline" size="md" customColor={tokens.colors.textPrimary} />
|
|
90
|
-
</TouchableOpacity>
|
|
91
|
-
</View>
|
|
80
|
+
// Prepare flat data for the list
|
|
81
|
+
const flatData = useMemo(() => {
|
|
82
|
+
const data: PaywallListItem[] = [];
|
|
83
|
+
|
|
84
|
+
// 1. Header (Hero, Title, Subtitle)
|
|
85
|
+
data.push({ type: 'HEADER' });
|
|
92
86
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
{heroImage && (
|
|
101
|
-
<View style={styles.heroContainer}>
|
|
102
|
-
<Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={0} />
|
|
103
|
-
</View>
|
|
104
|
-
)}
|
|
105
|
-
|
|
106
|
-
{/* Header */}
|
|
107
|
-
<View style={styles.header}>
|
|
108
|
-
<AtomicText
|
|
109
|
-
type="headlineMedium"
|
|
110
|
-
adjustsFontSizeToFit
|
|
111
|
-
numberOfLines={2}
|
|
112
|
-
minimumFontScale={0.75}
|
|
113
|
-
style={[styles.title, { color: tokens.colors.textPrimary }]}
|
|
114
|
-
>
|
|
115
|
-
{translations.title}
|
|
116
|
-
</AtomicText>
|
|
117
|
-
{translations.subtitle && (
|
|
118
|
-
<AtomicText
|
|
119
|
-
type="bodyMedium"
|
|
120
|
-
adjustsFontSizeToFit
|
|
121
|
-
numberOfLines={3}
|
|
122
|
-
minimumFontScale={0.8}
|
|
123
|
-
style={[styles.subtitle, { color: tokens.colors.textSecondary }]}
|
|
124
|
-
>
|
|
125
|
-
{translations.subtitle}
|
|
126
|
-
</AtomicText>
|
|
127
|
-
)}
|
|
128
|
-
</View>
|
|
87
|
+
// 2. Features Section
|
|
88
|
+
if (features.length > 0) {
|
|
89
|
+
data.push({ type: 'FEATURE_HEADER' });
|
|
90
|
+
features.forEach(feature => {
|
|
91
|
+
data.push({ type: 'FEATURE', feature });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
129
94
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
95
|
+
// 3. Plans Section
|
|
96
|
+
if (packages.length > 0) {
|
|
97
|
+
data.push({ type: 'PLAN_HEADER' });
|
|
98
|
+
packages.forEach(pkg => {
|
|
99
|
+
data.push({ type: 'PLAN', pkg });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return data;
|
|
104
|
+
}, [features, packages]);
|
|
105
|
+
|
|
106
|
+
const renderItem: ListRenderItem<PaywallListItem> = useCallback(({ item }) => {
|
|
107
|
+
switch (item.type) {
|
|
108
|
+
case 'HEADER':
|
|
109
|
+
return (
|
|
110
|
+
<View key="header">
|
|
111
|
+
{/* Hero Image */}
|
|
112
|
+
{heroImage && (
|
|
113
|
+
<View style={styles.heroContainer}>
|
|
114
|
+
<Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={200} />
|
|
115
|
+
</View>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
{/* Header Text */}
|
|
119
|
+
<View style={styles.header}>
|
|
120
|
+
<AtomicText type="headlineLarge" style={[styles.title, { color: tokens.colors.textPrimary }]}>
|
|
121
|
+
{translations.title}
|
|
122
|
+
</AtomicText>
|
|
123
|
+
{translations.subtitle && (
|
|
124
|
+
<AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>
|
|
125
|
+
{translations.subtitle}
|
|
144
126
|
</AtomicText>
|
|
145
127
|
)}
|
|
146
128
|
</View>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
case 'FEATURE_HEADER':
|
|
133
|
+
return (
|
|
134
|
+
<View key="feat-header" style={styles.sectionHeader}>
|
|
135
|
+
<AtomicText type="labelLarge" style={[styles.sectionTitle, { color: tokens.colors.textSecondary }]}>
|
|
136
|
+
{translations.featuresTitle || "WHAT'S INCLUDED"}
|
|
137
|
+
</AtomicText>
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
case 'FEATURE':
|
|
142
|
+
return (
|
|
143
|
+
<View key={`feat-${item.feature.text}`} style={[styles.featureRow, { marginHorizontal: 24, marginBottom: 16 }]}>
|
|
144
|
+
<View style={[styles.featureIcon, { backgroundColor: tokens.colors.primary }]}>
|
|
145
|
+
<AtomicIcon name={item.feature.icon as any} customSize={16} customColor={tokens.colors.onPrimary} />
|
|
155
146
|
</View>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
147
|
+
<AtomicText type="bodyMedium" style={[styles.featureText, { color: tokens.colors.textPrimary }]}>
|
|
148
|
+
{item.feature.text}
|
|
149
|
+
</AtomicText>
|
|
150
|
+
</View>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
case 'PLAN_HEADER':
|
|
154
|
+
return (
|
|
155
|
+
<View key="plan-header" style={styles.sectionHeader}>
|
|
156
|
+
<AtomicText type="labelLarge" style={[styles.sectionTitle, { color: tokens.colors.textSecondary }]}>
|
|
157
|
+
{translations.plansTitle || "CHOOSE YOUR PLAN"}
|
|
158
|
+
</AtomicText>
|
|
159
|
+
</View>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
case 'PLAN': {
|
|
163
|
+
const pid = item.pkg.product.identifier;
|
|
164
|
+
return (
|
|
165
|
+
<PlanCard
|
|
166
|
+
key={pid}
|
|
167
|
+
pkg={item.pkg}
|
|
168
|
+
isSelected={selectedPlanId === pid}
|
|
169
|
+
badge={bestValueIdentifier === pid ? translations.bestValueBadgeText : undefined}
|
|
170
|
+
creditAmount={creditAmounts?.[pid]}
|
|
171
|
+
creditsLabel={creditsLabel}
|
|
172
|
+
onSelect={() => setSelectedPlanId(pid)}
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
default:
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}, [heroImage, translations, tokens, selectedPlanId, bestValueIdentifier, creditAmounts, creditsLabel, setSelectedPlanId]);
|
|
181
|
+
|
|
182
|
+
// Performance Optimization: getItemLayout for FlatList
|
|
183
|
+
const getItemLayout = useCallback((_data: any, index: number) => {
|
|
184
|
+
return calculatePaywallItemLayout(flatData, index);
|
|
185
|
+
}, [flatData]);
|
|
186
|
+
|
|
187
|
+
const keyExtractor = useCallback((item: PaywallListItem, index: number) => {
|
|
188
|
+
if (item.type === 'FEATURE') return `feat-${item.feature.text}`;
|
|
189
|
+
if (item.type === 'PLAN') return `plan-${item.pkg.product.identifier}`;
|
|
190
|
+
return `${item.type}-${index}`;
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
if (isLoadingPackages) {
|
|
194
|
+
return (
|
|
195
|
+
<View style={[styles.container, { backgroundColor: tokens.colors.backgroundPrimary, paddingTop: insets.top }]}>
|
|
196
|
+
<View style={styles.loadingContainer}>
|
|
197
|
+
<AtomicSpinner size="lg" />
|
|
176
198
|
</View>
|
|
177
|
-
</
|
|
199
|
+
</View>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<View style={[styles.container, { backgroundColor: tokens.colors.backgroundPrimary }]}>
|
|
205
|
+
<StatusBar barStyle="light-content" />
|
|
206
|
+
|
|
207
|
+
{/* Absolute Close Button */}
|
|
208
|
+
<View style={{
|
|
209
|
+
position: 'absolute',
|
|
210
|
+
top: Math.max(insets.top, 16),
|
|
211
|
+
right: 0,
|
|
212
|
+
zIndex: 10,
|
|
213
|
+
}}>
|
|
214
|
+
<TouchableOpacity
|
|
215
|
+
onPress={onClose}
|
|
216
|
+
style={[styles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary }]}
|
|
217
|
+
activeOpacity={0.7}
|
|
218
|
+
>
|
|
219
|
+
<AtomicIcon name="close-outline" size="sm" customColor={tokens.colors.textPrimary} />
|
|
220
|
+
</TouchableOpacity>
|
|
221
|
+
</View>
|
|
222
|
+
|
|
223
|
+
{/* Main Content */}
|
|
224
|
+
<FlatList
|
|
225
|
+
data={flatData}
|
|
226
|
+
renderItem={renderItem}
|
|
227
|
+
keyExtractor={keyExtractor}
|
|
228
|
+
getItemLayout={getItemLayout}
|
|
229
|
+
windowSize={5}
|
|
230
|
+
removeClippedSubviews={true}
|
|
231
|
+
initialNumToRender={10}
|
|
232
|
+
maxToRenderPerBatch={10}
|
|
233
|
+
updateCellsBatchingPeriod={50}
|
|
234
|
+
contentContainerStyle={[
|
|
235
|
+
styles.listContent,
|
|
236
|
+
{
|
|
237
|
+
paddingTop: Math.max(insets.top, 20) + 40,
|
|
238
|
+
paddingBottom: 220
|
|
239
|
+
}
|
|
240
|
+
]}
|
|
241
|
+
showsVerticalScrollIndicator={false}
|
|
242
|
+
/>
|
|
178
243
|
|
|
179
|
-
{/* Fixed
|
|
180
|
-
<View style={[
|
|
244
|
+
{/* Fixed Footer */}
|
|
245
|
+
<View style={[
|
|
246
|
+
styles.stickyFooter,
|
|
247
|
+
{
|
|
248
|
+
backgroundColor: tokens.colors.backgroundPrimary,
|
|
249
|
+
paddingBottom: Math.max(insets.bottom, 24)
|
|
250
|
+
}
|
|
251
|
+
]}>
|
|
181
252
|
<TouchableOpacity
|
|
182
253
|
onPress={handlePurchase}
|
|
183
|
-
disabled={isProcessing ||
|
|
254
|
+
disabled={isProcessing || !selectedPlanId}
|
|
184
255
|
style={[
|
|
185
256
|
styles.cta,
|
|
186
257
|
{ backgroundColor: tokens.colors.primary },
|
|
187
|
-
(isProcessing ||
|
|
258
|
+
(isProcessing || !selectedPlanId) && styles.ctaDisabled
|
|
188
259
|
]}
|
|
189
|
-
activeOpacity={0.
|
|
260
|
+
activeOpacity={0.8}
|
|
190
261
|
>
|
|
191
262
|
<AtomicText type="titleLarge" style={[styles.ctaText, { color: tokens.colors.onPrimary }]}>
|
|
192
263
|
{isProcessing ? translations.processingText : translations.purchaseButtonText}
|
|
193
264
|
</AtomicText>
|
|
194
265
|
</TouchableOpacity>
|
|
266
|
+
|
|
195
267
|
<PaywallFooter
|
|
196
268
|
translations={translations}
|
|
197
269
|
legalUrls={legalUrls}
|
|
@@ -205,29 +277,3 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
205
277
|
});
|
|
206
278
|
|
|
207
279
|
PaywallScreen.displayName = "PaywallScreen";
|
|
208
|
-
|
|
209
|
-
const screenStyles = StyleSheet.create({
|
|
210
|
-
container: {
|
|
211
|
-
flex: 1,
|
|
212
|
-
},
|
|
213
|
-
headerContainer: {
|
|
214
|
-
position: 'absolute',
|
|
215
|
-
top: 0,
|
|
216
|
-
left: 0,
|
|
217
|
-
right: 0,
|
|
218
|
-
zIndex: 1000,
|
|
219
|
-
paddingHorizontal: 12,
|
|
220
|
-
paddingTop: 12,
|
|
221
|
-
},
|
|
222
|
-
closeBtn: {
|
|
223
|
-
width: 36,
|
|
224
|
-
height: 36,
|
|
225
|
-
borderRadius: 18,
|
|
226
|
-
justifyContent: 'center',
|
|
227
|
-
alignItems: 'center',
|
|
228
|
-
alignSelf: 'flex-end',
|
|
229
|
-
},
|
|
230
|
-
contentContainer: {
|
|
231
|
-
paddingTop: 60, // Space for close button
|
|
232
|
-
},
|
|
233
|
-
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { useState, useCallback, useRef } from "react";
|
|
1
|
+
import { useState, useCallback, useRef, useMemo } from "react";
|
|
2
2
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
3
|
-
import { usePurchaseLoadingStore } from "../../subscription/presentation/stores/purchaseLoadingStore";
|
|
3
|
+
import { usePurchaseLoadingStore, selectIsPurchasing } from "../../subscription/presentation/stores/purchaseLoadingStore";
|
|
4
4
|
import type { PurchaseSource } from "../../subscription/core/SubscriptionConstants";
|
|
5
5
|
|
|
6
6
|
interface UsePaywallActionsParams {
|
|
@@ -26,10 +26,17 @@ export function usePaywallActions({
|
|
|
26
26
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
|
27
27
|
const [isLocalProcessing, setIsLocalProcessing] = useState(false);
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
const isGlobalPurchasing = usePurchaseLoadingStore(
|
|
31
|
-
|
|
29
|
+
// Use optimized selector for global purchasing state
|
|
30
|
+
const isGlobalPurchasing = usePurchaseLoadingStore(selectIsPurchasing);
|
|
31
|
+
|
|
32
|
+
// Combine processing states
|
|
32
33
|
const isProcessing = isLocalProcessing || isGlobalPurchasing;
|
|
34
|
+
|
|
35
|
+
// Use ref for isProcessing to keep callbacks stable without re-creating them
|
|
36
|
+
const isProcessingRef = useRef(isProcessing);
|
|
37
|
+
isProcessingRef.current = isProcessing;
|
|
38
|
+
|
|
39
|
+
const { startPurchase, endPurchase } = usePurchaseLoadingStore();
|
|
33
40
|
|
|
34
41
|
const onPurchaseRef = useRef(onPurchase);
|
|
35
42
|
const onRestoreRef = useRef(onRestore);
|
|
@@ -37,101 +44,86 @@ export function usePaywallActions({
|
|
|
37
44
|
const onPurchaseErrorRef = useRef(onPurchaseError);
|
|
38
45
|
const onAuthRequiredRef = useRef(onAuthRequired);
|
|
39
46
|
const onCloseRef = useRef(onClose);
|
|
47
|
+
const packagesRef = useRef(packages);
|
|
40
48
|
|
|
41
|
-
// Update refs in render body — always in sync
|
|
49
|
+
// Update refs in render body — always in sync
|
|
42
50
|
onPurchaseRef.current = onPurchase;
|
|
43
51
|
onRestoreRef.current = onRestore;
|
|
44
52
|
onPurchaseSuccessRef.current = onPurchaseSuccess;
|
|
45
53
|
onPurchaseErrorRef.current = onPurchaseError;
|
|
46
54
|
onAuthRequiredRef.current = onAuthRequired;
|
|
47
55
|
onCloseRef.current = onClose;
|
|
56
|
+
packagesRef.current = packages;
|
|
48
57
|
|
|
49
58
|
const handlePurchase = useCallback(async () => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
// Access current state via refs to keep callback stable
|
|
60
|
+
const currentSelectedId = selectedPlanId;
|
|
61
|
+
if (!currentSelectedId) return;
|
|
53
62
|
|
|
54
63
|
if (!onPurchaseRef.current) {
|
|
55
|
-
|
|
56
|
-
onPurchaseErrorRef.current?.(err);
|
|
64
|
+
onPurchaseErrorRef.current?.(new Error("Purchase handler not configured"));
|
|
57
65
|
return;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
if (
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
68
|
+
if (isProcessingRef.current) return;
|
|
63
69
|
|
|
64
|
-
const pkg =
|
|
70
|
+
const pkg = packagesRef.current.find((p) => p.product.identifier === currentSelectedId);
|
|
65
71
|
|
|
66
72
|
if (!pkg) {
|
|
67
|
-
|
|
68
|
-
console.error("[usePaywallActions] Package not found", {
|
|
69
|
-
selectedPlanId,
|
|
70
|
-
availablePackages: packages.map(p => p.product.identifier),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
const err = new Error(`Package not found: ${selectedPlanId}`);
|
|
74
|
-
onPurchaseErrorRef.current?.(err);
|
|
73
|
+
onPurchaseErrorRef.current?.(new Error(`Package not found: ${currentSelectedId}`));
|
|
75
74
|
return;
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
setIsLocalProcessing(true);
|
|
79
|
-
startPurchase(
|
|
78
|
+
startPurchase(currentSelectedId, "manual");
|
|
80
79
|
|
|
81
80
|
try {
|
|
82
81
|
const success = await onPurchaseRef.current(pkg);
|
|
83
|
-
|
|
84
82
|
if (success === true) {
|
|
85
83
|
onPurchaseSuccessRef.current?.();
|
|
86
84
|
onCloseRef.current?.();
|
|
87
85
|
}
|
|
88
86
|
} catch (error) {
|
|
89
|
-
|
|
90
|
-
onPurchaseErrorRef.current?.(err);
|
|
87
|
+
onPurchaseErrorRef.current?.(error instanceof Error ? error : new Error(String(error)));
|
|
91
88
|
} finally {
|
|
92
89
|
setIsLocalProcessing(false);
|
|
93
|
-
endPurchase(
|
|
90
|
+
endPurchase(currentSelectedId);
|
|
94
91
|
}
|
|
95
|
-
}, [selectedPlanId,
|
|
92
|
+
}, [selectedPlanId, startPurchase, endPurchase]); // Only depend on state that must trigger re-creation if changed
|
|
96
93
|
|
|
97
94
|
const handleRestore = useCallback(async () => {
|
|
98
95
|
if (!onRestoreRef.current) {
|
|
99
|
-
|
|
100
|
-
onPurchaseErrorRef.current?.(err);
|
|
96
|
+
onPurchaseErrorRef.current?.(new Error("Restore handler not configured"));
|
|
101
97
|
return;
|
|
102
98
|
}
|
|
103
99
|
|
|
104
|
-
if (
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
100
|
+
if (isProcessingRef.current) return;
|
|
107
101
|
|
|
108
102
|
setIsLocalProcessing(true);
|
|
109
103
|
try {
|
|
110
104
|
const success = await onRestoreRef.current();
|
|
111
|
-
|
|
112
105
|
if (success === true) {
|
|
113
106
|
onPurchaseSuccessRef.current?.();
|
|
114
107
|
onCloseRef.current?.();
|
|
115
108
|
}
|
|
116
109
|
} catch (error) {
|
|
117
|
-
|
|
118
|
-
onPurchaseErrorRef.current?.(err);
|
|
110
|
+
onPurchaseErrorRef.current?.(error instanceof Error ? error : new Error(String(error)));
|
|
119
111
|
} finally {
|
|
120
112
|
setIsLocalProcessing(false);
|
|
121
113
|
}
|
|
122
|
-
}, [
|
|
114
|
+
}, []); // Truly stable callback
|
|
123
115
|
|
|
124
116
|
const resetState = useCallback(() => {
|
|
125
117
|
setSelectedPlanId(null);
|
|
126
118
|
setIsLocalProcessing(false);
|
|
127
119
|
}, []);
|
|
128
120
|
|
|
129
|
-
return {
|
|
121
|
+
return useMemo(() => ({
|
|
130
122
|
selectedPlanId,
|
|
131
123
|
setSelectedPlanId,
|
|
132
124
|
isProcessing,
|
|
133
125
|
handlePurchase,
|
|
134
126
|
handleRestore,
|
|
135
127
|
resetState,
|
|
136
|
-
};
|
|
128
|
+
}), [selectedPlanId, isProcessing, handlePurchase, handleRestore, resetState]);
|
|
137
129
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
2
|
+
import type { SubscriptionFeature } from "../entities/types";
|
|
3
|
+
|
|
4
|
+
export type PaywallListItem =
|
|
5
|
+
| { type: 'HEADER' }
|
|
6
|
+
| { type: 'FEATURE_HEADER' }
|
|
7
|
+
| { type: 'FEATURE'; feature: SubscriptionFeature }
|
|
8
|
+
| { type: 'PLAN_HEADER' }
|
|
9
|
+
| { type: 'PLAN'; pkg: PurchasesPackage };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Constants for estimated layout heights
|
|
13
|
+
*/
|
|
14
|
+
export const LAYOUT_CONSTANTS = {
|
|
15
|
+
HEADER_HEIGHT: 300,
|
|
16
|
+
SECTION_HEADER_HEIGHT: 60,
|
|
17
|
+
FEATURE_ITEM_HEIGHT: 46,
|
|
18
|
+
PLAN_ITEM_HEIGHT: 80,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Calculates the offset and length for FlatList items to optimize scrolling performance.
|
|
23
|
+
*/
|
|
24
|
+
export function calculatePaywallItemLayout(data: PaywallListItem[] | null | undefined, index: number) {
|
|
25
|
+
if (!data) return { length: 0, offset: 0, index };
|
|
26
|
+
|
|
27
|
+
let offset = 0;
|
|
28
|
+
for (let i = 0; i < index; i++) {
|
|
29
|
+
const item = data[i];
|
|
30
|
+
offset += getItemHeight(item);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const length = getItemHeight(data[index]);
|
|
34
|
+
|
|
35
|
+
return { length, offset, index };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the estimated height of a single paywall list item based on its type.
|
|
40
|
+
*/
|
|
41
|
+
function getItemHeight(item: PaywallListItem): number {
|
|
42
|
+
switch (item.type) {
|
|
43
|
+
case 'HEADER':
|
|
44
|
+
return LAYOUT_CONSTANTS.HEADER_HEIGHT;
|
|
45
|
+
case 'FEATURE_HEADER':
|
|
46
|
+
case 'PLAN_HEADER':
|
|
47
|
+
return LAYOUT_CONSTANTS.SECTION_HEADER_HEIGHT;
|
|
48
|
+
case 'FEATURE':
|
|
49
|
+
return LAYOUT_CONSTANTS.FEATURE_ITEM_HEIGHT;
|
|
50
|
+
case 'PLAN':
|
|
51
|
+
return LAYOUT_CONSTANTS.PLAN_ITEM_HEIGHT;
|
|
52
|
+
default:
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SubscriptionMetadata } from "../../../subscription/core/types";
|
|
1
|
+
import type { SubscriptionMetadata } from "../../../subscription/core/types/SubscriptionMetadata";
|
|
2
2
|
import type { PackageType } from "./RevenueCatTypes";
|
|
3
3
|
|
|
4
4
|
export interface RevenueCatData extends Omit<SubscriptionMetadata, 'willRenew' | 'productId'> {
|
|
@@ -88,9 +88,9 @@ export function isInvalidCredentialsError(error: unknown): boolean {
|
|
|
88
88
|
return code === "INVALID_CREDENTIALS_ERROR" || code === "9";
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
export function getRawErrorMessage(error: unknown
|
|
91
|
+
export function getRawErrorMessage(error: unknown): string {
|
|
92
92
|
if (error instanceof Error) {
|
|
93
93
|
return error.message;
|
|
94
94
|
}
|
|
95
|
-
return
|
|
95
|
+
return "Unknown error";
|
|
96
96
|
}
|
|
@@ -3,7 +3,7 @@ import type { InitializeResult } from "../../../../shared/application/ports/IRev
|
|
|
3
3
|
import type { InitializerDeps } from "./RevenueCatInitializer.types";
|
|
4
4
|
import { FAILED_INITIALIZATION_RESULT } from "./initializerConstants";
|
|
5
5
|
import { UserSwitchMutex } from "./UserSwitchMutex";
|
|
6
|
-
import { getPremiumEntitlement } from "../../core/types";
|
|
6
|
+
import { getPremiumEntitlement } from "../../core/types/RevenueCatTypes";
|
|
7
7
|
import { ANONYMOUS_CACHE_KEY, type PeriodType } from "../../../subscription/core/SubscriptionConstants";
|
|
8
8
|
|
|
9
9
|
declare const __DEV__: boolean;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CreditsConfig } from "../../credits/core/Credits";
|
|
2
2
|
import type { UserCreditsDocumentRead } from "../../credits/core/UserCreditsDocument";
|
|
3
3
|
import type { PurchaseSource, PurchaseType } from "../core/SubscriptionConstants";
|
|
4
|
-
import type { SubscriptionMetadata } from "../core/types";
|
|
4
|
+
import type { SubscriptionMetadata } from "../core/types/SubscriptionMetadata";
|
|
5
5
|
|
|
6
6
|
export interface FirebaseAuthLike {
|
|
7
7
|
currentUser: { uid: string; isAnonymous: boolean } | null;
|