@umituz/react-native-subscription 2.39.7 → 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.7",
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,93 +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: {
31
- alignItems: "center",
32
- paddingVertical: 32,
33
- },
34
- loadingText: {
35
- marginTop: 12,
36
- },
37
- stickyFooter: {
38
- paddingHorizontal: 16,
39
- paddingTop: 12,
40
- borderTopWidth: 1,
41
- borderTopColor: "rgba(255, 255, 255, 0.1)",
42
- },
43
- cta: {
44
- borderRadius: 14,
45
- paddingVertical: 16,
46
- alignItems: "center",
47
- },
48
- ctaDisabled: {
43
+ sectionTitle: {
44
+ fontWeight: "700",
45
+ textTransform: "uppercase",
46
+ letterSpacing: 1,
47
+ fontSize: 13,
49
48
  opacity: 0.6,
50
49
  },
51
- ctaText: {
52
- fontWeight: "600",
53
- },
54
- features: {
55
- padding: 16,
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)",
56
59
  },
57
60
  featureRow: {
58
61
  flexDirection: "row",
59
62
  alignItems: "center",
60
- marginBottom: 12,
63
+ gap: 14,
61
64
  },
62
65
  featureIcon: {
63
- width: 32,
64
- height: 32,
65
- borderRadius: 16,
66
- alignItems: "center",
66
+ width: 30,
67
+ height: 30,
68
+ borderRadius: 10,
67
69
  justifyContent: "center",
68
- marginRight: 12,
70
+ alignItems: "center",
71
+ shadowColor: "#000",
72
+ shadowOffset: { width: 0, height: 2 },
73
+ shadowOpacity: 0.2,
74
+ shadowRadius: 4,
75
+ elevation: 3,
69
76
  },
70
77
  featureText: {
71
78
  flex: 1,
79
+ fontWeight: "500",
80
+ fontSize: 15,
72
81
  },
73
- footer: {
74
- paddingHorizontal: 16,
75
- paddingBottom: 16,
82
+
83
+ // Fixed Footer
84
+ stickyFooter: {
85
+ position: "absolute",
86
+ bottom: 0,
87
+ left: 0,
88
+ right: 0,
89
+ paddingHorizontal: 24,
90
+ paddingTop: 20,
91
+ borderTopWidth: 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
+ }),
76
104
  },
77
- restoreButton: {
78
- paddingVertical: 8,
105
+ cta: {
106
+ borderRadius: 18,
107
+ height: 60,
108
+ justifyContent: "center",
109
+ alignItems: "center",
110
+ shadowColor: "#000",
111
+ shadowOffset: { width: 0, height: 4 },
112
+ shadowOpacity: 0.3,
113
+ shadowRadius: 8,
114
+ elevation: 6,
79
115
  },
80
- restoreButtonDisabled: {
116
+ ctaDisabled: {
81
117
  opacity: 0.5,
82
118
  },
119
+ ctaText: {
120
+ fontWeight: "700",
121
+ letterSpacing: 0.5,
122
+ },
123
+
124
+ // Footer Links
125
+ footer: {
126
+ marginTop: 16,
127
+ alignItems: "center",
128
+ gap: 12,
129
+ },
130
+ restoreButton: {
131
+ paddingVertical: 4,
132
+ },
83
133
  footerLink: {
84
134
  fontSize: 12,
135
+ fontWeight: "500",
136
+ textDecorationLine: "underline",
137
+ opacity: 0.7,
85
138
  },
86
139
  legalRow: {
87
140
  flexDirection: "row",
88
141
  justifyContent: "center",
89
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,
90
155
  marginTop: 8,
91
- 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",
92
168
  },
93
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,149 +78,224 @@ 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
- // Close button for header
81
- const closeButton = (
82
- <TouchableOpacity
83
- onPress={onClose}
84
- style={[screenStyles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary }]}
85
- hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
86
- >
87
- <AtomicIcon name="close-outline" size="md" customColor={tokens.colors.textPrimary} />
88
- </TouchableOpacity>
89
- );
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' });
90
91
 
91
- // Sticky footer with CTA and restore/legal links
92
- const footerContent = (
93
- <View style={[styles.stickyFooter, { paddingBottom: insets.bottom || 16 }]}>
94
- <TouchableOpacity
95
- onPress={handlePurchase}
96
- disabled={isProcessing || isLoadingPackages || !selectedPlanId}
97
- style={[
98
- styles.cta,
99
- { backgroundColor: tokens.colors.primary },
100
- (isProcessing || isLoadingPackages || !selectedPlanId) && styles.ctaDisabled
101
- ]}
102
- activeOpacity={0.75}
103
- >
104
- <AtomicText type="titleLarge" style={[styles.ctaText, { color: tokens.colors.onPrimary }]}>
105
- {isProcessing ? translations.processingText : translations.purchaseButtonText}
106
- </AtomicText>
107
- </TouchableOpacity>
108
- <PaywallFooter
109
- translations={translations}
110
- legalUrls={legalUrls}
111
- isProcessing={isProcessing}
112
- onRestore={onRestore ? handleRestore : undefined}
113
- onLegalClick={handleLegalUrl}
114
- />
115
- </View>
116
- );
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
+ }
117
99
 
118
- return (
119
- <ScreenLayout
120
- scrollable={true}
121
- backgroundColor={tokens.colors.backgroundPrimary}
122
- header={closeButton}
123
- footer={footerContent}
124
- contentContainerStyle={screenStyles.contentContainer}
125
- >
126
- {/* Hero Image */}
127
- {heroImage && (
128
- <View style={styles.heroContainer}>
129
- <Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={0} />
130
- </View>
131
- )}
132
-
133
- {/* Header */}
134
- <View style={styles.header}>
135
- <AtomicText
136
- type="headlineMedium"
137
- adjustsFontSizeToFit
138
- numberOfLines={2}
139
- minimumFontScale={0.75}
140
- style={[styles.title, { color: tokens.colors.textPrimary }]}
141
- >
142
- {translations.title}
143
- </AtomicText>
144
- {translations.subtitle && (
145
- <AtomicText
146
- type="bodyMedium"
147
- adjustsFontSizeToFit
148
- numberOfLines={3}
149
- minimumFontScale={0.8}
150
- style={[styles.subtitle, { color: tokens.colors.textSecondary }]}
151
- >
152
- {translations.subtitle}
153
- </AtomicText>
154
- )}
155
- </View>
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
+ }
156
107
 
157
- {/* Features */}
158
- <PaywallFeatures features={features} />
159
-
160
- {/* Plans */}
161
- <View style={styles.plans}>
162
- {isLoadingPackages ? (
163
- <View style={styles.loading}>
164
- <AtomicSpinner size="md" />
165
- {translations.processingText && (
166
- <AtomicText
167
- type="bodySmall"
168
- style={[styles.loadingText, { color: tokens.colors.textSecondary }]}
169
- >
170
- {translations.processingText}
171
- </AtomicText>
108
+ return data;
109
+ }, [features, packages]);
110
+
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>
172
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}
131
+ </AtomicText>
132
+ )}
133
+ </View>
173
134
  </View>
174
- ) : packages.length === 0 ? (
175
- <View style={styles.loading}>
176
- <AtomicText
177
- type="bodyMedium"
178
- style={{ color: tokens.colors.textSecondary, textAlign: "center" }}
179
- >
180
- {translations.emptyText ?? "No packages available"}
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} />
151
+ </View>
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"}
181
163
  </AtomicText>
182
164
  </View>
183
- ) : (
184
- packages.map((pkg) => {
185
- const pid = pkg.product.identifier;
186
- const isSelected = selectedPlanId === pid;
187
- const isBestValue = bestValueIdentifier === pid;
188
- const credits = creditAmounts?.[pid];
189
-
190
- return (
191
- <PlanCard
192
- key={pid}
193
- pkg={pkg}
194
- isSelected={isSelected}
195
- badge={isBestValue ? translations.bestValueBadgeText : undefined}
196
- creditAmount={credits}
197
- creditsLabel={creditsLabel}
198
- onSelect={() => setSelectedPlanId(pid)}
199
- />
200
- );
201
- })
202
- )}
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" />
220
+ </View>
203
221
  </View>
204
- </ScreenLayout>
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
+ />
265
+
266
+ {/* Fixed Footer */}
267
+ <View style={[
268
+ styles.stickyFooter,
269
+ {
270
+ backgroundColor: tokens.colors.backgroundPrimary,
271
+ paddingBottom: Math.max(insets.bottom, 24)
272
+ }
273
+ ]}>
274
+ <TouchableOpacity
275
+ onPress={handlePurchase}
276
+ disabled={isProcessing || !selectedPlanId}
277
+ style={[
278
+ styles.cta,
279
+ { backgroundColor: tokens.colors.primary },
280
+ (isProcessing || !selectedPlanId) && styles.ctaDisabled
281
+ ]}
282
+ activeOpacity={0.8}
283
+ >
284
+ <AtomicText type="titleLarge" style={[styles.ctaText, { color: tokens.colors.onPrimary }]}>
285
+ {isProcessing ? translations.processingText : translations.purchaseButtonText}
286
+ </AtomicText>
287
+ </TouchableOpacity>
288
+
289
+ <PaywallFooter
290
+ translations={translations}
291
+ legalUrls={legalUrls}
292
+ isProcessing={isProcessing}
293
+ onRestore={onRestore ? handleRestore : undefined}
294
+ onLegalClick={handleLegalUrl}
295
+ />
296
+ </View>
297
+ </View>
205
298
  );
206
299
  });
207
300
 
208
301
  PaywallScreen.displayName = "PaywallScreen";
209
-
210
- const screenStyles = StyleSheet.create({
211
- closeBtn: {
212
- width: 36,
213
- height: 36,
214
- borderRadius: 18,
215
- justifyContent: 'center',
216
- alignItems: 'center',
217
- },
218
- contentContainer: {
219
- paddingBottom: 16,
220
- },
221
- });
@@ -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
  }
@@ -139,7 +139,13 @@ export const PaywallFeedbackScreen: React.FC<PaywallFeedbackScreenProps> = React
139
139
 
140
140
  PaywallFeedbackScreen.displayName = "PaywallFeedbackScreen";
141
141
 
142
- const createScreenStyles = (tokens: any, _insets: any) => ({
142
+ const createScreenStyles = (
143
+ tokens: {
144
+ colors: { backgroundPrimary: string; border: string };
145
+ spacing: { xl: number; sm: number; md: number };
146
+ },
147
+ _insets: { top: number; bottom: number }
148
+ ) => ({
143
149
  container: {
144
150
  flex: 1,
145
151
  },
@@ -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