@umituz/react-native-subscription 2.43.21 → 2.44.1
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.renderItem.tsx +115 -0
- package/src/domains/paywall/components/PaywallScreen.tsx +41 -119
- package/src/domains/subscription/presentation/components/ManagedSubscriptionFlow.states.tsx +187 -0
- package/src/domains/subscription/presentation/components/ManagedSubscriptionFlow.tsx +87 -167
- package/src/domains/subscription/presentation/useSubscriptionFlow.ts +95 -46
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.44.1",
|
|
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",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PaywallScreen Render Item Component
|
|
3
|
+
* Separated for better maintainability
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { View, ListRenderItem } from "react-native";
|
|
8
|
+
import type { ImageSourcePropType } from "react-native";
|
|
9
|
+
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
10
|
+
import { Image } from "expo-image";
|
|
11
|
+
import { PlanCard } from "./PlanCard";
|
|
12
|
+
import type { PaywallListItem } from "../utils/paywallLayoutUtils";
|
|
13
|
+
import { paywallScreenStyles as styles } from "./PaywallScreen.styles";
|
|
14
|
+
|
|
15
|
+
interface PaywallRenderItemProps {
|
|
16
|
+
item: PaywallListItem;
|
|
17
|
+
tokens: any;
|
|
18
|
+
translations: any;
|
|
19
|
+
heroImage?: ImageSourcePropType;
|
|
20
|
+
selectedPlanId?: string;
|
|
21
|
+
bestValueIdentifier?: string;
|
|
22
|
+
creditAmounts?: Record<string, number>;
|
|
23
|
+
creditsLabel?: string;
|
|
24
|
+
onSelectPlan?: (planId: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const PaywallRenderItem: React.FC<PaywallRenderItemProps> = React.memo(({
|
|
28
|
+
item,
|
|
29
|
+
tokens,
|
|
30
|
+
translations,
|
|
31
|
+
heroImage,
|
|
32
|
+
selectedPlanId,
|
|
33
|
+
bestValueIdentifier,
|
|
34
|
+
creditAmounts,
|
|
35
|
+
creditsLabel,
|
|
36
|
+
onSelectPlan,
|
|
37
|
+
}) => {
|
|
38
|
+
if (!translations) return null;
|
|
39
|
+
|
|
40
|
+
switch (item.type) {
|
|
41
|
+
case 'HEADER':
|
|
42
|
+
return (
|
|
43
|
+
<View key="header">
|
|
44
|
+
{/* Hero Image */}
|
|
45
|
+
{heroImage && (
|
|
46
|
+
<View style={styles.heroContainer}>
|
|
47
|
+
<Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={200} />
|
|
48
|
+
</View>
|
|
49
|
+
)}
|
|
50
|
+
|
|
51
|
+
{/* Header Text */}
|
|
52
|
+
<View style={styles.header}>
|
|
53
|
+
<AtomicText type="headlineLarge" style={[styles.title, { color: tokens.colors.textPrimary }]}>
|
|
54
|
+
{translations.title}
|
|
55
|
+
</AtomicText>
|
|
56
|
+
{translations.subtitle && (
|
|
57
|
+
<AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>
|
|
58
|
+
{translations.subtitle}
|
|
59
|
+
</AtomicText>
|
|
60
|
+
)}
|
|
61
|
+
</View>
|
|
62
|
+
</View>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
case 'FEATURE_HEADER':
|
|
66
|
+
return (
|
|
67
|
+
<View key="feat-header" style={styles.sectionHeader}>
|
|
68
|
+
<AtomicText type="labelLarge" style={[styles.sectionTitle, { color: tokens.colors.textSecondary }]}>
|
|
69
|
+
{translations.featuresTitle || "WHAT'S INCLUDED"}
|
|
70
|
+
</AtomicText>
|
|
71
|
+
</View>
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
case 'FEATURE':
|
|
75
|
+
return (
|
|
76
|
+
<View key={`feat-${item.feature.text}`} style={[styles.featureRow, { marginHorizontal: 24, marginBottom: 16 }]}>
|
|
77
|
+
<View style={[styles.featureIcon, { backgroundColor: tokens.colors.primary }]}>
|
|
78
|
+
<AtomicIcon name={item.feature.icon} customSize={16} customColor={tokens.colors.onPrimary} />
|
|
79
|
+
</View>
|
|
80
|
+
<AtomicText type="bodyMedium" style={[styles.featureText, { color: tokens.colors.textPrimary }]}>
|
|
81
|
+
{item.feature.text}
|
|
82
|
+
</AtomicText>
|
|
83
|
+
</View>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
case 'PLAN_HEADER':
|
|
87
|
+
return (
|
|
88
|
+
<View key="plan-header" style={styles.sectionHeader}>
|
|
89
|
+
<AtomicText type="labelLarge" style={[styles.sectionTitle, { color: tokens.colors.textSecondary }]}>
|
|
90
|
+
{translations.plansTitle || "CHOOSE YOUR PLAN"}
|
|
91
|
+
</AtomicText>
|
|
92
|
+
</View>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
case 'PLAN': {
|
|
96
|
+
const pid = item.pkg.product.identifier;
|
|
97
|
+
return (
|
|
98
|
+
<PlanCard
|
|
99
|
+
key={pid}
|
|
100
|
+
pkg={item.pkg}
|
|
101
|
+
isSelected={selectedPlanId === pid}
|
|
102
|
+
badge={bestValueIdentifier === pid ? translations.bestValueBadgeText : undefined}
|
|
103
|
+
creditAmount={creditAmounts?.[pid]}
|
|
104
|
+
creditsLabel={creditsLabel}
|
|
105
|
+
onSelect={() => onSelectPlan?.(pid)}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
default:
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
PaywallRenderItem.displayName = "PaywallRenderItem";
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Paywall Screen Component
|
|
3
3
|
*
|
|
4
4
|
* Full-screen paywall with optimized FlatList for performance and modern design.
|
|
5
|
+
* Render logic separated to PaywallScreen.renderItem.tsx for better maintainability.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import React, { useCallback, useEffect, useMemo } from "react";
|
|
@@ -14,11 +15,9 @@ import {
|
|
|
14
15
|
StatusBar,
|
|
15
16
|
} from "react-native";
|
|
16
17
|
import { useNavigation } from "@react-navigation/native";
|
|
17
|
-
import {
|
|
18
|
+
import { AtomicIcon, AtomicSpinner } from "@umituz/react-native-design-system/atoms";
|
|
18
19
|
import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
|
|
19
20
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
20
|
-
import { Image } from "expo-image";
|
|
21
|
-
import { PlanCard } from "./PlanCard";
|
|
22
21
|
import { paywallScreenStyles as styles } from "./PaywallScreen.styles";
|
|
23
22
|
import { PaywallFooter } from "./PaywallFooter";
|
|
24
23
|
import { usePaywallActions } from "../hooks/usePaywallActions";
|
|
@@ -28,6 +27,7 @@ import {
|
|
|
28
27
|
type PaywallListItem
|
|
29
28
|
} from "../utils/paywallLayoutUtils";
|
|
30
29
|
import { hasItems } from "../../../shared/utils/arrayUtils";
|
|
30
|
+
import { PaywallRenderItem } from "./PaywallScreen.renderItem";
|
|
31
31
|
|
|
32
32
|
export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) => {
|
|
33
33
|
const navigation = useNavigation();
|
|
@@ -115,7 +115,7 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
115
115
|
// Prepare flat data for the list
|
|
116
116
|
const flatData = useMemo(() => {
|
|
117
117
|
const data: PaywallListItem[] = [];
|
|
118
|
-
|
|
118
|
+
|
|
119
119
|
// 1. Header (Hero, Title, Subtitle)
|
|
120
120
|
data.push({ type: 'HEADER' });
|
|
121
121
|
|
|
@@ -139,82 +139,20 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
139
139
|
}, [features, packages]);
|
|
140
140
|
|
|
141
141
|
const renderItem: ListRenderItem<PaywallListItem> = useCallback(({ item }) => {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
<AtomicText type="headlineLarge" style={[styles.title, { color: tokens.colors.textPrimary }]}>
|
|
157
|
-
{translations.title}
|
|
158
|
-
</AtomicText>
|
|
159
|
-
{translations.subtitle && (
|
|
160
|
-
<AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>
|
|
161
|
-
{translations.subtitle}
|
|
162
|
-
</AtomicText>
|
|
163
|
-
)}
|
|
164
|
-
</View>
|
|
165
|
-
</View>
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
case 'FEATURE_HEADER':
|
|
169
|
-
return (
|
|
170
|
-
<View key="feat-header" style={styles.sectionHeader}>
|
|
171
|
-
<AtomicText type="labelLarge" style={[styles.sectionTitle, { color: tokens.colors.textSecondary }]}>
|
|
172
|
-
{translations.featuresTitle || "WHAT'S INCLUDED"}
|
|
173
|
-
</AtomicText>
|
|
174
|
-
</View>
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
case 'FEATURE':
|
|
178
|
-
return (
|
|
179
|
-
<View key={`feat-${item.feature.text}`} style={[styles.featureRow, { marginHorizontal: 24, marginBottom: 16 }]}>
|
|
180
|
-
<View style={[styles.featureIcon, { backgroundColor: tokens.colors.primary }]}>
|
|
181
|
-
<AtomicIcon name={item.feature.icon} customSize={16} customColor={tokens.colors.onPrimary} />
|
|
182
|
-
</View>
|
|
183
|
-
<AtomicText type="bodyMedium" style={[styles.featureText, { color: tokens.colors.textPrimary }]}>
|
|
184
|
-
{item.feature.text}
|
|
185
|
-
</AtomicText>
|
|
186
|
-
</View>
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
case 'PLAN_HEADER':
|
|
190
|
-
return (
|
|
191
|
-
<View key="plan-header" style={styles.sectionHeader}>
|
|
192
|
-
<AtomicText type="labelLarge" style={[styles.sectionTitle, { color: tokens.colors.textSecondary }]}>
|
|
193
|
-
{translations.plansTitle || "CHOOSE YOUR PLAN"}
|
|
194
|
-
</AtomicText>
|
|
195
|
-
</View>
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
case 'PLAN': {
|
|
199
|
-
const pid = item.pkg.product.identifier;
|
|
200
|
-
return (
|
|
201
|
-
<PlanCard
|
|
202
|
-
key={pid}
|
|
203
|
-
pkg={item.pkg}
|
|
204
|
-
isSelected={selectedPlanId === pid}
|
|
205
|
-
badge={bestValueIdentifier === pid ? translations.bestValueBadgeText : undefined}
|
|
206
|
-
creditAmount={creditAmounts?.[pid]}
|
|
207
|
-
creditsLabel={creditsLabel}
|
|
208
|
-
onSelect={() => setSelectedPlanId(pid)}
|
|
209
|
-
/>
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
default:
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
}, [heroImage, translations, tokens, selectedPlanId, bestValueIdentifier, creditAmounts, creditsLabel, setSelectedPlanId]);
|
|
217
|
-
|
|
142
|
+
return (
|
|
143
|
+
<PaywallRenderItem
|
|
144
|
+
item={item}
|
|
145
|
+
tokens={tokens}
|
|
146
|
+
translations={translations}
|
|
147
|
+
heroImage={heroImage}
|
|
148
|
+
selectedPlanId={selectedPlanId}
|
|
149
|
+
bestValueIdentifier={bestValueIdentifier}
|
|
150
|
+
creditAmounts={creditAmounts}
|
|
151
|
+
creditsLabel={creditsLabel}
|
|
152
|
+
onSelectPlan={setSelectedPlanId}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}, [tokens, translations, heroImage, selectedPlanId, bestValueIdentifier, creditAmounts, creditsLabel, setSelectedPlanId]);
|
|
218
156
|
|
|
219
157
|
// Performance Optimization: getItemLayout for FlatList
|
|
220
158
|
const getItemLayout = useCallback((_data: ArrayLike<PaywallListItem> | null, index: number) => {
|
|
@@ -227,12 +165,13 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
227
165
|
return `${item.type}-${index}`;
|
|
228
166
|
}, []);
|
|
229
167
|
|
|
230
|
-
// Defensive check for translations
|
|
168
|
+
// Defensive check for translations
|
|
231
169
|
if (!translations) {
|
|
232
170
|
if (__DEV__) console.warn("[PaywallScreen] Translations prop is missing");
|
|
233
171
|
return null;
|
|
234
172
|
}
|
|
235
173
|
|
|
174
|
+
// Loading state
|
|
236
175
|
if (isSyncing) {
|
|
237
176
|
return (
|
|
238
177
|
<View style={[styles.container, { backgroundColor: tokens.colors.backgroundPrimary, paddingTop: insets.top }]}>
|
|
@@ -246,12 +185,12 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
246
185
|
return (
|
|
247
186
|
<View style={[styles.container, { backgroundColor: tokens.colors.backgroundPrimary }]}>
|
|
248
187
|
<StatusBar barStyle="light-content" />
|
|
249
|
-
|
|
188
|
+
|
|
250
189
|
{/* Absolute Close Button */}
|
|
251
|
-
<View style={{
|
|
252
|
-
position: 'absolute',
|
|
253
|
-
top: Math.max(insets.top, 16),
|
|
254
|
-
right: 0,
|
|
190
|
+
<View style={{
|
|
191
|
+
position: 'absolute',
|
|
192
|
+
top: Math.max(insets.top, 16),
|
|
193
|
+
right: 0,
|
|
255
194
|
zIndex: 10,
|
|
256
195
|
}}>
|
|
257
196
|
<TouchableOpacity
|
|
@@ -275,46 +214,29 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
275
214
|
maxToRenderPerBatch={10}
|
|
276
215
|
updateCellsBatchingPeriod={50}
|
|
277
216
|
contentContainerStyle={[
|
|
278
|
-
styles.listContent,
|
|
279
|
-
{
|
|
217
|
+
styles.listContent,
|
|
218
|
+
{
|
|
280
219
|
paddingTop: Math.max(insets.top, 20) + 40,
|
|
281
|
-
paddingBottom: 220
|
|
220
|
+
paddingBottom: 220
|
|
282
221
|
}
|
|
283
222
|
]}
|
|
284
223
|
showsVerticalScrollIndicator={false}
|
|
285
224
|
/>
|
|
286
225
|
|
|
287
226
|
{/* Fixed Footer */}
|
|
288
|
-
<
|
|
289
|
-
|
|
290
|
-
{
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
(isProcessing || !selectedPlanId) && styles.ctaDisabled
|
|
302
|
-
]}
|
|
303
|
-
activeOpacity={0.8}
|
|
304
|
-
>
|
|
305
|
-
<AtomicText type="titleLarge" style={[styles.ctaText, { color: tokens.colors.onPrimary }]}>
|
|
306
|
-
{isProcessing ? translations.processingText : translations.purchaseButtonText}
|
|
307
|
-
</AtomicText>
|
|
308
|
-
</TouchableOpacity>
|
|
309
|
-
|
|
310
|
-
<PaywallFooter
|
|
311
|
-
translations={translations}
|
|
312
|
-
legalUrls={legalUrls}
|
|
313
|
-
isProcessing={isProcessing}
|
|
314
|
-
onRestore={handleRestore}
|
|
315
|
-
onLegalClick={handleLegalUrl}
|
|
316
|
-
/>
|
|
317
|
-
</View>
|
|
227
|
+
<PaywallFooter
|
|
228
|
+
translations={translations}
|
|
229
|
+
legalUrls={legalUrls}
|
|
230
|
+
isProcessing={isProcessing}
|
|
231
|
+
selectedPlanId={selectedPlanId}
|
|
232
|
+
packages={packages}
|
|
233
|
+
onPurchase={handlePurchase}
|
|
234
|
+
onRestore={handleRestore}
|
|
235
|
+
onLegalUrl={handleLegalUrl}
|
|
236
|
+
insets={insets}
|
|
237
|
+
tokens={tokens}
|
|
238
|
+
styles={styles}
|
|
239
|
+
/>
|
|
318
240
|
</View>
|
|
319
241
|
);
|
|
320
242
|
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Components for ManagedSubscriptionFlow
|
|
3
|
+
* Separated for better maintainability
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import type { NavigationProp } from "@react-navigation/native";
|
|
8
|
+
import { SplashScreen } from "@umituz/react-native-design-system/molecules";
|
|
9
|
+
import { OnboardingScreen } from "@umituz/react-native-design-system/onboarding";
|
|
10
|
+
import { useSubscriptionFlowStore } from "../useSubscriptionFlow";
|
|
11
|
+
import type { ManagedSubscriptionFlowProps } from "./ManagedSubscriptionFlow";
|
|
12
|
+
import { PaywallScreen } from "../../../paywall/components/PaywallScreen";
|
|
13
|
+
import { PaywallFeedbackScreen } from "./feedback/PaywallFeedbackScreen";
|
|
14
|
+
import { usePaywallFeedbackSubmit } from "../../../../presentation/hooks/feedback/useFeedbackSubmit";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// INITIALIZING STATE
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
interface InitializingStateProps {
|
|
21
|
+
tokens: any;
|
|
22
|
+
splash?: ManagedSubscriptionFlowProps["splash"];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const InitializingState: React.FC<InitializingStateProps> = ({ tokens, splash }) => (
|
|
26
|
+
<SplashScreen
|
|
27
|
+
appName={splash?.appName || "Loading..."}
|
|
28
|
+
tagline={splash?.tagline || "Please wait while we set things up"}
|
|
29
|
+
colors={tokens.colors}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// ONBOARDING STATE
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
interface OnboardingStateProps {
|
|
38
|
+
config: ManagedSubscriptionFlowProps["onboarding"];
|
|
39
|
+
onComplete: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const OnboardingState: React.FC<OnboardingStateProps> = ({ config, onComplete }) => (
|
|
43
|
+
<OnboardingScreen
|
|
44
|
+
slides={config.slides}
|
|
45
|
+
onComplete={onComplete}
|
|
46
|
+
showSkipButton={config.showSkipButton ?? true}
|
|
47
|
+
showBackButton={config.showBackButton ?? true}
|
|
48
|
+
showProgressBar={config.showProgressBar ?? true}
|
|
49
|
+
themeColors={config.themeColors}
|
|
50
|
+
translations={config.translations}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// PAYWALL STATE
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
interface PaywallStateProps {
|
|
59
|
+
config: ManagedSubscriptionFlowProps["paywall"];
|
|
60
|
+
packages: any[];
|
|
61
|
+
isPremium: boolean;
|
|
62
|
+
credits: number | null;
|
|
63
|
+
isSyncing: boolean;
|
|
64
|
+
onPurchase: (pkgId: string) => Promise<any>;
|
|
65
|
+
onRestore: () => Promise<any>;
|
|
66
|
+
onClose: (purchased: boolean) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const PaywallState: React.FC<PaywallStateProps> = ({
|
|
70
|
+
config,
|
|
71
|
+
packages,
|
|
72
|
+
isPremium,
|
|
73
|
+
credits,
|
|
74
|
+
isSyncing,
|
|
75
|
+
onPurchase,
|
|
76
|
+
onRestore,
|
|
77
|
+
onClose,
|
|
78
|
+
}) => {
|
|
79
|
+
const [purchaseSuccessful, setPurchaseSuccessful] = React.useState(false);
|
|
80
|
+
|
|
81
|
+
const handlePurchase = async (pkgId: string) => {
|
|
82
|
+
const result = await onPurchase(pkgId);
|
|
83
|
+
if (result?.success) {
|
|
84
|
+
setPurchaseSuccessful(true);
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleClose = () => {
|
|
90
|
+
onClose(purchaseSuccessful);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<PaywallScreen
|
|
95
|
+
translations={config.translations}
|
|
96
|
+
legalUrls={config.legalUrls}
|
|
97
|
+
features={config.features}
|
|
98
|
+
bestValueIdentifier={config.bestValueIdentifier}
|
|
99
|
+
creditsLabel={config.creditsLabel}
|
|
100
|
+
heroImage={config.heroImage}
|
|
101
|
+
source="onboarding"
|
|
102
|
+
packages={packages}
|
|
103
|
+
isPremium={isPremium}
|
|
104
|
+
credits={credits}
|
|
105
|
+
isSyncing={isSyncing}
|
|
106
|
+
onPurchase={handlePurchase}
|
|
107
|
+
onRestore={onRestore}
|
|
108
|
+
onClose={handleClose}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ============================================================================
|
|
114
|
+
// FEEDBACK STATE
|
|
115
|
+
// ============================================================================
|
|
116
|
+
|
|
117
|
+
interface FeedbackStateProps {
|
|
118
|
+
config: ManagedSubscriptionFlowProps["feedback"];
|
|
119
|
+
onClose: () => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const FeedbackState: React.FC<FeedbackStateProps> = ({ config, onClose }) => {
|
|
123
|
+
const { submit: internalSubmit } = usePaywallFeedbackSubmit();
|
|
124
|
+
|
|
125
|
+
const handleSubmit = async (data: { reason: string; otherText?: string }) => {
|
|
126
|
+
if (config.onSubmit) {
|
|
127
|
+
await config.onSubmit(data);
|
|
128
|
+
} else {
|
|
129
|
+
const description = data.otherText ? `${data.reason}: ${data.otherText}` : data.reason;
|
|
130
|
+
await internalSubmit(description);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<PaywallFeedbackScreen
|
|
136
|
+
onClose={onClose}
|
|
137
|
+
onSubmit={handleSubmit}
|
|
138
|
+
translations={config.translations}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ============================================================================
|
|
144
|
+
// READY STATE (APP CONTENT)
|
|
145
|
+
// ============================================================================
|
|
146
|
+
|
|
147
|
+
interface ReadyStateProps {
|
|
148
|
+
children: React.ReactNode;
|
|
149
|
+
offline?: ManagedSubscriptionFlowProps["offline"];
|
|
150
|
+
feedbackConfig: ManagedSubscriptionFlowProps["feedback"];
|
|
151
|
+
showFeedback: boolean;
|
|
152
|
+
tokens: any;
|
|
153
|
+
onFeedbackClose: () => void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const ReadyState: React.FC<ReadyStateProps> = ({
|
|
157
|
+
children,
|
|
158
|
+
offline,
|
|
159
|
+
feedbackConfig,
|
|
160
|
+
showFeedback,
|
|
161
|
+
tokens,
|
|
162
|
+
onFeedbackClose,
|
|
163
|
+
}) => {
|
|
164
|
+
const { OfflineBanner } = require("@umituz/react-native-design-system/offline");
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<>
|
|
168
|
+
{children}
|
|
169
|
+
|
|
170
|
+
{offline && (
|
|
171
|
+
<OfflineBanner
|
|
172
|
+
visible={offline.isOffline}
|
|
173
|
+
message={offline.message}
|
|
174
|
+
backgroundColor={offline.backgroundColor || tokens.colors.error}
|
|
175
|
+
position={offline.position || "top"}
|
|
176
|
+
/>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{showFeedback && (
|
|
180
|
+
<FeedbackState
|
|
181
|
+
config={feedbackConfig}
|
|
182
|
+
onClose={onFeedbackClose}
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
</>
|
|
186
|
+
);
|
|
187
|
+
};
|
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ManagedSubscriptionFlow
|
|
3
|
+
*
|
|
4
|
+
* Clean state machine-based flow orchestration.
|
|
5
|
+
* All state components separated to individual files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useEffect } from "react";
|
|
2
9
|
import type { NavigationProp } from "@react-navigation/native";
|
|
3
10
|
import type { ImageSourcePropType } from "react-native";
|
|
4
|
-
import { SplashScreen, useSplashFlow } from "@umituz/react-native-design-system/molecules";
|
|
5
|
-
import { OnboardingScreen } from "@umituz/react-native-design-system/onboarding";
|
|
6
|
-
import { OfflineBanner } from "@umituz/react-native-design-system/offline";
|
|
7
11
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
8
12
|
import { usePremiumStatus } from "../../presentation/usePremiumStatus";
|
|
9
13
|
import { usePremiumPackages } from "../../presentation/usePremiumPackages";
|
|
10
14
|
import { usePremiumActions } from "../../presentation/usePremiumActions";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
import { useSubscriptionFlowStore, SubscriptionFlowStatus } from "../useSubscriptionFlow";
|
|
16
|
+
import type { PaywallFeedbackTranslations } from "./feedback/PaywallFeedbackScreen.types";
|
|
17
|
+
import type { PaywallTranslations, PaywallLegalUrls, SubscriptionFeature } from "../../../paywall/entities/types";
|
|
18
|
+
import {
|
|
19
|
+
InitializingState,
|
|
20
|
+
OnboardingState,
|
|
21
|
+
PaywallState,
|
|
22
|
+
ReadyState,
|
|
23
|
+
} from "./ManagedSubscriptionFlow.states";
|
|
17
24
|
|
|
18
25
|
export interface ManagedSubscriptionFlowProps {
|
|
19
26
|
children: React.ReactNode;
|
|
@@ -66,23 +73,13 @@ export interface ManagedSubscriptionFlowProps {
|
|
|
66
73
|
};
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
/**
|
|
70
|
-
* ManagedSubscriptionFlow
|
|
71
|
-
*
|
|
72
|
-
* A high-level layout component that orchestrates the entire application flow:
|
|
73
|
-
* Splash -> Onboarding -> [Managed Paywall Screens] -> Main Application Stack.
|
|
74
|
-
*
|
|
75
|
-
* Use this to reduce AppNavigator boilerplate to nearly zero.
|
|
76
|
-
*/
|
|
77
76
|
import {
|
|
78
77
|
SubscriptionFlowProvider,
|
|
79
78
|
useSubscriptionFlowStatus
|
|
80
79
|
} from "../providers/SubscriptionFlowProvider";
|
|
81
|
-
import { useSubscriptionFlowStore, SubscriptionFlowStatus } from "../useSubscriptionFlow";
|
|
82
80
|
|
|
83
|
-
const ManagedSubscriptionFlowInner
|
|
81
|
+
const ManagedSubscriptionFlowInner: React.FC<ManagedSubscriptionFlowProps> = ({
|
|
84
82
|
children,
|
|
85
|
-
navigation,
|
|
86
83
|
islocalizationReady,
|
|
87
84
|
splash,
|
|
88
85
|
onboarding,
|
|
@@ -92,168 +89,91 @@ const ManagedSubscriptionFlowInner = React.memo<ManagedSubscriptionFlowProps>(({
|
|
|
92
89
|
}) => {
|
|
93
90
|
const tokens = useAppDesignTokens();
|
|
94
91
|
const status = useSubscriptionFlowStatus();
|
|
95
|
-
const { isInitialized: isSplashComplete } = useSplashFlow({
|
|
96
|
-
duration: splash?.duration || 0,
|
|
97
|
-
});
|
|
98
92
|
|
|
99
|
-
//
|
|
100
|
-
const { isPremium, isSyncing, credits } = usePremiumStatus();
|
|
93
|
+
// Premium hooks
|
|
94
|
+
const { isPremium, isSyncing, credits, isLoading: isPremiumLoading } = usePremiumStatus();
|
|
101
95
|
const { packages } = usePremiumPackages();
|
|
102
|
-
const { purchasePackage
|
|
103
|
-
const { closePostOnboardingPaywall } = useSubscriptionFlowStore();
|
|
104
|
-
|
|
105
|
-
// Track if purchase was successful to avoid showing feedback
|
|
106
|
-
const [purchaseSuccessful, setPurchaseSuccessful] = useState(false);
|
|
107
|
-
|
|
108
|
-
// Wrap purchasePackage to track success
|
|
109
|
-
const purchasePackage = useCallback(async (pkgId: string) => {
|
|
110
|
-
const result = await originalPurchasePackage(pkgId);
|
|
111
|
-
if (result?.success) {
|
|
112
|
-
setPurchaseSuccessful(true);
|
|
113
|
-
}
|
|
114
|
-
return result;
|
|
115
|
-
}, [originalPurchasePackage]);
|
|
96
|
+
const { purchasePackage, restorePurchase } = usePremiumActions();
|
|
116
97
|
|
|
117
|
-
//
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
98
|
+
// Store actions
|
|
99
|
+
const completeOnboarding = useSubscriptionFlowStore((s) => s.completeOnboarding);
|
|
100
|
+
const showPaywall = useSubscriptionFlowStore((s) => s.showPaywall);
|
|
101
|
+
const completePaywall = useSubscriptionFlowStore((s) => s.completePaywall);
|
|
102
|
+
const hideFeedback = useSubscriptionFlowStore((s) => s.hideFeedback);
|
|
103
|
+
const showFeedbackScreen = useSubscriptionFlowStore((s) => s.showFeedbackScreen);
|
|
104
|
+
const showFeedback = useSubscriptionFlowStore((s) => s.showFeedback);
|
|
121
105
|
|
|
122
|
-
|
|
106
|
+
// ========================================================================
|
|
107
|
+
// STATE TRANSITIONS
|
|
108
|
+
// ========================================================================
|
|
123
109
|
|
|
124
|
-
// Mark navigation tree as ready after splash and localization
|
|
125
110
|
useEffect(() => {
|
|
126
|
-
if (
|
|
127
|
-
const
|
|
128
|
-
|
|
111
|
+
if (status === SubscriptionFlowStatus.CHECK_PREMIUM && !isPremiumLoading) {
|
|
112
|
+
const paywallShown = useSubscriptionFlowStore.getState().paywallShown;
|
|
113
|
+
|
|
114
|
+
if (isPremium) {
|
|
115
|
+
completePaywall(true);
|
|
116
|
+
} else if (!paywallShown) {
|
|
117
|
+
showPaywall();
|
|
118
|
+
} else {
|
|
119
|
+
completePaywall(false);
|
|
120
|
+
}
|
|
129
121
|
}
|
|
130
|
-
}, [
|
|
131
|
-
|
|
132
|
-
const {
|
|
133
|
-
flowState,
|
|
134
|
-
setShowFeedback,
|
|
135
|
-
completeOnboarding
|
|
136
|
-
} = usePaywallOrchestrator({
|
|
137
|
-
navigation,
|
|
138
|
-
isNavReady,
|
|
139
|
-
isLocalizationReady: islocalizationReady,
|
|
140
|
-
translations: paywall.translations,
|
|
141
|
-
features: paywall.features,
|
|
142
|
-
legalUrls: paywall.legalUrls,
|
|
143
|
-
heroImage: paywall.heroImage,
|
|
144
|
-
bestValueIdentifier: paywall.bestValueIdentifier,
|
|
145
|
-
creditsLabel: paywall.creditsLabel,
|
|
146
|
-
disableNavigation: true, // Paywall is rendered inline, don't navigate
|
|
147
|
-
});
|
|
122
|
+
}, [status, isPremium, isPremiumLoading, showPaywall, completePaywall]);
|
|
148
123
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (feedback.onSubmit) {
|
|
153
|
-
await feedback.onSubmit(data);
|
|
154
|
-
} else {
|
|
155
|
-
const description = data.otherText ? `${data.reason}: ${data.otherText}` : data.reason;
|
|
156
|
-
await internalSubmit(description);
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
// 1. Loading / Initialization View
|
|
161
|
-
if (status === SubscriptionFlowStatus.INITIALIZING || !islocalizationReady) {
|
|
162
|
-
if (__DEV__) {
|
|
163
|
-
console.log('[ManagedSubscriptionFlow] ⏳ Rendering Initialization state', {
|
|
164
|
-
status,
|
|
165
|
-
islocalizationReady,
|
|
166
|
-
hasSplashConfig: !!splash,
|
|
167
|
-
isSplashComplete
|
|
168
|
-
});
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (status === SubscriptionFlowStatus.READY && showFeedback) {
|
|
126
|
+
showFeedbackScreen();
|
|
169
127
|
}
|
|
128
|
+
}, [status, showFeedback, showFeedbackScreen]);
|
|
170
129
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
appName={splash?.appName || "Loading..."}
|
|
175
|
-
tagline={splash?.tagline || "Please wait while we set things up"}
|
|
176
|
-
colors={tokens.colors}
|
|
177
|
-
/>
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (__DEV__) {
|
|
182
|
-
console.log('[ManagedSubscriptionFlow] 🔄 Rendering Main state', {
|
|
183
|
-
status,
|
|
184
|
-
isSplashComplete,
|
|
185
|
-
islocalizationReady,
|
|
186
|
-
showFeedback: flowState.showFeedback
|
|
187
|
-
});
|
|
188
|
-
}
|
|
130
|
+
// ========================================================================
|
|
131
|
+
// RENDER BY STATE
|
|
132
|
+
// ========================================================================
|
|
189
133
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return (
|
|
193
|
-
<OnboardingScreen
|
|
194
|
-
slides={onboarding.slides}
|
|
195
|
-
onComplete={completeOnboarding}
|
|
196
|
-
showSkipButton={onboarding.showSkipButton ?? true}
|
|
197
|
-
showBackButton={onboarding.showBackButton ?? true}
|
|
198
|
-
showProgressBar={onboarding.showProgressBar ?? true}
|
|
199
|
-
themeColors={onboarding.themeColors}
|
|
200
|
-
translations={onboarding.translations}
|
|
201
|
-
/>
|
|
202
|
-
);
|
|
134
|
+
if (!islocalizationReady || status === SubscriptionFlowStatus.INITIALIZING) {
|
|
135
|
+
return <InitializingState tokens={tokens} splash={splash} />;
|
|
203
136
|
}
|
|
204
137
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
138
|
+
switch (status) {
|
|
139
|
+
case SubscriptionFlowStatus.ONBOARDING:
|
|
140
|
+
return <OnboardingState config={onboarding} onComplete={completeOnboarding} />;
|
|
141
|
+
|
|
142
|
+
case SubscriptionFlowStatus.CHECK_PREMIUM:
|
|
143
|
+
return <InitializingState tokens={tokens} splash={splash} />;
|
|
144
|
+
|
|
145
|
+
case SubscriptionFlowStatus.POST_ONBOARDING_PAYWALL:
|
|
146
|
+
return (
|
|
147
|
+
<PaywallState
|
|
148
|
+
config={paywall}
|
|
149
|
+
packages={packages}
|
|
150
|
+
isPremium={isPremium}
|
|
151
|
+
credits={credits}
|
|
152
|
+
isSyncing={isSyncing}
|
|
153
|
+
onPurchase={purchasePackage}
|
|
154
|
+
onRestore={restorePurchase}
|
|
155
|
+
onClose={(purchased) => completePaywall(purchased)}
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
case SubscriptionFlowStatus.READY:
|
|
160
|
+
return (
|
|
161
|
+
<ReadyState
|
|
162
|
+
children={children}
|
|
163
|
+
offline={offline}
|
|
164
|
+
feedbackConfig={feedback}
|
|
165
|
+
showFeedback={showFeedback}
|
|
166
|
+
tokens={tokens}
|
|
167
|
+
onFeedbackClose={hideFeedback}
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
212
170
|
|
|
213
|
-
|
|
214
|
-
<
|
|
215
|
-
translations={paywall.translations}
|
|
216
|
-
legalUrls={paywall.legalUrls}
|
|
217
|
-
features={paywall.features}
|
|
218
|
-
bestValueIdentifier={paywall.bestValueIdentifier}
|
|
219
|
-
creditsLabel={paywall.creditsLabel}
|
|
220
|
-
heroImage={paywall.heroImage}
|
|
221
|
-
source="onboarding"
|
|
222
|
-
packages={packages}
|
|
223
|
-
isPremium={isPremium}
|
|
224
|
-
credits={credits}
|
|
225
|
-
isSyncing={isSyncing}
|
|
226
|
-
onPurchase={purchasePackage}
|
|
227
|
-
onRestore={restorePurchase}
|
|
228
|
-
onClose={handleClose}
|
|
229
|
-
/>
|
|
230
|
-
);
|
|
171
|
+
default:
|
|
172
|
+
return <InitializingState tokens={tokens} splash={splash} />;
|
|
231
173
|
}
|
|
174
|
+
};
|
|
232
175
|
|
|
233
|
-
|
|
234
|
-
return (
|
|
235
|
-
<>
|
|
236
|
-
{children}
|
|
237
|
-
|
|
238
|
-
{offline && (
|
|
239
|
-
<OfflineBanner
|
|
240
|
-
visible={offline.isOffline}
|
|
241
|
-
message={offline.message}
|
|
242
|
-
backgroundColor={offline.backgroundColor || tokens.colors.error}
|
|
243
|
-
position={offline.position || "top"}
|
|
244
|
-
/>
|
|
245
|
-
)}
|
|
246
|
-
|
|
247
|
-
{flowState.showFeedback && (
|
|
248
|
-
<PaywallFeedbackScreen
|
|
249
|
-
onClose={() => setShowFeedback(false)}
|
|
250
|
-
onSubmit={handleFeedbackSubmit}
|
|
251
|
-
translations={feedback.translations}
|
|
252
|
-
/>
|
|
253
|
-
)}
|
|
254
|
-
</>
|
|
255
|
-
);
|
|
256
|
-
});
|
|
176
|
+
ManagedSubscriptionFlowInner.displayName = "ManagedSubscriptionFlowInner";
|
|
257
177
|
|
|
258
178
|
export const ManagedSubscriptionFlow: React.FC<ManagedSubscriptionFlowProps> = (props) => {
|
|
259
179
|
return (
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Subscription Flow
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Subscription Flow State Machine
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for app flow state.
|
|
5
|
+
* Clean state transitions without complex if/else logic.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { createStore } from "@umituz/react-native-design-system/storage";
|
|
@@ -10,9 +11,9 @@ import { subscriptionEventBus, FLOW_EVENTS } from "../../../shared/infrastructur
|
|
|
10
11
|
export enum SubscriptionFlowStatus {
|
|
11
12
|
INITIALIZING = "INITIALIZING",
|
|
12
13
|
ONBOARDING = "ONBOARDING",
|
|
13
|
-
|
|
14
|
-
READY = "READY",
|
|
14
|
+
CHECK_PREMIUM = "CHECK_PREMIUM",
|
|
15
15
|
POST_ONBOARDING_PAYWALL = "POST_ONBOARDING_PAYWALL",
|
|
16
|
+
READY = "READY",
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export enum SyncStatus {
|
|
@@ -23,28 +24,46 @@ export enum SyncStatus {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export interface SubscriptionFlowState {
|
|
27
|
+
// Flow state
|
|
26
28
|
status: SubscriptionFlowStatus;
|
|
29
|
+
|
|
30
|
+
// Sync state
|
|
27
31
|
syncStatus: SyncStatus;
|
|
28
32
|
syncError: string | null;
|
|
29
|
-
|
|
33
|
+
|
|
34
|
+
// Onboarding state
|
|
30
35
|
isOnboardingComplete: boolean;
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
|
|
37
|
+
// Paywall state
|
|
33
38
|
paywallShown: boolean;
|
|
39
|
+
|
|
40
|
+
// Feedback state
|
|
41
|
+
showFeedback: boolean;
|
|
42
|
+
|
|
43
|
+
// Auth modal state
|
|
34
44
|
isAuthModalOpen: boolean;
|
|
45
|
+
|
|
46
|
+
// Initialization flag
|
|
47
|
+
isInitialized: boolean;
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
export interface SubscriptionFlowActions {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
51
|
+
// Flow actions
|
|
52
|
+
completeOnboarding: () => void;
|
|
53
|
+
checkPremiumStatus: () => void;
|
|
54
|
+
showPaywall: () => void;
|
|
55
|
+
completePaywall: (purchased: boolean) => void;
|
|
56
|
+
showFeedbackScreen: () => void;
|
|
57
|
+
hideFeedback: () => void;
|
|
58
|
+
|
|
59
|
+
// Auth actions
|
|
41
60
|
setAuthModalOpen: (open: boolean) => void;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
resetFlow: () => Promise<void>;
|
|
45
|
-
setInitialized: (initialized: boolean) => void;
|
|
46
|
-
setStatus: (status: SubscriptionFlowStatus) => void;
|
|
61
|
+
|
|
62
|
+
// Sync actions
|
|
47
63
|
setSyncStatus: (status: SyncStatus, error?: string | null) => void;
|
|
64
|
+
|
|
65
|
+
// Reset
|
|
66
|
+
resetFlow: () => void;
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
export type SubscriptionFlowStore = SubscriptionFlowState & SubscriptionFlowActions;
|
|
@@ -53,73 +72,103 @@ const initialState: SubscriptionFlowState = {
|
|
|
53
72
|
status: SubscriptionFlowStatus.INITIALIZING,
|
|
54
73
|
syncStatus: SyncStatus.IDLE,
|
|
55
74
|
syncError: null,
|
|
56
|
-
isInitialized: false,
|
|
57
75
|
isOnboardingComplete: false,
|
|
58
|
-
showPostOnboardingPaywall: false,
|
|
59
|
-
showFeedback: false,
|
|
60
76
|
paywallShown: false,
|
|
77
|
+
showFeedback: false,
|
|
61
78
|
isAuthModalOpen: false,
|
|
79
|
+
isInitialized: false,
|
|
62
80
|
};
|
|
63
81
|
|
|
82
|
+
/**
|
|
83
|
+
* State transition rules:
|
|
84
|
+
*
|
|
85
|
+
* INITIALIZING -> ONBOARDING (first launch)
|
|
86
|
+
* INITIALIZING -> CHECK_PREMIUM (onboarding already done)
|
|
87
|
+
*
|
|
88
|
+
* ONBOARDING -> CHECK_PREMIUM (onboarding completed)
|
|
89
|
+
*
|
|
90
|
+
* CHECK_PREMIUM -> READY (user is premium)
|
|
91
|
+
* CHECK_PREMIUM -> POST_ONBOARDING_PAYWALL (user not premium, paywall not shown)
|
|
92
|
+
* CHECK_PREMIUM -> READY (user not premium but paywall already shown)
|
|
93
|
+
*
|
|
94
|
+
* POST_ONBOARDING_PAYWALL -> READY (paywall closed)
|
|
95
|
+
*
|
|
96
|
+
* READY -> READY (stays ready, shows overlays when needed)
|
|
97
|
+
*/
|
|
64
98
|
export const useSubscriptionFlowStore = createStore<SubscriptionFlowState, SubscriptionFlowActions>({
|
|
65
99
|
name: "subscription-flow-storage",
|
|
66
100
|
initialState,
|
|
67
|
-
persist:
|
|
101
|
+
persist: true,
|
|
68
102
|
onRehydrate: (state) => {
|
|
69
103
|
if (!state.isInitialized) {
|
|
70
104
|
state.setInitialized(true);
|
|
105
|
+
|
|
106
|
+
// First time: show onboarding
|
|
107
|
+
state.setStatus(SubscriptionFlowStatus.INITIALIZING);
|
|
108
|
+
} else if (state.isOnboardingComplete) {
|
|
109
|
+
// Onboarding done, check premium status
|
|
110
|
+
state.setStatus(SubscriptionFlowStatus.CHECK_PREMIUM);
|
|
111
|
+
} else {
|
|
112
|
+
// Show onboarding
|
|
113
|
+
state.setStatus(SubscriptionFlowStatus.ONBOARDING);
|
|
71
114
|
}
|
|
72
115
|
},
|
|
73
116
|
actions: (set) => ({
|
|
74
|
-
|
|
117
|
+
setInitialized: (initialized: boolean) => set({ isInitialized: initialized }),
|
|
118
|
+
setStatus: (status: SubscriptionFlowStatus) => set({ status }),
|
|
119
|
+
|
|
120
|
+
completeOnboarding: () => {
|
|
75
121
|
set({
|
|
76
122
|
isOnboardingComplete: true,
|
|
77
|
-
|
|
78
|
-
status: SubscriptionFlowStatus.POST_ONBOARDING_PAYWALL,
|
|
123
|
+
status: SubscriptionFlowStatus.CHECK_PREMIUM,
|
|
79
124
|
});
|
|
80
125
|
subscriptionEventBus.emit(FLOW_EVENTS.ONBOARDING_COMPLETED, { timestamp: Date.now() });
|
|
81
126
|
},
|
|
82
|
-
|
|
83
|
-
|
|
127
|
+
|
|
128
|
+
checkPremiumStatus: () => {
|
|
129
|
+
// This is a transient state - the component will check isPremium
|
|
130
|
+
// and transition accordingly
|
|
131
|
+
set({ status: SubscriptionFlowStatus.CHECK_PREMIUM });
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
showPaywall: () => {
|
|
84
135
|
set({
|
|
85
|
-
|
|
136
|
+
status: SubscriptionFlowStatus.POST_ONBOARDING_PAYWALL,
|
|
86
137
|
paywallShown: true,
|
|
138
|
+
});
|
|
139
|
+
subscriptionEventBus.emit(FLOW_EVENTS.PAYWALL_SHOWN, { timestamp: Date.now() });
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
completePaywall: (purchased: boolean) => {
|
|
143
|
+
set({
|
|
87
144
|
status: SubscriptionFlowStatus.READY,
|
|
88
|
-
|
|
145
|
+
paywallShown: true,
|
|
146
|
+
showFeedback: !purchased, // Only show feedback if not purchased
|
|
89
147
|
});
|
|
90
148
|
subscriptionEventBus.emit(FLOW_EVENTS.PAYWALL_CLOSED, {
|
|
91
149
|
timestamp: Date.now(),
|
|
92
150
|
purchased
|
|
93
151
|
});
|
|
94
152
|
},
|
|
95
|
-
|
|
153
|
+
|
|
154
|
+
showFeedbackScreen: () => set({ showFeedback: true }),
|
|
155
|
+
hideFeedback: () => set({ showFeedback: false }),
|
|
156
|
+
|
|
96
157
|
setAuthModalOpen: (open: boolean) => set({ isAuthModalOpen: open }),
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
set({ paywallShown: true });
|
|
100
|
-
subscriptionEventBus.emit(FLOW_EVENTS.PAYWALL_SHOWN, { timestamp: Date.now() });
|
|
101
|
-
},
|
|
102
|
-
setInitialized: (initialized: boolean) => set((state) => {
|
|
103
|
-
if (state.isInitialized === initialized) return state;
|
|
104
|
-
return { isInitialized: initialized };
|
|
105
|
-
}),
|
|
106
|
-
setStatus: (status: SubscriptionFlowStatus) => set((state) => {
|
|
107
|
-
if (state.status === status) return state;
|
|
108
|
-
return { status };
|
|
109
|
-
}),
|
|
110
|
-
setSyncStatus: (syncStatus: SyncStatus, syncError: string | null = null) =>
|
|
158
|
+
|
|
159
|
+
setSyncStatus: (syncStatus: SyncStatus, syncError: string | null = null) =>
|
|
111
160
|
set({ syncStatus, syncError }),
|
|
112
|
-
|
|
161
|
+
|
|
162
|
+
resetFlow: () => {
|
|
113
163
|
set({
|
|
114
164
|
status: SubscriptionFlowStatus.INITIALIZING,
|
|
115
165
|
syncStatus: SyncStatus.IDLE,
|
|
116
166
|
syncError: null,
|
|
117
|
-
isInitialized: false, // Reset isInitialized to allow fresh initialization
|
|
118
167
|
isOnboardingComplete: false,
|
|
119
|
-
showPostOnboardingPaywall: false,
|
|
120
|
-
showFeedback: false,
|
|
121
168
|
paywallShown: false,
|
|
169
|
+
showFeedback: false,
|
|
122
170
|
isAuthModalOpen: false,
|
|
171
|
+
isInitialized: false,
|
|
123
172
|
});
|
|
124
173
|
},
|
|
125
174
|
}),
|