@umituz/react-native-subscription 2.27.2 → 2.27.4
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.27.
|
|
3
|
+
"version": "2.27.4",
|
|
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,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useCredits Hook
|
|
3
3
|
*
|
|
4
|
-
* Fetches user credits
|
|
5
|
-
*
|
|
4
|
+
* Fetches user credits with TanStack Query best practices.
|
|
5
|
+
* Uses status-based state management for reliable loading detection.
|
|
6
6
|
* Auto-initializes free credits for registered users only.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useQuery } from "@umituz/react-native-design-system";
|
|
10
|
-
import { useCallback, useMemo, useEffect } from "react";
|
|
10
|
+
import { useCallback, useMemo, useEffect, useState } from "react";
|
|
11
11
|
import {
|
|
12
12
|
useAuthStore,
|
|
13
13
|
selectUserId,
|
|
@@ -29,9 +29,13 @@ export const creditsQueryKeys = {
|
|
|
29
29
|
|
|
30
30
|
const freeCreditsInitAttempted = new Set<string>();
|
|
31
31
|
|
|
32
|
+
export type CreditsLoadStatus = "idle" | "loading" | "initializing" | "ready" | "error";
|
|
33
|
+
|
|
32
34
|
export interface UseCreditsResult {
|
|
33
35
|
credits: UserCredits | null;
|
|
34
36
|
isLoading: boolean;
|
|
37
|
+
isCreditsLoaded: boolean;
|
|
38
|
+
loadStatus: CreditsLoadStatus;
|
|
35
39
|
error: Error | null;
|
|
36
40
|
hasCredits: boolean;
|
|
37
41
|
creditsPercent: number;
|
|
@@ -39,24 +43,36 @@ export interface UseCreditsResult {
|
|
|
39
43
|
canAfford: (cost: number) => boolean;
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
function deriveLoadStatus(
|
|
47
|
+
queryStatus: "pending" | "error" | "success",
|
|
48
|
+
isInitializing: boolean,
|
|
49
|
+
queryEnabled: boolean
|
|
50
|
+
): CreditsLoadStatus {
|
|
51
|
+
if (!queryEnabled) return "idle";
|
|
52
|
+
if (queryStatus === "pending") return "loading";
|
|
53
|
+
if (queryStatus === "error") return "error";
|
|
54
|
+
if (isInitializing) return "initializing";
|
|
55
|
+
return "ready";
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
export const useCredits = (): UseCreditsResult => {
|
|
43
59
|
const userId = useAuthStore(selectUserId);
|
|
44
60
|
const isAnonymous = useAuthStore(selectIsAnonymous);
|
|
45
61
|
const isRegisteredUser = !!userId && !isAnonymous;
|
|
62
|
+
const [isInitializingFreeCredits, setIsInitializingFreeCredits] = useState(false);
|
|
46
63
|
|
|
47
64
|
const isConfigured = isCreditsRepositoryConfigured();
|
|
48
65
|
const config = getCreditsConfig();
|
|
49
|
-
|
|
50
66
|
const queryEnabled = !!userId && isConfigured;
|
|
51
67
|
|
|
52
|
-
const { data,
|
|
68
|
+
const { data, status, error, refetch } = useQuery({
|
|
53
69
|
queryKey: creditsQueryKeys.user(userId ?? ""),
|
|
54
70
|
queryFn: async () => {
|
|
55
|
-
if (!userId || !isConfigured)
|
|
56
|
-
|
|
57
|
-
}
|
|
71
|
+
if (!userId || !isConfigured) return null;
|
|
72
|
+
|
|
58
73
|
const repository = getCreditsRepository();
|
|
59
74
|
const result = await repository.getCredits(userId);
|
|
75
|
+
|
|
60
76
|
if (!result.success) {
|
|
61
77
|
throw new Error(result.error?.message || "Failed to fetch credits");
|
|
62
78
|
}
|
|
@@ -80,67 +96,69 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
80
96
|
});
|
|
81
97
|
|
|
82
98
|
const credits = data ?? null;
|
|
83
|
-
|
|
84
99
|
const freeCredits = config.freeCredits ?? 0;
|
|
85
100
|
const autoInit = config.autoInitializeFreeCredits !== false && freeCredits > 0;
|
|
101
|
+
const querySuccess = status === "success";
|
|
86
102
|
|
|
87
103
|
useEffect(() => {
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
const shouldInitFreeCredits =
|
|
105
|
+
querySuccess &&
|
|
90
106
|
userId &&
|
|
91
107
|
isRegisteredUser &&
|
|
92
108
|
isConfigured &&
|
|
93
109
|
!credits &&
|
|
94
110
|
autoInit &&
|
|
95
|
-
!freeCreditsInitAttempted.has(userId)
|
|
96
|
-
|
|
111
|
+
!freeCreditsInitAttempted.has(userId);
|
|
112
|
+
|
|
113
|
+
if (shouldInitFreeCredits) {
|
|
97
114
|
freeCreditsInitAttempted.add(userId);
|
|
115
|
+
setIsInitializingFreeCredits(true);
|
|
98
116
|
|
|
99
117
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
100
|
-
console.log("[useCredits] Initializing free credits
|
|
118
|
+
console.log("[useCredits] Initializing free credits:", userId.slice(0, 8));
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
const repository = getCreditsRepository();
|
|
104
122
|
repository.initializeFreeCredits(userId).then((result) => {
|
|
123
|
+
setIsInitializingFreeCredits(false);
|
|
124
|
+
|
|
105
125
|
if (result.success) {
|
|
106
126
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
107
127
|
console.log("[useCredits] Free credits initialized:", result.data?.credits);
|
|
108
128
|
}
|
|
109
129
|
refetch();
|
|
110
|
-
} else {
|
|
111
|
-
|
|
112
|
-
console.warn("[useCredits] Free credits init failed:", result.error?.message);
|
|
113
|
-
}
|
|
130
|
+
} else if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
131
|
+
console.warn("[useCredits] Free credits init failed:", result.error?.message);
|
|
114
132
|
}
|
|
115
133
|
});
|
|
116
|
-
} else if (
|
|
134
|
+
} else if (querySuccess && userId && isAnonymous && !credits && autoInit) {
|
|
117
135
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
118
136
|
console.log("[useCredits] Skipping free credits - anonymous user must register first");
|
|
119
137
|
}
|
|
120
138
|
}
|
|
121
|
-
}, [
|
|
139
|
+
}, [querySuccess, userId, isRegisteredUser, isAnonymous, isConfigured, credits, autoInit, refetch]);
|
|
122
140
|
|
|
123
141
|
const derivedValues = useMemo(() => {
|
|
124
142
|
const has = (credits?.credits ?? 0) > 0;
|
|
125
|
-
const percent = credits
|
|
126
|
-
? Math.round((credits.credits / config.creditLimit) * 100)
|
|
127
|
-
: 0;
|
|
128
|
-
|
|
143
|
+
const percent = credits ? Math.round((credits.credits / config.creditLimit) * 100) : 0;
|
|
129
144
|
return { hasCredits: has, creditsPercent: percent };
|
|
130
145
|
}, [credits, config.creditLimit]);
|
|
131
146
|
|
|
132
147
|
const canAfford = useCallback(
|
|
133
|
-
(cost: number): boolean =>
|
|
134
|
-
if (!credits) return false;
|
|
135
|
-
return credits.credits >= cost;
|
|
136
|
-
},
|
|
148
|
+
(cost: number): boolean => (credits?.credits ?? 0) >= cost,
|
|
137
149
|
[credits]
|
|
138
150
|
);
|
|
139
151
|
|
|
152
|
+
const loadStatus = deriveLoadStatus(status, isInitializingFreeCredits, queryEnabled);
|
|
153
|
+
const isCreditsLoaded = loadStatus === "ready";
|
|
154
|
+
const isLoading = loadStatus === "loading" || loadStatus === "initializing";
|
|
155
|
+
|
|
140
156
|
return {
|
|
141
157
|
credits,
|
|
142
158
|
isLoading,
|
|
143
|
-
|
|
159
|
+
isCreditsLoaded,
|
|
160
|
+
loadStatus,
|
|
161
|
+
error: error instanceof Error ? error : null,
|
|
144
162
|
hasCredits: derivedValues.hasCredits,
|
|
145
163
|
creditsPercent: derivedValues.creditsPercent,
|
|
146
164
|
refetch,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* useFeatureGate Hook
|
|
3
3
|
* Unified feature gate: Auth → Subscription → Credits
|
|
4
4
|
* Uses ref pattern to avoid stale closure issues.
|
|
5
|
+
* Event-driven approach - no polling, no waiting.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { useCallback, useRef, useEffect } from "react";
|
|
@@ -15,6 +16,7 @@ export interface UseFeatureGateParams {
|
|
|
15
16
|
readonly creditBalance: number;
|
|
16
17
|
readonly requiredCredits?: number;
|
|
17
18
|
readonly onShowPaywall: (requiredCredits?: number) => void;
|
|
19
|
+
readonly isCreditsLoaded?: boolean;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export interface UseFeatureGateResult {
|
|
@@ -34,15 +36,18 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
|
|
|
34
36
|
creditBalance,
|
|
35
37
|
requiredCredits = 1,
|
|
36
38
|
onShowPaywall,
|
|
39
|
+
isCreditsLoaded = true,
|
|
37
40
|
} = params;
|
|
38
41
|
|
|
39
42
|
const pendingActionRef = useRef<(() => void | Promise<void>) | null>(null);
|
|
40
43
|
const prevCreditBalanceRef = useRef(creditBalance);
|
|
41
44
|
const isWaitingForPurchaseRef = useRef(false);
|
|
45
|
+
const isWaitingForAuthCreditsRef = useRef(false);
|
|
42
46
|
|
|
43
47
|
const creditBalanceRef = useRef(creditBalance);
|
|
44
48
|
const hasSubscriptionRef = useRef(hasSubscription);
|
|
45
49
|
const onShowPaywallRef = useRef(onShowPaywall);
|
|
50
|
+
const requiredCreditsRef = useRef(requiredCredits);
|
|
46
51
|
|
|
47
52
|
useEffect(() => {
|
|
48
53
|
creditBalanceRef.current = creditBalance;
|
|
@@ -56,6 +61,48 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
|
|
|
56
61
|
onShowPaywallRef.current = onShowPaywall;
|
|
57
62
|
}, [onShowPaywall]);
|
|
58
63
|
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
requiredCreditsRef.current = requiredCredits;
|
|
66
|
+
}, [requiredCredits]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!isWaitingForAuthCreditsRef.current || !isCreditsLoaded || !pendingActionRef.current) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
74
|
+
console.log("[useFeatureGate] Credits loaded after auth", {
|
|
75
|
+
credits: creditBalance,
|
|
76
|
+
hasSubscription,
|
|
77
|
+
isCreditsLoaded,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
isWaitingForAuthCreditsRef.current = false;
|
|
82
|
+
|
|
83
|
+
if (hasSubscription || creditBalance >= requiredCredits) {
|
|
84
|
+
const action = pendingActionRef.current;
|
|
85
|
+
pendingActionRef.current = null;
|
|
86
|
+
|
|
87
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
88
|
+
console.log("[useFeatureGate] Proceeding with action after auth", {
|
|
89
|
+
credits: creditBalance,
|
|
90
|
+
hasSubscription,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
action();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
98
|
+
console.log("[useFeatureGate] No credits after auth, showing paywall", {
|
|
99
|
+
credits: creditBalance,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
isWaitingForPurchaseRef.current = true;
|
|
103
|
+
onShowPaywall(requiredCredits);
|
|
104
|
+
}, [isCreditsLoaded, creditBalance, hasSubscription, requiredCredits, onShowPaywall]);
|
|
105
|
+
|
|
59
106
|
useEffect(() => {
|
|
60
107
|
const prevBalance = prevCreditBalanceRef.current ?? 0;
|
|
61
108
|
const creditsIncreased = creditBalance > prevBalance;
|
|
@@ -82,57 +129,18 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
|
|
|
82
129
|
hasSubscription,
|
|
83
130
|
creditBalance: creditBalanceRef.current,
|
|
84
131
|
requiredCredits,
|
|
132
|
+
isCreditsLoaded,
|
|
85
133
|
});
|
|
86
134
|
}
|
|
87
135
|
|
|
88
136
|
if (!isAuthenticated) {
|
|
89
|
-
const postAuthAction =
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const checkInterval = 100;
|
|
93
|
-
let waited = 0;
|
|
94
|
-
|
|
95
|
-
while (waited < maxWaitTime) {
|
|
96
|
-
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
|
97
|
-
waited += checkInterval;
|
|
98
|
-
|
|
99
|
-
if (creditBalanceRef.current > 0 || hasSubscriptionRef.current) {
|
|
100
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
101
|
-
console.log("[useFeatureGate] Credits/subscription detected after auth", {
|
|
102
|
-
credits: creditBalanceRef.current,
|
|
103
|
-
hasSubscription: hasSubscriptionRef.current,
|
|
104
|
-
waitedMs: waited,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (hasSubscriptionRef.current) {
|
|
112
|
-
action();
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const currentBalance = creditBalanceRef.current;
|
|
117
|
-
if (currentBalance < requiredCredits) {
|
|
118
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
119
|
-
console.log("[useFeatureGate] No credits after waiting, showing paywall", {
|
|
120
|
-
credits: currentBalance,
|
|
121
|
-
waitedMs: waited,
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
pendingActionRef.current = action;
|
|
125
|
-
isWaitingForPurchaseRef.current = true;
|
|
126
|
-
onShowPaywallRef.current(requiredCredits);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
137
|
+
const postAuthAction = () => {
|
|
138
|
+
pendingActionRef.current = action;
|
|
139
|
+
isWaitingForAuthCreditsRef.current = true;
|
|
129
140
|
|
|
130
141
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
131
|
-
console.log("[useFeatureGate]
|
|
132
|
-
credits: currentBalance,
|
|
133
|
-
});
|
|
142
|
+
console.log("[useFeatureGate] Auth completed, waiting for credits to load");
|
|
134
143
|
}
|
|
135
|
-
action();
|
|
136
144
|
};
|
|
137
145
|
onShowAuthModal(postAuthAction);
|
|
138
146
|
return;
|
|
@@ -156,7 +164,7 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
|
|
|
156
164
|
|
|
157
165
|
action();
|
|
158
166
|
},
|
|
159
|
-
[isAuthenticated, hasSubscription, requiredCredits, onShowAuthModal, onShowPaywall]
|
|
167
|
+
[isAuthenticated, hasSubscription, requiredCredits, onShowAuthModal, onShowPaywall, isCreditsLoaded]
|
|
160
168
|
);
|
|
161
169
|
|
|
162
170
|
const hasCredits = creditBalance >= requiredCredits;
|