@umituz/react-native-subscription 2.27.111 → 2.27.113
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 +25 -15
- package/src/domains/credits/application/credit-strategies/{CreditAllocationContext.ts → CreditAllocationOrchestrator.ts} +4 -4
- package/src/domains/credits/application/credit-strategies/SyncCreditStrategy.ts +1 -1
- package/src/domains/credits/core/CreditsMapper.ts +8 -13
- package/src/domains/credits/infrastructure/{CreditsRepositoryProvider.ts → CreditsRepositoryManager.ts} +2 -2
- package/src/domains/credits/presentation/useCredits.ts +1 -1
- package/src/domains/credits/presentation/useDeductCredit.ts +4 -4
- package/src/domains/paywall/components/PaywallContainer.types.ts +1 -1
- package/src/domains/paywall/components/PaywallModal.tsx +28 -52
- package/src/domains/paywall/hooks/usePaywallActions.ts +79 -32
- package/src/domains/subscription/application/SubscriptionInitializer.ts +1 -1
- package/src/domains/subscription/application/SubscriptionSyncService.ts +33 -9
- package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +1 -1
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +1 -1
- package/src/domains/subscription/presentation/usePaywallVisibility.ts +1 -1
- package/src/domains/wallet/index.ts +0 -6
- package/src/domains/wallet/presentation/screens/WalletScreen.tsx +57 -43
- package/src/index.ts +1 -1
- package/src/shared/application/ActivationHandler.ts +6 -6
- package/src/shared/presentation/index.ts +1 -0
- package/src/shared/presentation/layouts/ScreenLayout.tsx +79 -0
- package/src/shared/utils/Logger.ts +21 -21
- package/src/shared/utils/SubscriptionConfig.ts +1 -1
- package/src/utils/appUtils.ts +34 -0
- package/src/utils/dateUtils.ts +32 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/packageTypeDetector.ts +1 -1
- package/src/domains/wallet/presentation/screens/WalletScreenContainer.tsx +0 -88
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.27.
|
|
3
|
+
"version": "2.27.113",
|
|
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",
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { Platform } from "react-native";
|
|
2
|
-
import Constants from "expo-constants";
|
|
3
1
|
import {
|
|
4
2
|
getFirestore,
|
|
5
3
|
} from "@umituz/react-native-firebase";
|
|
@@ -14,7 +12,8 @@ import type { UserCreditsDocumentRead } from "../core/UserCreditsDocument";
|
|
|
14
12
|
import { resolveSubscriptionStatus } from "../../subscription/core/SubscriptionStatus";
|
|
15
13
|
import { CreditLimitCalculator } from "./CreditLimitCalculator";
|
|
16
14
|
import { PurchaseMetadataGenerator } from "./PurchaseMetadataGenerator";
|
|
17
|
-
import {
|
|
15
|
+
import { creditAllocationOrchestrator } from "./credit-strategies/CreditAllocationOrchestrator";
|
|
16
|
+
import { getAppVersion, validatePlatform, isPast } from "../../../utils";
|
|
18
17
|
import type { InitializeCreditsMetadata, InitializationResult } from "../../subscription/application/SubscriptionInitializerTypes";
|
|
19
18
|
|
|
20
19
|
export async function initializeCreditsTransaction(
|
|
@@ -40,7 +39,7 @@ export async function initializeCreditsTransaction(
|
|
|
40
39
|
status: "none",
|
|
41
40
|
processedPurchases: [],
|
|
42
41
|
purchaseHistory: [],
|
|
43
|
-
platform:
|
|
42
|
+
platform: validatePlatform() as any,
|
|
44
43
|
lastUpdatedAt: now,
|
|
45
44
|
purchasedAt: now,
|
|
46
45
|
expirationDate: null,
|
|
@@ -68,15 +67,8 @@ export async function initializeCreditsTransaction(
|
|
|
68
67
|
|
|
69
68
|
const creditLimit = CreditLimitCalculator.calculate(metadata.productId, config);
|
|
70
69
|
|
|
71
|
-
const platform =
|
|
72
|
-
|
|
73
|
-
throw new Error(`Invalid platform: ${platform}`);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const appVersion = Constants.expoConfig?.version;
|
|
77
|
-
if (!appVersion) {
|
|
78
|
-
throw new Error("appVersion is required in expoConfig");
|
|
79
|
-
}
|
|
70
|
+
const platform = validatePlatform();
|
|
71
|
+
const appVersion = getAppVersion();
|
|
80
72
|
|
|
81
73
|
const { purchaseHistory } = PurchaseMetadataGenerator.generate({
|
|
82
74
|
productId: metadata.productId,
|
|
@@ -91,7 +83,7 @@ export async function initializeCreditsTransaction(
|
|
|
91
83
|
|
|
92
84
|
let isExpired = false;
|
|
93
85
|
if (metadata.expirationDate) {
|
|
94
|
-
isExpired =
|
|
86
|
+
isExpired = isPast(metadata.expirationDate);
|
|
95
87
|
}
|
|
96
88
|
|
|
97
89
|
const status = resolveSubscriptionStatus({
|
|
@@ -104,7 +96,7 @@ export async function initializeCreditsTransaction(
|
|
|
104
96
|
const isStatusSync = purchaseId.startsWith("status_sync_");
|
|
105
97
|
const isSubscriptionActive = isPremium && !isExpired;
|
|
106
98
|
|
|
107
|
-
const newCredits =
|
|
99
|
+
const newCredits = creditAllocationOrchestrator.allocate({
|
|
108
100
|
status,
|
|
109
101
|
isStatusSync,
|
|
110
102
|
existingData,
|
|
@@ -150,6 +142,24 @@ export async function initializeCreditsTransaction(
|
|
|
150
142
|
creditsData.productId = metadata.productId;
|
|
151
143
|
creditsData.platform = platform;
|
|
152
144
|
|
|
145
|
+
// Skip write if it's a status sync and data hasn't changed to save costs
|
|
146
|
+
if (isStatusSync && existingData) {
|
|
147
|
+
const hasChanged =
|
|
148
|
+
existingData.isPremium !== creditsData.isPremium ||
|
|
149
|
+
existingData.status !== creditsData.status ||
|
|
150
|
+
existingData.credits !== creditsData.credits ||
|
|
151
|
+
existingData.creditLimit !== creditsData.creditLimit ||
|
|
152
|
+
existingData.productId !== creditsData.productId;
|
|
153
|
+
|
|
154
|
+
if (!hasChanged) {
|
|
155
|
+
return {
|
|
156
|
+
credits: existingData.credits,
|
|
157
|
+
alreadyProcessed: true,
|
|
158
|
+
finalData: existingData
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
153
163
|
transaction.set(creditsRef, creditsData, { merge: true });
|
|
154
164
|
|
|
155
165
|
const finalData: UserCreditsDocumentRead = {
|
|
@@ -4,9 +4,9 @@ import { TrialCreditStrategy } from "./TrialCreditStrategy";
|
|
|
4
4
|
import { StandardPurchaseCreditStrategy } from "./StandardPurchaseCreditStrategy";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Orchestrator to coordinate credit allocation logic using the Strategy Pattern.
|
|
8
8
|
*/
|
|
9
|
-
export class
|
|
9
|
+
export class CreditAllocationOrchestrator {
|
|
10
10
|
private strategies: ICreditStrategy[] = [
|
|
11
11
|
new SyncCreditStrategy(),
|
|
12
12
|
new TrialCreditStrategy(),
|
|
@@ -25,11 +25,11 @@ export class CreditAllocationContext {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
if (__DEV__) {
|
|
28
|
-
console.log(`[
|
|
28
|
+
console.log(`[CreditAllocationOrchestrator] Using strategy: ${strategy.constructor.name}`);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
return strategy.execute(params);
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export const
|
|
35
|
+
export const creditAllocationOrchestrator = new CreditAllocationOrchestrator();
|
|
@@ -6,7 +6,7 @@ import { ICreditStrategy, type CreditAllocationParams } from "./ICreditStrategy"
|
|
|
6
6
|
*/
|
|
7
7
|
export class SyncCreditStrategy implements ICreditStrategy {
|
|
8
8
|
canHandle(params: CreditAllocationParams): boolean {
|
|
9
|
-
return params.isStatusSync
|
|
9
|
+
return params.isStatusSync;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
execute(params: CreditAllocationParams): number {
|
|
@@ -3,17 +3,12 @@ import { resolveSubscriptionStatus } from "../../subscription/core/SubscriptionS
|
|
|
3
3
|
import type { PeriodType, SubscriptionStatusType } from "../../subscription/core/SubscriptionConstants";
|
|
4
4
|
import type { UserCreditsDocumentRead } from "./UserCreditsDocument";
|
|
5
5
|
|
|
6
|
+
import { toSafeDate } from "../../../utils/dateUtils";
|
|
7
|
+
|
|
6
8
|
/** Maps Firestore document to domain entity with expiration validation */
|
|
7
9
|
export class CreditsMapper {
|
|
8
10
|
static toEntity(doc: UserCreditsDocumentRead): UserCredits {
|
|
9
|
-
const
|
|
10
|
-
if (!ts) return null;
|
|
11
|
-
if (typeof ts.toDate === "function") return ts.toDate();
|
|
12
|
-
if (ts instanceof Date) return ts;
|
|
13
|
-
return null;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const expirationDate = safeDate(doc.expirationDate);
|
|
11
|
+
const expirationDate = toSafeDate(doc.expirationDate);
|
|
17
12
|
const periodType = doc.periodType;
|
|
18
13
|
|
|
19
14
|
// Validate isPremium against expirationDate (real-time check)
|
|
@@ -25,10 +20,10 @@ export class CreditsMapper {
|
|
|
25
20
|
status,
|
|
26
21
|
|
|
27
22
|
// Dates
|
|
28
|
-
purchasedAt:
|
|
23
|
+
purchasedAt: toSafeDate(doc.purchasedAt) ?? new Date(),
|
|
29
24
|
expirationDate,
|
|
30
|
-
lastUpdatedAt:
|
|
31
|
-
lastPurchaseAt:
|
|
25
|
+
lastUpdatedAt: toSafeDate(doc.lastUpdatedAt) ?? new Date(),
|
|
26
|
+
lastPurchaseAt: toSafeDate(doc.lastPurchaseAt),
|
|
32
27
|
|
|
33
28
|
// RevenueCat details
|
|
34
29
|
willRenew: doc.willRenew,
|
|
@@ -39,8 +34,8 @@ export class CreditsMapper {
|
|
|
39
34
|
// Trial fields
|
|
40
35
|
periodType,
|
|
41
36
|
isTrialing: doc.isTrialing,
|
|
42
|
-
trialStartDate:
|
|
43
|
-
trialEndDate:
|
|
37
|
+
trialStartDate: toSafeDate(doc.trialStartDate),
|
|
38
|
+
trialEndDate: toSafeDate(doc.trialEndDate),
|
|
44
39
|
trialCredits: doc.trialCredits,
|
|
45
40
|
convertedFromTrial: doc.convertedFromTrial,
|
|
46
41
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Credits Repository
|
|
2
|
+
* Credits Repository Manager
|
|
3
3
|
* Module-level singleton for credits repository configuration
|
|
4
|
-
*
|
|
4
|
+
* Provides a clean, testable approach for repository access
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { CreditsConfig } from "../core/Credits";
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
getCreditsRepository,
|
|
15
15
|
getCreditsConfig,
|
|
16
16
|
isCreditsRepositoryConfigured,
|
|
17
|
-
} from "../infrastructure/
|
|
17
|
+
} from "../infrastructure/CreditsRepositoryManager";
|
|
18
18
|
import { calculateCreditPercentage, canAffordCost } from "../utils/creditCalculations";
|
|
19
19
|
|
|
20
20
|
export const creditsQueryKeys = {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { useCallback } from "react";
|
|
7
7
|
import { useMutation, useQueryClient } from "@umituz/react-native-design-system";
|
|
8
8
|
import type { UserCredits } from "../core/Credits";
|
|
9
|
-
import { getCreditsRepository } from "../infrastructure/
|
|
9
|
+
import { getCreditsRepository } from "../infrastructure/CreditsRepositoryManager";
|
|
10
10
|
import { creditsQueryKeys } from "./useCredits";
|
|
11
11
|
import { calculateRemainingCredits } from "../utils/creditCalculations";
|
|
12
12
|
|
|
@@ -65,11 +65,11 @@ export const useDeductCredit = ({
|
|
|
65
65
|
wasInsufficient: previousCredits.credits < cost
|
|
66
66
|
};
|
|
67
67
|
},
|
|
68
|
-
onError: (_err, _cost,
|
|
68
|
+
onError: (_err, _cost, mutationData) => {
|
|
69
69
|
// Always restore previous credits on error to prevent UI desync
|
|
70
70
|
// Use optional chaining to be safe
|
|
71
|
-
if (userId &&
|
|
72
|
-
queryClient.setQueryData(creditsQueryKeys.user(userId),
|
|
71
|
+
if (userId && mutationData?.previousCredits && !mutationData.skippedOptimistic) {
|
|
72
|
+
queryClient.setQueryData(creditsQueryKeys.user(userId), mutationData.previousCredits);
|
|
73
73
|
}
|
|
74
74
|
},
|
|
75
75
|
onSuccess: () => {
|
|
@@ -45,7 +45,7 @@ export interface PaywallContainerProps {
|
|
|
45
45
|
/** Callback when purchase succeeds */
|
|
46
46
|
readonly onPurchaseSuccess?: () => void;
|
|
47
47
|
/** Callback when purchase fails */
|
|
48
|
-
readonly onPurchaseError?: (error: string) => void;
|
|
48
|
+
readonly onPurchaseError?: (error: Error | string) => void;
|
|
49
49
|
/** Callback when auth is required (for anonymous users) */
|
|
50
50
|
readonly onAuthRequired?: () => void;
|
|
51
51
|
/** Visibility override */
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Paywall Modal
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import React, {
|
|
6
|
-
import { View,
|
|
7
|
-
import { BaseModal, useAppDesignTokens, AtomicText, AtomicIcon, AtomicSpinner } from "@umituz/react-native-design-system";
|
|
5
|
+
import React, { useCallback, useEffect } from "react";
|
|
6
|
+
import { View, TouchableOpacity, Linking, type ImageSourcePropType } from "react-native";
|
|
7
|
+
import { BaseModal, useAppDesignTokens, AtomicText, AtomicIcon, AtomicSpinner, useSafeAreaInsets } from "@umituz/react-native-design-system";
|
|
8
|
+
import { ScreenLayout } from "../../../shared/presentation";
|
|
8
9
|
import { Image } from "expo-image";
|
|
9
10
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
10
11
|
import { PlanCard } from "./PlanCard";
|
|
@@ -12,7 +13,7 @@ import type { SubscriptionFeature, PaywallTranslations, PaywallLegalUrls } from
|
|
|
12
13
|
import { paywallModalStyles as styles } from "./PaywallModal.styles";
|
|
13
14
|
import { PaywallFeatures } from "./PaywallFeatures";
|
|
14
15
|
import { PaywallFooter } from "./PaywallFooter";
|
|
15
|
-
import {
|
|
16
|
+
import { usePaywallActions } from "../hooks/usePaywallActions";
|
|
16
17
|
|
|
17
18
|
/** Trial eligibility info per product */
|
|
18
19
|
export interface TrialEligibilityInfo {
|
|
@@ -47,60 +48,28 @@ export interface PaywallModalProps {
|
|
|
47
48
|
export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
48
49
|
const { visible, onClose, translations, packages = [], features = [], isLoading = false, legalUrls = {}, bestValueIdentifier, creditAmounts, creditsLabel, heroImage, onPurchase, onRestore, trialEligibility = {}, trialSubtitleText } = props;
|
|
49
50
|
const tokens = useAppDesignTokens();
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
const insets = useSafeAreaInsets();
|
|
52
|
+
|
|
53
|
+
const {
|
|
54
|
+
selectedPlanId,
|
|
55
|
+
setSelectedPlanId,
|
|
56
|
+
isProcessing,
|
|
57
|
+
handlePurchase,
|
|
58
|
+
handleRestore,
|
|
59
|
+
resetState,
|
|
60
|
+
} = usePaywallActions({ packages, onPurchase, onRestore });
|
|
56
61
|
|
|
57
62
|
// Reset selected plan when packages change
|
|
58
63
|
useEffect(() => {
|
|
59
64
|
setSelectedPlanId(null);
|
|
60
|
-
}, [packages]);
|
|
65
|
+
}, [packages, setSelectedPlanId]);
|
|
61
66
|
|
|
62
67
|
// Cleanup state when modal closes to prevent stale state
|
|
63
68
|
useEffect(() => {
|
|
64
69
|
if (!visible) {
|
|
65
|
-
|
|
66
|
-
setIsLocalProcessing(false);
|
|
67
|
-
}
|
|
68
|
-
}, [visible]);
|
|
69
|
-
|
|
70
|
-
// Combined processing state
|
|
71
|
-
const isProcessing = isLocalProcessing || isGlobalPurchasing;
|
|
72
|
-
|
|
73
|
-
const handlePurchase = useCallback(async () => {
|
|
74
|
-
if (!selectedPlanId || !onPurchase) return;
|
|
75
|
-
|
|
76
|
-
setIsLocalProcessing(true);
|
|
77
|
-
startPurchase(selectedPlanId, "manual");
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
const pkg = packages.find((p) => p.product.identifier === selectedPlanId);
|
|
81
|
-
if (pkg) {
|
|
82
|
-
await onPurchase(pkg);
|
|
83
|
-
}
|
|
84
|
-
} catch (error) {
|
|
85
|
-
if (__DEV__) {
|
|
86
|
-
console.error("[PaywallModal] Purchase failed:", error);
|
|
87
|
-
}
|
|
88
|
-
} finally {
|
|
89
|
-
setIsLocalProcessing(false);
|
|
90
|
-
endPurchase();
|
|
91
|
-
}
|
|
92
|
-
}, [selectedPlanId, packages, onPurchase, startPurchase, endPurchase]);
|
|
93
|
-
|
|
94
|
-
const handleRestore = useCallback(async () => {
|
|
95
|
-
if (!onRestore || isProcessing) return;
|
|
96
|
-
|
|
97
|
-
setIsLocalProcessing(true);
|
|
98
|
-
try {
|
|
99
|
-
await onRestore();
|
|
100
|
-
} finally {
|
|
101
|
-
setIsLocalProcessing(false);
|
|
70
|
+
resetState();
|
|
102
71
|
}
|
|
103
|
-
}, [
|
|
72
|
+
}, [visible, resetState]);
|
|
104
73
|
|
|
105
74
|
const handleLegalUrl = useCallback(async (url: string | undefined) => {
|
|
106
75
|
if (!url) return;
|
|
@@ -110,11 +79,18 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
|
110
79
|
return (
|
|
111
80
|
<BaseModal visible={visible} onClose={onClose} contentStyle={styles.modalContent}>
|
|
112
81
|
<View style={[styles.container, { backgroundColor: tokens.colors.surface }]}>
|
|
113
|
-
<TouchableOpacity
|
|
82
|
+
<TouchableOpacity
|
|
83
|
+
onPress={onClose}
|
|
84
|
+
style={[
|
|
85
|
+
styles.closeBtn,
|
|
86
|
+
{ backgroundColor: tokens.colors.surfaceSecondary, top: Math.max(insets.top, 12) }
|
|
87
|
+
]}
|
|
88
|
+
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
89
|
+
>
|
|
114
90
|
<AtomicIcon name="close-outline" size="md" customColor={tokens.colors.textPrimary} />
|
|
115
91
|
</TouchableOpacity>
|
|
116
92
|
|
|
117
|
-
<
|
|
93
|
+
<ScreenLayout scrollable={true} edges={["bottom"]} backgroundColor="transparent" contentContainerStyle={styles.scroll}>
|
|
118
94
|
{heroImage && (
|
|
119
95
|
<View style={styles.heroContainer}>
|
|
120
96
|
<Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={0} />
|
|
@@ -171,7 +147,7 @@ export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
|
|
|
171
147
|
</TouchableOpacity>
|
|
172
148
|
|
|
173
149
|
<PaywallFooter translations={translations} legalUrls={legalUrls} isProcessing={isProcessing} onRestore={onRestore ? handleRestore : undefined} onLegalClick={handleLegalUrl} />
|
|
174
|
-
</
|
|
150
|
+
</ScreenLayout>
|
|
175
151
|
</View>
|
|
176
152
|
</BaseModal>
|
|
177
153
|
);
|
|
@@ -1,55 +1,102 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* usePaywallActions Hook
|
|
3
|
+
* Encapsulates purchase and restore flow for the paywall.
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useCallback } from "react";
|
|
2
6
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
3
|
-
import {
|
|
4
|
-
import { useAuthAwarePurchase } from "../../subscription/presentation/useAuthAwarePurchase";
|
|
7
|
+
import { usePurchaseLoadingStore } from "../../subscription/presentation/stores";
|
|
5
8
|
import type { PurchaseSource } from "../../subscription/core/SubscriptionConstants";
|
|
6
9
|
|
|
7
|
-
interface
|
|
10
|
+
export interface UsePaywallActionsParams {
|
|
11
|
+
packages?: PurchasesPackage[];
|
|
12
|
+
onPurchase?: (pkg: PurchasesPackage) => Promise<void | boolean>;
|
|
13
|
+
onRestore?: () => Promise<void | boolean>;
|
|
8
14
|
source?: PurchaseSource;
|
|
9
15
|
onPurchaseSuccess?: () => void;
|
|
10
|
-
onPurchaseError?: (error: string) => void;
|
|
16
|
+
onPurchaseError?: (error: Error | string) => void;
|
|
11
17
|
onAuthRequired?: () => void;
|
|
12
|
-
onClose
|
|
18
|
+
onClose?: () => void;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
|
-
export
|
|
16
|
-
|
|
21
|
+
export function usePaywallActions({
|
|
22
|
+
packages = [],
|
|
23
|
+
onPurchase,
|
|
24
|
+
onRestore,
|
|
17
25
|
onPurchaseSuccess,
|
|
18
26
|
onPurchaseError,
|
|
19
|
-
onAuthRequired
|
|
27
|
+
onAuthRequired,
|
|
20
28
|
onClose,
|
|
21
|
-
}:
|
|
22
|
-
const
|
|
23
|
-
const
|
|
29
|
+
}: UsePaywallActionsParams) {
|
|
30
|
+
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
|
31
|
+
const [isLocalProcessing, setIsLocalProcessing] = useState(false);
|
|
32
|
+
|
|
33
|
+
const { startPurchase, endPurchase } = usePurchaseLoadingStore();
|
|
34
|
+
const isGlobalPurchasing = usePurchaseLoadingStore((state) => state.isPurchasing);
|
|
35
|
+
|
|
36
|
+
const isProcessing = isLocalProcessing || isGlobalPurchasing;
|
|
37
|
+
|
|
38
|
+
const handlePurchase = useCallback(async () => {
|
|
39
|
+
// If no plan selected, use the first available one as fallback or return
|
|
40
|
+
const planId = selectedPlanId || (packages.length > 0 ? packages[0]?.product.identifier : null);
|
|
41
|
+
|
|
42
|
+
if (!planId || !onPurchase || isProcessing) {
|
|
43
|
+
if (!planId && onAuthRequired) onAuthRequired();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setIsLocalProcessing(true);
|
|
48
|
+
// Map PurchaseSource to store's expected "manual" | "auto-execution"
|
|
49
|
+
startPurchase(planId, "manual");
|
|
24
50
|
|
|
25
|
-
const handlePurchase = useCallback(async (pkg: PurchasesPackage) => {
|
|
26
51
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
52
|
+
const pkg = packages.find((p) => p.product.identifier === planId);
|
|
53
|
+
if (pkg) {
|
|
54
|
+
const success = await onPurchase(pkg);
|
|
55
|
+
if (success !== false) {
|
|
56
|
+
onPurchaseSuccess?.();
|
|
57
|
+
onClose?.(); // Close on success if provided
|
|
58
|
+
}
|
|
29
59
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
62
|
+
onPurchaseError?.(err);
|
|
63
|
+
if (__DEV__) {
|
|
64
|
+
console.error("[usePaywallActions] Purchase failed:", err);
|
|
34
65
|
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
66
|
+
} finally {
|
|
67
|
+
setIsLocalProcessing(false);
|
|
68
|
+
endPurchase();
|
|
38
69
|
}
|
|
39
|
-
}, [
|
|
70
|
+
}, [selectedPlanId, packages, onPurchase, isProcessing, startPurchase, endPurchase, onPurchaseSuccess, onPurchaseError, onAuthRequired, onClose]);
|
|
40
71
|
|
|
41
72
|
const handleRestore = useCallback(async () => {
|
|
73
|
+
if (!onRestore || isProcessing) return;
|
|
74
|
+
|
|
75
|
+
setIsLocalProcessing(true);
|
|
42
76
|
try {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
77
|
+
const success = await onRestore();
|
|
78
|
+
if (success !== false) {
|
|
45
79
|
onPurchaseSuccess?.();
|
|
46
|
-
onClose();
|
|
47
80
|
}
|
|
48
|
-
} catch (
|
|
49
|
-
const
|
|
50
|
-
onPurchaseError?.(
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
83
|
+
onPurchaseError?.(err);
|
|
84
|
+
} finally {
|
|
85
|
+
setIsLocalProcessing(false);
|
|
51
86
|
}
|
|
52
|
-
}, [
|
|
87
|
+
}, [onRestore, isProcessing, onPurchaseSuccess, onPurchaseError]);
|
|
53
88
|
|
|
54
|
-
|
|
55
|
-
|
|
89
|
+
const resetState = useCallback(() => {
|
|
90
|
+
setSelectedPlanId(null);
|
|
91
|
+
setIsLocalProcessing(false);
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
selectedPlanId,
|
|
96
|
+
setSelectedPlanId,
|
|
97
|
+
isProcessing,
|
|
98
|
+
handlePurchase,
|
|
99
|
+
handleRestore,
|
|
100
|
+
resetState,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { Platform } from "react-native";
|
|
10
|
-
import { configureCreditsRepository } from "../../credits/infrastructure/
|
|
10
|
+
import { configureCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
11
11
|
import { SubscriptionManager } from "../infrastructure/managers/SubscriptionManager";
|
|
12
12
|
import { configureAuthProvider } from "../presentation/useAuthAwarePurchase";
|
|
13
13
|
import { SubscriptionSyncService } from "./SubscriptionSyncService";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CustomerInfo } from "react-native-purchases";
|
|
2
2
|
import type { RevenueCatData } from "../core/RevenueCatData";
|
|
3
3
|
import { type PeriodType, type PurchaseSource, PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
|
|
4
|
-
import { getCreditsRepository } from "../../credits/infrastructure/
|
|
4
|
+
import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
5
5
|
import { extractRevenueCatData } from "./SubscriptionSyncUtils";
|
|
6
6
|
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
7
7
|
|
|
@@ -69,32 +69,56 @@ export class SubscriptionSyncService {
|
|
|
69
69
|
periodType?: PeriodType
|
|
70
70
|
) {
|
|
71
71
|
try {
|
|
72
|
+
// Handle subscription expiration explicitly
|
|
72
73
|
if (!isPremium && productId) {
|
|
73
74
|
await getCreditsRepository().syncExpiredStatus(userId);
|
|
74
75
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
75
76
|
return;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
// If
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
79
|
+
// If not premium and no product, this is a freemium user.
|
|
80
|
+
// We only want to run initializeCredits for them if it's their first time,
|
|
81
|
+
// which initializeCredits handles, but we should avoid doing it on every sync.
|
|
82
|
+
if (!isPremium && !productId) {
|
|
83
|
+
// Option 1: Just skip if they are already known non-premium (handled by repository check)
|
|
84
|
+
// For now, let's just use a more stable sync ID to allow the repository to skip if possible
|
|
85
|
+
const stableSyncId = `init_sync_${userId}`;
|
|
86
|
+
|
|
87
|
+
await getCreditsRepository().initializeCredits(
|
|
88
|
+
userId,
|
|
89
|
+
stableSyncId,
|
|
90
|
+
'no_subscription',
|
|
91
|
+
PURCHASE_SOURCE.SETTINGS,
|
|
92
|
+
{
|
|
93
|
+
isPremium: false,
|
|
94
|
+
expirationDate: null,
|
|
95
|
+
willRenew: false,
|
|
96
|
+
periodType: null,
|
|
97
|
+
originalTransactionId: null
|
|
98
|
+
},
|
|
99
|
+
PURCHASE_TYPE.INITIAL
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
82
105
|
|
|
106
|
+
// Standard status sync for premium users
|
|
83
107
|
const revenueCatData: RevenueCatData = {
|
|
84
108
|
expirationDate: expiresAt ?? null,
|
|
85
109
|
willRenew: willRenew ?? false,
|
|
86
110
|
isPremium,
|
|
87
|
-
periodType: periodType ?? null,
|
|
88
|
-
originalTransactionId: null
|
|
111
|
+
periodType: periodType ?? null,
|
|
112
|
+
originalTransactionId: null
|
|
89
113
|
};
|
|
90
114
|
|
|
91
115
|
await getCreditsRepository().initializeCredits(
|
|
92
116
|
userId,
|
|
93
117
|
`status_sync_${Date.now()}`,
|
|
94
|
-
|
|
118
|
+
productId ?? 'no_subscription',
|
|
95
119
|
PURCHASE_SOURCE.SETTINGS,
|
|
96
120
|
revenueCatData,
|
|
97
|
-
PURCHASE_TYPE.INITIAL
|
|
121
|
+
PURCHASE_TYPE.INITIAL
|
|
98
122
|
);
|
|
99
123
|
|
|
100
124
|
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
@@ -44,7 +44,7 @@ export class PackageHandler {
|
|
|
44
44
|
|
|
45
45
|
return packages;
|
|
46
46
|
} catch (error) {
|
|
47
|
-
// Re-throw with more
|
|
47
|
+
// Re-throw with more detail
|
|
48
48
|
throw new Error(
|
|
49
49
|
`Failed to fetch subscription packages. ${
|
|
50
50
|
error instanceof Error ? error.message : "Unknown error"
|
|
@@ -8,8 +8,8 @@ import React, { useMemo } from "react";
|
|
|
8
8
|
import { StyleSheet, View } from "react-native";
|
|
9
9
|
import {
|
|
10
10
|
useAppDesignTokens,
|
|
11
|
-
ScreenLayout,
|
|
12
11
|
} from "@umituz/react-native-design-system";
|
|
12
|
+
import { ScreenLayout } from "../../../../shared/presentation";
|
|
13
13
|
import { SubscriptionHeader } from "./components/SubscriptionHeader";
|
|
14
14
|
import { CreditsList, type CreditItem } from "./components/CreditsList";
|
|
15
15
|
import { UpgradePrompt, type Benefit } from "./components/UpgradePrompt";
|
|
@@ -30,7 +30,7 @@ const setPaywallState = (visible: boolean, source?: PurchaseSource): void => {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Direct paywall control for non-React
|
|
33
|
+
* Direct paywall control for non-React services (e.g., appInitializer)
|
|
34
34
|
*/
|
|
35
35
|
export const paywallControl = {
|
|
36
36
|
open: (source?: PurchaseSource) => setPaywallState(true, source),
|
|
@@ -119,11 +119,5 @@ export {
|
|
|
119
119
|
export {
|
|
120
120
|
WalletScreen,
|
|
121
121
|
type WalletScreenProps,
|
|
122
|
-
type WalletScreenConfig,
|
|
123
122
|
type WalletScreenTranslations,
|
|
124
123
|
} from "./presentation/screens/WalletScreen";
|
|
125
|
-
|
|
126
|
-
export {
|
|
127
|
-
WalletScreenContainer,
|
|
128
|
-
type WalletScreenContainerProps,
|
|
129
|
-
} from "./presentation/screens/WalletScreenContainer";
|
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
import React from "react";
|
|
10
10
|
import { View, StyleSheet, TouchableOpacity } from "react-native";
|
|
11
11
|
import {
|
|
12
|
-
useSafeAreaInsets,
|
|
13
12
|
useAppDesignTokens,
|
|
14
13
|
AtomicText,
|
|
15
14
|
AtomicIcon,
|
|
16
15
|
AtomicSpinner,
|
|
17
|
-
ScreenLayout,
|
|
18
16
|
} from "@umituz/react-native-design-system";
|
|
17
|
+
import { ScreenLayout } from "../../../../shared/presentation";
|
|
18
|
+
import { useNavigation } from "@react-navigation/native";
|
|
19
|
+
import { useWallet } from "../hooks/useWallet";
|
|
20
|
+
import { getWalletConfig } from "../../infrastructure/config/walletConfig";
|
|
19
21
|
import {
|
|
20
22
|
BalanceCard,
|
|
21
23
|
type BalanceCardTranslations,
|
|
@@ -24,7 +26,6 @@ import {
|
|
|
24
26
|
TransactionList,
|
|
25
27
|
type TransactionListTranslations,
|
|
26
28
|
} from "../components/TransactionList";
|
|
27
|
-
import type { CreditLog } from "../../domain/types/transaction.types";
|
|
28
29
|
|
|
29
30
|
export interface WalletScreenTranslations
|
|
30
31
|
extends BalanceCardTranslations,
|
|
@@ -32,58 +33,72 @@ export interface WalletScreenTranslations
|
|
|
32
33
|
screenTitle: string;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
export interface
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
transactionsLoading: boolean;
|
|
40
|
-
translations: WalletScreenTranslations;
|
|
36
|
+
export interface WalletScreenProps {
|
|
37
|
+
/** Translations (overrides global config) */
|
|
38
|
+
translations?: WalletScreenTranslations;
|
|
39
|
+
/** Override onBack handler (default: navigation.goBack) */
|
|
41
40
|
onBack?: () => void;
|
|
41
|
+
/** Custom date formatter */
|
|
42
42
|
dateFormatter?: (timestamp: number) => string;
|
|
43
|
-
|
|
44
|
-
balanceIconName?: string;
|
|
43
|
+
/** Footer component */
|
|
45
44
|
footer?: React.ReactNode;
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
export
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
export const WalletScreen: React.FC<WalletScreenProps> = ({
|
|
48
|
+
translations,
|
|
49
|
+
onBack,
|
|
50
|
+
dateFormatter,
|
|
51
|
+
footer,
|
|
52
|
+
}) => {
|
|
53
53
|
const tokens = useAppDesignTokens();
|
|
54
|
-
const
|
|
54
|
+
const navigation = useNavigation();
|
|
55
|
+
const config = getWalletConfig();
|
|
56
|
+
|
|
57
|
+
const {
|
|
58
|
+
balance,
|
|
59
|
+
balanceLoading,
|
|
60
|
+
transactions,
|
|
61
|
+
transactionsLoading,
|
|
62
|
+
} = useWallet({
|
|
63
|
+
transactionConfig: {
|
|
64
|
+
collectionName: config.transactionCollection,
|
|
65
|
+
useUserSubcollection: config.useUserSubcollection,
|
|
66
|
+
},
|
|
67
|
+
transactionLimit: config.transactionLimit,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const activeTranslations = translations ?? config.translations;
|
|
71
|
+
const handleBack = onBack ?? (() => navigation.goBack());
|
|
55
72
|
|
|
56
73
|
const renderHeader = () => (
|
|
57
|
-
<View style={[styles.header, { paddingTop:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
</TouchableOpacity>
|
|
70
|
-
)}
|
|
74
|
+
<View style={[styles.header, { paddingTop: 12 }]}>
|
|
75
|
+
<TouchableOpacity
|
|
76
|
+
onPress={handleBack}
|
|
77
|
+
style={styles.backButton}
|
|
78
|
+
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
79
|
+
>
|
|
80
|
+
<AtomicIcon
|
|
81
|
+
name="arrow-left"
|
|
82
|
+
size="lg"
|
|
83
|
+
customColor={tokens.colors.textPrimary}
|
|
84
|
+
/>
|
|
85
|
+
</TouchableOpacity>
|
|
71
86
|
<AtomicText
|
|
72
87
|
type="titleLarge"
|
|
73
88
|
style={{ color: tokens.colors.textPrimary, fontWeight: "700" }}
|
|
74
89
|
>
|
|
75
|
-
{
|
|
90
|
+
{activeTranslations.screenTitle}
|
|
76
91
|
</AtomicText>
|
|
77
92
|
</View>
|
|
78
93
|
);
|
|
79
94
|
|
|
80
95
|
const renderBalance = () => {
|
|
81
|
-
if (
|
|
96
|
+
if (balanceLoading) {
|
|
82
97
|
return (
|
|
83
98
|
<AtomicSpinner
|
|
84
99
|
size="xl"
|
|
85
100
|
color="primary"
|
|
86
|
-
text={
|
|
101
|
+
text={activeTranslations.loading}
|
|
87
102
|
fullContainer
|
|
88
103
|
style={styles.loadingContainer}
|
|
89
104
|
/>
|
|
@@ -92,8 +107,8 @@ export const WalletScreen: React.FC<WalletScreenProps> = ({ config }) => {
|
|
|
92
107
|
|
|
93
108
|
return (
|
|
94
109
|
<BalanceCard
|
|
95
|
-
balance={
|
|
96
|
-
translations={
|
|
110
|
+
balance={balance}
|
|
111
|
+
translations={activeTranslations}
|
|
97
112
|
iconName={config.balanceIconName}
|
|
98
113
|
/>
|
|
99
114
|
);
|
|
@@ -102,19 +117,18 @@ export const WalletScreen: React.FC<WalletScreenProps> = ({ config }) => {
|
|
|
102
117
|
return (
|
|
103
118
|
<ScreenLayout
|
|
104
119
|
scrollable={true}
|
|
105
|
-
edges={["bottom"]}
|
|
120
|
+
edges={["top", "bottom"]}
|
|
106
121
|
backgroundColor={tokens.colors.backgroundPrimary}
|
|
107
122
|
contentContainerStyle={styles.content}
|
|
108
|
-
footer={
|
|
123
|
+
footer={footer}
|
|
109
124
|
>
|
|
110
125
|
{renderHeader()}
|
|
111
126
|
{renderBalance()}
|
|
112
127
|
<TransactionList
|
|
113
|
-
transactions={
|
|
114
|
-
loading={
|
|
115
|
-
translations={
|
|
116
|
-
|
|
117
|
-
dateFormatter={config.dateFormatter}
|
|
128
|
+
transactions={transactions}
|
|
129
|
+
loading={transactionsLoading}
|
|
130
|
+
translations={activeTranslations}
|
|
131
|
+
dateFormatter={dateFormatter}
|
|
118
132
|
/>
|
|
119
133
|
</ScreenLayout>
|
|
120
134
|
);
|
package/src/index.ts
CHANGED
|
@@ -48,7 +48,7 @@ export {
|
|
|
48
48
|
getCreditsRepository,
|
|
49
49
|
getCreditsConfig,
|
|
50
50
|
isCreditsRepositoryConfigured
|
|
51
|
-
} from "./domains/credits/infrastructure/
|
|
51
|
+
} from "./domains/credits/infrastructure/CreditsRepositoryManager";
|
|
52
52
|
|
|
53
53
|
// Presentation Layer - Hooks (Point to the bridge)
|
|
54
54
|
export * from "./presentation/hooks";
|
|
@@ -9,7 +9,7 @@ export interface ActivationHandlerConfig {
|
|
|
9
9
|
userId: string,
|
|
10
10
|
status: SubscriptionStatus
|
|
11
11
|
) => Promise<void> | void;
|
|
12
|
-
onError?: (error: Error,
|
|
12
|
+
onError?: (error: Error, operation: string) => Promise<void> | void;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -85,15 +85,15 @@ async function notifyStatusChange(
|
|
|
85
85
|
* Safe error handler - wraps error callbacks to prevent secondary failures
|
|
86
86
|
*/
|
|
87
87
|
export async function safeHandleError(
|
|
88
|
-
onError: ((error: Error,
|
|
88
|
+
onError: ((error: Error, operation: string) => Promise<void> | void) | undefined,
|
|
89
89
|
error: unknown,
|
|
90
|
-
|
|
90
|
+
operation: string
|
|
91
91
|
): Promise<void> {
|
|
92
92
|
if (!onError) return;
|
|
93
93
|
|
|
94
94
|
try {
|
|
95
95
|
const err = error instanceof Error ? error : new Error("Unknown error");
|
|
96
|
-
await onError(err,
|
|
96
|
+
await onError(err, operation);
|
|
97
97
|
} catch {
|
|
98
98
|
// Ignore callback errors
|
|
99
99
|
}
|
|
@@ -102,7 +102,7 @@ export async function safeHandleError(
|
|
|
102
102
|
async function handleError(
|
|
103
103
|
config: ActivationHandlerConfig,
|
|
104
104
|
error: unknown,
|
|
105
|
-
|
|
105
|
+
operation: string
|
|
106
106
|
): Promise<void> {
|
|
107
|
-
await safeHandleError(config.onError, error, `ActivationHandler.${
|
|
107
|
+
await safeHandleError(config.onError, error, `ActivationHandler.${operation}`);
|
|
108
108
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./layouts/ScreenLayout";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen Layout Component
|
|
3
|
+
* Centralized layout with safe area handling and consistent styling.
|
|
4
|
+
*/
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { StyleSheet, View, ScrollView, type ViewStyle, type ColorValue } from "react-native";
|
|
7
|
+
import { useSafeAreaInsets, type Edge } from "react-native-safe-area-context";
|
|
8
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system";
|
|
9
|
+
|
|
10
|
+
export interface ScreenLayoutProps {
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
scrollable?: boolean;
|
|
13
|
+
edges?: Edge[];
|
|
14
|
+
backgroundColor?: ColorValue;
|
|
15
|
+
contentContainerStyle?: ViewStyle;
|
|
16
|
+
footer?: React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const ScreenLayout: React.FC<ScreenLayoutProps> = ({
|
|
20
|
+
children,
|
|
21
|
+
scrollable = false,
|
|
22
|
+
edges = ["top", "bottom", "left", "right"],
|
|
23
|
+
backgroundColor,
|
|
24
|
+
contentContainerStyle,
|
|
25
|
+
footer,
|
|
26
|
+
}) => {
|
|
27
|
+
const tokens = useAppDesignTokens();
|
|
28
|
+
const insets = useSafeAreaInsets();
|
|
29
|
+
|
|
30
|
+
const containerStyle = [
|
|
31
|
+
styles.container,
|
|
32
|
+
{
|
|
33
|
+
backgroundColor: backgroundColor ?? tokens.colors.backgroundPrimary,
|
|
34
|
+
paddingTop: edges.includes("top") ? insets.top : 0,
|
|
35
|
+
paddingBottom: edges.includes("bottom") ? insets.bottom : 0,
|
|
36
|
+
paddingLeft: edges.includes("left") ? insets.left : 0,
|
|
37
|
+
paddingRight: edges.includes("right") ? insets.right : 0,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const content = (
|
|
42
|
+
<>
|
|
43
|
+
<View style={[styles.flex, contentContainerStyle]}>
|
|
44
|
+
{children}
|
|
45
|
+
</View>
|
|
46
|
+
{footer}
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (scrollable) {
|
|
51
|
+
return (
|
|
52
|
+
<ScrollView
|
|
53
|
+
style={containerStyle}
|
|
54
|
+
contentContainerStyle={[styles.scrollContent]}
|
|
55
|
+
showsVerticalScrollIndicator={false}
|
|
56
|
+
>
|
|
57
|
+
{content}
|
|
58
|
+
</ScrollView>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<View style={containerStyle}>
|
|
64
|
+
{content}
|
|
65
|
+
</View>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const styles = StyleSheet.create({
|
|
70
|
+
container: {
|
|
71
|
+
flex: 1,
|
|
72
|
+
},
|
|
73
|
+
scrollContent: {
|
|
74
|
+
flexGrow: 1,
|
|
75
|
+
},
|
|
76
|
+
flex: {
|
|
77
|
+
flex: 1,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
8
8
|
|
|
9
|
-
export interface
|
|
9
|
+
export interface LogMetadata {
|
|
10
10
|
[key: string]: unknown;
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -57,54 +57,54 @@ class SubscriptionLogger {
|
|
|
57
57
|
return `[${category}] ${message}`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
debug(category: LogCategory, message: string,
|
|
60
|
+
debug(category: LogCategory, message: string, metadata?: LogMetadata): void {
|
|
61
61
|
if (!this.shouldLog(category)) return;
|
|
62
|
-
console.log(this.formatMessage(category, message),
|
|
62
|
+
console.log(this.formatMessage(category, message), metadata ?? "");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
info(category: LogCategory, message: string,
|
|
65
|
+
info(category: LogCategory, message: string, metadata?: LogMetadata): void {
|
|
66
66
|
if (!this.shouldLog(category)) return;
|
|
67
|
-
console.log(this.formatMessage(category, message),
|
|
67
|
+
console.log(this.formatMessage(category, message), metadata ?? "");
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
warn(category: LogCategory, message: string,
|
|
70
|
+
warn(category: LogCategory, message: string, metadata?: LogMetadata): void {
|
|
71
71
|
if (!this.shouldLog(category)) return;
|
|
72
|
-
console.warn(this.formatMessage(category, message),
|
|
72
|
+
console.warn(this.formatMessage(category, message), metadata ?? "");
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
error(category: LogCategory, message: string, error?: unknown,
|
|
75
|
+
error(category: LogCategory, message: string, error?: unknown, metadata?: LogMetadata): void {
|
|
76
76
|
if (!this.shouldLog(category)) return;
|
|
77
|
-
console.error(this.formatMessage(category, message), { error, ...
|
|
77
|
+
console.error(this.formatMessage(category, message), { error, ...metadata });
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** Log purchase flow events */
|
|
81
|
-
purchase(message: string,
|
|
82
|
-
this.debug(LOG_CATEGORY.PURCHASE, message,
|
|
81
|
+
purchase(message: string, metadata?: LogMetadata): void {
|
|
82
|
+
this.debug(LOG_CATEGORY.PURCHASE, message, metadata);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/** Log credits-related events */
|
|
86
|
-
credits(message: string,
|
|
87
|
-
this.debug(LOG_CATEGORY.CREDITS, message,
|
|
86
|
+
credits(message: string, metadata?: LogMetadata): void {
|
|
87
|
+
this.debug(LOG_CATEGORY.CREDITS, message, metadata);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/** Log trial-related events */
|
|
91
|
-
trial(message: string,
|
|
92
|
-
this.debug(LOG_CATEGORY.TRIAL, message,
|
|
91
|
+
trial(message: string, metadata?: LogMetadata): void {
|
|
92
|
+
this.debug(LOG_CATEGORY.TRIAL, message, metadata);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/** Log RevenueCat SDK events */
|
|
96
|
-
revenueCat(message: string,
|
|
97
|
-
this.debug(LOG_CATEGORY.REVENUECAT, message,
|
|
96
|
+
revenueCat(message: string, metadata?: LogMetadata): void {
|
|
97
|
+
this.debug(LOG_CATEGORY.REVENUECAT, message, metadata);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/** Log feature gate events */
|
|
101
|
-
featureGate(message: string,
|
|
102
|
-
this.debug(LOG_CATEGORY.FEATURE_GATE, message,
|
|
101
|
+
featureGate(message: string, metadata?: LogMetadata): void {
|
|
102
|
+
this.debug(LOG_CATEGORY.FEATURE_GATE, message, metadata);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/** Log sync operations */
|
|
106
|
-
sync(message: string,
|
|
107
|
-
this.debug(LOG_CATEGORY.SYNC, message,
|
|
106
|
+
sync(message: string, metadata?: LogMetadata): void {
|
|
107
|
+
this.debug(LOG_CATEGORY.SYNC, message, metadata);
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
@@ -11,5 +11,5 @@ export interface SubscriptionConfig {
|
|
|
11
11
|
entitlements?: string[];
|
|
12
12
|
debugMode?: boolean;
|
|
13
13
|
onStatusChanged?: (userId: string, status: SubscriptionStatus) => Promise<void> | void;
|
|
14
|
-
onError?: (error: Error,
|
|
14
|
+
onError?: (error: Error, operation: string) => Promise<void> | void;
|
|
15
15
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App and Platform Utilities
|
|
3
|
+
*/
|
|
4
|
+
import { Platform } from "react-native";
|
|
5
|
+
import Constants from "expo-constants";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Gets the current app version from Expo constants
|
|
9
|
+
*/
|
|
10
|
+
export function getAppVersion(): string {
|
|
11
|
+
const version = Constants.expoConfig?.version ?? Constants.manifest2?.extra?.expoClient?.version;
|
|
12
|
+
if (!version) {
|
|
13
|
+
throw new Error("appVersion is required in expoConfig");
|
|
14
|
+
}
|
|
15
|
+
return version;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validates if the current platform is supported
|
|
20
|
+
*/
|
|
21
|
+
export function validatePlatform(): "ios" | "android" {
|
|
22
|
+
const platform = Platform.OS;
|
|
23
|
+
if (platform !== "ios" && platform !== "android") {
|
|
24
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
25
|
+
}
|
|
26
|
+
return platform;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Checks if the app is currently in development mode
|
|
31
|
+
*/
|
|
32
|
+
export function isDev(): boolean {
|
|
33
|
+
return __DEV__;
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checks if a date is in the past
|
|
7
|
+
*/
|
|
8
|
+
export function isPast(date: Date | string | number): boolean {
|
|
9
|
+
const d = new Date(date);
|
|
10
|
+
return d.getTime() < Date.now();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Converts various timestamp formats to a safe Date object
|
|
15
|
+
*/
|
|
16
|
+
export function toSafeDate(ts: any): Date | null {
|
|
17
|
+
if (!ts) return null;
|
|
18
|
+
if (typeof ts.toDate === "function") return ts.toDate();
|
|
19
|
+
if (ts instanceof Date) return ts;
|
|
20
|
+
if (typeof ts === "string" || typeof ts === "number") {
|
|
21
|
+
const d = new Date(ts);
|
|
22
|
+
return isNaN(d.getTime()) ? null : d;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Formats a date to ISO string safely
|
|
29
|
+
*/
|
|
30
|
+
export function formatISO(date: Date | null): string | null {
|
|
31
|
+
return date ? date.toISOString() : null;
|
|
32
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function detectPackageType(productIdentifier: string): SubscriptionPackag
|
|
|
54
54
|
return PACKAGE_TYPE.LIFETIME;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
if (__DEV__ && productIdentifier !== '
|
|
57
|
+
if (__DEV__ && productIdentifier !== 'no_subscription') {
|
|
58
58
|
console.warn("[PackageTypeDetector] Unknown package type for:", productIdentifier);
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wallet Screen Container
|
|
3
|
-
*
|
|
4
|
-
* Self-contained wallet screen.
|
|
5
|
-
* Uses global config from configureWallet() - no props needed!
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* 1. Call configureWallet() during app init
|
|
9
|
-
* 2. Use WalletScreenContainer directly in navigation
|
|
10
|
-
*
|
|
11
|
-
* ```tsx
|
|
12
|
-
* // In init
|
|
13
|
-
* configureWallet({ translations: myTranslations });
|
|
14
|
-
*
|
|
15
|
-
* // In navigation
|
|
16
|
-
* <Stack.Screen name="Wallet" component={WalletScreenContainer} />
|
|
17
|
-
* ```
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import React, { useMemo } from "react";
|
|
21
|
-
import { useNavigation } from "@react-navigation/native";
|
|
22
|
-
import { WalletScreen, type WalletScreenTranslations } from "./WalletScreen";
|
|
23
|
-
import { useWallet } from "../hooks/useWallet";
|
|
24
|
-
import { getWalletConfig } from "../../infrastructure/config/walletConfig";
|
|
25
|
-
|
|
26
|
-
export interface WalletScreenContainerProps {
|
|
27
|
-
/** Translations (overrides global config) */
|
|
28
|
-
translations?: WalletScreenTranslations;
|
|
29
|
-
/** Override onBack handler (default: navigation.goBack) */
|
|
30
|
-
onBack?: () => void;
|
|
31
|
-
/** Custom date formatter */
|
|
32
|
-
dateFormatter?: (timestamp: number) => string;
|
|
33
|
-
/** Footer component */
|
|
34
|
-
footer?: React.ReactNode;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export const WalletScreenContainer: React.FC<WalletScreenContainerProps> = ({
|
|
38
|
-
translations,
|
|
39
|
-
onBack,
|
|
40
|
-
dateFormatter,
|
|
41
|
-
footer,
|
|
42
|
-
}) => {
|
|
43
|
-
const navigation = useNavigation();
|
|
44
|
-
const config = getWalletConfig();
|
|
45
|
-
|
|
46
|
-
const {
|
|
47
|
-
balance,
|
|
48
|
-
balanceLoading,
|
|
49
|
-
transactions,
|
|
50
|
-
transactionsLoading,
|
|
51
|
-
} = useWallet({
|
|
52
|
-
transactionConfig: {
|
|
53
|
-
collectionName: config.transactionCollection,
|
|
54
|
-
useUserSubcollection: config.useUserSubcollection,
|
|
55
|
-
},
|
|
56
|
-
transactionLimit: config.transactionLimit,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const screenConfig = useMemo(
|
|
60
|
-
() => ({
|
|
61
|
-
balance,
|
|
62
|
-
balanceLoading,
|
|
63
|
-
transactions,
|
|
64
|
-
transactionsLoading,
|
|
65
|
-
translations: translations ?? config.translations,
|
|
66
|
-
onBack: onBack ?? (() => navigation.goBack()),
|
|
67
|
-
dateFormatter,
|
|
68
|
-
balanceIconName: config.balanceIconName,
|
|
69
|
-
footer,
|
|
70
|
-
}),
|
|
71
|
-
[
|
|
72
|
-
balance,
|
|
73
|
-
balanceLoading,
|
|
74
|
-
transactions,
|
|
75
|
-
transactionsLoading,
|
|
76
|
-
translations,
|
|
77
|
-
config,
|
|
78
|
-
onBack,
|
|
79
|
-
navigation,
|
|
80
|
-
dateFormatter,
|
|
81
|
-
footer,
|
|
82
|
-
],
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
return <WalletScreen config={screenConfig} />;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
export default WalletScreenContainer;
|