@umituz/react-native-subscription 2.43.0 → 2.43.2
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/credits/application/CreditsInitializer.ts +3 -2
- package/src/domains/credits/domain/services/CreditLimitService.ts +76 -0
- package/src/domains/credits/infrastructure/CreditsRepository.ts +10 -3
- package/src/domains/credits/infrastructure/operations/CreditsInitializer.ts +3 -2
- package/src/domains/paywall/components/PaywallScreen.tsx +8 -8
- package/src/domains/paywall/components/PaywallScreen.types.ts +15 -4
- package/src/domains/paywall/hooks/usePaywallActions.ts +9 -16
- package/src/domains/paywall/hooks/usePaywallOrchestrator.ts +26 -8
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +51 -19
- package/src/domains/subscription/application/initializer/ServiceConfigurator.ts +9 -3
- package/src/domains/subscription/core/events/FlowEvents.ts +24 -0
- package/src/domains/subscription/core/events/SubscriptionEvents.ts +22 -0
- package/src/domains/subscription/presentation/providers/SubscriptionFlowProvider.tsx +4 -0
- package/src/domains/subscription/presentation/usePremium.ts +24 -87
- package/src/domains/subscription/presentation/usePremiumActions.ts +94 -0
- package/src/domains/subscription/presentation/usePremiumPackages.ts +25 -0
- package/src/domains/subscription/presentation/usePremiumStatus.ts +38 -0
- package/src/domains/subscription/presentation/useSubscriptionFlow.ts +7 -3
- package/src/domains/subscription/presentation/useSyncStatusListener.ts +47 -0
- package/src/index.ts +17 -5
- package/src/shared/infrastructure/SubscriptionEventBus.ts +7 -9
- package/src/shared/infrastructure/react-query/queryConfig.ts +0 -3
- package/src/domains/credits/application/CreditLimitCalculator.ts +0 -10
- package/src/domains/subscription/presentation/components/overlay/PurchaseLoadingOverlay.tsx +0 -60
- package/src/domains/subscription/presentation/components/overlay/PurchaseLoadingOverlay.types.ts +0 -4
- package/src/domains/subscription/presentation/stores/purchaseLoadingStore.ts +0 -53
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.43.
|
|
3
|
+
"version": "2.43.2",
|
|
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",
|
|
@@ -5,7 +5,7 @@ import type { InitializeCreditsMetadata, InitializationResult } from "../../subs
|
|
|
5
5
|
import { runTransaction, type Transaction, type DocumentReference, type Firestore } from "@umituz/react-native-firebase";
|
|
6
6
|
import { getCreditDocumentOrDefault } from "./creditDocumentHelpers";
|
|
7
7
|
import { calculateNewCredits, buildCreditsData } from "./creditOperationUtils";
|
|
8
|
-
import {
|
|
8
|
+
import { CreditLimitService } from "../domain/services/CreditLimitService";
|
|
9
9
|
import { generatePurchaseMetadata } from "./PurchaseMetadataGenerator";
|
|
10
10
|
import { PURCHASE_ID_PREFIXES } from "../core/CreditsConstants";
|
|
11
11
|
|
|
@@ -69,7 +69,8 @@ export async function initializeCreditsTransaction(
|
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
const
|
|
72
|
+
const creditLimitService = new CreditLimitService(config);
|
|
73
|
+
const creditLimit = creditLimitService.calculate(metadata.productId);
|
|
73
74
|
const { purchaseHistory } = generatePurchaseMetadata({
|
|
74
75
|
productId: metadata.productId,
|
|
75
76
|
source: metadata.source,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CreditsConfig } from "../../core/Credits";
|
|
2
|
+
import { detectPackageType } from "../../../../utils/packageTypeDetector";
|
|
3
|
+
import { getCreditAllocation } from "../../../../utils/creditMapper";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Domain service for credit limit calculations.
|
|
7
|
+
*
|
|
8
|
+
* This service contains business logic for determining credit limits
|
|
9
|
+
* based on product configuration. It's part of the domain layer and can be
|
|
10
|
+
* used by infrastructure, application, or presentation layers.
|
|
11
|
+
*/
|
|
12
|
+
export class CreditLimitService {
|
|
13
|
+
constructor(private config: CreditsConfig) {}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Calculate credit limit for a specific product ID.
|
|
17
|
+
*
|
|
18
|
+
* Strategy:
|
|
19
|
+
* 1. Check for explicit amount override in config
|
|
20
|
+
* 2. Calculate from package type allocations
|
|
21
|
+
* 3. Throw if cannot determine limit
|
|
22
|
+
*
|
|
23
|
+
* @param productId - The product identifier
|
|
24
|
+
* @returns The credit limit for this product
|
|
25
|
+
* @throws Error if productId is missing or limit cannot be determined
|
|
26
|
+
*/
|
|
27
|
+
calculate(productId: string | undefined): number {
|
|
28
|
+
if (!productId) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"[CreditLimitService] Cannot calculate credit limit without productId"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check for explicit amount override
|
|
35
|
+
const explicitAmount = this.config.creditPackageAmounts?.[productId];
|
|
36
|
+
if (
|
|
37
|
+
explicitAmount !== undefined &&
|
|
38
|
+
explicitAmount !== null &&
|
|
39
|
+
typeof explicitAmount === "number"
|
|
40
|
+
) {
|
|
41
|
+
return explicitAmount;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Calculate from package type allocations
|
|
45
|
+
const packageType = detectPackageType(productId);
|
|
46
|
+
const dynamicLimit = getCreditAllocation(packageType, this.config.packageAllocations);
|
|
47
|
+
|
|
48
|
+
if (dynamicLimit === null || dynamicLimit === undefined) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`[CreditLimitService] Cannot determine credit limit for productId: ${productId}, packageType: ${packageType}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return dynamicLimit;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a factory function for this service with the given config.
|
|
59
|
+
* This makes dependency injection easier.
|
|
60
|
+
*/
|
|
61
|
+
static createFactory(config: CreditsConfig) {
|
|
62
|
+
return () => new CreditLimitService(config);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Convenience function to calculate credit limit without instantiating service.
|
|
68
|
+
* Useful for one-off calculations.
|
|
69
|
+
*/
|
|
70
|
+
export function calculateCreditLimit(
|
|
71
|
+
productId: string | undefined,
|
|
72
|
+
config: CreditsConfig
|
|
73
|
+
): number {
|
|
74
|
+
const service = new CreditLimitService(config);
|
|
75
|
+
return service.calculate(productId);
|
|
76
|
+
}
|
|
@@ -11,11 +11,18 @@ import { fetchCredits, checkHasCredits, documentExists } from "./operations/Cred
|
|
|
11
11
|
import { syncExpiredStatus, syncPremiumMetadata, createRecoveryCreditsDocument } from "./operations/CreditsWriter";
|
|
12
12
|
import type { SubscriptionMetadata } from "../../subscription/core/types/SubscriptionMetadata";
|
|
13
13
|
import { initializeCreditsWithRetry } from "./operations/CreditsInitializer";
|
|
14
|
-
import {
|
|
14
|
+
import { CreditLimitService } from "../domain/services/CreditLimitService";
|
|
15
15
|
|
|
16
16
|
export class CreditsRepository extends BaseRepository {
|
|
17
|
-
|
|
17
|
+
private creditLimitService: CreditLimitService;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private config: CreditsConfig,
|
|
21
|
+
creditLimitService?: CreditLimitService
|
|
22
|
+
) {
|
|
18
23
|
super(config.collectionName);
|
|
24
|
+
// Allow dependency injection or create default instance
|
|
25
|
+
this.creditLimitService = creditLimitService ?? new CreditLimitService(config);
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
private getCollectionConfig(): CollectionConfig {
|
|
@@ -96,7 +103,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
96
103
|
_storeTransactionId?: string | null,
|
|
97
104
|
): Promise<boolean> {
|
|
98
105
|
const db = requireFirestore();
|
|
99
|
-
const creditLimit =
|
|
106
|
+
const creditLimit = this.creditLimitService.calculate(productId);
|
|
100
107
|
return createRecoveryCreditsDocument(
|
|
101
108
|
this.getRef(db, userId),
|
|
102
109
|
creditLimit,
|
|
@@ -4,7 +4,7 @@ import type { PurchaseSource } from "../../core/UserCreditsDocument";
|
|
|
4
4
|
import { initializeCreditsTransaction } from "../../application/CreditsInitializer";
|
|
5
5
|
import { mapCreditsDocumentToEntity } from "../../core/CreditsMapper";
|
|
6
6
|
import type { RevenueCatData } from "../../../revenuecat/core/types/RevenueCatData";
|
|
7
|
-
import {
|
|
7
|
+
import { CreditLimitService } from "../../domain/services/CreditLimitService";
|
|
8
8
|
import { PURCHASE_TYPE, type PurchaseType } from "../../../subscription/core/SubscriptionConstants";
|
|
9
9
|
|
|
10
10
|
interface InitializeCreditsParams {
|
|
@@ -45,7 +45,8 @@ function isTransientError(error: unknown): boolean {
|
|
|
45
45
|
export async function initializeCreditsWithRetry(params: InitializeCreditsParams): Promise<CreditsResult> {
|
|
46
46
|
const { db, ref, config, userId, purchaseId, productId, source, revenueCatData, type = PURCHASE_TYPE.INITIAL } = params;
|
|
47
47
|
|
|
48
|
-
const
|
|
48
|
+
const creditLimitService = new CreditLimitService(config);
|
|
49
|
+
const creditLimit = creditLimitService.calculate(productId);
|
|
49
50
|
const cfg = { ...config, creditLimit };
|
|
50
51
|
|
|
51
52
|
const maxRetries = 3;
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
StatusBar,
|
|
15
15
|
} from "react-native";
|
|
16
16
|
import { useNavigation } from "@react-navigation/native";
|
|
17
|
-
import { usePremium } from "../../subscription/presentation/usePremium";
|
|
18
17
|
import { AtomicText, AtomicIcon, AtomicSpinner } from "@umituz/react-native-design-system/atoms";
|
|
19
18
|
import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
|
|
20
19
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
@@ -37,13 +36,11 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
37
36
|
console.log('[PaywallScreen] 📱 Rendering PaywallScreen', {
|
|
38
37
|
hasPackages: !!props.packages?.length,
|
|
39
38
|
packagesCount: props.packages?.length || 0,
|
|
39
|
+
isPremium: props.isPremium,
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const { purchasePackage, restorePurchase } = usePremium();
|
|
44
|
-
|
|
45
43
|
const {
|
|
46
|
-
onClose,
|
|
47
44
|
translations,
|
|
48
45
|
packages = [],
|
|
49
46
|
features = [],
|
|
@@ -52,11 +49,14 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
52
49
|
creditAmounts,
|
|
53
50
|
creditsLabel,
|
|
54
51
|
heroImage,
|
|
52
|
+
isSyncing,
|
|
53
|
+
onPurchase,
|
|
54
|
+
onRestore,
|
|
55
|
+
onClose,
|
|
55
56
|
onPurchaseSuccess,
|
|
56
57
|
onPurchaseError,
|
|
57
58
|
onAuthRequired,
|
|
58
59
|
source,
|
|
59
|
-
isLoadingPackages
|
|
60
60
|
} = props;
|
|
61
61
|
|
|
62
62
|
const tokens = useAppDesignTokens();
|
|
@@ -80,8 +80,8 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
80
80
|
resetState
|
|
81
81
|
} = usePaywallActions({
|
|
82
82
|
packages,
|
|
83
|
-
purchasePackage,
|
|
84
|
-
restorePurchase,
|
|
83
|
+
purchasePackage: onPurchase,
|
|
84
|
+
restorePurchase: onRestore,
|
|
85
85
|
source,
|
|
86
86
|
onPurchaseSuccess,
|
|
87
87
|
onPurchaseError,
|
|
@@ -233,7 +233,7 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
233
233
|
return null;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
if (
|
|
236
|
+
if (isSyncing) {
|
|
237
237
|
return (
|
|
238
238
|
<View style={[styles.container, { backgroundColor: tokens.colors.backgroundPrimary, paddingTop: insets.top }]}>
|
|
239
239
|
<View style={styles.loadingContainer}>
|
|
@@ -2,21 +2,32 @@ import type { ImageSourcePropType } from "react-native";
|
|
|
2
2
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
3
3
|
import type { SubscriptionFeature, PaywallTranslations, PaywallLegalUrls } from "../entities/types";
|
|
4
4
|
import type { PurchaseSource } from "../../subscription/core/SubscriptionConstants";
|
|
5
|
+
import type { UserCredits } from "../../credits/core/Credits";
|
|
5
6
|
|
|
6
7
|
export interface PaywallScreenProps {
|
|
7
|
-
|
|
8
|
+
// UI Props (required)
|
|
8
9
|
translations: PaywallTranslations;
|
|
9
|
-
packages?: PurchasesPackage[];
|
|
10
10
|
features?: SubscriptionFeature[];
|
|
11
11
|
legalUrls?: PaywallLegalUrls;
|
|
12
12
|
bestValueIdentifier?: string;
|
|
13
13
|
creditAmounts?: Record<string, number>;
|
|
14
14
|
creditsLabel?: string;
|
|
15
15
|
heroImage?: ImageSourcePropType;
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
// Data Props (required)
|
|
18
|
+
packages: PurchasesPackage[];
|
|
19
|
+
isPremium: boolean;
|
|
20
|
+
credits: UserCredits | null;
|
|
21
|
+
isSyncing: boolean;
|
|
22
|
+
|
|
23
|
+
// Action Props (required)
|
|
24
|
+
onPurchase: (pkg: PurchasesPackage) => Promise<boolean>;
|
|
25
|
+
onRestore: () => Promise<boolean>;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
|
|
28
|
+
// Optional Callbacks
|
|
17
29
|
onPurchaseSuccess?: () => void;
|
|
18
30
|
onPurchaseError?: (error: Error | string) => void;
|
|
19
31
|
onAuthRequired?: () => void;
|
|
20
32
|
source?: PurchaseSource;
|
|
21
|
-
isLoadingPackages?: boolean;
|
|
22
33
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useMemo } from "react";
|
|
2
2
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
3
|
-
import { usePurchaseLoadingStore, selectIsPurchasing } from "../../subscription/presentation/stores/purchaseLoadingStore";
|
|
4
3
|
import type { PurchaseSource } from "../../subscription/core/SubscriptionConstants";
|
|
5
4
|
import { useSubscriptionStatus } from "../../subscription/presentation/useSubscriptionStatus";
|
|
6
5
|
import { useCredits } from "../../credits/presentation/useCredits";
|
|
@@ -26,17 +25,13 @@ export function usePaywallActions({
|
|
|
26
25
|
onClose,
|
|
27
26
|
}: UsePaywallActionsParams) {
|
|
28
27
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
|
29
|
-
const [
|
|
28
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
30
29
|
|
|
31
|
-
const isGlobalPurchasing = usePurchaseLoadingStore(selectIsPurchasing);
|
|
32
|
-
const { refetch: refetchStatus } = useSubscriptionStatus();
|
|
33
|
-
const { refetch: refetchCredits } = useCredits();
|
|
34
|
-
|
|
35
|
-
const isProcessing = isLocalProcessing || isGlobalPurchasing;
|
|
36
30
|
const isProcessingRef = useRef(isProcessing);
|
|
37
31
|
isProcessingRef.current = isProcessing;
|
|
38
32
|
|
|
39
|
-
const {
|
|
33
|
+
const { refetch: refetchStatus } = useSubscriptionStatus();
|
|
34
|
+
const { refetch: refetchCredits } = useCredits();
|
|
40
35
|
|
|
41
36
|
const purchasePackageRef = useRef(purchasePackage);
|
|
42
37
|
const restorePurchaseRef = useRef(restorePurchase);
|
|
@@ -72,8 +67,7 @@ export function usePaywallActions({
|
|
|
72
67
|
});
|
|
73
68
|
}
|
|
74
69
|
|
|
75
|
-
|
|
76
|
-
startPurchase(currentSelectedId, "manual");
|
|
70
|
+
setIsProcessing(true);
|
|
77
71
|
|
|
78
72
|
try {
|
|
79
73
|
const success = await purchasePackageRef.current(pkg);
|
|
@@ -125,10 +119,9 @@ export function usePaywallActions({
|
|
|
125
119
|
}
|
|
126
120
|
onPurchaseErrorRef.current?.(error instanceof Error ? error : new Error(String(error)));
|
|
127
121
|
} finally {
|
|
128
|
-
|
|
129
|
-
endPurchase(currentSelectedId);
|
|
122
|
+
setIsProcessing(false);
|
|
130
123
|
}
|
|
131
|
-
}, [selectedPlanId,
|
|
124
|
+
}, [selectedPlanId, refetchStatus, refetchCredits]);
|
|
132
125
|
|
|
133
126
|
const handleRestore = useCallback(async () => {
|
|
134
127
|
if (isProcessingRef.current) return;
|
|
@@ -137,7 +130,7 @@ export function usePaywallActions({
|
|
|
137
130
|
console.log('[usePaywallActions] 🔄 Starting restore');
|
|
138
131
|
}
|
|
139
132
|
|
|
140
|
-
|
|
133
|
+
setIsProcessing(true);
|
|
141
134
|
try {
|
|
142
135
|
const success = await restorePurchaseRef.current();
|
|
143
136
|
|
|
@@ -172,7 +165,7 @@ export function usePaywallActions({
|
|
|
172
165
|
}
|
|
173
166
|
onPurchaseErrorRef.current?.(error instanceof Error ? error : new Error(String(error)));
|
|
174
167
|
} finally {
|
|
175
|
-
|
|
168
|
+
setIsProcessing(false);
|
|
176
169
|
}
|
|
177
170
|
}, [refetchStatus, refetchCredits]);
|
|
178
171
|
|
|
@@ -181,7 +174,7 @@ export function usePaywallActions({
|
|
|
181
174
|
console.log('[usePaywallActions] 🧹 Resetting state');
|
|
182
175
|
}
|
|
183
176
|
setSelectedPlanId(null);
|
|
184
|
-
|
|
177
|
+
setIsProcessing(false);
|
|
185
178
|
}, []);
|
|
186
179
|
|
|
187
180
|
return useMemo(() => ({
|
|
@@ -34,12 +34,14 @@ export function usePaywallOrchestrator({
|
|
|
34
34
|
bestValueIdentifier = "yearly",
|
|
35
35
|
creditsLabel,
|
|
36
36
|
}: PaywallOrchestratorOptions) {
|
|
37
|
-
//
|
|
38
|
-
// purchasePackage ve restoreRestore fonksiyonlarını ALMIYORUZ
|
|
39
|
-
// Çünkü PaywallScreen zaten usePremium hook'unu doğrudan kullanıyor
|
|
37
|
+
// Get all premium data and actions from usePremium
|
|
40
38
|
const {
|
|
41
39
|
isPremium,
|
|
42
|
-
packages
|
|
40
|
+
packages,
|
|
41
|
+
credits,
|
|
42
|
+
isSyncing,
|
|
43
|
+
purchasePackage,
|
|
44
|
+
restorePurchase,
|
|
43
45
|
} = usePremium();
|
|
44
46
|
|
|
45
47
|
// Selectors for stable references and fine-grained updates
|
|
@@ -55,6 +57,10 @@ export function usePaywallOrchestrator({
|
|
|
55
57
|
const { showPaywall, closePaywall } = usePaywallVisibility();
|
|
56
58
|
const hasNavigatedRef = useRef(false);
|
|
57
59
|
|
|
60
|
+
const handleClose = () => {
|
|
61
|
+
closePaywall();
|
|
62
|
+
};
|
|
63
|
+
|
|
58
64
|
useEffect(() => {
|
|
59
65
|
if (!isNavReady || !isLocalizationReady) return;
|
|
60
66
|
|
|
@@ -77,9 +83,8 @@ export function usePaywallOrchestrator({
|
|
|
77
83
|
packagesCount: packages.length
|
|
78
84
|
});
|
|
79
85
|
|
|
80
|
-
// SADECE DATA geçiyoruz - FONKSİYON YOK
|
|
81
|
-
// PaywallScreen kendi usePremium hook'unu kullanacak
|
|
82
86
|
navigation.navigate("PaywallScreen", {
|
|
87
|
+
// UI Props
|
|
83
88
|
translations,
|
|
84
89
|
legalUrls,
|
|
85
90
|
features,
|
|
@@ -87,7 +92,17 @@ export function usePaywallOrchestrator({
|
|
|
87
92
|
creditsLabel,
|
|
88
93
|
heroImage,
|
|
89
94
|
source: shouldShowPostOnboarding ? "onboarding" : "manual",
|
|
95
|
+
|
|
96
|
+
// Data Props
|
|
90
97
|
packages,
|
|
98
|
+
isPremium,
|
|
99
|
+
credits,
|
|
100
|
+
isSyncing,
|
|
101
|
+
|
|
102
|
+
// Action Props
|
|
103
|
+
onPurchase: purchasePackage,
|
|
104
|
+
onRestore: restorePurchase,
|
|
105
|
+
onClose: handleClose,
|
|
91
106
|
});
|
|
92
107
|
|
|
93
108
|
if (shouldShowPostOnboarding) {
|
|
@@ -120,13 +135,16 @@ export function usePaywallOrchestrator({
|
|
|
120
135
|
closePaywall,
|
|
121
136
|
bestValueIdentifier,
|
|
122
137
|
creditsLabel,
|
|
138
|
+
credits,
|
|
139
|
+
isSyncing,
|
|
140
|
+
purchasePackage,
|
|
141
|
+
restorePurchase,
|
|
142
|
+
handleClose,
|
|
123
143
|
]);
|
|
124
144
|
|
|
125
145
|
const completeOnboarding = useSubscriptionFlowStore((state) => state.completeOnboarding);
|
|
126
146
|
|
|
127
147
|
return {
|
|
128
|
-
isPremium,
|
|
129
|
-
packages,
|
|
130
148
|
flowState: {
|
|
131
149
|
isOnboardingComplete,
|
|
132
150
|
showPostOnboardingPaywall,
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
|
|
2
|
-
import
|
|
2
|
+
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
3
|
+
import type { PurchaseCompletedEvent, RenewalDetectedEvent, PremiumStatusChangedEvent } from "../core/SubscriptionEvents";
|
|
3
4
|
import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
4
5
|
import { extractRevenueCatData } from "./SubscriptionSyncUtils";
|
|
5
6
|
import { generatePurchaseId, generateRenewalId } from "./syncIdGenerators";
|
|
6
|
-
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
7
|
-
import { useSubscriptionFlowStore, SyncStatus } from "../presentation/useSubscriptionFlow";
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Central processor for all subscription sync operations.
|
|
@@ -26,10 +25,14 @@ export class SubscriptionSyncProcessor {
|
|
|
26
25
|
|
|
27
26
|
// ─── Public API (replaces SubscriptionSyncService) ────────────────
|
|
28
27
|
|
|
29
|
-
async handlePurchase(event: PurchaseCompletedEvent): Promise<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
async handlePurchase(event: PurchaseCompletedEvent): Promise<{ success: boolean; error?: string }> {
|
|
29
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.SYNC_STATUS_CHANGED, {
|
|
30
|
+
status: 'syncing',
|
|
31
|
+
phase: 'purchase',
|
|
32
|
+
userId: event.userId,
|
|
33
|
+
productId: event.productId,
|
|
34
|
+
});
|
|
35
|
+
|
|
33
36
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
34
37
|
console.log('[SubscriptionSyncProcessor] 🔵 PURCHASE START', {
|
|
35
38
|
userId: event.userId,
|
|
@@ -45,7 +48,12 @@ export class SubscriptionSyncProcessor {
|
|
|
45
48
|
userId: event.userId,
|
|
46
49
|
productId: event.productId,
|
|
47
50
|
});
|
|
48
|
-
|
|
51
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.SYNC_STATUS_CHANGED, {
|
|
52
|
+
status: 'success',
|
|
53
|
+
phase: 'purchase',
|
|
54
|
+
userId: event.userId,
|
|
55
|
+
productId: event.productId,
|
|
56
|
+
});
|
|
49
57
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
50
58
|
console.log('[SubscriptionSyncProcessor] 🟢 PURCHASE SUCCESS', {
|
|
51
59
|
userId: event.userId,
|
|
@@ -53,22 +61,33 @@ export class SubscriptionSyncProcessor {
|
|
|
53
61
|
timestamp: new Date().toISOString(),
|
|
54
62
|
});
|
|
55
63
|
}
|
|
64
|
+
return { success: true };
|
|
56
65
|
} catch (error) {
|
|
57
66
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
58
|
-
|
|
67
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.SYNC_STATUS_CHANGED, {
|
|
68
|
+
status: 'error',
|
|
69
|
+
phase: 'purchase',
|
|
70
|
+
userId: event.userId,
|
|
71
|
+
productId: event.productId,
|
|
72
|
+
error: errorMsg,
|
|
73
|
+
});
|
|
59
74
|
console.error('[SubscriptionSyncProcessor] 🔴 PURCHASE FAILED', {
|
|
60
75
|
userId: event.userId,
|
|
61
76
|
productId: event.productId,
|
|
62
77
|
error: errorMsg,
|
|
63
78
|
timestamp: new Date().toISOString(),
|
|
64
79
|
});
|
|
65
|
-
|
|
80
|
+
return { success: false, error: errorMsg };
|
|
66
81
|
}
|
|
67
82
|
}
|
|
68
83
|
|
|
69
|
-
async handleRenewal(event: RenewalDetectedEvent): Promise<
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
async handleRenewal(event: RenewalDetectedEvent): Promise<{ success: boolean; error?: string }> {
|
|
85
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.SYNC_STATUS_CHANGED, {
|
|
86
|
+
status: 'syncing',
|
|
87
|
+
phase: 'renewal',
|
|
88
|
+
userId: event.userId,
|
|
89
|
+
productId: event.productId,
|
|
90
|
+
});
|
|
72
91
|
|
|
73
92
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
74
93
|
console.log('[SubscriptionSyncProcessor] 🔵 RENEWAL START', {
|
|
@@ -84,7 +103,12 @@ export class SubscriptionSyncProcessor {
|
|
|
84
103
|
userId: event.userId,
|
|
85
104
|
productId: event.productId,
|
|
86
105
|
});
|
|
87
|
-
|
|
106
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.SYNC_STATUS_CHANGED, {
|
|
107
|
+
status: 'success',
|
|
108
|
+
phase: 'renewal',
|
|
109
|
+
userId: event.userId,
|
|
110
|
+
productId: event.productId,
|
|
111
|
+
});
|
|
88
112
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
89
113
|
console.log('[SubscriptionSyncProcessor] 🟢 RENEWAL SUCCESS', {
|
|
90
114
|
userId: event.userId,
|
|
@@ -92,20 +116,27 @@ export class SubscriptionSyncProcessor {
|
|
|
92
116
|
timestamp: new Date().toISOString(),
|
|
93
117
|
});
|
|
94
118
|
}
|
|
119
|
+
return { success: true };
|
|
95
120
|
} catch (error) {
|
|
96
121
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
97
|
-
|
|
122
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.SYNC_STATUS_CHANGED, {
|
|
123
|
+
status: 'error',
|
|
124
|
+
phase: 'renewal',
|
|
125
|
+
userId: event.userId,
|
|
126
|
+
productId: event.productId,
|
|
127
|
+
error: errorMsg,
|
|
128
|
+
});
|
|
98
129
|
console.error('[SubscriptionSyncProcessor] 🔴 RENEWAL FAILED', {
|
|
99
130
|
userId: event.userId,
|
|
100
131
|
productId: event.productId,
|
|
101
132
|
error: errorMsg,
|
|
102
133
|
timestamp: new Date().toISOString(),
|
|
103
134
|
});
|
|
104
|
-
|
|
135
|
+
return { success: false, error: errorMsg };
|
|
105
136
|
}
|
|
106
137
|
}
|
|
107
138
|
|
|
108
|
-
async handlePremiumStatusChanged(event: PremiumStatusChangedEvent): Promise<
|
|
139
|
+
async handlePremiumStatusChanged(event: PremiumStatusChangedEvent): Promise<{ success: boolean; error?: string }> {
|
|
109
140
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
110
141
|
console.log('[SubscriptionSyncProcessor] 🔵 STATUS CHANGE START', {
|
|
111
142
|
userId: event.userId,
|
|
@@ -130,6 +161,7 @@ export class SubscriptionSyncProcessor {
|
|
|
130
161
|
timestamp: new Date().toISOString(),
|
|
131
162
|
});
|
|
132
163
|
}
|
|
164
|
+
return { success: true };
|
|
133
165
|
} catch (error) {
|
|
134
166
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
135
167
|
console.error('[SubscriptionSyncProcessor] 🔴 STATUS CHANGE FAILED', {
|
|
@@ -139,8 +171,8 @@ export class SubscriptionSyncProcessor {
|
|
|
139
171
|
error: errorMsg,
|
|
140
172
|
timestamp: new Date().toISOString(),
|
|
141
173
|
});
|
|
142
|
-
// We don't
|
|
143
|
-
|
|
174
|
+
// We don't emit sync status change here for passive status changes to avoid UI noise
|
|
175
|
+
return { success: false, error: errorMsg };
|
|
144
176
|
}
|
|
145
177
|
}
|
|
146
178
|
|
|
@@ -23,9 +23,15 @@ export function configureServices(config: SubscriptionInitConfig, apiKey: string
|
|
|
23
23
|
apiKey,
|
|
24
24
|
entitlementIdentifier: entitlementId,
|
|
25
25
|
consumableProductIdentifiers: [creditPackages.identifierPattern],
|
|
26
|
-
onPurchaseCompleted: (event) =>
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
onPurchaseCompleted: async (event) => {
|
|
27
|
+
await syncProcessor.handlePurchase(event);
|
|
28
|
+
},
|
|
29
|
+
onRenewalDetected: async (event) => {
|
|
30
|
+
await syncProcessor.handleRenewal(event);
|
|
31
|
+
},
|
|
32
|
+
onPremiumStatusChanged: async (event) => {
|
|
33
|
+
await syncProcessor.handlePremiumStatusChanged(event);
|
|
34
|
+
},
|
|
29
35
|
onCreditsUpdated,
|
|
30
36
|
},
|
|
31
37
|
apiKey,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application flow events
|
|
3
|
+
* Events emitted during high-level application flow transitions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const FLOW_EVENTS = {
|
|
7
|
+
ONBOARDING_COMPLETED: "flow_onboarding_completed",
|
|
8
|
+
PAYWALL_SHOWN: "flow_paywall_shown",
|
|
9
|
+
PAYWALL_CLOSED: "flow_paywall_closed",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
export type FlowEventType = typeof FLOW_EVENTS[keyof typeof FLOW_EVENTS];
|
|
13
|
+
|
|
14
|
+
export interface OnboardingCompletedEvent {
|
|
15
|
+
timestamp: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PaywallShownEvent {
|
|
19
|
+
timestamp: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PaywallClosedEvent {
|
|
23
|
+
timestamp: number;
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription-related events
|
|
3
|
+
* Events emitted during subscription lifecycle operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const SUBSCRIPTION_EVENTS = {
|
|
7
|
+
CREDITS_UPDATED: "credits_updated",
|
|
8
|
+
PURCHASE_COMPLETED: "purchase_completed",
|
|
9
|
+
RENEWAL_DETECTED: "renewal_detected",
|
|
10
|
+
PREMIUM_STATUS_CHANGED: "premium_status_changed",
|
|
11
|
+
SYNC_STATUS_CHANGED: "sync_status_changed",
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export type SubscriptionEventType = typeof SUBSCRIPTION_EVENTS[keyof typeof SUBSCRIPTION_EVENTS];
|
|
15
|
+
|
|
16
|
+
export interface SyncStatusChangedEvent {
|
|
17
|
+
status: 'syncing' | 'success' | 'error';
|
|
18
|
+
phase: 'purchase' | 'renewal';
|
|
19
|
+
userId?: string;
|
|
20
|
+
productId?: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { createContext, useContext, useEffect } from "react";
|
|
2
2
|
import { useSubscriptionFlowStore, SubscriptionFlowStatus } from "../useSubscriptionFlow";
|
|
3
|
+
import { useSyncStatusListener } from "../useSyncStatusListener";
|
|
3
4
|
import { initializationState } from "../../infrastructure/state/initializationState";
|
|
4
5
|
|
|
5
6
|
interface SubscriptionFlowContextType {
|
|
@@ -9,6 +10,9 @@ interface SubscriptionFlowContextType {
|
|
|
9
10
|
const SubscriptionFlowContext = createContext<SubscriptionFlowContextType | undefined>(undefined);
|
|
10
11
|
|
|
11
12
|
export const SubscriptionFlowProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
13
|
+
// Listen to sync status events from application layer
|
|
14
|
+
useSyncStatusListener();
|
|
15
|
+
|
|
12
16
|
// Selectors for stable references and only what we need
|
|
13
17
|
const isInitialized = useSubscriptionFlowStore((state) => state.isInitialized);
|
|
14
18
|
const isOnboardingComplete = useSubscriptionFlowStore((state) => state.isOnboardingComplete);
|
|
@@ -1,96 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
useSubscriptionPackages,
|
|
7
|
-
usePurchasePackage,
|
|
8
|
-
useRestorePurchase,
|
|
9
|
-
} from '../infrastructure/hooks/useSubscriptionQueries';
|
|
10
|
-
import { usePaywallVisibility } from './usePaywallVisibility';
|
|
11
|
-
import { isPremiumSyncPending } from '../utils/syncStatus';
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { usePremiumStatus } from './usePremiumStatus';
|
|
3
|
+
import { usePremiumPackages } from './usePremiumPackages';
|
|
4
|
+
import { usePremiumActions } from './usePremiumActions';
|
|
12
5
|
import { UsePremiumResult } from './usePremium.types';
|
|
13
6
|
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Facade hook that combines status, packages, and actions.
|
|
9
|
+
*
|
|
10
|
+
* Consider using the focused hooks for better performance:
|
|
11
|
+
* - usePremiumStatus() - when you only need premium status
|
|
12
|
+
* - usePremiumPackages() - when you only need package data
|
|
13
|
+
* - usePremiumActions() - when you only need actions
|
|
14
|
+
*
|
|
15
|
+
* This facade re-renders when ANY of the sub-hooks change, whereas focused hooks
|
|
16
|
+
* only re-render when their specific data changes.
|
|
17
|
+
*/
|
|
16
18
|
export const usePremium = (): UsePremiumResult => {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const { data: packages = EMPTY_PACKAGES, isLoading: packagesLoading } = useSubscriptionPackages();
|
|
21
|
-
|
|
22
|
-
const purchaseMutation = usePurchasePackage();
|
|
23
|
-
const restoreMutation = useRestorePurchase();
|
|
24
|
-
|
|
25
|
-
const { showPaywall, setShowPaywall, closePaywall, openPaywall } = usePaywallVisibility();
|
|
26
|
-
|
|
27
|
-
const isPremium = subscriptionActive || (credits?.isPremium ?? false);
|
|
28
|
-
const isSyncing = isPremiumSyncPending({
|
|
29
|
-
statusLoading,
|
|
30
|
-
creditsLoading,
|
|
31
|
-
subscriptionActive,
|
|
32
|
-
credits,
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const handlePurchase = useCallback(
|
|
36
|
-
async (pkg: PurchasesPackage): Promise<boolean> => {
|
|
37
|
-
try {
|
|
38
|
-
const result = await purchaseMutation.mutateAsync(pkg);
|
|
39
|
-
return result.success;
|
|
40
|
-
} catch (error) {
|
|
41
|
-
if (__DEV__) {
|
|
42
|
-
console.error('[usePremium] Purchase failed:', error);
|
|
43
|
-
}
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
|
-
[purchaseMutation],
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
const handleRestore = useCallback(async (): Promise<boolean> => {
|
|
51
|
-
try {
|
|
52
|
-
const result = await restoreMutation.mutateAsync();
|
|
53
|
-
return result.success;
|
|
54
|
-
} catch (error) {
|
|
55
|
-
if (__DEV__) {
|
|
56
|
-
console.error('[usePremium] Restore failed:', error);
|
|
57
|
-
}
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
}, [restoreMutation]);
|
|
19
|
+
const status = usePremiumStatus();
|
|
20
|
+
const packages = usePremiumPackages();
|
|
21
|
+
const actions = usePremiumActions();
|
|
61
22
|
|
|
62
23
|
return useMemo(() => ({
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
packagesLoading ||
|
|
68
|
-
purchaseMutation.isPending ||
|
|
69
|
-
restoreMutation.isPending,
|
|
70
|
-
packages,
|
|
71
|
-
credits,
|
|
72
|
-
showPaywall,
|
|
73
|
-
isSyncing,
|
|
74
|
-
purchasePackage: handlePurchase,
|
|
75
|
-
restorePurchase: handleRestore,
|
|
76
|
-
setShowPaywall,
|
|
77
|
-
closePaywall,
|
|
78
|
-
openPaywall,
|
|
24
|
+
...status,
|
|
25
|
+
...packages,
|
|
26
|
+
...actions,
|
|
27
|
+
isLoading: status.isSyncing || packages.isLoading || actions.isLoading,
|
|
79
28
|
}), [
|
|
80
|
-
|
|
81
|
-
statusLoading,
|
|
82
|
-
creditsLoading,
|
|
83
|
-
packagesLoading,
|
|
84
|
-
purchaseMutation.isPending,
|
|
85
|
-
restoreMutation.isPending,
|
|
29
|
+
status,
|
|
86
30
|
packages,
|
|
87
|
-
|
|
88
|
-
showPaywall,
|
|
89
|
-
isSyncing,
|
|
90
|
-
handlePurchase,
|
|
91
|
-
handleRestore,
|
|
92
|
-
setShowPaywall,
|
|
93
|
-
closePaywall,
|
|
94
|
-
openPaywall,
|
|
31
|
+
actions,
|
|
95
32
|
]);
|
|
96
33
|
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useCallback, useMemo } from 'react';
|
|
2
|
+
import type { PurchasesPackage } from 'react-native-purchases';
|
|
3
|
+
import {
|
|
4
|
+
usePurchasePackage,
|
|
5
|
+
useRestorePurchase,
|
|
6
|
+
} from '../infrastructure/hooks/useSubscriptionQueries';
|
|
7
|
+
import { usePaywallVisibility } from './usePaywallVisibility';
|
|
8
|
+
|
|
9
|
+
export interface PremiumActions {
|
|
10
|
+
purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>;
|
|
11
|
+
restorePurchase: () => Promise<boolean>;
|
|
12
|
+
showPaywall: boolean;
|
|
13
|
+
setShowPaywall: (show: boolean) => void;
|
|
14
|
+
closePaywall: () => void;
|
|
15
|
+
openPaywall: () => void;
|
|
16
|
+
isPurchasing: boolean;
|
|
17
|
+
isRestoring: boolean;
|
|
18
|
+
isLoading: boolean;
|
|
19
|
+
isProductPurchasing: (productId: string) => boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hook for premium actions - mutations and visibility control.
|
|
24
|
+
*
|
|
25
|
+
* This hook is focused on user actions: purchasing, restoring, and paywall visibility.
|
|
26
|
+
* It does not include data fetching - use usePremiumStatus and usePremiumPackages for that.
|
|
27
|
+
*
|
|
28
|
+
* This separation allows components to only re-render when the specific state they care about changes.
|
|
29
|
+
*/
|
|
30
|
+
export function usePremiumActions(): PremiumActions {
|
|
31
|
+
const purchaseMutation = usePurchasePackage();
|
|
32
|
+
const restoreMutation = useRestorePurchase();
|
|
33
|
+
const { showPaywall, setShowPaywall, closePaywall, openPaywall } = usePaywallVisibility();
|
|
34
|
+
|
|
35
|
+
const purchasePackage = useCallback(
|
|
36
|
+
async (pkg: PurchasesPackage): Promise<boolean> => {
|
|
37
|
+
try {
|
|
38
|
+
const result = await purchaseMutation.mutateAsync(pkg);
|
|
39
|
+
return result.success;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (__DEV__) {
|
|
42
|
+
console.error('[usePremiumActions] Purchase failed:', error);
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
[purchaseMutation],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const restorePurchase = useCallback(async (): Promise<boolean> => {
|
|
51
|
+
try {
|
|
52
|
+
const result = await restoreMutation.mutateAsync();
|
|
53
|
+
return result.success;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (__DEV__) {
|
|
56
|
+
console.error('[usePremiumActions] Restore failed:', error);
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}, [restoreMutation]);
|
|
61
|
+
|
|
62
|
+
const isPurchasing = purchaseMutation.isPending;
|
|
63
|
+
const isRestoring = restoreMutation.isPending;
|
|
64
|
+
const isLoading = isPurchasing || isRestoring;
|
|
65
|
+
|
|
66
|
+
const isProductPurchasing = useCallback((productId: string): boolean => {
|
|
67
|
+
return purchaseMutation.variables?.product?.identifier === productId &&
|
|
68
|
+
purchaseMutation.isPending;
|
|
69
|
+
}, [purchaseMutation]);
|
|
70
|
+
|
|
71
|
+
return useMemo(() => ({
|
|
72
|
+
purchasePackage,
|
|
73
|
+
restorePurchase,
|
|
74
|
+
showPaywall,
|
|
75
|
+
setShowPaywall,
|
|
76
|
+
closePaywall,
|
|
77
|
+
openPaywall,
|
|
78
|
+
isPurchasing,
|
|
79
|
+
isRestoring,
|
|
80
|
+
isLoading,
|
|
81
|
+
isProductPurchasing,
|
|
82
|
+
}), [
|
|
83
|
+
purchasePackage,
|
|
84
|
+
restorePurchase,
|
|
85
|
+
showPaywall,
|
|
86
|
+
setShowPaywall,
|
|
87
|
+
closePaywall,
|
|
88
|
+
openPaywall,
|
|
89
|
+
isPurchasing,
|
|
90
|
+
isRestoring,
|
|
91
|
+
isLoading,
|
|
92
|
+
isProductPurchasing,
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { PurchasesPackage } from 'react-native-purchases';
|
|
3
|
+
import { useSubscriptionPackages } from '../infrastructure/hooks/useSubscriptionQueries';
|
|
4
|
+
|
|
5
|
+
const EMPTY_PACKAGES: PurchasesPackage[] = [];
|
|
6
|
+
|
|
7
|
+
export interface PremiumPackages {
|
|
8
|
+
packages: PurchasesPackage[];
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hook for fetching subscription packages.
|
|
14
|
+
*
|
|
15
|
+
* This hook is focused solely on package data - no premium status or mutations.
|
|
16
|
+
* Use this when you only need package information for display purposes.
|
|
17
|
+
*/
|
|
18
|
+
export function usePremiumPackages(): PremiumPackages {
|
|
19
|
+
const { data: packages = EMPTY_PACKAGES, isLoading } = useSubscriptionPackages();
|
|
20
|
+
|
|
21
|
+
return useMemo(() => ({
|
|
22
|
+
packages,
|
|
23
|
+
isLoading,
|
|
24
|
+
}), [packages, isLoading]);
|
|
25
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { UserCredits } from '../../credits/core/Credits';
|
|
3
|
+
import { useCredits } from '../../credits/presentation/useCredits';
|
|
4
|
+
import { useSubscriptionStatus } from './useSubscriptionStatus';
|
|
5
|
+
import { isPremiumSyncPending } from '../utils/syncStatus';
|
|
6
|
+
|
|
7
|
+
export interface PremiumStatus {
|
|
8
|
+
isPremium: boolean;
|
|
9
|
+
credits: UserCredits | null;
|
|
10
|
+
isSyncing: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read-only hook for premium status.
|
|
15
|
+
* Combines subscription status and credits to determine overall premium state.
|
|
16
|
+
*
|
|
17
|
+
* This hook is focused on data reading only - no mutations or side effects.
|
|
18
|
+
* Use this when you only need to know the premium status without triggering
|
|
19
|
+
* purchases or other actions.
|
|
20
|
+
*/
|
|
21
|
+
export function usePremiumStatus(): PremiumStatus {
|
|
22
|
+
const { isPremium: subscriptionActive, isLoading: statusLoading } = useSubscriptionStatus();
|
|
23
|
+
const { credits, isLoading: creditsLoading } = useCredits();
|
|
24
|
+
|
|
25
|
+
const isPremium = subscriptionActive || (credits?.isPremium ?? false);
|
|
26
|
+
const isSyncing = isPremiumSyncPending({
|
|
27
|
+
statusLoading,
|
|
28
|
+
creditsLoading,
|
|
29
|
+
subscriptionActive,
|
|
30
|
+
credits,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return useMemo(() => ({
|
|
34
|
+
isPremium,
|
|
35
|
+
credits,
|
|
36
|
+
isSyncing,
|
|
37
|
+
}), [isPremium, credits, isSyncing]);
|
|
38
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Uses @umituz/react-native-design-system's storage utility for standardized persistence.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { DeviceEventEmitter } from "react-native";
|
|
8
7
|
import { createStore } from "@umituz/react-native-design-system/storage";
|
|
8
|
+
import { subscriptionEventBus, FLOW_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
9
9
|
|
|
10
10
|
export enum SubscriptionFlowStatus {
|
|
11
11
|
INITIALIZING = "INITIALIZING",
|
|
@@ -77,7 +77,7 @@ export const useSubscriptionFlowStore = createStore<SubscriptionFlowState, Subsc
|
|
|
77
77
|
showPostOnboardingPaywall: true,
|
|
78
78
|
status: SubscriptionFlowStatus.POST_ONBOARDING_PAYWALL,
|
|
79
79
|
});
|
|
80
|
-
|
|
80
|
+
subscriptionEventBus.emit(FLOW_EVENTS.ONBOARDING_COMPLETED, { timestamp: Date.now() });
|
|
81
81
|
},
|
|
82
82
|
closePostOnboardingPaywall: async () => {
|
|
83
83
|
set({
|
|
@@ -85,11 +85,15 @@ export const useSubscriptionFlowStore = createStore<SubscriptionFlowState, Subsc
|
|
|
85
85
|
paywallShown: true,
|
|
86
86
|
status: SubscriptionFlowStatus.READY,
|
|
87
87
|
});
|
|
88
|
+
subscriptionEventBus.emit(FLOW_EVENTS.PAYWALL_CLOSED, { timestamp: Date.now() });
|
|
88
89
|
},
|
|
89
90
|
closeFeedback: () => set({ showFeedback: false }),
|
|
90
91
|
setAuthModalOpen: (open: boolean) => set({ isAuthModalOpen: open }),
|
|
91
92
|
setShowFeedback: (show: boolean) => set({ showFeedback: show }),
|
|
92
|
-
markPaywallShown: async () =>
|
|
93
|
+
markPaywallShown: async () => {
|
|
94
|
+
set({ paywallShown: true });
|
|
95
|
+
subscriptionEventBus.emit(FLOW_EVENTS.PAYWALL_SHOWN, { timestamp: Date.now() });
|
|
96
|
+
},
|
|
93
97
|
setInitialized: (initialized: boolean) => set((state) => {
|
|
94
98
|
if (state.isInitialized === initialized) return state;
|
|
95
99
|
return { isInitialized: initialized };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
3
|
+
import { useSubscriptionFlowStore, SyncStatus } from "./useSubscriptionFlow";
|
|
4
|
+
|
|
5
|
+
interface SyncStatusEvent {
|
|
6
|
+
status: 'syncing' | 'success' | 'error';
|
|
7
|
+
phase: 'purchase' | 'renewal';
|
|
8
|
+
userId?: string;
|
|
9
|
+
productId?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hook that listens to sync status events from the application layer
|
|
15
|
+
* and updates the presentation layer's Zustand store accordingly.
|
|
16
|
+
*
|
|
17
|
+
* This maintains clean architecture separation: Application layer emits events,
|
|
18
|
+
* Presentation layer consumes them and updates its own state.
|
|
19
|
+
*/
|
|
20
|
+
export function useSyncStatusListener() {
|
|
21
|
+
const setSyncStatus = useSubscriptionFlowStore((state) => state.setSyncStatus);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const unsubscribe = subscriptionEventBus.on<SyncStatusEvent>(
|
|
25
|
+
SUBSCRIPTION_EVENTS.SYNC_STATUS_CHANGED,
|
|
26
|
+
(event) => {
|
|
27
|
+
const syncStatus = event.status === 'syncing' ? SyncStatus.SYNCING :
|
|
28
|
+
event.status === 'success' ? SyncStatus.SUCCESS :
|
|
29
|
+
SyncStatus.ERROR;
|
|
30
|
+
|
|
31
|
+
setSyncStatus(syncStatus, event.error);
|
|
32
|
+
|
|
33
|
+
if (__DEV__) {
|
|
34
|
+
console.log('[useSyncStatusListener] Sync status updated', {
|
|
35
|
+
status: event.status,
|
|
36
|
+
phase: event.phase,
|
|
37
|
+
userId: event.userId,
|
|
38
|
+
productId: event.productId,
|
|
39
|
+
error: event.error,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return unsubscribe;
|
|
46
|
+
}, [setSyncStatus]);
|
|
47
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,13 @@ export {
|
|
|
9
9
|
PURCHASE_TYPE,
|
|
10
10
|
ANONYMOUS_CACHE_KEY,
|
|
11
11
|
} from "./domains/subscription/core/SubscriptionConstants";
|
|
12
|
+
|
|
13
|
+
// Domain Events
|
|
14
|
+
export { SUBSCRIPTION_EVENTS } from "./domains/subscription/core/events/SubscriptionEvents";
|
|
15
|
+
export type { SubscriptionEventType, SyncStatusChangedEvent } from "./domains/subscription/core/events/SubscriptionEvents";
|
|
16
|
+
export type { PurchaseCompletedEvent, RenewalDetectedEvent, PremiumStatusChangedEvent } from "./domains/subscription/core/SubscriptionEvents";
|
|
17
|
+
export { FLOW_EVENTS } from "./domains/subscription/core/events/FlowEvents";
|
|
18
|
+
export type { FlowEventType, OnboardingCompletedEvent, PaywallShownEvent, PaywallClosedEvent } from "./domains/subscription/core/events/FlowEvents";
|
|
12
19
|
export type {
|
|
13
20
|
UserTierType,
|
|
14
21
|
SubscriptionStatusType,
|
|
@@ -19,7 +26,7 @@ export type {
|
|
|
19
26
|
PurchaseType,
|
|
20
27
|
} from "./domains/subscription/core/SubscriptionConstants";
|
|
21
28
|
export type { SubscriptionMetadata } from "./domains/subscription/core/types/SubscriptionMetadata";
|
|
22
|
-
export type { PremiumStatus } from "./domains/subscription/core/types/PremiumStatus";
|
|
29
|
+
export type { PremiumStatus as PremiumStatusMetadata } from "./domains/subscription/core/types/PremiumStatus";
|
|
23
30
|
export type { CreditInfo } from "./domains/subscription/core/types/CreditInfo";
|
|
24
31
|
export {
|
|
25
32
|
createDefaultSubscriptionStatus,
|
|
@@ -63,10 +70,18 @@ export { useDeductCredit } from "./domains/credits/presentation/deduct-credit/us
|
|
|
63
70
|
export { useFeatureGate } from "./domains/subscription/presentation/useFeatureGate";
|
|
64
71
|
export { usePaywallVisibility, paywallControl } from "./domains/subscription/presentation/usePaywallVisibility";
|
|
65
72
|
export { usePremium } from "./domains/subscription/presentation/usePremium";
|
|
73
|
+
export { usePremiumStatus } from "./domains/subscription/presentation/usePremiumStatus";
|
|
74
|
+
export { usePremiumPackages } from "./domains/subscription/presentation/usePremiumPackages";
|
|
75
|
+
export { usePremiumActions } from "./domains/subscription/presentation/usePremiumActions";
|
|
76
|
+
export type { UsePremiumResult } from "./domains/subscription/presentation/usePremium.types";
|
|
77
|
+
export type { PremiumStatus } from "./domains/subscription/presentation/usePremiumStatus";
|
|
78
|
+
export type { PremiumPackages } from "./domains/subscription/presentation/usePremiumPackages";
|
|
79
|
+
export type { PremiumActions } from "./domains/subscription/presentation/usePremiumActions";
|
|
66
80
|
export { useSubscriptionFlowStore } from "./domains/subscription/presentation/useSubscriptionFlow";
|
|
67
81
|
export type { SubscriptionFlowState, SubscriptionFlowActions, SubscriptionFlowStore } from "./domains/subscription/presentation/useSubscriptionFlow";
|
|
68
82
|
export { useSubscriptionStatus } from "./domains/subscription/presentation/useSubscriptionStatus";
|
|
69
83
|
export type { SubscriptionStatusResult } from "./domains/subscription/presentation/useSubscriptionStatus.types";
|
|
84
|
+
export { useSyncStatusListener } from "./domains/subscription/presentation/useSyncStatusListener";
|
|
70
85
|
export { usePaywallFeedback } from "./presentation/hooks/feedback/usePaywallFeedback";
|
|
71
86
|
export {
|
|
72
87
|
usePaywallFeedbackSubmit,
|
|
@@ -108,6 +123,7 @@ export type {
|
|
|
108
123
|
CreditsResult,
|
|
109
124
|
DeductCreditsResult,
|
|
110
125
|
} from "./domains/credits/core/Credits";
|
|
126
|
+
export { CreditLimitService, calculateCreditLimit } from "./domains/credits/domain/services/CreditLimitService";
|
|
111
127
|
|
|
112
128
|
// Utils
|
|
113
129
|
export {
|
|
@@ -167,10 +183,6 @@ export type { ManagedSubscriptionFlowProps } from "./domains/subscription/presen
|
|
|
167
183
|
export { SubscriptionFlowStatus } from "./domains/subscription/presentation/useSubscriptionFlow";
|
|
168
184
|
export { SubscriptionFlowProvider, useSubscriptionFlowStatus } from "./domains/subscription/presentation/providers/SubscriptionFlowProvider";
|
|
169
185
|
|
|
170
|
-
// Purchase Loading Overlay
|
|
171
|
-
export { PurchaseLoadingOverlay } from "./domains/subscription/presentation/components/overlay/PurchaseLoadingOverlay";
|
|
172
|
-
export type { PurchaseLoadingOverlayProps } from "./domains/subscription/presentation/components/overlay/PurchaseLoadingOverlay";
|
|
173
|
-
|
|
174
186
|
// Init Module Factory
|
|
175
187
|
export {
|
|
176
188
|
createSubscriptionInitModule,
|
|
@@ -17,7 +17,7 @@ class SubscriptionEventBus {
|
|
|
17
17
|
if (!this.listeners.has(event)) {
|
|
18
18
|
this.listeners.set(event, new Set());
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
const eventSet = this.listeners.get(event)!;
|
|
22
22
|
eventSet.add(callback as EventCallback);
|
|
23
23
|
|
|
@@ -36,8 +36,6 @@ class SubscriptionEventBus {
|
|
|
36
36
|
const listeners = this.listeners.get(event);
|
|
37
37
|
if (!listeners || listeners.size === 0) return;
|
|
38
38
|
|
|
39
|
-
// Use microtask for async execution to not block main thread
|
|
40
|
-
// but keep it fast.
|
|
41
39
|
listeners.forEach(callback => {
|
|
42
40
|
queueMicrotask(() => {
|
|
43
41
|
try {
|
|
@@ -71,9 +69,9 @@ class SubscriptionEventBus {
|
|
|
71
69
|
|
|
72
70
|
export const subscriptionEventBus = SubscriptionEventBus.getInstance();
|
|
73
71
|
|
|
74
|
-
export
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
// Re-export event constants for external use
|
|
73
|
+
export { SUBSCRIPTION_EVENTS } from "../../domains/subscription/core/events/SubscriptionEvents";
|
|
74
|
+
export { FLOW_EVENTS } from "../../domains/subscription/core/events/FlowEvents";
|
|
75
|
+
export type { SubscriptionEventType } from "../../domains/subscription/core/events/SubscriptionEvents";
|
|
76
|
+
export type { FlowEventType } from "../../domains/subscription/core/events/FlowEvents";
|
|
77
|
+
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import type { CreditsConfig } from "../core/Credits";
|
|
2
|
-
import { calculateCreditLimit as calculateLimit } from "../utils/creditCalculations";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Service to calculate credit limits based on product configuration.
|
|
6
|
-
* Uses centralized utility functions for calculations.
|
|
7
|
-
*/
|
|
8
|
-
export function calculateCreditLimit(productId: string | undefined, config: CreditsConfig): number {
|
|
9
|
-
return calculateLimit(productId, config);
|
|
10
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Purchase Loading Overlay
|
|
3
|
-
* Full-screen overlay shown during purchase operations
|
|
4
|
-
* Locks the UI and shows a spinner with optional message
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import React from "react";
|
|
8
|
-
import { View, Modal, StyleSheet } from "react-native";
|
|
9
|
-
import { AtomicSpinner, AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
10
|
-
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
11
|
-
import { usePurchaseLoadingStore, selectIsPurchasing } from "../../stores/purchaseLoadingStore";
|
|
12
|
-
import type { PurchaseLoadingOverlayProps } from "./PurchaseLoadingOverlay.types";
|
|
13
|
-
|
|
14
|
-
export type { PurchaseLoadingOverlayProps };
|
|
15
|
-
|
|
16
|
-
export const PurchaseLoadingOverlay: React.FC<PurchaseLoadingOverlayProps> = React.memo(
|
|
17
|
-
({ loadingText }) => {
|
|
18
|
-
const tokens = useAppDesignTokens();
|
|
19
|
-
const isPurchasing = usePurchaseLoadingStore(selectIsPurchasing);
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<Modal visible={isPurchasing} transparent animationType="none" statusBarTranslucent>
|
|
23
|
-
<View style={[styles.container, { backgroundColor: "rgba(0, 0, 0, 0.7)" }]}>
|
|
24
|
-
<View style={[styles.content, { backgroundColor: tokens.colors.surface }]}>
|
|
25
|
-
<AtomicSpinner size="lg" color="primary" />
|
|
26
|
-
{loadingText && (
|
|
27
|
-
<AtomicText
|
|
28
|
-
type="bodyLarge"
|
|
29
|
-
style={[styles.text, { color: tokens.colors.textPrimary }]}
|
|
30
|
-
>
|
|
31
|
-
{loadingText}
|
|
32
|
-
</AtomicText>
|
|
33
|
-
)}
|
|
34
|
-
</View>
|
|
35
|
-
</View>
|
|
36
|
-
</Modal>
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
PurchaseLoadingOverlay.displayName = "PurchaseLoadingOverlay";
|
|
42
|
-
|
|
43
|
-
const styles = StyleSheet.create({
|
|
44
|
-
container: {
|
|
45
|
-
flex: 1,
|
|
46
|
-
justifyContent: "center",
|
|
47
|
-
alignItems: "center",
|
|
48
|
-
},
|
|
49
|
-
content: {
|
|
50
|
-
paddingHorizontal: 32,
|
|
51
|
-
paddingVertical: 24,
|
|
52
|
-
borderRadius: 16,
|
|
53
|
-
alignItems: "center",
|
|
54
|
-
minWidth: 200,
|
|
55
|
-
},
|
|
56
|
-
text: {
|
|
57
|
-
marginTop: 16,
|
|
58
|
-
textAlign: "center",
|
|
59
|
-
},
|
|
60
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { create } from "zustand";
|
|
2
|
-
|
|
3
|
-
interface PurchaseLoadingState {
|
|
4
|
-
activePurchases: Map<string, "manual" | "auto-execution">;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
interface PurchaseLoadingActions {
|
|
8
|
-
startPurchase: (productId: string, source: "manual" | "auto-execution") => void;
|
|
9
|
-
endPurchase: (productId: string) => void;
|
|
10
|
-
reset: () => void;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type PurchaseLoadingStore = PurchaseLoadingState & PurchaseLoadingActions;
|
|
14
|
-
|
|
15
|
-
const createInitialState = (): PurchaseLoadingState => ({
|
|
16
|
-
activePurchases: new Map(),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
export const usePurchaseLoadingStore = create<PurchaseLoadingStore>((set) => ({
|
|
20
|
-
...createInitialState(),
|
|
21
|
-
|
|
22
|
-
startPurchase: (productId, source) => {
|
|
23
|
-
set((state) => {
|
|
24
|
-
const newPurchases = new Map(state.activePurchases);
|
|
25
|
-
newPurchases.set(productId, source);
|
|
26
|
-
return { activePurchases: newPurchases };
|
|
27
|
-
});
|
|
28
|
-
},
|
|
29
|
-
|
|
30
|
-
endPurchase: (productId) => {
|
|
31
|
-
set((state) => {
|
|
32
|
-
const newPurchases = new Map(state.activePurchases);
|
|
33
|
-
newPurchases.delete(productId);
|
|
34
|
-
return { activePurchases: newPurchases };
|
|
35
|
-
});
|
|
36
|
-
},
|
|
37
|
-
|
|
38
|
-
reset: () => {
|
|
39
|
-
set(createInitialState());
|
|
40
|
-
},
|
|
41
|
-
}));
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Optimized selector for purchasing state.
|
|
45
|
-
* Use this to avoid re-renders when other parts of the state change.
|
|
46
|
-
*/
|
|
47
|
-
export const selectIsPurchasing = (state: PurchaseLoadingStore) => state.activePurchases.size > 0;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Optimized selector for a specific product's purchasing state.
|
|
51
|
-
*/
|
|
52
|
-
export const selectIsProductPurchasing = (productId: string) => (state: PurchaseLoadingStore) =>
|
|
53
|
-
state.activePurchases.has(productId);
|