@umituz/react-native-subscription 2.44.0 → 2.45.0
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/paywall/hooks/usePaywallActions.ts +65 -61
- package/src/domains/paywall/hooks/usePaywallActions.utils.ts +39 -0
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +84 -296
- package/src/domains/subscription/application/sync/CreditDocumentOperations.ts +64 -0
- package/src/domains/subscription/application/sync/PurchaseSyncHandler.ts +83 -0
- package/src/domains/subscription/application/sync/RenewalSyncHandler.ts +69 -0
- package/src/domains/subscription/application/sync/StatusChangeSyncHandler.ts +57 -0
- package/src/domains/subscription/application/sync/SyncProcessorLogger.ts +120 -0
- package/src/domains/subscription/application/sync/UserIdResolver.ts +31 -0
- package/src/domains/subscription/presentation/components/ManagedSubscriptionFlow.states.tsx +187 -0
- package/src/domains/subscription/presentation/components/ManagedSubscriptionFlow.tsx +20 -159
- package/src/domains/subscription/presentation/components/feedback/PaywallFeedbackScreen.parts.tsx +201 -0
- package/src/domains/subscription/presentation/components/feedback/PaywallFeedbackScreen.tsx +89 -185
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status Change Sync Handler
|
|
3
|
+
* Handles premium status changes (expire, sync metadata, recovery)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getCreditsRepository } from "../../../credits/infrastructure/CreditsRepositoryManager";
|
|
7
|
+
import type { PremiumStatusChangedEvent } from "../../core/SubscriptionEvents";
|
|
8
|
+
import { UserIdResolver } from "./UserIdResolver";
|
|
9
|
+
import { CreditDocumentOperations } from "./CreditDocumentOperations";
|
|
10
|
+
import { PurchaseSyncHandler } from "./PurchaseSyncHandler";
|
|
11
|
+
|
|
12
|
+
export class StatusChangeSyncHandler {
|
|
13
|
+
constructor(
|
|
14
|
+
private userIdResolver: UserIdResolver,
|
|
15
|
+
private creditOps: CreditDocumentOperations,
|
|
16
|
+
private purchaseHandler: PurchaseSyncHandler
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async processStatusChange(event: PremiumStatusChangedEvent): Promise<void> {
|
|
20
|
+
// If purchase is in progress, only do recovery sync
|
|
21
|
+
if (this.purchaseHandler.isProcessing()) {
|
|
22
|
+
if (__DEV__) {
|
|
23
|
+
console.log("[StatusChangeSyncHandler] Purchase in progress - running recovery only");
|
|
24
|
+
}
|
|
25
|
+
if (event.isPremium && event.productId) {
|
|
26
|
+
const creditsUserId = await this.userIdResolver.resolveCreditsUserId(event.userId);
|
|
27
|
+
await this.creditOps.syncPremiumStatus(creditsUserId, event);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const creditsUserId = await this.userIdResolver.resolveCreditsUserId(event.userId);
|
|
33
|
+
|
|
34
|
+
// Expired subscription
|
|
35
|
+
if (!event.isPremium && event.productId) {
|
|
36
|
+
await this.creditOps.expireSubscription(creditsUserId);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// No product ID - check if credits doc exists and expire if needed
|
|
41
|
+
if (!event.isPremium && !event.productId) {
|
|
42
|
+
const hasDoc = await getCreditsRepository().creditsDocumentExists(creditsUserId);
|
|
43
|
+
if (hasDoc) {
|
|
44
|
+
await this.creditOps.expireSubscription(creditsUserId);
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// No product ID and is premium - nothing to do
|
|
50
|
+
if (!event.productId) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Sync premium status (with recovery if needed)
|
|
55
|
+
await this.creditOps.syncPremiumStatus(creditsUserId, event);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Processor Logger
|
|
3
|
+
* Centralized logging for subscription sync operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../../shared/infrastructure/SubscriptionEventBus";
|
|
7
|
+
import type { PurchaseCompletedEvent, RenewalDetectedEvent, PremiumStatusChangedEvent } from "../../core/SubscriptionEvents";
|
|
8
|
+
|
|
9
|
+
export type SyncPhase = 'purchase' | 'renewal' | 'status_change';
|
|
10
|
+
|
|
11
|
+
export class SyncProcessorLogger {
|
|
12
|
+
emitSyncStatus(phase: SyncPhase, status: 'syncing' | 'success' | 'error', data: {
|
|
13
|
+
userId: string;
|
|
14
|
+
productId: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
}) {
|
|
17
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.SYNC_STATUS_CHANGED, {
|
|
18
|
+
status,
|
|
19
|
+
phase,
|
|
20
|
+
userId: data.userId,
|
|
21
|
+
productId: data.productId,
|
|
22
|
+
error: data.error,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
logPurchaseStart(event: PurchaseCompletedEvent) {
|
|
27
|
+
if (__DEV__) {
|
|
28
|
+
console.log('[SubscriptionSyncProcessor] 🔵 PURCHASE START', {
|
|
29
|
+
userId: event.userId,
|
|
30
|
+
productId: event.productId,
|
|
31
|
+
source: event.source,
|
|
32
|
+
packageType: event.packageType,
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
logPurchaseSuccess(userId: string, productId: string) {
|
|
39
|
+
if (__DEV__) {
|
|
40
|
+
console.log('[SubscriptionSyncProcessor] 🟢 PURCHASE SUCCESS', {
|
|
41
|
+
userId,
|
|
42
|
+
productId,
|
|
43
|
+
timestamp: new Date().toISOString(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
logPurchaseError(userId: string, productId: string, error: string) {
|
|
49
|
+
console.error('[SubscriptionSyncProcessor] 🔴 PURCHASE FAILED', {
|
|
50
|
+
userId,
|
|
51
|
+
productId,
|
|
52
|
+
error,
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
logRenewalStart(event: RenewalDetectedEvent) {
|
|
58
|
+
if (__DEV__) {
|
|
59
|
+
console.log('[SubscriptionSyncProcessor] 🔵 RENEWAL START', {
|
|
60
|
+
userId: event.userId,
|
|
61
|
+
productId: event.productId,
|
|
62
|
+
newExpirationDate: event.newExpirationDate,
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
logRenewalSuccess(userId: string, productId: string) {
|
|
69
|
+
if (__DEV__) {
|
|
70
|
+
console.log('[SubscriptionSyncProcessor] 🟢 RENEWAL SUCCESS', {
|
|
71
|
+
userId,
|
|
72
|
+
productId,
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
logRenewalError(userId: string, productId: string, error: string) {
|
|
79
|
+
console.error('[SubscriptionSyncProcessor] 🔴 RENEWAL FAILED', {
|
|
80
|
+
userId,
|
|
81
|
+
productId,
|
|
82
|
+
error,
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
logStatusChangeStart(event: PremiumStatusChangedEvent) {
|
|
88
|
+
if (__DEV__) {
|
|
89
|
+
console.log('[SubscriptionSyncProcessor] 🔵 STATUS CHANGE START', {
|
|
90
|
+
userId: event.userId,
|
|
91
|
+
isPremium: event.isPremium,
|
|
92
|
+
productId: event.productId,
|
|
93
|
+
willRenew: event.willRenew,
|
|
94
|
+
expirationDate: event.expirationDate,
|
|
95
|
+
timestamp: new Date().toISOString(),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
logStatusChangeSuccess(userId: string, isPremium: boolean, productId?: string) {
|
|
101
|
+
if (__DEV__) {
|
|
102
|
+
console.log('[SubscriptionSyncProcessor] 🟢 STATUS CHANGE SUCCESS', {
|
|
103
|
+
userId,
|
|
104
|
+
isPremium,
|
|
105
|
+
productId,
|
|
106
|
+
timestamp: new Date().toISOString(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
logStatusChangeError(userId: string, isPremium: boolean, productId: string | undefined, error: string) {
|
|
112
|
+
console.error('[SubscriptionSyncProcessor] 🔴 STATUS CHANGE FAILED', {
|
|
113
|
+
userId,
|
|
114
|
+
isPremium,
|
|
115
|
+
productId,
|
|
116
|
+
error,
|
|
117
|
+
timestamp: new Date().toISOString(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User ID Resolver
|
|
3
|
+
* Handles resolution of RevenueCat user ID to credits user ID
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class UserIdResolver {
|
|
7
|
+
constructor(private getAnonymousUserId: () => Promise<string>) {}
|
|
8
|
+
|
|
9
|
+
async resolveCreditsUserId(revenueCatUserId: string | null | undefined): Promise<string> {
|
|
10
|
+
// Try revenueCatUserId first
|
|
11
|
+
const trimmed = revenueCatUserId?.trim();
|
|
12
|
+
if (this.isValidUserId(trimmed)) {
|
|
13
|
+
return trimmed;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Fallback to anonymous user ID
|
|
17
|
+
console.warn("[UserIdResolver] revenueCatUserId is empty/null, falling back to anonymousUserId");
|
|
18
|
+
const anonymousId = await this.getAnonymousUserId();
|
|
19
|
+
const trimmedAnonymous = anonymousId?.trim();
|
|
20
|
+
|
|
21
|
+
if (!this.isValidUserId(trimmedAnonymous)) {
|
|
22
|
+
throw new Error("[UserIdResolver] Cannot resolve credits userId: both revenueCatUserId and anonymousUserId are empty");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return trimmedAnonymous;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private isValidUserId(userId: string | undefined): boolean {
|
|
29
|
+
return !!userId && userId.length > 0 && userId !== 'undefined' && userId !== 'null';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -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
|
|