@umituz/react-native-subscription 2.39.8 → 2.39.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.39.8",
3
+ "version": "2.39.9",
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,97 +1,169 @@
1
- import { StyleSheet } from "react-native";
1
+ import { StyleSheet, Platform } from "react-native";
2
2
 
3
3
  export const paywallScreenStyles = StyleSheet.create({
4
4
  container: {
5
5
  flex: 1,
6
6
  },
7
+ // Header / Hero
7
8
  heroContainer: {
8
9
  alignItems: "center",
9
- marginBottom: 24,
10
+ justifyContent: "center",
11
+ marginVertical: 24,
12
+ height: 140,
10
13
  },
11
14
  heroImage: {
12
15
  width: 120,
13
16
  height: 120,
14
- borderRadius: 60,
17
+ borderRadius: 30, // More modern rounded corners
15
18
  },
16
19
  header: {
17
20
  paddingHorizontal: 24,
18
- marginBottom: 24,
21
+ marginBottom: 32,
22
+ alignItems: "center",
19
23
  },
20
24
  title: {
21
- marginBottom: 8,
25
+ marginBottom: 12,
26
+ textAlign: "center",
27
+ fontWeight: "800",
28
+ letterSpacing: -0.5,
22
29
  },
23
30
  subtitle: {
24
31
  lineHeight: 22,
32
+ textAlign: "center",
33
+ opacity: 0.8,
34
+ paddingHorizontal: 12,
25
35
  },
26
- plans: {
27
- paddingHorizontal: 16,
36
+
37
+ // Sections
38
+ sectionHeader: {
39
+ paddingHorizontal: 24,
40
+ marginTop: 24,
28
41
  marginBottom: 16,
29
42
  },
30
- loading: {
43
+ sectionTitle: {
44
+ fontWeight: "700",
45
+ textTransform: "uppercase",
46
+ letterSpacing: 1,
47
+ fontSize: 13,
48
+ opacity: 0.6,
49
+ },
50
+
51
+ // Features
52
+ featuresContainer: {
53
+ marginHorizontal: 24,
54
+ padding: 20,
55
+ borderRadius: 24,
56
+ gap: 16,
57
+ borderWidth: 1,
58
+ borderColor: "rgba(255, 255, 255, 0.1)",
59
+ },
60
+ featureRow: {
61
+ flexDirection: "row",
62
+ alignItems: "center",
63
+ gap: 14,
64
+ },
65
+ featureIcon: {
66
+ width: 30,
67
+ height: 30,
68
+ borderRadius: 10,
69
+ justifyContent: "center",
31
70
  alignItems: "center",
32
- paddingVertical: 32,
71
+ shadowColor: "#000",
72
+ shadowOffset: { width: 0, height: 2 },
73
+ shadowOpacity: 0.2,
74
+ shadowRadius: 4,
75
+ elevation: 3,
33
76
  },
34
- loadingText: {
35
- marginTop: 12,
77
+ featureText: {
78
+ flex: 1,
79
+ fontWeight: "500",
80
+ fontSize: 15,
36
81
  },
82
+
83
+ // Fixed Footer
37
84
  stickyFooter: {
38
85
  position: "absolute",
39
86
  bottom: 0,
40
87
  left: 0,
41
88
  right: 0,
42
- paddingHorizontal: 16,
43
- paddingTop: 12,
89
+ paddingHorizontal: 24,
90
+ paddingTop: 20,
44
91
  borderTopWidth: 1,
45
- borderTopColor: "rgba(255, 255, 255, 0.1)",
92
+ borderTopColor: "rgba(255, 255, 255, 0.08)",
93
+ ...Platform.select({
94
+ ios: {
95
+ shadowColor: "#000",
96
+ shadowOffset: { width: 0, height: -4 },
97
+ shadowOpacity: 0.1,
98
+ shadowRadius: 12,
99
+ },
100
+ android: {
101
+ elevation: 8,
102
+ },
103
+ }),
46
104
  },
47
105
  cta: {
48
- borderRadius: 14,
49
- paddingVertical: 16,
106
+ borderRadius: 18,
107
+ height: 60,
108
+ justifyContent: "center",
50
109
  alignItems: "center",
110
+ shadowColor: "#000",
111
+ shadowOffset: { width: 0, height: 4 },
112
+ shadowOpacity: 0.3,
113
+ shadowRadius: 8,
114
+ elevation: 6,
51
115
  },
52
116
  ctaDisabled: {
53
- opacity: 0.6,
117
+ opacity: 0.5,
54
118
  },
55
119
  ctaText: {
56
- fontWeight: "600",
57
- },
58
- features: {
59
- padding: 16,
60
- },
61
- featureRow: {
62
- flexDirection: "row",
63
- alignItems: "center",
64
- marginBottom: 12,
65
- },
66
- featureIcon: {
67
- width: 32,
68
- height: 32,
69
- borderRadius: 16,
70
- alignItems: "center",
71
- justifyContent: "center",
72
- marginRight: 12,
73
- },
74
- featureText: {
75
- flex: 1,
120
+ fontWeight: "700",
121
+ letterSpacing: 0.5,
76
122
  },
123
+
124
+ // Footer Links
77
125
  footer: {
78
- paddingHorizontal: 16,
79
- paddingBottom: 16,
126
+ marginTop: 16,
127
+ alignItems: "center",
128
+ gap: 12,
80
129
  },
81
130
  restoreButton: {
82
- paddingVertical: 8,
83
- },
84
- restoreButtonDisabled: {
85
- opacity: 0.5,
131
+ paddingVertical: 4,
86
132
  },
87
133
  footerLink: {
88
134
  fontSize: 12,
135
+ fontWeight: "500",
136
+ textDecorationLine: "underline",
137
+ opacity: 0.7,
89
138
  },
90
139
  legalRow: {
91
140
  flexDirection: "row",
92
141
  justifyContent: "center",
93
142
  alignItems: "center",
143
+ gap: 20,
144
+ marginBottom: 8,
145
+ },
146
+
147
+ // Close Button
148
+ closeBtn: {
149
+ width: 40,
150
+ height: 40,
151
+ borderRadius: 20,
152
+ justifyContent: "center",
153
+ alignItems: "center",
154
+ marginRight: 16,
94
155
  marginTop: 8,
95
- gap: 16,
156
+ borderWidth: 1,
157
+ borderColor: "rgba(255, 255, 255, 0.1)",
158
+ },
159
+
160
+ // List
161
+ listContent: {
162
+ paddingBottom: 40,
163
+ },
164
+ loadingContainer: {
165
+ flex: 1,
166
+ justifyContent: "center",
167
+ alignItems: "center",
96
168
  },
97
169
  });
@@ -1,23 +1,36 @@
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";
20
+ import type { PurchasesPackage } from "react-native-purchases";
15
21
  import { PlanCard } from "./PlanCard";
16
22
  import { paywallScreenStyles as styles } from "./PaywallScreen.styles";
17
- import { PaywallFeatures } from "./PaywallFeatures";
18
23
  import { PaywallFooter } from "./PaywallFooter";
19
24
  import { usePaywallActions } from "../hooks/usePaywallActions";
20
25
  import { PaywallScreenProps } from "./PaywallScreen.types";
26
+ import type { SubscriptionFeature } from "../entities/types";
27
+
28
+ type PaywallListItem =
29
+ | { type: 'HEADER' }
30
+ | { type: 'FEATURE_HEADER' }
31
+ | { type: 'FEATURE'; feature: SubscriptionFeature }
32
+ | { type: 'PLAN_HEADER' }
33
+ | { type: 'PLAN'; pkg: PurchasesPackage };
21
34
 
22
35
  export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) => {
23
36
  const {
@@ -42,14 +55,6 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
42
55
  const tokens = useAppDesignTokens();
43
56
  const insets = useSafeAreaInsets();
44
57
 
45
- if (__DEV__) {
46
- console.log("[PaywallScreen] Render:", {
47
- packagesCount: packages.length,
48
- isLoadingPackages,
49
- source,
50
- });
51
- }
52
-
53
58
  const { selectedPlanId, setSelectedPlanId, isProcessing, handlePurchase, handleRestore } = usePaywallActions({
54
59
  packages,
55
60
  onPurchase,
@@ -61,7 +66,7 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
61
66
  onClose
62
67
  });
63
68
 
64
- // Auto-select first package when packages load and none is selected
69
+ // Auto-select first package
65
70
  useEffect(() => {
66
71
  if (packages.length > 0 && !selectedPlanId) {
67
72
  setSelectedPlanId(packages[0].product.identifier);
@@ -73,125 +78,214 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
73
78
  try {
74
79
  if (await Linking.canOpenURL(url)) await Linking.openURL(url);
75
80
  } catch (error) {
76
- console.error('[PaywallScreen] Failed to open URL:', error instanceof Error ? error.message : String(error));
81
+ console.error('[PaywallScreen] Failed to open URL:', error);
77
82
  }
78
83
  }, []);
79
84
 
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>
85
+ // Prepare flat data for the list
86
+ const flatData = useMemo(() => {
87
+ const data: PaywallListItem[] = [];
88
+
89
+ // 1. Header (Hero, Title, Subtitle)
90
+ data.push({ type: 'HEADER' });
92
91
 
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>
92
+ // 2. Features Section
93
+ if (features.length > 0) {
94
+ data.push({ type: 'FEATURE_HEADER' });
95
+ features.forEach(feature => {
96
+ data.push({ type: 'FEATURE', feature });
97
+ });
98
+ }
99
+
100
+ // 3. Plans Section
101
+ if (packages.length > 0) {
102
+ data.push({ type: 'PLAN_HEADER' });
103
+ packages.forEach(pkg => {
104
+ data.push({ type: 'PLAN', pkg });
105
+ });
106
+ }
107
+
108
+ return data;
109
+ }, [features, packages]);
129
110
 
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}
111
+ const renderItem: ListRenderItem<PaywallListItem> = useCallback(({ item }) => {
112
+ switch (item.type) {
113
+ case 'HEADER':
114
+ return (
115
+ <View key="header">
116
+ {/* Hero Image */}
117
+ {heroImage && (
118
+ <View style={styles.heroContainer}>
119
+ <Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={200} />
120
+ </View>
121
+ )}
122
+
123
+ {/* Header Text */}
124
+ <View style={styles.header}>
125
+ <AtomicText type="headlineLarge" style={[styles.title, { color: tokens.colors.textPrimary }]}>
126
+ {translations.title}
127
+ </AtomicText>
128
+ {translations.subtitle && (
129
+ <AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>
130
+ {translations.subtitle}
144
131
  </AtomicText>
145
132
  )}
146
133
  </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>
134
+ </View>
135
+ );
136
+
137
+ case 'FEATURE_HEADER':
138
+ return (
139
+ <View key="feat-header" style={styles.sectionHeader}>
140
+ <AtomicText type="labelLarge" style={[styles.sectionTitle, { color: tokens.colors.textSecondary }]}>
141
+ {translations.featuresTitle || "WHAT'S INCLUDED"}
142
+ </AtomicText>
143
+ </View>
144
+ );
145
+
146
+ case 'FEATURE':
147
+ return (
148
+ <View key={`feat-${item.feature.text}`} style={[styles.featureRow, { marginHorizontal: 24, marginBottom: 16 }]}>
149
+ <View style={[styles.featureIcon, { backgroundColor: tokens.colors.primary }]}>
150
+ <AtomicIcon name={item.feature.icon as any} customSize={16} customColor={tokens.colors.onPrimary} />
155
151
  </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
- )}
152
+ <AtomicText type="bodyMedium" style={[styles.featureText, { color: tokens.colors.textPrimary }]}>
153
+ {item.feature.text}
154
+ </AtomicText>
155
+ </View>
156
+ );
157
+
158
+ case 'PLAN_HEADER':
159
+ return (
160
+ <View key="plan-header" style={styles.sectionHeader}>
161
+ <AtomicText type="labelLarge" style={[styles.sectionTitle, { color: tokens.colors.textSecondary }]}>
162
+ {translations.plansTitle || "CHOOSE YOUR PLAN"}
163
+ </AtomicText>
164
+ </View>
165
+ );
166
+
167
+ case 'PLAN': {
168
+ const pid = item.pkg.product.identifier;
169
+ return (
170
+ <PlanCard
171
+ key={pid}
172
+ pkg={item.pkg}
173
+ isSelected={selectedPlanId === pid}
174
+ badge={bestValueIdentifier === pid ? translations.bestValueBadgeText : undefined}
175
+ creditAmount={creditAmounts?.[pid]}
176
+ creditsLabel={creditsLabel}
177
+ onSelect={() => setSelectedPlanId(pid)}
178
+ />
179
+ );
180
+ }
181
+
182
+ default:
183
+ return null;
184
+ }
185
+ }, [heroImage, translations, tokens, selectedPlanId, bestValueIdentifier, creditAmounts, creditsLabel, setSelectedPlanId]);
186
+
187
+ // Performance Optimization: getItemLayout for FlatList
188
+ const getItemLayout = useCallback((_data: any, index: number) => {
189
+ // Estimated heights for different item types
190
+ // HEADER: ~300, FEATURE_HEADER: ~60, FEATURE: ~46, PLAN_HEADER: ~60, PLAN: ~80
191
+ let offset = 0;
192
+ for (let i = 0; i < index; i++) {
193
+ const item = flatData[i];
194
+ if (item.type === 'HEADER') offset += 300;
195
+ else if (item.type === 'FEATURE_HEADER' || item.type === 'PLAN_HEADER') offset += 60;
196
+ else if (item.type === 'FEATURE') offset += 46;
197
+ else if (item.type === 'PLAN') offset += 80;
198
+ }
199
+
200
+ const currentItem = flatData[index];
201
+ let length = 80;
202
+ if (currentItem.type === 'HEADER') length = 300;
203
+ else if (currentItem.type === 'FEATURE_HEADER' || currentItem.type === 'PLAN_HEADER') length = 60;
204
+ else if (currentItem.type === 'FEATURE') length = 46;
205
+
206
+ return { length, offset, index };
207
+ }, [flatData]);
208
+
209
+ const keyExtractor = useCallback((item: PaywallListItem, index: number) => {
210
+ if (item.type === 'FEATURE') return `feat-${item.feature.text}`;
211
+ if (item.type === 'PLAN') return `plan-${item.pkg.product.identifier}`;
212
+ return `${item.type}-${index}`;
213
+ }, []);
214
+
215
+ if (isLoadingPackages) {
216
+ return (
217
+ <View style={[styles.container, { backgroundColor: tokens.colors.backgroundPrimary, paddingTop: insets.top }]}>
218
+ <View style={styles.loadingContainer}>
219
+ <AtomicSpinner size="lg" />
176
220
  </View>
177
- </ScreenLayout>
221
+ </View>
222
+ );
223
+ }
224
+
225
+ return (
226
+ <View style={[styles.container, { backgroundColor: tokens.colors.backgroundPrimary }]}>
227
+ <StatusBar barStyle="light-content" />
228
+
229
+ {/* Absolute Close Button */}
230
+ <View style={{
231
+ position: 'absolute',
232
+ top: Math.max(insets.top, 16),
233
+ right: 0,
234
+ zIndex: 10,
235
+ }}>
236
+ <TouchableOpacity
237
+ onPress={onClose}
238
+ style={[styles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary }]}
239
+ activeOpacity={0.7}
240
+ >
241
+ <AtomicIcon name="close-outline" size="sm" customColor={tokens.colors.textPrimary} />
242
+ </TouchableOpacity>
243
+ </View>
244
+
245
+ {/* Main Content */}
246
+ <FlatList
247
+ data={flatData}
248
+ renderItem={renderItem}
249
+ keyExtractor={keyExtractor}
250
+ getItemLayout={getItemLayout}
251
+ windowSize={5}
252
+ removeClippedSubviews={true}
253
+ initialNumToRender={10}
254
+ maxToRenderPerBatch={10}
255
+ updateCellsBatchingPeriod={50}
256
+ contentContainerStyle={[
257
+ styles.listContent,
258
+ {
259
+ paddingTop: Math.max(insets.top, 20) + 40,
260
+ paddingBottom: 220
261
+ }
262
+ ]}
263
+ showsVerticalScrollIndicator={false}
264
+ />
178
265
 
179
- {/* Fixed footer overlay - always visible at bottom */}
180
- <View style={[styles.stickyFooter, { backgroundColor: tokens.colors.backgroundPrimary, paddingBottom: Math.max(insets.bottom, 16) }]}>
266
+ {/* Fixed Footer */}
267
+ <View style={[
268
+ styles.stickyFooter,
269
+ {
270
+ backgroundColor: tokens.colors.backgroundPrimary,
271
+ paddingBottom: Math.max(insets.bottom, 24)
272
+ }
273
+ ]}>
181
274
  <TouchableOpacity
182
275
  onPress={handlePurchase}
183
- disabled={isProcessing || isLoadingPackages || !selectedPlanId}
276
+ disabled={isProcessing || !selectedPlanId}
184
277
  style={[
185
278
  styles.cta,
186
279
  { backgroundColor: tokens.colors.primary },
187
- (isProcessing || isLoadingPackages || !selectedPlanId) && styles.ctaDisabled
280
+ (isProcessing || !selectedPlanId) && styles.ctaDisabled
188
281
  ]}
189
- activeOpacity={0.75}
282
+ activeOpacity={0.8}
190
283
  >
191
284
  <AtomicText type="titleLarge" style={[styles.ctaText, { color: tokens.colors.onPrimary }]}>
192
285
  {isProcessing ? translations.processingText : translations.purchaseButtonText}
193
286
  </AtomicText>
194
287
  </TouchableOpacity>
288
+
195
289
  <PaywallFooter
196
290
  translations={translations}
197
291
  legalUrls={legalUrls}
@@ -205,29 +299,3 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
205
299
  });
206
300
 
207
301
  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
  }
@@ -7,7 +7,6 @@ interface PurchaseLoadingState {
7
7
  interface PurchaseLoadingActions {
8
8
  startPurchase: (productId: string, source: "manual" | "auto-execution") => void;
9
9
  endPurchase: (productId: string) => void;
10
- isPurchasing: (productId?: string) => boolean;
11
10
  reset: () => void;
12
11
  }
13
12
 
@@ -17,7 +16,7 @@ const createInitialState = (): PurchaseLoadingState => ({
17
16
  activePurchases: new Map(),
18
17
  });
19
18
 
20
- export const usePurchaseLoadingStore = create<PurchaseLoadingStore>((set, get) => ({
19
+ export const usePurchaseLoadingStore = create<PurchaseLoadingStore>((set) => ({
21
20
  ...createInitialState(),
22
21
 
23
22
  startPurchase: (productId, source) => {
@@ -36,15 +35,19 @@ export const usePurchaseLoadingStore = create<PurchaseLoadingStore>((set, get) =
36
35
  });
37
36
  },
38
37
 
39
- isPurchasing: (productId) => {
40
- const state = get();
41
- if (productId) return state.activePurchases.has(productId);
42
- return state.activePurchases.size > 0;
43
- },
44
-
45
38
  reset: () => {
46
39
  set(createInitialState());
47
40
  },
48
41
  }));
49
42
 
43
+ /**
44
+ * Optimized selector for purchasing state.
45
+ * Use this to avoid re-renders when other parts of the state change.
46
+ */
50
47
  export const selectIsPurchasing = (state: PurchaseLoadingStore) => state.activePurchases.size > 0;
48
+
49
+ /**
50
+ * Optimized selector for a specific product's purchasing state.
51
+ */
52
+ export const selectIsProductPurchasing = (productId: string) => (state: PurchaseLoadingStore) =>
53
+ state.activePurchases.has(productId);
@@ -2,7 +2,7 @@ type EventCallback<T = unknown> = (data: T) => void;
2
2
 
3
3
  class SubscriptionEventBus {
4
4
  private static instance: SubscriptionEventBus;
5
- private listeners: Record<string, EventCallback[]> = {};
5
+ private listeners: Map<string, Set<EventCallback>> = new Map();
6
6
 
7
7
  private constructor() {}
8
8
 
@@ -14,52 +14,58 @@ class SubscriptionEventBus {
14
14
  }
15
15
 
16
16
  on<T>(event: string, callback: EventCallback<T>): () => void {
17
- if (!this.listeners[event]) {
18
- this.listeners[event] = [];
17
+ if (!this.listeners.has(event)) {
18
+ this.listeners.set(event, new Set());
19
19
  }
20
- this.listeners[event].push(callback as EventCallback);
20
+
21
+ const eventSet = this.listeners.get(event)!;
22
+ eventSet.add(callback as EventCallback);
21
23
 
22
24
  return () => {
23
- const listeners = this.listeners[event];
25
+ const listeners = this.listeners.get(event);
24
26
  if (listeners) {
25
- this.listeners[event] = listeners.filter(l => l !== callback);
26
-
27
- if (this.listeners[event].length === 0) {
28
- delete this.listeners[event];
27
+ listeners.delete(callback as EventCallback);
28
+ if (listeners.size === 0) {
29
+ this.listeners.delete(event);
29
30
  }
30
31
  }
31
32
  };
32
33
  }
33
34
 
34
35
  emit<T>(event: string, data: T): void {
35
- if (!this.listeners[event]) return;
36
+ const listeners = this.listeners.get(event);
37
+ if (!listeners || listeners.size === 0) return;
36
38
 
37
- this.listeners[event].forEach(callback => {
38
- Promise.resolve().then(() => {
39
+ // Use microtask for async execution to not block main thread
40
+ // but keep it fast.
41
+ listeners.forEach(callback => {
42
+ queueMicrotask(() => {
39
43
  try {
40
44
  callback(data);
41
45
  } catch (error) {
42
46
  console.error('[SubscriptionEventBus] Listener error for event:', event, { error });
43
47
  }
44
- }).catch(error => {
45
- console.error('[SubscriptionEventBus] Async listener error for event:', event, { error });
46
48
  });
47
49
  });
48
50
  }
49
51
 
50
52
  clear(event?: string): void {
51
53
  if (event) {
52
- delete this.listeners[event];
54
+ this.listeners.delete(event);
53
55
  } else {
54
- this.listeners = {};
56
+ this.listeners.clear();
55
57
  }
56
58
  }
57
59
 
58
60
  getListenerCount(event?: string): number {
59
61
  if (event) {
60
- return this.listeners[event]?.length ?? 0;
62
+ return this.listeners.get(event)?.size ?? 0;
61
63
  }
62
- return Object.values(this.listeners).reduce((total, arr) => total + arr.length, 0);
64
+ let total = 0;
65
+ this.listeners.forEach(set => {
66
+ total += set.size;
67
+ });
68
+ return total;
63
69
  }
64
70
  }
65
71