@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.43.21",
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 { AtomicText, AtomicIcon, AtomicSpinner } from "@umituz/react-native-design-system/atoms";
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
- if (!translations) return null;
143
- switch (item.type) {
144
- case 'HEADER':
145
- return (
146
- <View key="header">
147
- {/* Hero Image */}
148
- {heroImage && (
149
- <View style={styles.heroContainer}>
150
- <Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={200} />
151
- </View>
152
- )}
153
-
154
- {/* Header Text */}
155
- <View style={styles.header}>
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 moved to the end of hooks
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
- <View style={[
289
- styles.stickyFooter,
290
- {
291
- backgroundColor: tokens.colors.backgroundPrimary,
292
- paddingBottom: Math.max(insets.bottom, 24)
293
- }
294
- ]}>
295
- <TouchableOpacity
296
- onPress={handlePurchase}
297
- disabled={isProcessing || !selectedPlanId}
298
- style={[
299
- styles.cta,
300
- { backgroundColor: tokens.colors.primary },
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
- import React, { useState, useEffect, useCallback } from "react";
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 { usePaywallOrchestrator } from "../../../paywall/hooks/usePaywallOrchestrator";
12
- import { PaywallFeedbackScreen } from "../../../subscription/presentation/components/feedback/PaywallFeedbackScreen";
13
- import { PaywallFeedbackTranslations } from "../../../subscription/presentation/components/feedback/PaywallFeedbackScreen.types";
14
- import { PaywallTranslations, PaywallLegalUrls, SubscriptionFeature } from "../../../paywall/entities/types";
15
- import { usePaywallFeedbackSubmit } from "../../../../presentation/hooks/feedback/useFeedbackSubmit";
16
- import { PaywallScreen } from "../../../paywall/components/PaywallScreen";
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 = React.memo<ManagedSubscriptionFlowProps>(({
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
- // Hooks for paywall (called at component level, not inside conditional render)
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: originalPurchasePackage, restorePurchase } = usePremiumActions();
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
- // Wrap onClose to pass purchase status
118
- const handleClose = useCallback(() => {
119
- closePostOnboardingPaywall({ purchased: purchaseSuccessful });
120
- }, [closePostOnboardingPaywall, purchaseSuccessful]);
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
- const [isNavReady, setIsNavReady] = useState(false);
106
+ // ========================================================================
107
+ // STATE TRANSITIONS
108
+ // ========================================================================
123
109
 
124
- // Mark navigation tree as ready after splash and localization
125
110
  useEffect(() => {
126
- if (isSplashComplete && islocalizationReady) {
127
- const timer = setTimeout(() => setIsNavReady(true), 500);
128
- return () => clearTimeout(timer);
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
- }, [isSplashComplete, islocalizationReady]);
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
- const { submit: internalSubmit } = usePaywallFeedbackSubmit();
150
-
151
- const handleFeedbackSubmit = async (data: { reason: string; otherText?: string }) => {
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
- // Even if no splash config provided, we should show a basic splash to avoid white screen
172
- return (
173
- <SplashScreen
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
- // 2. Onboarding View
191
- if (status === SubscriptionFlowStatus.ONBOARDING) {
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
- // 2.5. Post-Onboarding Paywall View
206
- if (status === SubscriptionFlowStatus.POST_ONBOARDING_PAYWALL) {
207
- if (__DEV__) {
208
- console.log('[ManagedSubscriptionFlow] 💳 Rendering Post-Onboarding Paywall', {
209
- hasPackages: flowState.showPostOnboardingPaywall
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
- return (
214
- <PaywallScreen
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
- // 3. Application Content + Overlays
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 Store
3
- * Manages the high-level application flow: Splash -> Onboarding -> Paywall -> Main App.
4
- * Uses @umituz/react-native-design-system's storage utility for standardized persistence.
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
- PAYWALL = "PAYWALL",
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
- isInitialized: boolean;
33
+
34
+ // Onboarding state
30
35
  isOnboardingComplete: boolean;
31
- showPostOnboardingPaywall: boolean;
32
- showFeedback: boolean;
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
- completeOnboarding: () => Promise<void>;
39
- closePostOnboardingPaywall: (params?: { purchased: boolean }) => Promise<void>;
40
- closeFeedback: () => void;
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
- markPaywallShown: () => Promise<void>;
43
- setShowFeedback: (show: boolean) => void;
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: false,
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
- completeOnboarding: async () => {
117
+ setInitialized: (initialized: boolean) => set({ isInitialized: initialized }),
118
+ setStatus: (status: SubscriptionFlowStatus) => set({ status }),
119
+
120
+ completeOnboarding: () => {
75
121
  set({
76
122
  isOnboardingComplete: true,
77
- showPostOnboardingPaywall: true,
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
- closePostOnboardingPaywall: async (params?: { purchased: boolean }) => {
83
- const purchased = params?.purchased ?? false;
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
- showPostOnboardingPaywall: false,
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
- showFeedback: !purchased, // Only show feedback if NOT purchased
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
- closeFeedback: () => set({ showFeedback: false }),
153
+
154
+ showFeedbackScreen: () => set({ showFeedback: true }),
155
+ hideFeedback: () => set({ showFeedback: false }),
156
+
96
157
  setAuthModalOpen: (open: boolean) => set({ isAuthModalOpen: open }),
97
- setShowFeedback: (show: boolean) => set({ showFeedback: show }),
98
- markPaywallShown: async () => {
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
- resetFlow: async () => {
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
  }),