@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 +1 -1
- package/src/domains/paywall/components/PaywallScreen.styles.ts +116 -44
- package/src/domains/paywall/components/PaywallScreen.tsx +206 -138
- package/src/domains/paywall/entities/types.ts +2 -0
- package/src/domains/paywall/hooks/usePaywallActions.ts +32 -40
- package/src/domains/subscription/presentation/stores/purchaseLoadingStore.ts +11 -8
- package/src/shared/infrastructure/SubscriptionEventBus.ts +24 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.39.
|
|
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
|
-
|
|
10
|
+
justifyContent: "center",
|
|
11
|
+
marginVertical: 24,
|
|
12
|
+
height: 140,
|
|
10
13
|
},
|
|
11
14
|
heroImage: {
|
|
12
15
|
width: 120,
|
|
13
16
|
height: 120,
|
|
14
|
-
borderRadius:
|
|
17
|
+
borderRadius: 30, // More modern rounded corners
|
|
15
18
|
},
|
|
16
19
|
header: {
|
|
17
20
|
paddingHorizontal: 24,
|
|
18
|
-
marginBottom:
|
|
21
|
+
marginBottom: 32,
|
|
22
|
+
alignItems: "center",
|
|
19
23
|
},
|
|
20
24
|
title: {
|
|
21
|
-
marginBottom:
|
|
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
|
-
|
|
27
|
-
|
|
36
|
+
|
|
37
|
+
// Sections
|
|
38
|
+
sectionHeader: {
|
|
39
|
+
paddingHorizontal: 24,
|
|
40
|
+
marginTop: 24,
|
|
28
41
|
marginBottom: 16,
|
|
29
42
|
},
|
|
30
|
-
|
|
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
|
-
|
|
71
|
+
shadowColor: "#000",
|
|
72
|
+
shadowOffset: { width: 0, height: 2 },
|
|
73
|
+
shadowOpacity: 0.2,
|
|
74
|
+
shadowRadius: 4,
|
|
75
|
+
elevation: 3,
|
|
33
76
|
},
|
|
34
|
-
|
|
35
|
-
|
|
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:
|
|
43
|
-
paddingTop:
|
|
89
|
+
paddingHorizontal: 24,
|
|
90
|
+
paddingTop: 20,
|
|
44
91
|
borderTopWidth: 1,
|
|
45
|
-
borderTopColor: "rgba(255, 255, 255, 0.
|
|
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:
|
|
49
|
-
|
|
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.
|
|
117
|
+
opacity: 0.5,
|
|
54
118
|
},
|
|
55
119
|
ctaText: {
|
|
56
|
-
fontWeight: "
|
|
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
|
-
|
|
79
|
-
|
|
126
|
+
marginTop: 16,
|
|
127
|
+
alignItems: "center",
|
|
128
|
+
gap: 12,
|
|
80
129
|
},
|
|
81
130
|
restoreButton: {
|
|
82
|
-
paddingVertical:
|
|
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
|
-
|
|
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
|
|
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";
|
|
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
|
|
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
|
|
81
|
+
console.error('[PaywallScreen] Failed to open URL:', error);
|
|
77
82
|
}
|
|
78
83
|
}, []);
|
|
79
84
|
|
|
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>
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
</
|
|
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
|
|
180
|
-
<View style={[
|
|
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 ||
|
|
276
|
+
disabled={isProcessing || !selectedPlanId}
|
|
184
277
|
style={[
|
|
185
278
|
styles.cta,
|
|
186
279
|
{ backgroundColor: tokens.colors.primary },
|
|
187
|
-
(isProcessing ||
|
|
280
|
+
(isProcessing || !selectedPlanId) && styles.ctaDisabled
|
|
188
281
|
]}
|
|
189
|
-
activeOpacity={0.
|
|
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
|
-
});
|
|
@@ -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
|
}
|
|
@@ -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
|
|
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:
|
|
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
|
|
18
|
-
this.listeners
|
|
17
|
+
if (!this.listeners.has(event)) {
|
|
18
|
+
this.listeners.set(event, new Set());
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
const eventSet = this.listeners.get(event)!;
|
|
22
|
+
eventSet.add(callback as EventCallback);
|
|
21
23
|
|
|
22
24
|
return () => {
|
|
23
|
-
const listeners = this.listeners
|
|
25
|
+
const listeners = this.listeners.get(event);
|
|
24
26
|
if (listeners) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
36
|
+
const listeners = this.listeners.get(event);
|
|
37
|
+
if (!listeners || listeners.size === 0) return;
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
62
|
+
return this.listeners.get(event)?.size ?? 0;
|
|
61
63
|
}
|
|
62
|
-
|
|
64
|
+
let total = 0;
|
|
65
|
+
this.listeners.forEach(set => {
|
|
66
|
+
total += set.size;
|
|
67
|
+
});
|
|
68
|
+
return total;
|
|
63
69
|
}
|
|
64
70
|
}
|
|
65
71
|
|