@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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/domains/credits/application/CreditsInitializer.ts +25 -15
  3. package/src/domains/credits/application/credit-strategies/{CreditAllocationContext.ts → CreditAllocationOrchestrator.ts} +4 -4
  4. package/src/domains/credits/application/credit-strategies/SyncCreditStrategy.ts +1 -1
  5. package/src/domains/credits/core/CreditsMapper.ts +8 -13
  6. package/src/domains/credits/infrastructure/{CreditsRepositoryProvider.ts → CreditsRepositoryManager.ts} +2 -2
  7. package/src/domains/credits/presentation/useCredits.ts +1 -1
  8. package/src/domains/credits/presentation/useDeductCredit.ts +4 -4
  9. package/src/domains/paywall/components/PaywallContainer.types.ts +1 -1
  10. package/src/domains/paywall/components/PaywallModal.tsx +28 -52
  11. package/src/domains/paywall/hooks/usePaywallActions.ts +79 -32
  12. package/src/domains/subscription/application/SubscriptionInitializer.ts +1 -1
  13. package/src/domains/subscription/application/SubscriptionSyncService.ts +33 -9
  14. package/src/domains/subscription/infrastructure/handlers/PackageHandler.ts +1 -1
  15. package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +1 -1
  16. package/src/domains/subscription/presentation/usePaywallVisibility.ts +1 -1
  17. package/src/domains/wallet/index.ts +0 -6
  18. package/src/domains/wallet/presentation/screens/WalletScreen.tsx +57 -43
  19. package/src/index.ts +1 -1
  20. package/src/shared/application/ActivationHandler.ts +6 -6
  21. package/src/shared/presentation/index.ts +1 -0
  22. package/src/shared/presentation/layouts/ScreenLayout.tsx +79 -0
  23. package/src/shared/utils/Logger.ts +21 -21
  24. package/src/shared/utils/SubscriptionConfig.ts +1 -1
  25. package/src/utils/appUtils.ts +34 -0
  26. package/src/utils/dateUtils.ts +32 -0
  27. package/src/utils/index.ts +2 -0
  28. package/src/utils/packageTypeDetector.ts +1 -1
  29. 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.111",
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 { creditAllocationContext } from "./credit-strategies/CreditAllocationContext";
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: Platform.OS as any,
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 = Platform.OS;
72
- if (platform !== "ios" && platform !== "android") {
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 = new Date(metadata.expirationDate).getTime() < Date.now();
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 = creditAllocationContext.allocate({
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
- * Strategy Context to coordinate credit allocation logic using the Strategy Pattern.
7
+ * Orchestrator to coordinate credit allocation logic using the Strategy Pattern.
8
8
  */
9
- export class CreditAllocationContext {
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(`[CreditAllocationContext] Using strategy: ${strategy.constructor.name}`);
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 creditAllocationContext = new CreditAllocationContext();
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 && params.existingData?.isPremium === true && params.isSubscriptionActive;
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 safeDate = (ts: any): Date | null => {
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: safeDate(doc.purchasedAt) ?? new Date(),
23
+ purchasedAt: toSafeDate(doc.purchasedAt) ?? new Date(),
29
24
  expirationDate,
30
- lastUpdatedAt: safeDate(doc.lastUpdatedAt) ?? new Date(),
31
- lastPurchaseAt: safeDate(doc.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: safeDate(doc.trialStartDate),
43
- trialEndDate: safeDate(doc.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 Provider
2
+ * Credits Repository Manager
3
3
  * Module-level singleton for credits repository configuration
4
- * Replaces Context API with a simpler, testable approach
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/CreditsRepositoryProvider";
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/CreditsRepositoryProvider";
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, context) => {
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 && context?.previousCredits && !context.skippedOptimistic) {
72
- queryClient.setQueryData(creditsQueryKeys.user(userId), context.previousCredits);
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, { useState, useCallback, useEffect } from "react";
6
- import { View, ScrollView, TouchableOpacity, Linking, type ImageSourcePropType } from "react-native";
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 { usePurchaseLoadingStore, selectIsPurchasing } from "../../subscription/presentation/stores";
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 [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
51
- const [isLocalProcessing, setIsLocalProcessing] = useState(false);
52
-
53
- // Global purchase loading state (for auto-execution after auth)
54
- const isGlobalPurchasing = usePurchaseLoadingStore(selectIsPurchasing);
55
- const { startPurchase, endPurchase } = usePurchaseLoadingStore();
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
- setSelectedPlanId(null);
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
- }, [onRestore, isProcessing]);
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 onPress={onClose} style={[styles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary }]} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
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
- <ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
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
- </ScrollView>
150
+ </ScreenLayout>
175
151
  </View>
176
152
  </BaseModal>
177
153
  );
@@ -1,55 +1,102 @@
1
- import { useCallback } from "react";
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 { useRestorePurchase } from "../../subscription/infrastructure/hooks/useRestorePurchase";
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 UsePaywallActionsProps {
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: () => void;
18
+ onClose?: () => void;
13
19
  }
14
20
 
15
- export const usePaywallActions = ({
16
- source,
21
+ export function usePaywallActions({
22
+ packages = [],
23
+ onPurchase,
24
+ onRestore,
17
25
  onPurchaseSuccess,
18
26
  onPurchaseError,
19
- onAuthRequired: _onAuthRequired,
27
+ onAuthRequired,
20
28
  onClose,
21
- }: UsePaywallActionsProps) => {
22
- const { handlePurchase: authAwarePurchase } = useAuthAwarePurchase({ source });
23
- const { mutateAsync: restorePurchases } = useRestorePurchase();
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
- if (typeof __DEV__ !== "undefined" && __DEV__) {
28
- console.log("[PaywallActions] Purchase started:", pkg.product.identifier);
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
- const res = await authAwarePurchase(pkg, source);
31
- if (res) {
32
- onPurchaseSuccess?.();
33
- onClose();
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
- } catch (err: unknown) {
36
- const message = err instanceof Error ? err.message : String(err);
37
- onPurchaseError?.(message);
66
+ } finally {
67
+ setIsLocalProcessing(false);
68
+ endPurchase();
38
69
  }
39
- }, [authAwarePurchase, source, onClose, onPurchaseSuccess, onPurchaseError]);
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 res = await restorePurchases();
44
- if (res.success) {
77
+ const success = await onRestore();
78
+ if (success !== false) {
45
79
  onPurchaseSuccess?.();
46
- onClose();
47
80
  }
48
- } catch (err: unknown) {
49
- const message = err instanceof Error ? err.message : String(err);
50
- onPurchaseError?.(message);
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
- }, [restorePurchases, onClose, onPurchaseSuccess, onPurchaseError]);
87
+ }, [onRestore, isProcessing, onPurchaseSuccess, onPurchaseError]);
53
88
 
54
- return { handlePurchase, handleRestore };
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/CreditsRepositoryProvider";
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/CreditsRepositoryProvider";
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 productId is missing, we can't initialize credits fully,
79
- // but if isPremium is true, we should have it.
80
- // Fallback to 'unknown' if missing, but this might throw in CreditLimitCalculator.
81
- const validProductId = productId ?? 'unknown_product';
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, // Fix undefined vs null
88
- originalTransactionId: null // Initialize with null as we might not have it here
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
- validProductId,
118
+ productId ?? 'no_subscription',
95
119
  PURCHASE_SOURCE.SETTINGS,
96
120
  revenueCatData,
97
- PURCHASE_TYPE.INITIAL // Status sync treated as Initial or Update
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 context
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 contexts (e.g., appInitializer)
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 WalletScreenConfig {
36
- balance: number;
37
- balanceLoading: boolean;
38
- transactions: CreditLog[];
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
- maxTransactionHeight?: number;
44
- balanceIconName?: string;
43
+ /** Footer component */
45
44
  footer?: React.ReactNode;
46
45
  }
47
46
 
48
- export interface WalletScreenProps {
49
- config: WalletScreenConfig;
50
- }
51
-
52
- export const WalletScreen: React.FC<WalletScreenProps> = ({ config }) => {
47
+ export const WalletScreen: React.FC<WalletScreenProps> = ({
48
+ translations,
49
+ onBack,
50
+ dateFormatter,
51
+ footer,
52
+ }) => {
53
53
  const tokens = useAppDesignTokens();
54
- const insets = useSafeAreaInsets();
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: insets.top + 12 }]}>
58
- {config.onBack && (
59
- <TouchableOpacity
60
- onPress={config.onBack}
61
- style={styles.backButton}
62
- hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
63
- >
64
- <AtomicIcon
65
- name="arrow-left"
66
- size="lg"
67
- customColor={tokens.colors.textPrimary}
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
- {config.translations.screenTitle}
90
+ {activeTranslations.screenTitle}
76
91
  </AtomicText>
77
92
  </View>
78
93
  );
79
94
 
80
95
  const renderBalance = () => {
81
- if (config.balanceLoading) {
96
+ if (balanceLoading) {
82
97
  return (
83
98
  <AtomicSpinner
84
99
  size="xl"
85
100
  color="primary"
86
- text={config.translations.loading}
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={config.balance}
96
- translations={config.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={config.footer}
123
+ footer={footer}
109
124
  >
110
125
  {renderHeader()}
111
126
  {renderBalance()}
112
127
  <TransactionList
113
- transactions={config.transactions}
114
- loading={config.transactionsLoading}
115
- translations={config.translations}
116
- maxHeight={config.maxTransactionHeight}
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/CreditsRepositoryProvider";
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, context: string) => Promise<void> | void;
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, context: string) => Promise<void> | void) | undefined,
88
+ onError: ((error: Error, operation: string) => Promise<void> | void) | undefined,
89
89
  error: unknown,
90
- context: string
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, context);
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
- context: string
105
+ operation: string
106
106
  ): Promise<void> {
107
- await safeHandleError(config.onError, error, `ActivationHandler.${context}`);
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 LogContext {
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, context?: LogContext): void {
60
+ debug(category: LogCategory, message: string, metadata?: LogMetadata): void {
61
61
  if (!this.shouldLog(category)) return;
62
- console.log(this.formatMessage(category, message), context ?? "");
62
+ console.log(this.formatMessage(category, message), metadata ?? "");
63
63
  }
64
64
 
65
- info(category: LogCategory, message: string, context?: LogContext): void {
65
+ info(category: LogCategory, message: string, metadata?: LogMetadata): void {
66
66
  if (!this.shouldLog(category)) return;
67
- console.log(this.formatMessage(category, message), context ?? "");
67
+ console.log(this.formatMessage(category, message), metadata ?? "");
68
68
  }
69
69
 
70
- warn(category: LogCategory, message: string, context?: LogContext): void {
70
+ warn(category: LogCategory, message: string, metadata?: LogMetadata): void {
71
71
  if (!this.shouldLog(category)) return;
72
- console.warn(this.formatMessage(category, message), context ?? "");
72
+ console.warn(this.formatMessage(category, message), metadata ?? "");
73
73
  }
74
74
 
75
- error(category: LogCategory, message: string, error?: unknown, context?: LogContext): void {
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, ...context });
77
+ console.error(this.formatMessage(category, message), { error, ...metadata });
78
78
  }
79
79
 
80
80
  /** Log purchase flow events */
81
- purchase(message: string, context?: LogContext): void {
82
- this.debug(LOG_CATEGORY.PURCHASE, message, context);
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, context?: LogContext): void {
87
- this.debug(LOG_CATEGORY.CREDITS, message, context);
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, context?: LogContext): void {
92
- this.debug(LOG_CATEGORY.TRIAL, message, context);
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, context?: LogContext): void {
97
- this.debug(LOG_CATEGORY.REVENUECAT, message, context);
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, context?: LogContext): void {
102
- this.debug(LOG_CATEGORY.FEATURE_GATE, message, context);
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, context?: LogContext): void {
107
- this.debug(LOG_CATEGORY.SYNC, message, context);
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, context: string) => Promise<void> | void;
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
+ }
@@ -7,3 +7,5 @@ export * from "./priceUtils";
7
7
  export * from "./tierUtils";
8
8
  export * from "./types";
9
9
  export * from "./validation";
10
+ export * from "./dateUtils";
11
+ export * from "./appUtils";
@@ -54,7 +54,7 @@ export function detectPackageType(productIdentifier: string): SubscriptionPackag
54
54
  return PACKAGE_TYPE.LIFETIME;
55
55
  }
56
56
 
57
- if (__DEV__ && productIdentifier !== 'unknown_product') {
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;