@umituz/react-native-subscription 2.44.0 → 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 +20 -159
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.44.
|
|
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,27 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ManagedSubscriptionFlow
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Clean state machine-based flow orchestration.
|
|
5
|
+
* All state components separated to individual files.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useEffect
|
|
8
|
+
import React, { useEffect } from "react";
|
|
9
9
|
import type { NavigationProp } from "@react-navigation/native";
|
|
10
10
|
import type { ImageSourcePropType } from "react-native";
|
|
11
|
-
import { View } from "react-native";
|
|
12
|
-
import { SplashScreen, useSplashFlow } from "@umituz/react-native-design-system/molecules";
|
|
13
|
-
import { OnboardingScreen } from "@umituz/react-native-design-system/onboarding";
|
|
14
|
-
import { OfflineBanner } from "@umituz/react-native-design-system/offline";
|
|
15
11
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
16
12
|
import { usePremiumStatus } from "../../presentation/usePremiumStatus";
|
|
17
13
|
import { usePremiumPackages } from "../../presentation/usePremiumPackages";
|
|
18
14
|
import { usePremiumActions } from "../../presentation/usePremiumActions";
|
|
19
15
|
import { useSubscriptionFlowStore, SubscriptionFlowStatus } from "../useSubscriptionFlow";
|
|
20
|
-
import { PaywallFeedbackScreen } from "./feedback/PaywallFeedbackScreen";
|
|
21
16
|
import type { PaywallFeedbackTranslations } from "./feedback/PaywallFeedbackScreen.types";
|
|
22
17
|
import type { PaywallTranslations, PaywallLegalUrls, SubscriptionFeature } from "../../../paywall/entities/types";
|
|
23
|
-
import {
|
|
24
|
-
|
|
18
|
+
import {
|
|
19
|
+
InitializingState,
|
|
20
|
+
OnboardingState,
|
|
21
|
+
PaywallState,
|
|
22
|
+
ReadyState,
|
|
23
|
+
} from "./ManagedSubscriptionFlow.states";
|
|
25
24
|
|
|
26
25
|
export interface ManagedSubscriptionFlowProps {
|
|
27
26
|
children: React.ReactNode;
|
|
@@ -79,123 +78,8 @@ import {
|
|
|
79
78
|
useSubscriptionFlowStatus
|
|
80
79
|
} from "../providers/SubscriptionFlowProvider";
|
|
81
80
|
|
|
82
|
-
|
|
83
|
-
// STATE MACHINE COMPONENTS
|
|
84
|
-
// ============================================================================
|
|
85
|
-
|
|
86
|
-
interface StateComponentProps {
|
|
87
|
-
status: SubscriptionFlowStatus;
|
|
88
|
-
tokens: any;
|
|
89
|
-
onboardingConfig: ManagedSubscriptionFlowProps["onboarding"];
|
|
90
|
-
paywallConfig: ManagedSubscriptionFlowProps["paywall"];
|
|
91
|
-
feedbackConfig: ManagedSubscriptionFlowProps["feedback"];
|
|
92
|
-
isPremium: boolean;
|
|
93
|
-
packages: any[];
|
|
94
|
-
credits: number | null;
|
|
95
|
-
isSyncing: boolean;
|
|
96
|
-
onPurchasePackage: (pkgId: string) => Promise<any>;
|
|
97
|
-
onRestorePurchase: () => Promise<any>;
|
|
98
|
-
navigation: NavigationProp<any>;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const InitializingState: React.FC<{ tokens: any; splash?: ManagedSubscriptionFlowProps["splash"] }> = ({ tokens, splash }) => (
|
|
102
|
-
<SplashScreen
|
|
103
|
-
appName={splash?.appName || "Loading..."}
|
|
104
|
-
tagline={splash?.tagline || "Please wait while we set things up"}
|
|
105
|
-
colors={tokens.colors}
|
|
106
|
-
/>
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const OnboardingState: React.FC<{
|
|
110
|
-
config: ManagedSubscriptionFlowProps["onboarding"];
|
|
111
|
-
onComplete: () => void;
|
|
112
|
-
}> = ({ config, onComplete }) => (
|
|
113
|
-
<OnboardingScreen
|
|
114
|
-
slides={config.slides}
|
|
115
|
-
onComplete={onComplete}
|
|
116
|
-
showSkipButton={config.showSkipButton ?? true}
|
|
117
|
-
showBackButton={config.showBackButton ?? true}
|
|
118
|
-
showProgressBar={config.showProgressBar ?? true}
|
|
119
|
-
themeColors={config.themeColors}
|
|
120
|
-
translations={config.translations}
|
|
121
|
-
/>
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
const PaywallState: React.FC<{
|
|
125
|
-
config: ManagedSubscriptionFlowProps["paywall"];
|
|
126
|
-
packages: any[];
|
|
127
|
-
isPremium: boolean;
|
|
128
|
-
credits: number | null;
|
|
129
|
-
isSyncing: boolean;
|
|
130
|
-
onPurchase: (pkgId: string) => Promise<any>;
|
|
131
|
-
onRestore: () => Promise<any>;
|
|
132
|
-
onClose: (purchased: boolean) => void;
|
|
133
|
-
}> = ({ config, packages, isPremium, credits, isSyncing, onPurchase, onRestore, onClose }) => {
|
|
134
|
-
const [purchaseSuccessful, setPurchaseSuccessful] = React.useState(false);
|
|
135
|
-
|
|
136
|
-
const handlePurchase = async (pkgId: string) => {
|
|
137
|
-
const result = await onPurchase(pkgId);
|
|
138
|
-
if (result?.success) {
|
|
139
|
-
setPurchaseSuccessful(true);
|
|
140
|
-
}
|
|
141
|
-
return result;
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const handleClose = () => {
|
|
145
|
-
onClose(purchaseSuccessful);
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
return (
|
|
149
|
-
<PaywallScreen
|
|
150
|
-
translations={config.translations}
|
|
151
|
-
legalUrls={config.legalUrls}
|
|
152
|
-
features={config.features}
|
|
153
|
-
bestValueIdentifier={config.bestValueIdentifier}
|
|
154
|
-
creditsLabel={config.creditsLabel}
|
|
155
|
-
heroImage={config.heroImage}
|
|
156
|
-
source="onboarding"
|
|
157
|
-
packages={packages}
|
|
158
|
-
isPremium={isPremium}
|
|
159
|
-
credits={credits}
|
|
160
|
-
isSyncing={isSyncing}
|
|
161
|
-
onPurchase={handlePurchase}
|
|
162
|
-
onRestore={onRestore}
|
|
163
|
-
onClose={handleClose}
|
|
164
|
-
/>
|
|
165
|
-
);
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
const FeedbackState: React.FC<{
|
|
169
|
-
config: ManagedSubscriptionFlowProps["feedback"];
|
|
170
|
-
onClose: () => void;
|
|
171
|
-
}> = ({ config, onClose }) => {
|
|
172
|
-
const { submit: internalSubmit } = usePaywallFeedbackSubmit();
|
|
173
|
-
|
|
174
|
-
const handleSubmit = async (data: { reason: string; otherText?: string }) => {
|
|
175
|
-
if (config.onSubmit) {
|
|
176
|
-
await config.onSubmit(data);
|
|
177
|
-
} else {
|
|
178
|
-
const description = data.otherText ? `${data.reason}: ${data.otherText}` : data.reason;
|
|
179
|
-
await internalSubmit(description);
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
return (
|
|
184
|
-
<PaywallFeedbackScreen
|
|
185
|
-
onClose={onClose}
|
|
186
|
-
onSubmit={handleSubmit}
|
|
187
|
-
translations={config.translations}
|
|
188
|
-
/>
|
|
189
|
-
);
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
// ============================================================================
|
|
193
|
-
// MAIN COMPONENT
|
|
194
|
-
// ============================================================================
|
|
195
|
-
|
|
196
|
-
const ManagedSubscriptionFlowInner = React.memo<ManagedSubscriptionFlowProps>(({
|
|
81
|
+
const ManagedSubscriptionFlowInner: React.FC<ManagedSubscriptionFlowProps> = ({
|
|
197
82
|
children,
|
|
198
|
-
navigation,
|
|
199
83
|
islocalizationReady,
|
|
200
84
|
splash,
|
|
201
85
|
onboarding,
|
|
@@ -205,9 +89,6 @@ const ManagedSubscriptionFlowInner = React.memo<ManagedSubscriptionFlowProps>(({
|
|
|
205
89
|
}) => {
|
|
206
90
|
const tokens = useAppDesignTokens();
|
|
207
91
|
const status = useSubscriptionFlowStatus();
|
|
208
|
-
const { isInitialized: isSplashComplete } = useSplashFlow({
|
|
209
|
-
duration: splash?.duration || 0,
|
|
210
|
-
});
|
|
211
92
|
|
|
212
93
|
// Premium hooks
|
|
213
94
|
const { isPremium, isSyncing, credits, isLoading: isPremiumLoading } = usePremiumStatus();
|
|
@@ -220,32 +101,26 @@ const ManagedSubscriptionFlowInner = React.memo<ManagedSubscriptionFlowProps>(({
|
|
|
220
101
|
const completePaywall = useSubscriptionFlowStore((s) => s.completePaywall);
|
|
221
102
|
const hideFeedback = useSubscriptionFlowStore((s) => s.hideFeedback);
|
|
222
103
|
const showFeedbackScreen = useSubscriptionFlowStore((s) => s.showFeedbackScreen);
|
|
223
|
-
|
|
224
104
|
const showFeedback = useSubscriptionFlowStore((s) => s.showFeedback);
|
|
225
105
|
|
|
226
106
|
// ========================================================================
|
|
227
107
|
// STATE TRANSITIONS
|
|
228
108
|
// ========================================================================
|
|
229
109
|
|
|
230
|
-
// CHECK_PREMIUM state transition logic
|
|
231
110
|
useEffect(() => {
|
|
232
111
|
if (status === SubscriptionFlowStatus.CHECK_PREMIUM && !isPremiumLoading) {
|
|
233
112
|
const paywallShown = useSubscriptionFlowStore.getState().paywallShown;
|
|
234
113
|
|
|
235
114
|
if (isPremium) {
|
|
236
|
-
// User is premium, go to ready
|
|
237
115
|
completePaywall(true);
|
|
238
116
|
} else if (!paywallShown) {
|
|
239
|
-
// User not premium and paywall not shown, show paywall
|
|
240
117
|
showPaywall();
|
|
241
118
|
} else {
|
|
242
|
-
// Paywall already shown, go to ready
|
|
243
119
|
completePaywall(false);
|
|
244
120
|
}
|
|
245
121
|
}
|
|
246
122
|
}, [status, isPremium, isPremiumLoading, showPaywall, completePaywall]);
|
|
247
123
|
|
|
248
|
-
// Show feedback when needed
|
|
249
124
|
useEffect(() => {
|
|
250
125
|
if (status === SubscriptionFlowStatus.READY && showFeedback) {
|
|
251
126
|
showFeedbackScreen();
|
|
@@ -253,21 +128,18 @@ const ManagedSubscriptionFlowInner = React.memo<ManagedSubscriptionFlowProps>(({
|
|
|
253
128
|
}, [status, showFeedback, showFeedbackScreen]);
|
|
254
129
|
|
|
255
130
|
// ========================================================================
|
|
256
|
-
// RENDER
|
|
131
|
+
// RENDER BY STATE
|
|
257
132
|
// ========================================================================
|
|
258
133
|
|
|
259
|
-
// Wait for localization
|
|
260
134
|
if (!islocalizationReady || status === SubscriptionFlowStatus.INITIALIZING) {
|
|
261
135
|
return <InitializingState tokens={tokens} splash={splash} />;
|
|
262
136
|
}
|
|
263
137
|
|
|
264
|
-
// Render by state
|
|
265
138
|
switch (status) {
|
|
266
139
|
case SubscriptionFlowStatus.ONBOARDING:
|
|
267
140
|
return <OnboardingState config={onboarding} onComplete={completeOnboarding} />;
|
|
268
141
|
|
|
269
142
|
case SubscriptionFlowStatus.CHECK_PREMIUM:
|
|
270
|
-
// Show loading while checking premium
|
|
271
143
|
return <InitializingState tokens={tokens} splash={splash} />;
|
|
272
144
|
|
|
273
145
|
case SubscriptionFlowStatus.POST_ONBOARDING_PAYWALL:
|
|
@@ -286,31 +158,20 @@ const ManagedSubscriptionFlowInner = React.memo<ManagedSubscriptionFlowProps>(({
|
|
|
286
158
|
|
|
287
159
|
case SubscriptionFlowStatus.READY:
|
|
288
160
|
return (
|
|
289
|
-
|
|
290
|
-
{children}
|
|
291
|
-
|
|
292
|
-
{
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
position={offline.position || "top"}
|
|
298
|
-
/>
|
|
299
|
-
)}
|
|
300
|
-
|
|
301
|
-
{showFeedback && (
|
|
302
|
-
<FeedbackState
|
|
303
|
-
config={feedback}
|
|
304
|
-
onClose={hideFeedback}
|
|
305
|
-
/>
|
|
306
|
-
)}
|
|
307
|
-
</>
|
|
161
|
+
<ReadyState
|
|
162
|
+
children={children}
|
|
163
|
+
offline={offline}
|
|
164
|
+
feedbackConfig={feedback}
|
|
165
|
+
showFeedback={showFeedback}
|
|
166
|
+
tokens={tokens}
|
|
167
|
+
onFeedbackClose={hideFeedback}
|
|
168
|
+
/>
|
|
308
169
|
);
|
|
309
170
|
|
|
310
171
|
default:
|
|
311
172
|
return <InitializingState tokens={tokens} splash={splash} />;
|
|
312
173
|
}
|
|
313
|
-
}
|
|
174
|
+
};
|
|
314
175
|
|
|
315
176
|
ManagedSubscriptionFlowInner.displayName = "ManagedSubscriptionFlowInner";
|
|
316
177
|
|