@umituz/react-native-subscription 3.1.9 → 3.1.10
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/presentation/useCreditsRealTime.ts +21 -68
- package/src/domains/subscription/presentation/flowInitialState.ts +22 -0
- package/src/domains/subscription/presentation/flowTypes.ts +106 -0
- package/src/domains/subscription/presentation/usePremiumActions.ts +5 -6
- package/src/domains/subscription/presentation/useSubscriptionFlow.ts +25 -92
- package/src/domains/wallet/presentation/hooks/useTransactionHistory.ts +34 -60
- package/src/shared/presentation/hooks/useFirestoreRealTime.ts +214 -0
- package/src/shared/presentation/types/hookState.types.ts +97 -0
- package/src/shared/utils/errorUtils.ts +195 -0
- package/src/shared/utils/logger.ts +140 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.10",
|
|
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,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { DocumentReference } from "firebase/firestore";
|
|
3
|
+
import type { UserCreditsDocumentRead } from "../core/UserCreditsDocument";
|
|
4
4
|
import { getCreditsConfig } from "../infrastructure/CreditsRepositoryManager";
|
|
5
5
|
import { mapCreditsDocumentToEntity } from "../core/CreditsMapper";
|
|
6
|
-
import { requireFirestore, buildDocRef
|
|
7
|
-
import
|
|
6
|
+
import { requireFirestore, buildDocRef } from "../../../shared/infrastructure/firestore/collectionUtils";
|
|
7
|
+
import { useFirestoreDocumentRealTime } from "../../../shared/presentation/hooks/useFirestoreRealTime";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Real-time sync for credits using Firestore onSnapshot.
|
|
@@ -20,76 +20,29 @@ import type { UserCreditsDocumentRead } from "../core/UserCreditsDocument";
|
|
|
20
20
|
* @returns Credits state and loading status
|
|
21
21
|
*/
|
|
22
22
|
export function useCreditsRealTime(userId: string | null | undefined) {
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
// Reset state when userId changes
|
|
29
|
-
if (!userId) {
|
|
30
|
-
setCredits(null);
|
|
31
|
-
setIsLoading(false);
|
|
32
|
-
setError(null);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
setIsLoading(true);
|
|
37
|
-
setError(null);
|
|
23
|
+
// Build document reference
|
|
24
|
+
const docRef = useMemo(() => {
|
|
25
|
+
if (!userId) return null;
|
|
38
26
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// Build doc ref using same logic as repository
|
|
44
|
-
const collectionConfig: CollectionConfig = {
|
|
45
|
-
collectionName: config.collectionName,
|
|
46
|
-
useUserSubcollection: config.useUserSubcollection,
|
|
47
|
-
};
|
|
48
|
-
const docRef = buildDocRef(db, userId, "balance", collectionConfig);
|
|
49
|
-
|
|
50
|
-
// Real-time listener
|
|
51
|
-
const unsubscribe = onSnapshot(
|
|
52
|
-
docRef,
|
|
53
|
-
(snapshot: DocumentSnapshot) => {
|
|
54
|
-
if (snapshot.exists()) {
|
|
55
|
-
const entity = mapCreditsDocumentToEntity(snapshot.data() as UserCreditsDocumentRead);
|
|
56
|
-
setCredits(entity);
|
|
57
|
-
} else {
|
|
58
|
-
setCredits(null);
|
|
59
|
-
}
|
|
60
|
-
setIsLoading(false);
|
|
61
|
-
},
|
|
62
|
-
(err: Error) => {
|
|
63
|
-
console.error("[useCreditsRealTime] Snapshot error:", err);
|
|
64
|
-
setError(err);
|
|
65
|
-
setIsLoading(false);
|
|
66
|
-
}
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
return () => {
|
|
70
|
-
unsubscribe();
|
|
71
|
-
};
|
|
72
|
-
} catch (err) {
|
|
73
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
74
|
-
console.error("[useCreditsRealTime] Setup error:", err);
|
|
75
|
-
setError(error);
|
|
76
|
-
setIsLoading(false);
|
|
77
|
-
}
|
|
27
|
+
const db = requireFirestore();
|
|
28
|
+
const config = getCreditsConfig();
|
|
29
|
+
const ref = buildDocRef(db, userId, "balance", config);
|
|
30
|
+
return ref as DocumentReference<UserCreditsDocumentRead>;
|
|
78
31
|
}, [userId]);
|
|
79
32
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
33
|
+
// Use generic real-time sync hook
|
|
34
|
+
const { data, isLoading, error, refetch } = useFirestoreDocumentRealTime(
|
|
35
|
+
userId,
|
|
36
|
+
docRef,
|
|
37
|
+
mapCreditsDocumentToEntity,
|
|
38
|
+
"useCreditsRealTime"
|
|
39
|
+
);
|
|
87
40
|
|
|
88
41
|
return {
|
|
89
|
-
credits,
|
|
42
|
+
credits: data,
|
|
90
43
|
isLoading,
|
|
91
44
|
error,
|
|
92
|
-
refetch,
|
|
45
|
+
refetch,
|
|
93
46
|
};
|
|
94
47
|
}
|
|
95
48
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Flow Initial State
|
|
3
|
+
*
|
|
4
|
+
* Default state for the subscription flow state machine.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { SubscriptionFlowStatus, SyncStatus, type SubscriptionFlowState } from "./flowTypes";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Initial state for the subscription flow store.
|
|
11
|
+
* Represents a fresh install scenario.
|
|
12
|
+
*/
|
|
13
|
+
export const initialFlowState: SubscriptionFlowState = {
|
|
14
|
+
status: SubscriptionFlowStatus.INITIALIZING,
|
|
15
|
+
syncStatus: SyncStatus.IDLE,
|
|
16
|
+
syncError: null,
|
|
17
|
+
isOnboardingComplete: false,
|
|
18
|
+
paywallShown: false,
|
|
19
|
+
showFeedback: false,
|
|
20
|
+
isAuthModalOpen: false,
|
|
21
|
+
isInitialized: false,
|
|
22
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Flow State Machine Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for the subscription flow state machine.
|
|
5
|
+
* Separated from implementation for better maintainability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* States in the subscription flow.
|
|
10
|
+
* Represents the user's journey from first launch to premium access.
|
|
11
|
+
*/
|
|
12
|
+
export enum SubscriptionFlowStatus {
|
|
13
|
+
/** App is initializing, determining next state */
|
|
14
|
+
INITIALIZING = "INITIALIZING",
|
|
15
|
+
/** User is seeing onboarding for the first time */
|
|
16
|
+
ONBOARDING = "ONBOARDING",
|
|
17
|
+
/** Checking if user has premium access */
|
|
18
|
+
CHECK_PREMIUM = "CHECK_PREMIUM",
|
|
19
|
+
/** Showing paywall after onboarding */
|
|
20
|
+
POST_ONBOARDING_PAYWALL = "POST_ONBOARDING_PAYWALL",
|
|
21
|
+
/** App is ready to use */
|
|
22
|
+
READY = "READY",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Sync status for subscription/credits data.
|
|
27
|
+
*/
|
|
28
|
+
export enum SyncStatus {
|
|
29
|
+
/** No sync in progress */
|
|
30
|
+
IDLE = "IDLE",
|
|
31
|
+
/** Syncing data from RevenueCat/Firestore */
|
|
32
|
+
SYNCING = "SYNCING",
|
|
33
|
+
/** Sync completed successfully */
|
|
34
|
+
SUCCESS = "SUCCESS",
|
|
35
|
+
/** Sync failed with error */
|
|
36
|
+
ERROR = "ERROR",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Complete state shape for the subscription flow store.
|
|
41
|
+
*/
|
|
42
|
+
export interface SubscriptionFlowState {
|
|
43
|
+
/** Current flow status */
|
|
44
|
+
status: SubscriptionFlowStatus;
|
|
45
|
+
|
|
46
|
+
/** Sync status for subscription/credits data */
|
|
47
|
+
syncStatus: SyncStatus;
|
|
48
|
+
|
|
49
|
+
/** Error message from last sync failure */
|
|
50
|
+
syncError: string | null;
|
|
51
|
+
|
|
52
|
+
/** Whether user has completed onboarding */
|
|
53
|
+
isOnboardingComplete: boolean;
|
|
54
|
+
|
|
55
|
+
/** Whether paywall has been shown at least once */
|
|
56
|
+
paywallShown: boolean;
|
|
57
|
+
|
|
58
|
+
/** Whether to show feedback screen */
|
|
59
|
+
showFeedback: boolean;
|
|
60
|
+
|
|
61
|
+
/** Whether auth modal is currently open */
|
|
62
|
+
isAuthModalOpen: boolean;
|
|
63
|
+
|
|
64
|
+
/** Whether store has been initialized */
|
|
65
|
+
isInitialized: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Actions available on the subscription flow store.
|
|
70
|
+
*/
|
|
71
|
+
export interface SubscriptionFlowActions {
|
|
72
|
+
/** Mark onboarding as complete and transition to CHECK_PREMIUM */
|
|
73
|
+
completeOnboarding: () => void;
|
|
74
|
+
|
|
75
|
+
/** Show paywall and transition to POST_ONBOARDING_PAYWALL */
|
|
76
|
+
showPaywall: () => void;
|
|
77
|
+
|
|
78
|
+
/** Complete paywall interaction and transition to READY */
|
|
79
|
+
completePaywall: (purchased: boolean) => void;
|
|
80
|
+
|
|
81
|
+
/** Show feedback screen */
|
|
82
|
+
showFeedbackScreen: () => void;
|
|
83
|
+
|
|
84
|
+
/** Hide feedback screen */
|
|
85
|
+
hideFeedback: () => void;
|
|
86
|
+
|
|
87
|
+
/** Open/close auth modal */
|
|
88
|
+
setAuthModalOpen: (open: boolean) => void;
|
|
89
|
+
|
|
90
|
+
/** Update sync status */
|
|
91
|
+
setSyncStatus: (status: SyncStatus, error?: string | null) => void;
|
|
92
|
+
|
|
93
|
+
/** Set initialized flag (internal use) */
|
|
94
|
+
setInitialized: (initialized: boolean) => void;
|
|
95
|
+
|
|
96
|
+
/** Set flow status (internal use) */
|
|
97
|
+
setStatus: (status: SubscriptionFlowStatus) => void;
|
|
98
|
+
|
|
99
|
+
/** Reset flow to initial state */
|
|
100
|
+
resetFlow: () => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Combined store type with state and actions.
|
|
105
|
+
*/
|
|
106
|
+
export type SubscriptionFlowStore = SubscriptionFlowState & SubscriptionFlowActions;
|
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
useRestorePurchase,
|
|
6
6
|
} from '../infrastructure/hooks/useSubscriptionQueries';
|
|
7
7
|
import { usePaywallVisibility } from './usePaywallVisibility';
|
|
8
|
+
import { createLogger } from '../../../shared/utils/logger';
|
|
9
|
+
|
|
10
|
+
const logger = createLogger('usePremiumActions');
|
|
8
11
|
|
|
9
12
|
export interface PremiumActions {
|
|
10
13
|
purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>;
|
|
@@ -38,9 +41,7 @@ export function usePremiumActions(): PremiumActions {
|
|
|
38
41
|
const result = await purchaseMutation.mutateAsync(pkg);
|
|
39
42
|
return result.success;
|
|
40
43
|
} catch (error) {
|
|
41
|
-
|
|
42
|
-
console.error('[usePremiumActions] Purchase failed:', error);
|
|
43
|
-
}
|
|
44
|
+
logger.error('Purchase failed', error, { packageId: pkg.identifier });
|
|
44
45
|
return false;
|
|
45
46
|
}
|
|
46
47
|
},
|
|
@@ -52,9 +53,7 @@ export function usePremiumActions(): PremiumActions {
|
|
|
52
53
|
const result = await restoreMutation.mutateAsync();
|
|
53
54
|
return result.success;
|
|
54
55
|
} catch (error) {
|
|
55
|
-
|
|
56
|
-
console.error('[usePremiumActions] Restore failed:', error);
|
|
57
|
-
}
|
|
56
|
+
logger.error('Restore failed', error);
|
|
58
57
|
return false;
|
|
59
58
|
}
|
|
60
59
|
}, [restoreMutation]);
|
|
@@ -3,109 +3,35 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for app flow state.
|
|
5
5
|
* Clean state transitions without complex if/else logic.
|
|
6
|
+
*
|
|
7
|
+
* State transition rules:
|
|
8
|
+
* - INITIALIZING -> ONBOARDING (first launch)
|
|
9
|
+
* - INITIALIZING -> CHECK_PREMIUM (onboarding already done)
|
|
10
|
+
* - ONBOARDING -> CHECK_PREMIUM (onboarding completed)
|
|
11
|
+
* - CHECK_PREMIUM -> READY (user is premium)
|
|
12
|
+
* - CHECK_PREMIUM -> POST_ONBOARDING_PAYWALL (user not premium, paywall not shown)
|
|
13
|
+
* - CHECK_PREMIUM -> READY (user not premium but paywall already shown)
|
|
14
|
+
* - POST_ONBOARDING_PAYWALL -> READY (paywall closed)
|
|
15
|
+
* - READY -> READY (stays ready, shows overlays when needed)
|
|
6
16
|
*/
|
|
7
17
|
|
|
8
18
|
import { createStore } from "@umituz/react-native-design-system/storage";
|
|
9
19
|
import { subscriptionEventBus, FLOW_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
20
|
+
import {
|
|
21
|
+
SubscriptionFlowStatus,
|
|
22
|
+
SyncStatus,
|
|
23
|
+
type SubscriptionFlowState,
|
|
24
|
+
type SubscriptionFlowActions,
|
|
25
|
+
} from "./flowTypes";
|
|
26
|
+
import { initialFlowState } from "./flowInitialState";
|
|
10
27
|
|
|
11
|
-
export enum SubscriptionFlowStatus {
|
|
12
|
-
INITIALIZING = "INITIALIZING",
|
|
13
|
-
ONBOARDING = "ONBOARDING",
|
|
14
|
-
CHECK_PREMIUM = "CHECK_PREMIUM",
|
|
15
|
-
POST_ONBOARDING_PAYWALL = "POST_ONBOARDING_PAYWALL",
|
|
16
|
-
READY = "READY",
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export enum SyncStatus {
|
|
20
|
-
IDLE = "IDLE",
|
|
21
|
-
SYNCING = "SYNCING",
|
|
22
|
-
SUCCESS = "SUCCESS",
|
|
23
|
-
ERROR = "ERROR",
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface SubscriptionFlowState {
|
|
27
|
-
// Flow state
|
|
28
|
-
status: SubscriptionFlowStatus;
|
|
29
|
-
|
|
30
|
-
// Sync state
|
|
31
|
-
syncStatus: SyncStatus;
|
|
32
|
-
syncError: string | null;
|
|
33
|
-
|
|
34
|
-
// Onboarding state
|
|
35
|
-
isOnboardingComplete: boolean;
|
|
36
|
-
|
|
37
|
-
// Paywall state
|
|
38
|
-
paywallShown: boolean;
|
|
39
|
-
|
|
40
|
-
// Feedback state
|
|
41
|
-
showFeedback: boolean;
|
|
42
|
-
|
|
43
|
-
// Auth modal state
|
|
44
|
-
isAuthModalOpen: boolean;
|
|
45
|
-
|
|
46
|
-
// Initialization flag
|
|
47
|
-
isInitialized: boolean;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface SubscriptionFlowActions {
|
|
51
|
-
// Flow actions
|
|
52
|
-
completeOnboarding: () => void;
|
|
53
|
-
showPaywall: () => void;
|
|
54
|
-
completePaywall: (purchased: boolean) => void;
|
|
55
|
-
showFeedbackScreen: () => void;
|
|
56
|
-
hideFeedback: () => void;
|
|
57
|
-
|
|
58
|
-
// Auth actions
|
|
59
|
-
setAuthModalOpen: (open: boolean) => void;
|
|
60
|
-
|
|
61
|
-
// Sync actions
|
|
62
|
-
setSyncStatus: (status: SyncStatus, error?: string | null) => void;
|
|
63
|
-
|
|
64
|
-
// State setters (for internal use)
|
|
65
|
-
setInitialized: (initialized: boolean) => void;
|
|
66
|
-
setStatus: (status: SubscriptionFlowStatus) => void;
|
|
67
|
-
|
|
68
|
-
// Reset
|
|
69
|
-
resetFlow: () => void;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export type SubscriptionFlowStore = SubscriptionFlowState & SubscriptionFlowActions;
|
|
73
|
-
|
|
74
|
-
const initialState: SubscriptionFlowState = {
|
|
75
|
-
status: SubscriptionFlowStatus.INITIALIZING,
|
|
76
|
-
syncStatus: SyncStatus.IDLE,
|
|
77
|
-
syncError: null,
|
|
78
|
-
isOnboardingComplete: false,
|
|
79
|
-
paywallShown: false,
|
|
80
|
-
showFeedback: false,
|
|
81
|
-
isAuthModalOpen: false,
|
|
82
|
-
isInitialized: false,
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* State transition rules:
|
|
87
|
-
*
|
|
88
|
-
* INITIALIZING -> ONBOARDING (first launch)
|
|
89
|
-
* INITIALIZING -> CHECK_PREMIUM (onboarding already done)
|
|
90
|
-
*
|
|
91
|
-
* ONBOARDING -> CHECK_PREMIUM (onboarding completed)
|
|
92
|
-
*
|
|
93
|
-
* CHECK_PREMIUM -> READY (user is premium)
|
|
94
|
-
* CHECK_PREMIUM -> POST_ONBOARDING_PAYWALL (user not premium, paywall not shown)
|
|
95
|
-
* CHECK_PREMIUM -> READY (user not premium but paywall already shown)
|
|
96
|
-
*
|
|
97
|
-
* POST_ONBOARDING_PAYWALL -> READY (paywall closed)
|
|
98
|
-
*
|
|
99
|
-
* READY -> READY (stays ready, shows overlays when needed)
|
|
100
|
-
*/
|
|
101
28
|
export const useSubscriptionFlowStore = createStore<SubscriptionFlowState, SubscriptionFlowActions>({
|
|
102
29
|
name: "subscription-flow-storage",
|
|
103
|
-
initialState,
|
|
30
|
+
initialState: initialFlowState,
|
|
104
31
|
persist: true,
|
|
105
32
|
onRehydrate: (state) => {
|
|
106
33
|
if (!state.isInitialized) {
|
|
107
34
|
state.setInitialized(true);
|
|
108
|
-
|
|
109
35
|
// First time: show onboarding
|
|
110
36
|
state.setStatus(SubscriptionFlowStatus.INITIALIZING);
|
|
111
37
|
} else if (state.isOnboardingComplete) {
|
|
@@ -176,3 +102,10 @@ export const useSubscriptionFlowStore = createStore<SubscriptionFlowState, Subsc
|
|
|
176
102
|
},
|
|
177
103
|
}),
|
|
178
104
|
});
|
|
105
|
+
|
|
106
|
+
// Re-export types for convenience
|
|
107
|
+
export type { SubscriptionFlowState, SubscriptionFlowActions } from "./flowTypes";
|
|
108
|
+
export { SubscriptionFlowStatus, SyncStatus } from "./flowTypes";
|
|
109
|
+
|
|
110
|
+
// Re-export store type inferred from createStore
|
|
111
|
+
export type SubscriptionFlowStore = ReturnType<typeof useSubscriptionFlowStore>;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo } from "react";
|
|
2
2
|
import { useAuthStore, selectUserId } from "@umituz/react-native-auth";
|
|
3
|
-
import { collection,
|
|
3
|
+
import { collection, query, orderBy, limit } from "firebase/firestore";
|
|
4
4
|
import type {
|
|
5
5
|
CreditLog,
|
|
6
6
|
TransactionRepositoryConfig,
|
|
7
7
|
} from "../../domain/types/transaction.types";
|
|
8
8
|
import { requireFirestore } from "../../../../shared/infrastructure/firestore/collectionUtils";
|
|
9
|
+
import { useFirestoreCollectionRealTime } from "../../../../shared/presentation/hooks/useFirestoreRealTime";
|
|
9
10
|
|
|
10
11
|
export interface UseTransactionHistoryParams {
|
|
11
12
|
config: TransactionRepositoryConfig;
|
|
@@ -20,78 +21,51 @@ interface UseTransactionHistoryResult {
|
|
|
20
21
|
isEmpty: boolean;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Mapper to convert Firestore document to CreditLog entity.
|
|
26
|
+
*/
|
|
27
|
+
function mapTransactionLog(doc: any, docId: string): CreditLog {
|
|
28
|
+
return {
|
|
29
|
+
id: docId,
|
|
30
|
+
...doc,
|
|
31
|
+
} as CreditLog;
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
export function useTransactionHistory({
|
|
24
35
|
config,
|
|
25
36
|
limit: limitCount = 50,
|
|
26
37
|
}: UseTransactionHistoryParams): UseTransactionHistoryResult {
|
|
27
38
|
const userId = useAuthStore(selectUserId);
|
|
28
|
-
const [transactions, setTransactions] = useState<CreditLog[]>([]);
|
|
29
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
30
|
-
const [error, setError] = useState<Error | null>(null);
|
|
31
|
-
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
if (!userId) {
|
|
34
|
-
setTransactions([]);
|
|
35
|
-
setIsLoading(false);
|
|
36
|
-
setError(null);
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Build collection query
|
|
41
|
+
const queryRef = useMemo(() => {
|
|
42
|
+
if (!userId) return null;
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
: config.collectionName;
|
|
44
|
+
const db = requireFirestore();
|
|
45
|
+
const collectionPath = config.useUserSubcollection
|
|
46
|
+
? `users/${userId}/${config.collectionName}`
|
|
47
|
+
: config.collectionName;
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const unsubscribe = onSnapshot(
|
|
56
|
-
q,
|
|
57
|
-
(snapshot) => {
|
|
58
|
-
const logs: CreditLog[] = [];
|
|
59
|
-
snapshot.forEach((doc) => {
|
|
60
|
-
logs.push({
|
|
61
|
-
id: doc.id,
|
|
62
|
-
...doc.data(),
|
|
63
|
-
} as CreditLog);
|
|
64
|
-
});
|
|
65
|
-
setTransactions(logs);
|
|
66
|
-
setIsLoading(false);
|
|
67
|
-
},
|
|
68
|
-
(err) => {
|
|
69
|
-
console.error("[useTransactionHistory] Snapshot error:", err);
|
|
70
|
-
setError(err as Error);
|
|
71
|
-
setIsLoading(false);
|
|
72
|
-
}
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
return () => unsubscribe();
|
|
76
|
-
} catch (err) {
|
|
77
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
78
|
-
console.error("[useTransactionHistory] Setup error:", err);
|
|
79
|
-
setError(error);
|
|
80
|
-
setIsLoading(false);
|
|
81
|
-
}
|
|
49
|
+
return query(
|
|
50
|
+
collection(db, collectionPath),
|
|
51
|
+
orderBy("timestamp", "desc"),
|
|
52
|
+
limit(limitCount)
|
|
53
|
+
);
|
|
82
54
|
}, [userId, config.collectionName, config.useUserSubcollection, limitCount]);
|
|
83
55
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
56
|
+
// Use generic real-time sync hook
|
|
57
|
+
const { data, isLoading, error, refetch, isEmpty } = useFirestoreCollectionRealTime(
|
|
58
|
+
userId,
|
|
59
|
+
queryRef,
|
|
60
|
+
mapTransactionLog,
|
|
61
|
+
"useTransactionHistory"
|
|
62
|
+
);
|
|
89
63
|
|
|
90
64
|
return {
|
|
91
|
-
transactions,
|
|
65
|
+
transactions: data,
|
|
92
66
|
isLoading,
|
|
93
67
|
error,
|
|
94
68
|
refetch,
|
|
95
|
-
isEmpty
|
|
69
|
+
isEmpty,
|
|
96
70
|
};
|
|
97
71
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic real-time sync hook for Firestore.
|
|
3
|
+
*
|
|
4
|
+
* Eliminates 90% duplication from real-time hooks:
|
|
5
|
+
* - useCreditsRealTime: 116 → ~35 lines
|
|
6
|
+
* - useTransactionHistory: 98 → ~30 lines
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Generic type support for any document/collection
|
|
10
|
+
* - Automatic cleanup with unsubscribe
|
|
11
|
+
* - Consistent error handling
|
|
12
|
+
* - Loading state management
|
|
13
|
+
* - Type-safe mapper function
|
|
14
|
+
* - Support for both document and collection queries
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useEffect, useState, useCallback } from "react";
|
|
18
|
+
import {
|
|
19
|
+
onSnapshot,
|
|
20
|
+
type Query,
|
|
21
|
+
type DocumentReference,
|
|
22
|
+
} from "firebase/firestore";
|
|
23
|
+
import type { HookState, HookStateWithEmpty } from "../types/hookState.types";
|
|
24
|
+
import { logError, logWarn } from "../../utils/logger";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Configuration for building a Firestore document reference.
|
|
28
|
+
*/
|
|
29
|
+
export interface DocumentConfig {
|
|
30
|
+
/** Collection name */
|
|
31
|
+
collectionName: string;
|
|
32
|
+
|
|
33
|
+
/** Whether to use a user subcollection (users/{userId}/{collectionName}) */
|
|
34
|
+
useUserSubcollection: boolean;
|
|
35
|
+
|
|
36
|
+
/** Document ID (fixed string or function that takes userId) */
|
|
37
|
+
docId: string | ((userId: string) => string);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Configuration for building a Firestore collection query.
|
|
42
|
+
*/
|
|
43
|
+
export interface CollectionConfig {
|
|
44
|
+
/** Collection name */
|
|
45
|
+
collectionName: string;
|
|
46
|
+
|
|
47
|
+
/** Whether to use a user subcollection (users/{userId}/{collectionName}) */
|
|
48
|
+
useUserSubcollection: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Query builder for collection queries.
|
|
53
|
+
* Takes a Firestore collection reference and returns a Query with constraints.
|
|
54
|
+
*/
|
|
55
|
+
export type QueryBuilder<T> = (collection: any) => Query<T>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mapper function to convert Firestore document data to domain entity.
|
|
59
|
+
*/
|
|
60
|
+
export type Mapper<TDocument, TEntity> = (
|
|
61
|
+
doc: TDocument,
|
|
62
|
+
docId: string
|
|
63
|
+
) => TEntity;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generic hook for real-time document sync via Firestore onSnapshot.
|
|
67
|
+
*
|
|
68
|
+
* @template TDocument - Firestore document type
|
|
69
|
+
* @template TEntity - Domain entity type
|
|
70
|
+
*
|
|
71
|
+
* @param userId - User ID to fetch document for
|
|
72
|
+
* @param docRef - Document reference from Firestore
|
|
73
|
+
* @param mapper - Function to map document data to entity
|
|
74
|
+
* @param tag - Logging tag for debugging
|
|
75
|
+
*
|
|
76
|
+
* @returns Hook state with data, loading, error, and refetch
|
|
77
|
+
*/
|
|
78
|
+
export function useFirestoreDocumentRealTime<TDocument, TEntity>(
|
|
79
|
+
userId: string | null | undefined,
|
|
80
|
+
docRef: DocumentReference<TDocument> | null,
|
|
81
|
+
mapper: Mapper<TDocument, TEntity>,
|
|
82
|
+
tag: string
|
|
83
|
+
): HookState<TEntity> {
|
|
84
|
+
const [data, setData] = useState<TEntity | null>(null);
|
|
85
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
86
|
+
const [error, setError] = useState<Error | null>(null);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
// Reset state when userId changes
|
|
90
|
+
if (!userId) {
|
|
91
|
+
setData(null);
|
|
92
|
+
setIsLoading(false);
|
|
93
|
+
setError(null);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
setIsLoading(true);
|
|
98
|
+
setError(null);
|
|
99
|
+
|
|
100
|
+
if (!docRef) {
|
|
101
|
+
setIsLoading(false);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const unsubscribe = onSnapshot(
|
|
106
|
+
docRef,
|
|
107
|
+
(snapshot) => {
|
|
108
|
+
if (snapshot.exists()) {
|
|
109
|
+
const entity = mapper(snapshot.data() as TDocument, snapshot.id);
|
|
110
|
+
setData(entity);
|
|
111
|
+
} else {
|
|
112
|
+
setData(null);
|
|
113
|
+
}
|
|
114
|
+
setIsLoading(false);
|
|
115
|
+
},
|
|
116
|
+
(err: Error) => {
|
|
117
|
+
logError(tag, "Snapshot error", err, { userId });
|
|
118
|
+
setError(err);
|
|
119
|
+
setIsLoading(false);
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return () => {
|
|
124
|
+
unsubscribe();
|
|
125
|
+
};
|
|
126
|
+
}, [userId, docRef, mapper, tag]);
|
|
127
|
+
|
|
128
|
+
const refetch = useCallback(() => {
|
|
129
|
+
// Real-time sync doesn't need refetch, but keep for API compatibility
|
|
130
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
131
|
+
logWarn(tag, "Refetch called - not needed for real-time sync");
|
|
132
|
+
}
|
|
133
|
+
}, [tag]);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
data,
|
|
137
|
+
isLoading,
|
|
138
|
+
error,
|
|
139
|
+
refetch,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Generic hook for real-time collection sync via Firestore onSnapshot.
|
|
145
|
+
*
|
|
146
|
+
* @template TDocument - Firestore document type
|
|
147
|
+
* @template TEntity - Domain entity type
|
|
148
|
+
*
|
|
149
|
+
* @param userId - User ID to fetch collection for
|
|
150
|
+
* @param query - Firestore query to listen to
|
|
151
|
+
* @param mapper - Function to map document data to entity
|
|
152
|
+
* @param tag - Logging tag for debugging
|
|
153
|
+
*
|
|
154
|
+
* @returns Hook state with array data, loading, error, refetch, and isEmpty
|
|
155
|
+
*/
|
|
156
|
+
export function useFirestoreCollectionRealTime<TDocument, TEntity>(
|
|
157
|
+
userId: string | null | undefined,
|
|
158
|
+
query: Query<TDocument>,
|
|
159
|
+
mapper: Mapper<TDocument, TEntity>,
|
|
160
|
+
tag: string
|
|
161
|
+
): HookStateWithEmpty<TEntity> {
|
|
162
|
+
const [data, setData] = useState<TEntity[]>([]);
|
|
163
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
164
|
+
const [error, setError] = useState<Error | null>(null);
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
// Reset state when userId changes
|
|
168
|
+
if (!userId) {
|
|
169
|
+
setData([]);
|
|
170
|
+
setIsLoading(false);
|
|
171
|
+
setError(null);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setIsLoading(true);
|
|
176
|
+
setError(null);
|
|
177
|
+
|
|
178
|
+
const unsubscribe = onSnapshot(
|
|
179
|
+
query,
|
|
180
|
+
(snapshot) => {
|
|
181
|
+
const entities: TEntity[] = [];
|
|
182
|
+
snapshot.forEach((doc) => {
|
|
183
|
+
entities.push(mapper(doc.data() as TDocument, doc.id));
|
|
184
|
+
});
|
|
185
|
+
setData(entities);
|
|
186
|
+
setIsLoading(false);
|
|
187
|
+
},
|
|
188
|
+
(err: Error) => {
|
|
189
|
+
logError(tag, "Snapshot error", err, { userId });
|
|
190
|
+
setError(err);
|
|
191
|
+
setIsLoading(false);
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return () => {
|
|
196
|
+
unsubscribe();
|
|
197
|
+
};
|
|
198
|
+
}, [userId, query, mapper, tag]);
|
|
199
|
+
|
|
200
|
+
const refetch = useCallback(() => {
|
|
201
|
+
// Real-time sync doesn't need refetch, but keep for API compatibility
|
|
202
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
203
|
+
logWarn(tag, "Refetch called - not needed for real-time sync");
|
|
204
|
+
}
|
|
205
|
+
}, [tag]);
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
data,
|
|
209
|
+
isLoading,
|
|
210
|
+
error,
|
|
211
|
+
refetch,
|
|
212
|
+
isEmpty: data.length === 0,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standardized hook state types for consistent return values across all hooks.
|
|
3
|
+
*
|
|
4
|
+
* Benefits:
|
|
5
|
+
* - Type consistency across 25+ hooks
|
|
6
|
+
* - Easier to understand and maintain
|
|
7
|
+
* - Better IDE autocomplete
|
|
8
|
+
* - Predictable API surface
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Standard hook state returned by data-fetching hooks.
|
|
13
|
+
* Provides consistent interface for loading, error, and data states.
|
|
14
|
+
*
|
|
15
|
+
* @template T - The type of data returned by the hook
|
|
16
|
+
*/
|
|
17
|
+
export interface HookState<T> {
|
|
18
|
+
/** The fetched data, or null if not yet loaded or on error */
|
|
19
|
+
data: T | null;
|
|
20
|
+
|
|
21
|
+
/** Whether the hook is currently fetching data */
|
|
22
|
+
isLoading: boolean;
|
|
23
|
+
|
|
24
|
+
/** Any error that occurred during fetching, or null if no error */
|
|
25
|
+
error: Error | null;
|
|
26
|
+
|
|
27
|
+
/** Function to manually refetch data (no-op for real-time sync hooks) */
|
|
28
|
+
refetch: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extended hook state that includes isEmpty for collection queries.
|
|
33
|
+
* Useful when you need to distinguish between "no data" and "loading".
|
|
34
|
+
*
|
|
35
|
+
* @template T - The type of data in the array (typically a document type)
|
|
36
|
+
*/
|
|
37
|
+
export interface HookStateWithEmpty<T> extends HookState<T[]> {
|
|
38
|
+
/** Whether the data array is empty (only meaningful when isLoading is false) */
|
|
39
|
+
isEmpty: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extended hook state that includes metadata.
|
|
44
|
+
* Useful for hooks that need to return additional computed values.
|
|
45
|
+
*
|
|
46
|
+
* @template T - The type of data returned by the hook
|
|
47
|
+
* @template M - The type of metadata
|
|
48
|
+
*/
|
|
49
|
+
export interface HookStateWithMeta<T, M> extends HookState<T> {
|
|
50
|
+
/** Additional computed or derived metadata */
|
|
51
|
+
meta: M;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Factory function to create a standard HookState.
|
|
56
|
+
* Useful for testing or when you need to construct state objects.
|
|
57
|
+
*/
|
|
58
|
+
export function createHookState<T>(
|
|
59
|
+
data: T | null,
|
|
60
|
+
isLoading: boolean,
|
|
61
|
+
error: Error | null,
|
|
62
|
+
refetch: () => void = () => {}
|
|
63
|
+
): HookState<T> {
|
|
64
|
+
return { data, isLoading, error, refetch };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Factory function to create a HookStateWithEmpty.
|
|
69
|
+
* Automatically computes isEmpty from the data array.
|
|
70
|
+
*/
|
|
71
|
+
export function createHookStateWithEmpty<T>(
|
|
72
|
+
data: T[] | null,
|
|
73
|
+
isLoading: boolean,
|
|
74
|
+
error: Error | null,
|
|
75
|
+
refetch: () => void = () => {}
|
|
76
|
+
): HookStateWithEmpty<T> {
|
|
77
|
+
return {
|
|
78
|
+
data,
|
|
79
|
+
isLoading,
|
|
80
|
+
error,
|
|
81
|
+
refetch,
|
|
82
|
+
isEmpty: data === null ? false : data.length === 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Factory function to create a HookStateWithMeta.
|
|
88
|
+
*/
|
|
89
|
+
export function createHookStateWithMeta<T, M>(
|
|
90
|
+
data: T | null,
|
|
91
|
+
isLoading: boolean,
|
|
92
|
+
error: Error | null,
|
|
93
|
+
meta: M,
|
|
94
|
+
refetch: () => void = () => {}
|
|
95
|
+
): HookStateWithMeta<T, M> {
|
|
96
|
+
return { data, isLoading, error, refetch, meta };
|
|
97
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized error handling utilities.
|
|
3
|
+
*
|
|
4
|
+
* Benefits:
|
|
5
|
+
* - Removes 45+ duplicated error handling blocks
|
|
6
|
+
* - Consistent error logging everywhere
|
|
7
|
+
* - Easier debugging with better error context
|
|
8
|
+
* - Type-safe error handling
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { logError } from "./logger";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Safely convert unknown error to Error object.
|
|
15
|
+
* Useful when catching errors from external APIs.
|
|
16
|
+
*/
|
|
17
|
+
export function toError(error: unknown): Error {
|
|
18
|
+
if (error instanceof Error) {
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof error === "string") {
|
|
23
|
+
return new Error(error);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (error === null || error === undefined) {
|
|
27
|
+
return new Error("Unknown error occurred");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
return new Error(JSON.stringify(error));
|
|
32
|
+
} catch {
|
|
33
|
+
return new Error(String(error));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create an error with a code and optional cause.
|
|
39
|
+
* Wraps the BaseError pattern for convenience.
|
|
40
|
+
*/
|
|
41
|
+
export function createError(
|
|
42
|
+
message: string,
|
|
43
|
+
code: string,
|
|
44
|
+
cause?: Error
|
|
45
|
+
): Error {
|
|
46
|
+
const error = new Error(message);
|
|
47
|
+
error.name = code;
|
|
48
|
+
|
|
49
|
+
if (cause) {
|
|
50
|
+
// @ts-ignore - adding cause property
|
|
51
|
+
error.cause = cause;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return error;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Log an error with consistent formatting.
|
|
59
|
+
* Only logs in __DEV__ mode.
|
|
60
|
+
*/
|
|
61
|
+
export function logAndReturnError(
|
|
62
|
+
tag: string,
|
|
63
|
+
message: string,
|
|
64
|
+
error: unknown,
|
|
65
|
+
context?: Record<string, unknown>
|
|
66
|
+
): Error {
|
|
67
|
+
const normalizedError = toError(error);
|
|
68
|
+
|
|
69
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
70
|
+
logError(tag, message, normalizedError, {
|
|
71
|
+
...context,
|
|
72
|
+
originalError: error,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return normalizedError;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Type guard to check if value is an Error.
|
|
81
|
+
*/
|
|
82
|
+
export function isError(value: unknown): value is Error {
|
|
83
|
+
return value instanceof Error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Type guard to check if error has a code property.
|
|
88
|
+
*/
|
|
89
|
+
export function isErrorWithCode(error: Error): error is Error & { code: string } {
|
|
90
|
+
return "code" in error && typeof error.code === "string";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Type guard to check if error has a cause property.
|
|
95
|
+
*/
|
|
96
|
+
export function isErrorWithCause(error: Error): error is Error & { cause: Error } {
|
|
97
|
+
return "cause" in error && error.cause instanceof Error;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Wrap an async function with error handling.
|
|
102
|
+
* Returns a Result type with success/error states.
|
|
103
|
+
*/
|
|
104
|
+
export async function tryAsync<T>(
|
|
105
|
+
fn: () => Promise<T>,
|
|
106
|
+
context: { tag: string; operation: string }
|
|
107
|
+
): Promise<{ success: true; data: T } | { success: false; error: Error }> {
|
|
108
|
+
try {
|
|
109
|
+
const data = await fn();
|
|
110
|
+
return { success: true, data };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const normalizedError = logAndReturnError(
|
|
113
|
+
context.tag,
|
|
114
|
+
`Failed to ${context.operation}`,
|
|
115
|
+
error
|
|
116
|
+
);
|
|
117
|
+
return { success: false, error: normalizedError };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Wrap a synchronous function with error handling.
|
|
123
|
+
* Returns a Result type with success/error states.
|
|
124
|
+
*/
|
|
125
|
+
export function trySync<T>(
|
|
126
|
+
fn: () => T,
|
|
127
|
+
context: { tag: string; operation: string }
|
|
128
|
+
): { success: true; data: T } | { success: false; error: Error } {
|
|
129
|
+
try {
|
|
130
|
+
const data = fn();
|
|
131
|
+
return { success: true, data };
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const normalizedError = logAndReturnError(
|
|
134
|
+
context.tag,
|
|
135
|
+
`Failed to ${context.operation}`,
|
|
136
|
+
error
|
|
137
|
+
);
|
|
138
|
+
return { success: false, error: normalizedError };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Assert that a condition is true, throw error otherwise.
|
|
144
|
+
* Useful for validation and runtime checks.
|
|
145
|
+
*/
|
|
146
|
+
export function assert(
|
|
147
|
+
condition: boolean,
|
|
148
|
+
message: string,
|
|
149
|
+
code: string = "ASSERTION_ERROR"
|
|
150
|
+
): asserts condition {
|
|
151
|
+
if (!condition) {
|
|
152
|
+
throw createError(message, code);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Assert that a value is not null/undefined.
|
|
158
|
+
* Throws error if value is null/undefined, returns value otherwise.
|
|
159
|
+
* Useful for type narrowing.
|
|
160
|
+
*/
|
|
161
|
+
export function assertNotNil<T>(
|
|
162
|
+
value: T | null | undefined,
|
|
163
|
+
message: string = "Value should not be null or undefined"
|
|
164
|
+
): T {
|
|
165
|
+
assert(value !== null && value !== undefined, message, "NOT_NIL_ERROR");
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create a Firestore-specific error with consistent formatting.
|
|
171
|
+
*/
|
|
172
|
+
export function createFirestoreError(
|
|
173
|
+
operation: string,
|
|
174
|
+
error: unknown
|
|
175
|
+
): Error {
|
|
176
|
+
return createError(
|
|
177
|
+
`Firestore ${operation} failed`,
|
|
178
|
+
"FIRESTORE_ERROR",
|
|
179
|
+
toError(error)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Create a RevenueCat-specific error with consistent formatting.
|
|
185
|
+
*/
|
|
186
|
+
export function createRevenueCatError(
|
|
187
|
+
operation: string,
|
|
188
|
+
error: unknown
|
|
189
|
+
): Error {
|
|
190
|
+
return createError(
|
|
191
|
+
`RevenueCat ${operation} failed`,
|
|
192
|
+
"REVENUECAT_ERROR",
|
|
193
|
+
toError(error)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized logging utilities for development-only logging.
|
|
3
|
+
*
|
|
4
|
+
* Benefits:
|
|
5
|
+
* - Removes 8+ duplicated __DEV__ check patterns
|
|
6
|
+
* - Single source for logging configuration
|
|
7
|
+
* - Easier to add log aggregation later
|
|
8
|
+
* - Consistent formatting across all logs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Log level for categorizing log messages.
|
|
13
|
+
*/
|
|
14
|
+
export enum LogLevel {
|
|
15
|
+
DEBUG = "DEBUG",
|
|
16
|
+
INFO = "INFO",
|
|
17
|
+
WARN = "WARN",
|
|
18
|
+
ERROR = "ERROR",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Structured log context for better debugging.
|
|
23
|
+
*/
|
|
24
|
+
export interface LogContext {
|
|
25
|
+
/** Additional key-value pairs to include in the log */
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Internal logging function that only logs in __DEV__ mode.
|
|
31
|
+
*/
|
|
32
|
+
function log(
|
|
33
|
+
level: LogLevel,
|
|
34
|
+
tag: string,
|
|
35
|
+
message: string,
|
|
36
|
+
context?: LogContext
|
|
37
|
+
): void {
|
|
38
|
+
if (typeof __DEV__ === "undefined" || !__DEV__) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const timestamp = new Date().toISOString();
|
|
43
|
+
const logData = {
|
|
44
|
+
timestamp,
|
|
45
|
+
level,
|
|
46
|
+
tag,
|
|
47
|
+
message,
|
|
48
|
+
...context,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
switch (level) {
|
|
52
|
+
case LogLevel.DEBUG:
|
|
53
|
+
case LogLevel.INFO:
|
|
54
|
+
console.log(`[${tag}]`, message, context ? logData : "");
|
|
55
|
+
break;
|
|
56
|
+
case LogLevel.WARN:
|
|
57
|
+
console.warn(`[${tag}]`, message, context ? logData : "");
|
|
58
|
+
break;
|
|
59
|
+
case LogLevel.ERROR:
|
|
60
|
+
console.error(`[${tag}]`, message, context ? logData : "");
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Log a debug message. Only shown in __DEV__ mode.
|
|
67
|
+
*
|
|
68
|
+
* @param tag - Component or feature name for filtering
|
|
69
|
+
* @param message - Log message
|
|
70
|
+
* @param context - Optional additional context data
|
|
71
|
+
*/
|
|
72
|
+
export function logDebug(tag: string, message: string, context?: LogContext): void {
|
|
73
|
+
log(LogLevel.DEBUG, tag, message, context);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Log an info message. Only shown in __DEV__ mode.
|
|
78
|
+
*
|
|
79
|
+
* @param tag - Component or feature name for filtering
|
|
80
|
+
* @param message - Log message
|
|
81
|
+
* @param context - Optional additional context data
|
|
82
|
+
*/
|
|
83
|
+
export function logInfo(tag: string, message: string, context?: LogContext): void {
|
|
84
|
+
log(LogLevel.INFO, tag, message, context);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Log a warning message. Only shown in __DEV__ mode.
|
|
89
|
+
*
|
|
90
|
+
* @param tag - Component or feature name for filtering
|
|
91
|
+
* @param message - Warning message
|
|
92
|
+
* @param context - Optional additional context data
|
|
93
|
+
*/
|
|
94
|
+
export function logWarn(tag: string, message: string, context?: LogContext): void {
|
|
95
|
+
log(LogLevel.WARN, tag, message, context);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Log an error message. Only shown in __DEV__ mode.
|
|
100
|
+
*
|
|
101
|
+
* @param tag - Component or feature name for filtering
|
|
102
|
+
* @param message - Error message
|
|
103
|
+
* @param error - Error object (will be serialized)
|
|
104
|
+
* @param context - Optional additional context data
|
|
105
|
+
*/
|
|
106
|
+
export function logError(
|
|
107
|
+
tag: string,
|
|
108
|
+
message: string,
|
|
109
|
+
error?: Error | unknown,
|
|
110
|
+
context?: LogContext
|
|
111
|
+
): void {
|
|
112
|
+
const errorContext = {
|
|
113
|
+
...context,
|
|
114
|
+
error: error instanceof Error ? {
|
|
115
|
+
name: error.name,
|
|
116
|
+
message: error.message,
|
|
117
|
+
stack: error.stack,
|
|
118
|
+
} : error,
|
|
119
|
+
};
|
|
120
|
+
log(LogLevel.ERROR, tag, message, errorContext);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create a tagged logger for a specific component or feature.
|
|
125
|
+
* Returns functions that automatically include the tag.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* const logger = createLogger("useCredits");
|
|
129
|
+
* logger.info("Credits loaded", { credits: 100 });
|
|
130
|
+
* logger.error("Failed to load", error);
|
|
131
|
+
*/
|
|
132
|
+
export function createLogger(tag: string) {
|
|
133
|
+
return {
|
|
134
|
+
debug: (message: string, context?: LogContext) => logDebug(tag, message, context),
|
|
135
|
+
info: (message: string, context?: LogContext) => logInfo(tag, message, context),
|
|
136
|
+
warn: (message: string, context?: LogContext) => logWarn(tag, message, context),
|
|
137
|
+
error: (message: string, error?: Error | unknown, context?: LogContext) =>
|
|
138
|
+
logError(tag, message, error, context),
|
|
139
|
+
};
|
|
140
|
+
}
|