@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.
Files changed (67) hide show
  1. package/package.json +1 -1
  2. package/src/domains/credits/application/CreditLimitCalculator.ts +6 -17
  3. package/src/domains/credits/core/UserCreditsDocument.ts +1 -1
  4. package/src/domains/credits/infrastructure/CreditsRepository.ts +3 -3
  5. package/src/domains/credits/infrastructure/operations/CreditsInitializer.ts +1 -1
  6. package/src/domains/credits/infrastructure/operations/CreditsWriter.ts +1 -1
  7. package/src/domains/paywall/components/PaywallFeatures.tsx +1 -1
  8. package/src/domains/paywall/components/PaywallFooter.tsx +1 -1
  9. package/src/domains/paywall/components/PaywallScreen.styles.ts +116 -44
  10. package/src/domains/paywall/components/PaywallScreen.tsx +184 -138
  11. package/src/domains/paywall/entities/types.ts +2 -0
  12. package/src/domains/paywall/hooks/usePaywallActions.ts +32 -40
  13. package/src/domains/paywall/utils/paywallLayoutUtils.ts +55 -0
  14. package/src/domains/revenuecat/core/types/RevenueCatData.ts +1 -1
  15. package/src/domains/revenuecat/core/types/RevenueCatTypes.ts +2 -2
  16. package/src/domains/revenuecat/infrastructure/services/RevenueCatInitializer.types.ts +1 -1
  17. package/src/domains/revenuecat/infrastructure/services/userSwitchHandler.ts +1 -1
  18. package/src/domains/subscription/application/SubscriptionInitializerTypes.ts +1 -1
  19. package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +5 -22
  20. package/src/domains/subscription/application/SubscriptionSyncUtils.ts +1 -1
  21. package/src/domains/subscription/application/featureGate/featureGateBusinessRules.ts +27 -10
  22. package/src/domains/subscription/application/initializer/BackgroundInitializer.ts +42 -41
  23. package/src/domains/subscription/core/SubscriptionEvents.ts +1 -1
  24. package/src/domains/subscription/core/SubscriptionStatusHandlers.ts +1 -5
  25. package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +4 -6
  26. package/src/domains/subscription/infrastructure/handlers/PurchaseStatusResolver.ts +2 -2
  27. package/src/domains/subscription/infrastructure/handlers/package-operations/PackageRestorer.ts +1 -1
  28. package/src/domains/subscription/infrastructure/hooks/usePurchasePackage.ts +1 -1
  29. package/src/domains/subscription/infrastructure/hooks/useRestorePurchase.ts +1 -1
  30. package/src/domains/subscription/infrastructure/managers/SubscriptionManager.types.ts +2 -2
  31. package/src/domains/subscription/infrastructure/managers/initializationHandler.ts +1 -1
  32. package/src/domains/subscription/infrastructure/services/CustomerInfoListenerManager.ts +1 -1
  33. package/src/domains/subscription/infrastructure/services/PurchaseHandler.ts +2 -2
  34. package/src/domains/subscription/infrastructure/services/RestoreHandler.ts +4 -4
  35. package/src/domains/subscription/infrastructure/services/RevenueCatService.types.ts +1 -1
  36. package/src/domains/subscription/infrastructure/services/ServiceStateManager.ts +1 -1
  37. package/src/domains/subscription/infrastructure/services/listeners/CustomerInfoHandler.ts +4 -2
  38. package/src/domains/subscription/infrastructure/services/listeners/ListenerState.ts +1 -1
  39. package/src/domains/subscription/infrastructure/services/purchase/PurchaseErrorHandler.ts +3 -3
  40. package/src/domains/subscription/infrastructure/services/purchase/PurchaseExecutor.ts +2 -1
  41. package/src/domains/subscription/infrastructure/services/purchase/PurchaseValidator.ts +1 -1
  42. package/src/domains/subscription/infrastructure/services/revenueCatServiceInstance.ts +1 -1
  43. package/src/domains/subscription/infrastructure/utils/InitializationCache.ts +35 -42
  44. package/src/domains/subscription/infrastructure/utils/PremiumStatusSyncer.ts +3 -3
  45. package/src/domains/subscription/presentation/components/details/PremiumDetailsCardTypes.ts +1 -1
  46. package/src/domains/subscription/presentation/components/sections/SubscriptionSection.types.ts +1 -1
  47. package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +1 -1
  48. package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.types.ts +1 -1
  49. package/src/domains/subscription/presentation/stores/purchaseLoadingStore.ts +11 -8
  50. package/src/domains/subscription/presentation/useSubscriptionStatus.types.ts +1 -1
  51. package/src/domains/subscription/utils/featureGateUtils.ts +37 -0
  52. package/src/domains/subscription/utils/packageTypeFormatter.ts +1 -1
  53. package/src/domains/wallet/infrastructure/repositories/transaction/CollectionBuilder.ts +1 -1
  54. package/src/domains/wallet/infrastructure/repositories/transaction/TransactionFetcher.ts +2 -1
  55. package/src/domains/wallet/infrastructure/repositories/transaction/TransactionWriter.ts +2 -1
  56. package/src/domains/wallet/presentation/screens/WalletScreen.tsx +1 -1
  57. package/src/index.ts +5 -2
  58. package/src/init/createSubscriptionInitModule.ts +2 -1
  59. package/src/shared/infrastructure/SubscriptionEventBus.ts +24 -18
  60. package/src/domains/revenuecat/core/errors/index.ts +0 -3
  61. package/src/domains/revenuecat/core/types/index.ts +0 -3
  62. package/src/domains/subscription/application/initializer/index.ts +0 -2
  63. package/src/domains/subscription/core/types/index.ts +0 -3
  64. package/src/domains/subscription/infrastructure/handlers/package-operations/index.ts +0 -4
  65. package/src/domains/subscription/infrastructure/utils/renewal/index.ts +0 -3
  66. package/src/shared/infrastructure/firestore/index.ts +0 -2
  67. 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 (not modal). Use when you want the 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 { View, TouchableOpacity, Linking, StyleSheet } from "react-native";
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 when packages load and none is selected
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 instanceof Error ? error.message : String(error));
76
+ console.error('[PaywallScreen] Failed to open URL:', error);
77
77
  }
78
78
  }, []);
79
79
 
80
- return (
81
- <View style={[screenStyles.container, { backgroundColor: tokens.colors.backgroundPrimary }]}>
82
- {/* Close button - positioned absolutely at top */}
83
- <View style={[screenStyles.headerContainer, { paddingTop: insets.top }]}>
84
- <TouchableOpacity
85
- onPress={onClose}
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
- {/* Scrollable content */}
94
- <ScreenLayout
95
- scrollable={true}
96
- backgroundColor="transparent"
97
- contentContainerStyle={{ ...screenStyles.contentContainer, paddingBottom: 120 }}
98
- >
99
- {/* Hero Image */}
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
- {/* Features */}
131
- <PaywallFeatures features={features} />
132
-
133
- {/* Plans */}
134
- <View style={styles.plans}>
135
- {isLoadingPackages ? (
136
- <View style={styles.loading}>
137
- <AtomicSpinner size="md" />
138
- {translations.processingText && (
139
- <AtomicText
140
- type="bodySmall"
141
- style={[styles.loadingText, { color: tokens.colors.textSecondary }]}
142
- >
143
- {translations.processingText}
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
- ) : packages.length === 0 ? (
148
- <View style={styles.loading}>
149
- <AtomicText
150
- type="bodyMedium"
151
- style={{ color: tokens.colors.textSecondary, textAlign: "center" }}
152
- >
153
- {translations.emptyText ?? "No packages available"}
154
- </AtomicText>
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
- packages.map((pkg) => {
158
- const pid = pkg.product.identifier;
159
- const isSelected = selectedPlanId === pid;
160
- const isBestValue = bestValueIdentifier === pid;
161
- const credits = creditAmounts?.[pid];
162
-
163
- return (
164
- <PlanCard
165
- key={pid}
166
- pkg={pkg}
167
- isSelected={isSelected}
168
- badge={isBestValue ? translations.bestValueBadgeText : undefined}
169
- creditAmount={credits}
170
- creditsLabel={creditsLabel}
171
- onSelect={() => setSelectedPlanId(pid)}
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
- </ScreenLayout>
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 footer overlay - always visible at bottom */}
180
- <View style={[styles.stickyFooter, { backgroundColor: tokens.colors.backgroundPrimary, paddingBottom: Math.max(insets.bottom, 16) }]}>
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 || isLoadingPackages || !selectedPlanId}
254
+ disabled={isProcessing || !selectedPlanId}
184
255
  style={[
185
256
  styles.cta,
186
257
  { backgroundColor: tokens.colors.primary },
187
- (isProcessing || isLoadingPackages || !selectedPlanId) && styles.ctaDisabled
258
+ (isProcessing || !selectedPlanId) && styles.ctaDisabled
188
259
  ]}
189
- activeOpacity={0.75}
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
- });
@@ -6,6 +6,8 @@ export interface SubscriptionFeature {
6
6
  export interface PaywallTranslations {
7
7
  title: string;
8
8
  subtitle?: string;
9
+ featuresTitle?: string;
10
+ plansTitle?: string;
9
11
  purchaseButtonText: string;
10
12
  restoreButtonText: string;
11
13
  loadingText: string;
@@ -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
- const { startPurchase, endPurchase } = usePurchaseLoadingStore();
30
- const isGlobalPurchasing = usePurchaseLoadingStore((state) => state.isPurchasing());
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, no 1-frame delay
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
- if (!selectedPlanId) {
51
- return;
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
- const err = new Error("Purchase handler not configured");
56
- onPurchaseErrorRef.current?.(err);
64
+ onPurchaseErrorRef.current?.(new Error("Purchase handler not configured"));
57
65
  return;
58
66
  }
59
67
 
60
- if (isProcessing) {
61
- return;
62
- }
68
+ if (isProcessingRef.current) return;
63
69
 
64
- const pkg = packages.find((p) => p.product.identifier === selectedPlanId);
70
+ const pkg = packagesRef.current.find((p) => p.product.identifier === currentSelectedId);
65
71
 
66
72
  if (!pkg) {
67
- if (typeof __DEV__ !== "undefined" && __DEV__) {
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(selectedPlanId, "manual");
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
- const err = error instanceof Error ? error : new Error(String(error));
90
- onPurchaseErrorRef.current?.(err);
87
+ onPurchaseErrorRef.current?.(error instanceof Error ? error : new Error(String(error)));
91
88
  } finally {
92
89
  setIsLocalProcessing(false);
93
- endPurchase(selectedPlanId);
90
+ endPurchase(currentSelectedId);
94
91
  }
95
- }, [selectedPlanId, packages, isProcessing, startPurchase, endPurchase]);
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
- const err = new Error("Restore handler not configured");
100
- onPurchaseErrorRef.current?.(err);
96
+ onPurchaseErrorRef.current?.(new Error("Restore handler not configured"));
101
97
  return;
102
98
  }
103
99
 
104
- if (isProcessing) {
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
- const err = error instanceof Error ? error : new Error(String(error));
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
- }, [isProcessing]);
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, fallback: string): string {
91
+ export function getRawErrorMessage(error: unknown): string {
92
92
  if (error instanceof Error) {
93
93
  return error.message;
94
94
  }
95
- return fallback;
95
+ return "Unknown error";
96
96
  }
@@ -1,4 +1,4 @@
1
- import type { RevenueCatConfig } from "../../core/types";
1
+ import type { RevenueCatConfig } from "../../core/types/RevenueCatConfig";
2
2
 
3
3
  export interface InitializerDeps {
4
4
  config: RevenueCatConfig;
@@ -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;