@umituz/react-native-subscription 2.43.0 → 2.43.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/domains/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 +9 -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 +30 -8
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +49 -17
- package/src/domains/subscription/application/initializer/ServiceConfigurator.ts +9 -3
- package/src/domains/subscription/presentation/components/overlay/PurchaseLoadingOverlay.tsx +4 -4
- package/src/domains/subscription/presentation/components/overlay/PurchaseLoadingOverlay.types.ts +2 -0
- package/src/domains/subscription/presentation/providers/SubscriptionFlowProvider.tsx +4 -0
- package/src/domains/subscription/presentation/usePremium.ts +29 -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 +10 -1
- package/src/shared/infrastructure/SubscriptionEventBus.ts +11 -1
- package/src/domains/credits/application/CreditLimitCalculator.ts +0 -10
- 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.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",
|
|
@@ -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;
|
|
@@ -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
|
+
* This is a "dumb" component that receives all data and actions via props.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import React, { useCallback, useEffect, useMemo } from "react";
|
|
@@ -14,7 +15,6 @@ import {
|
|
|
14
15
|
StatusBar,
|
|
15
16
|
} from "react-native";
|
|
16
17
|
import { useNavigation } from "@react-navigation/native";
|
|
17
|
-
import { usePremium } from "../../subscription/presentation/usePremium";
|
|
18
18
|
import { AtomicText, AtomicIcon, AtomicSpinner } from "@umituz/react-native-design-system/atoms";
|
|
19
19
|
import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
|
|
20
20
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
@@ -37,13 +37,11 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
37
37
|
console.log('[PaywallScreen] 📱 Rendering PaywallScreen', {
|
|
38
38
|
hasPackages: !!props.packages?.length,
|
|
39
39
|
packagesCount: props.packages?.length || 0,
|
|
40
|
+
isPremium: props.isPremium,
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
const { purchasePackage, restorePurchase } = usePremium();
|
|
44
|
-
|
|
45
44
|
const {
|
|
46
|
-
onClose,
|
|
47
45
|
translations,
|
|
48
46
|
packages = [],
|
|
49
47
|
features = [],
|
|
@@ -52,11 +50,14 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
52
50
|
creditAmounts,
|
|
53
51
|
creditsLabel,
|
|
54
52
|
heroImage,
|
|
53
|
+
isSyncing,
|
|
54
|
+
onPurchase,
|
|
55
|
+
onRestore,
|
|
56
|
+
onClose,
|
|
55
57
|
onPurchaseSuccess,
|
|
56
58
|
onPurchaseError,
|
|
57
59
|
onAuthRequired,
|
|
58
60
|
source,
|
|
59
|
-
isLoadingPackages
|
|
60
61
|
} = props;
|
|
61
62
|
|
|
62
63
|
const tokens = useAppDesignTokens();
|
|
@@ -80,8 +81,8 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
80
81
|
resetState
|
|
81
82
|
} = usePaywallActions({
|
|
82
83
|
packages,
|
|
83
|
-
purchasePackage,
|
|
84
|
-
restorePurchase,
|
|
84
|
+
purchasePackage: onPurchase,
|
|
85
|
+
restorePurchase: onRestore,
|
|
85
86
|
source,
|
|
86
87
|
onPurchaseSuccess,
|
|
87
88
|
onPurchaseError,
|
|
@@ -233,7 +234,7 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
233
234
|
return null;
|
|
234
235
|
}
|
|
235
236
|
|
|
236
|
-
if (
|
|
237
|
+
if (isSyncing) {
|
|
237
238
|
return (
|
|
238
239
|
<View style={[styles.container, { backgroundColor: tokens.colors.backgroundPrimary, paddingTop: insets.top }]}>
|
|
239
240
|
<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(() => ({
|
|
@@ -22,6 +22,9 @@ export interface PaywallOrchestratorOptions {
|
|
|
22
22
|
* High-level orchestrator for Paywall navigation.
|
|
23
23
|
* Handles automatic triggers (post-onboarding) and manual triggers (showPaywall state).
|
|
24
24
|
* Centralizes handlers for success, close, and feedback triggers.
|
|
25
|
+
*
|
|
26
|
+
* This orchestrator fetches all subscription data and passes it to PaywallScreen as props.
|
|
27
|
+
* PaywallScreen is now a "dumb" component that doesn't call usePremium internally.
|
|
25
28
|
*/
|
|
26
29
|
export function usePaywallOrchestrator({
|
|
27
30
|
navigation,
|
|
@@ -34,12 +37,14 @@ export function usePaywallOrchestrator({
|
|
|
34
37
|
bestValueIdentifier = "yearly",
|
|
35
38
|
creditsLabel,
|
|
36
39
|
}: PaywallOrchestratorOptions) {
|
|
37
|
-
//
|
|
38
|
-
// purchasePackage ve restoreRestore fonksiyonlarını ALMIYORUZ
|
|
39
|
-
// Çünkü PaywallScreen zaten usePremium hook'unu doğrudan kullanıyor
|
|
40
|
+
// Get all premium data and actions from usePremium
|
|
40
41
|
const {
|
|
41
42
|
isPremium,
|
|
42
|
-
packages
|
|
43
|
+
packages,
|
|
44
|
+
credits,
|
|
45
|
+
isSyncing,
|
|
46
|
+
purchasePackage,
|
|
47
|
+
restorePurchase,
|
|
43
48
|
} = usePremium();
|
|
44
49
|
|
|
45
50
|
// Selectors for stable references and fine-grained updates
|
|
@@ -55,6 +60,10 @@ export function usePaywallOrchestrator({
|
|
|
55
60
|
const { showPaywall, closePaywall } = usePaywallVisibility();
|
|
56
61
|
const hasNavigatedRef = useRef(false);
|
|
57
62
|
|
|
63
|
+
const handleClose = () => {
|
|
64
|
+
closePaywall();
|
|
65
|
+
};
|
|
66
|
+
|
|
58
67
|
useEffect(() => {
|
|
59
68
|
if (!isNavReady || !isLocalizationReady) return;
|
|
60
69
|
|
|
@@ -77,9 +86,9 @@ export function usePaywallOrchestrator({
|
|
|
77
86
|
packagesCount: packages.length
|
|
78
87
|
});
|
|
79
88
|
|
|
80
|
-
//
|
|
81
|
-
// PaywallScreen kendi usePremium hook'unu kullanacak
|
|
89
|
+
// Pass all data and actions as props - PaywallScreen is now a dumb component
|
|
82
90
|
navigation.navigate("PaywallScreen", {
|
|
91
|
+
// UI Props
|
|
83
92
|
translations,
|
|
84
93
|
legalUrls,
|
|
85
94
|
features,
|
|
@@ -87,7 +96,17 @@ export function usePaywallOrchestrator({
|
|
|
87
96
|
creditsLabel,
|
|
88
97
|
heroImage,
|
|
89
98
|
source: shouldShowPostOnboarding ? "onboarding" : "manual",
|
|
99
|
+
|
|
100
|
+
// Data Props
|
|
90
101
|
packages,
|
|
102
|
+
isPremium,
|
|
103
|
+
credits,
|
|
104
|
+
isSyncing,
|
|
105
|
+
|
|
106
|
+
// Action Props
|
|
107
|
+
onPurchase: purchasePackage,
|
|
108
|
+
onRestore: restorePurchase,
|
|
109
|
+
onClose: handleClose,
|
|
91
110
|
});
|
|
92
111
|
|
|
93
112
|
if (shouldShowPostOnboarding) {
|
|
@@ -120,13 +139,16 @@ export function usePaywallOrchestrator({
|
|
|
120
139
|
closePaywall,
|
|
121
140
|
bestValueIdentifier,
|
|
122
141
|
creditsLabel,
|
|
142
|
+
credits,
|
|
143
|
+
isSyncing,
|
|
144
|
+
purchasePackage,
|
|
145
|
+
restorePurchase,
|
|
146
|
+
handleClose,
|
|
123
147
|
]);
|
|
124
148
|
|
|
125
149
|
const completeOnboarding = useSubscriptionFlowStore((state) => state.completeOnboarding);
|
|
126
150
|
|
|
127
151
|
return {
|
|
128
|
-
isPremium,
|
|
129
|
-
packages,
|
|
130
152
|
flowState: {
|
|
131
153
|
isOnboardingComplete,
|
|
132
154
|
showPostOnboardingPaywall,
|
|
@@ -4,7 +4,6 @@ import { getCreditsRepository } from "../../credits/infrastructure/CreditsReposi
|
|
|
4
4
|
import { extractRevenueCatData } from "./SubscriptionSyncUtils";
|
|
5
5
|
import { generatePurchaseId, generateRenewalId } from "./syncIdGenerators";
|
|
6
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,
|
|
@@ -2,24 +2,24 @@
|
|
|
2
2
|
* Purchase Loading Overlay
|
|
3
3
|
* Full-screen overlay shown during purchase operations
|
|
4
4
|
* Locks the UI and shows a spinner with optional message
|
|
5
|
+
*
|
|
6
|
+
* This is now a props-based component. Pass isLoading from parent component.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import React from "react";
|
|
8
10
|
import { View, Modal, StyleSheet } from "react-native";
|
|
9
11
|
import { AtomicSpinner, AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
10
12
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
11
|
-
import { usePurchaseLoadingStore, selectIsPurchasing } from "../../stores/purchaseLoadingStore";
|
|
12
13
|
import type { PurchaseLoadingOverlayProps } from "./PurchaseLoadingOverlay.types";
|
|
13
14
|
|
|
14
15
|
export type { PurchaseLoadingOverlayProps };
|
|
15
16
|
|
|
16
17
|
export const PurchaseLoadingOverlay: React.FC<PurchaseLoadingOverlayProps> = React.memo(
|
|
17
|
-
({ loadingText }) => {
|
|
18
|
+
({ loadingText, isLoading }) => {
|
|
18
19
|
const tokens = useAppDesignTokens();
|
|
19
|
-
const isPurchasing = usePurchaseLoadingStore(selectIsPurchasing);
|
|
20
20
|
|
|
21
21
|
return (
|
|
22
|
-
<Modal visible={
|
|
22
|
+
<Modal visible={isLoading} transparent animationType="none" statusBarTranslucent>
|
|
23
23
|
<View style={[styles.container, { backgroundColor: "rgba(0, 0, 0, 0.7)" }]}>
|
|
24
24
|
<View style={[styles.content, { backgroundColor: tokens.colors.surface }]}>
|
|
25
25
|
<AtomicSpinner size="lg" color="primary" />
|
|
@@ -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,38 @@
|
|
|
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
|
+
* This provides backward compatibility with existing code while allowing
|
|
11
|
+
* components to use more focused hooks (usePremiumStatus, usePremiumPackages, usePremiumActions)
|
|
12
|
+
* for better performance and testability.
|
|
13
|
+
*
|
|
14
|
+
* For new components, consider using the focused hooks:
|
|
15
|
+
* - usePremiumStatus() - when you only need premium status
|
|
16
|
+
* - usePremiumPackages() - when you only need package data
|
|
17
|
+
* - usePremiumActions() - when you only need actions
|
|
18
|
+
*
|
|
19
|
+
* This facade re-renders when ANY of the sub-hooks change, whereas focused hooks
|
|
20
|
+
* only re-render when their specific data changes.
|
|
21
|
+
*/
|
|
16
22
|
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]);
|
|
23
|
+
const status = usePremiumStatus();
|
|
24
|
+
const packages = usePremiumPackages();
|
|
25
|
+
const actions = usePremiumActions();
|
|
61
26
|
|
|
62
27
|
return useMemo(() => ({
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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,
|
|
28
|
+
...status,
|
|
29
|
+
...packages,
|
|
30
|
+
...actions,
|
|
31
|
+
// Merge loading states for backward compatibility
|
|
32
|
+
isLoading: status.isSyncing || packages.isLoading || actions.isLoading,
|
|
79
33
|
}), [
|
|
80
|
-
|
|
81
|
-
statusLoading,
|
|
82
|
-
creditsLoading,
|
|
83
|
-
packagesLoading,
|
|
84
|
-
purchaseMutation.isPending,
|
|
85
|
-
restoreMutation.isPending,
|
|
34
|
+
status,
|
|
86
35
|
packages,
|
|
87
|
-
|
|
88
|
-
showPaywall,
|
|
89
|
-
isSyncing,
|
|
90
|
-
handlePurchase,
|
|
91
|
-
handleRestore,
|
|
92
|
-
setShowPaywall,
|
|
93
|
-
closePaywall,
|
|
94
|
-
openPaywall,
|
|
36
|
+
actions,
|
|
95
37
|
]);
|
|
96
38
|
};
|
|
@@ -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
|
@@ -19,7 +19,7 @@ export type {
|
|
|
19
19
|
PurchaseType,
|
|
20
20
|
} from "./domains/subscription/core/SubscriptionConstants";
|
|
21
21
|
export type { SubscriptionMetadata } from "./domains/subscription/core/types/SubscriptionMetadata";
|
|
22
|
-
export type { PremiumStatus } from "./domains/subscription/core/types/PremiumStatus";
|
|
22
|
+
export type { PremiumStatus as PremiumStatusMetadata } from "./domains/subscription/core/types/PremiumStatus";
|
|
23
23
|
export type { CreditInfo } from "./domains/subscription/core/types/CreditInfo";
|
|
24
24
|
export {
|
|
25
25
|
createDefaultSubscriptionStatus,
|
|
@@ -63,10 +63,18 @@ export { useDeductCredit } from "./domains/credits/presentation/deduct-credit/us
|
|
|
63
63
|
export { useFeatureGate } from "./domains/subscription/presentation/useFeatureGate";
|
|
64
64
|
export { usePaywallVisibility, paywallControl } from "./domains/subscription/presentation/usePaywallVisibility";
|
|
65
65
|
export { usePremium } from "./domains/subscription/presentation/usePremium";
|
|
66
|
+
export { usePremiumStatus } from "./domains/subscription/presentation/usePremiumStatus";
|
|
67
|
+
export { usePremiumPackages } from "./domains/subscription/presentation/usePremiumPackages";
|
|
68
|
+
export { usePremiumActions } from "./domains/subscription/presentation/usePremiumActions";
|
|
69
|
+
export type { UsePremiumResult } from "./domains/subscription/presentation/usePremium.types";
|
|
70
|
+
export type { PremiumStatus } from "./domains/subscription/presentation/usePremiumStatus";
|
|
71
|
+
export type { PremiumPackages } from "./domains/subscription/presentation/usePremiumPackages";
|
|
72
|
+
export type { PremiumActions } from "./domains/subscription/presentation/usePremiumActions";
|
|
66
73
|
export { useSubscriptionFlowStore } from "./domains/subscription/presentation/useSubscriptionFlow";
|
|
67
74
|
export type { SubscriptionFlowState, SubscriptionFlowActions, SubscriptionFlowStore } from "./domains/subscription/presentation/useSubscriptionFlow";
|
|
68
75
|
export { useSubscriptionStatus } from "./domains/subscription/presentation/useSubscriptionStatus";
|
|
69
76
|
export type { SubscriptionStatusResult } from "./domains/subscription/presentation/useSubscriptionStatus.types";
|
|
77
|
+
export { useSyncStatusListener } from "./domains/subscription/presentation/useSyncStatusListener";
|
|
70
78
|
export { usePaywallFeedback } from "./presentation/hooks/feedback/usePaywallFeedback";
|
|
71
79
|
export {
|
|
72
80
|
usePaywallFeedbackSubmit,
|
|
@@ -108,6 +116,7 @@ export type {
|
|
|
108
116
|
CreditsResult,
|
|
109
117
|
DeductCreditsResult,
|
|
110
118
|
} from "./domains/credits/core/Credits";
|
|
119
|
+
export { CreditLimitService, calculateCreditLimit } from "./domains/credits/domain/services/CreditLimitService";
|
|
111
120
|
|
|
112
121
|
// Utils
|
|
113
122
|
export {
|
|
@@ -76,4 +76,14 @@ export const SUBSCRIPTION_EVENTS = {
|
|
|
76
76
|
PURCHASE_COMPLETED: "purchase_completed",
|
|
77
77
|
RENEWAL_DETECTED: "renewal_detected",
|
|
78
78
|
PREMIUM_STATUS_CHANGED: "premium_status_changed",
|
|
79
|
-
|
|
79
|
+
SYNC_STATUS_CHANGED: "sync_status_changed",
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
export const FLOW_EVENTS = {
|
|
83
|
+
ONBOARDING_COMPLETED: "flow_onboarding_completed",
|
|
84
|
+
PAYWALL_SHOWN: "flow_paywall_shown",
|
|
85
|
+
PAYWALL_CLOSED: "flow_paywall_closed",
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
export type SubscriptionEventType = typeof SUBSCRIPTION_EVENTS[keyof typeof SUBSCRIPTION_EVENTS];
|
|
89
|
+
export type FlowEventType = typeof FLOW_EVENTS[keyof typeof FLOW_EVENTS];
|
|
@@ -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,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);
|