@umituz/react-native-subscription 2.27.138 → 2.27.140
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/useCredits.ts +15 -8
- package/src/domains/credits/utils/creditValidation.ts +27 -0
- package/src/domains/subscription/infrastructure/hooks/useCustomerInfo.ts +7 -5
- package/src/domains/subscription/presentation/components/details/PremiumDetailsCard.tsx +1 -0
- package/src/domains/subscription/presentation/usePremium.ts +8 -3
- package/src/domains/subscription/presentation/useSubscriptionStatus.ts +13 -5
- package/src/domains/subscription/utils/authGuards.ts +5 -0
- package/src/domains/subscription/utils/syncStatus.ts +21 -0
- package/src/shared/utils/numberUtils.core.ts +3 -36
- package/src/shared/utils/queryKeyFactory.ts +9 -0
- package/src/shared/utils/typeGuards.ts +15 -0
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.140",
|
|
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,5 @@
|
|
|
1
1
|
import { useQuery, useQueryClient } from "@umituz/react-native-design-system";
|
|
2
|
-
import { useCallback, useMemo, useEffect } from "react";
|
|
2
|
+
import { useCallback, useMemo, useEffect, useRef } from "react";
|
|
3
3
|
import { useAuthStore, selectUserId } from "@umituz/react-native-auth";
|
|
4
4
|
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
5
5
|
import type { UserCredits } from "../core/Credits";
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
isCreditsRepositoryConfigured,
|
|
10
10
|
} from "../infrastructure/CreditsRepositoryManager";
|
|
11
11
|
import { calculateCreditPercentage, canAfford as canAffordCheck } from "../../../shared/utils/numberUtils";
|
|
12
|
+
import { createUserQueryKey } from "../../../shared/utils/queryKeyFactory";
|
|
13
|
+
import { isAuthenticated } from "../../subscription/utils/authGuards";
|
|
12
14
|
|
|
13
15
|
export const creditsQueryKeys = {
|
|
14
16
|
all: ["credits"] as const,
|
|
@@ -42,14 +44,14 @@ function deriveLoadStatus(
|
|
|
42
44
|
export const useCredits = (): UseCreditsResult => {
|
|
43
45
|
const userId = useAuthStore(selectUserId);
|
|
44
46
|
const isConfigured = isCreditsRepositoryConfigured();
|
|
45
|
-
|
|
47
|
+
|
|
46
48
|
const config = isConfigured ? getCreditsConfig() : null;
|
|
47
|
-
const queryEnabled =
|
|
49
|
+
const queryEnabled = isAuthenticated(userId) && isConfigured;
|
|
48
50
|
|
|
49
51
|
const { data, status, error, refetch } = useQuery({
|
|
50
|
-
queryKey: creditsQueryKeys.
|
|
52
|
+
queryKey: createUserQueryKey(creditsQueryKeys.all, userId, creditsQueryKeys.user),
|
|
51
53
|
queryFn: async () => {
|
|
52
|
-
if (!userId || !isConfigured) return null;
|
|
54
|
+
if (!isAuthenticated(userId) || !isConfigured) return null;
|
|
53
55
|
|
|
54
56
|
const repository = getCreditsRepository();
|
|
55
57
|
const result = await repository.getCredits(userId);
|
|
@@ -67,18 +69,23 @@ export const useCredits = (): UseCreditsResult => {
|
|
|
67
69
|
});
|
|
68
70
|
|
|
69
71
|
const queryClient = useQueryClient();
|
|
72
|
+
const queryClientRef = useRef(queryClient);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
queryClientRef.current = queryClient;
|
|
76
|
+
}, [queryClient]);
|
|
70
77
|
|
|
71
78
|
useEffect(() => {
|
|
72
|
-
if (!userId) return;
|
|
79
|
+
if (!isAuthenticated(userId)) return;
|
|
73
80
|
|
|
74
81
|
const unsubscribe = subscriptionEventBus.on(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, (updatedUserId) => {
|
|
75
82
|
if (updatedUserId === userId) {
|
|
76
|
-
|
|
83
|
+
queryClientRef.current.invalidateQueries({ queryKey: creditsQueryKeys.user(userId) });
|
|
77
84
|
}
|
|
78
85
|
});
|
|
79
86
|
|
|
80
87
|
return unsubscribe;
|
|
81
|
-
}, [userId
|
|
88
|
+
}, [userId]);
|
|
82
89
|
|
|
83
90
|
const credits = data ?? null;
|
|
84
91
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isDefined, isValidNumber, isNonNegative } from "../../../shared/utils/typeGuards";
|
|
2
|
+
|
|
3
|
+
export const isValidBalance = (balance: number | null | undefined): balance is number => {
|
|
4
|
+
return isValidNumber(balance) && isNonNegative(balance);
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const isValidCost = (cost: number): boolean => {
|
|
8
|
+
return isValidNumber(cost) && isNonNegative(cost);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const isValidMaxCredits = (max: number): boolean => {
|
|
12
|
+
return isValidNumber(max) && max > 0;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const canAffordAmount = (balance: number | null | undefined, cost: number): boolean => {
|
|
16
|
+
if (!isValidBalance(balance) || !isValidCost(cost)) return false;
|
|
17
|
+
return balance >= cost;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const calculateSafePercentage = (
|
|
21
|
+
current: number | null | undefined,
|
|
22
|
+
max: number
|
|
23
|
+
): number => {
|
|
24
|
+
if (!isValidNumber(current) || !isValidMaxCredits(max)) return 0;
|
|
25
|
+
const percentage = (current / max) * 100;
|
|
26
|
+
return Math.min(Math.max(percentage, 0), 100);
|
|
27
|
+
};
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* @see https://www.revenuecat.com/docs/customers/customer-info
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { useEffect, useState, useCallback } from "react";
|
|
17
|
+
import { useEffect, useState, useCallback, useRef } from "react";
|
|
18
18
|
import Purchases, { type CustomerInfo } from "react-native-purchases";
|
|
19
19
|
|
|
20
20
|
export interface UseCustomerInfoResult {
|
|
@@ -81,21 +81,23 @@ export function useCustomerInfo(): UseCustomerInfoResult {
|
|
|
81
81
|
}
|
|
82
82
|
}, []);
|
|
83
83
|
|
|
84
|
+
const listenerRef = useRef<((info: CustomerInfo) => void) | null>(null);
|
|
85
|
+
|
|
84
86
|
useEffect(() => {
|
|
85
|
-
// Initial fetch
|
|
86
87
|
fetchCustomerInfo();
|
|
87
88
|
|
|
88
|
-
// Listen for real-time updates (renewals, purchases, restore)
|
|
89
89
|
const listener = (info: CustomerInfo) => {
|
|
90
90
|
setCustomerInfo(info);
|
|
91
91
|
setError(null);
|
|
92
92
|
};
|
|
93
93
|
|
|
94
|
+
listenerRef.current = listener;
|
|
94
95
|
Purchases.addCustomerInfoUpdateListener(listener);
|
|
95
96
|
|
|
96
|
-
// Cleanup listener on unmount
|
|
97
97
|
return () => {
|
|
98
|
-
|
|
98
|
+
if (listenerRef.current) {
|
|
99
|
+
Purchases.removeCustomerInfoUpdateListener(listenerRef.current);
|
|
100
|
+
}
|
|
99
101
|
};
|
|
100
102
|
}, [fetchCustomerInfo]);
|
|
101
103
|
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
useRestorePurchase,
|
|
9
9
|
} from '../infrastructure/hooks/useSubscriptionQueries';
|
|
10
10
|
import { usePaywallVisibility } from './usePaywallVisibility';
|
|
11
|
-
|
|
11
|
+
import { isPremiumSyncPending } from '../utils/syncStatus';
|
|
12
12
|
import { UsePremiumResult } from './usePremium.types';
|
|
13
13
|
|
|
14
14
|
export const usePremium = (): UsePremiumResult => {
|
|
@@ -22,9 +22,14 @@ export const usePremium = (): UsePremiumResult => {
|
|
|
22
22
|
const restoreMutation = useRestorePurchase();
|
|
23
23
|
|
|
24
24
|
const { showPaywall, setShowPaywall, closePaywall, openPaywall } = usePaywallVisibility();
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
const isPremium = subscriptionActive || (credits?.isPremium ?? false);
|
|
27
|
-
const isSyncing =
|
|
27
|
+
const isSyncing = isPremiumSyncPending({
|
|
28
|
+
statusLoading,
|
|
29
|
+
creditsLoading,
|
|
30
|
+
subscriptionActive,
|
|
31
|
+
credits,
|
|
32
|
+
});
|
|
28
33
|
|
|
29
34
|
const handlePurchase = useCallback(
|
|
30
35
|
async (pkg: PurchasesPackage): Promise<boolean> => {
|
|
@@ -4,6 +4,8 @@ import { useAuthStore, selectUserId } from "@umituz/react-native-auth";
|
|
|
4
4
|
import { SubscriptionManager } from "../infrastructure/managers/SubscriptionManager";
|
|
5
5
|
import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
|
|
6
6
|
import { SubscriptionStatusResult } from "./useSubscriptionStatus.types";
|
|
7
|
+
import { createUserQueryKey } from "../../../shared/utils/queryKeyFactory";
|
|
8
|
+
import { isAuthenticated } from "../utils/authGuards";
|
|
7
9
|
|
|
8
10
|
export const subscriptionStatusQueryKeys = {
|
|
9
11
|
all: ["subscriptionStatus"] as const,
|
|
@@ -14,10 +16,16 @@ export const useSubscriptionStatus = (): SubscriptionStatusResult => {
|
|
|
14
16
|
const userId = useAuthStore(selectUserId);
|
|
15
17
|
const queryClient = useQueryClient();
|
|
16
18
|
|
|
19
|
+
const queryEnabled = isAuthenticated(userId) && SubscriptionManager.isInitializedForUser(userId);
|
|
20
|
+
|
|
17
21
|
const { data, status, error, refetch } = useQuery({
|
|
18
|
-
queryKey:
|
|
22
|
+
queryKey: createUserQueryKey(
|
|
23
|
+
subscriptionStatusQueryKeys.all,
|
|
24
|
+
userId,
|
|
25
|
+
subscriptionStatusQueryKeys.user
|
|
26
|
+
),
|
|
19
27
|
queryFn: async () => {
|
|
20
|
-
if (!userId) {
|
|
28
|
+
if (!isAuthenticated(userId)) {
|
|
21
29
|
return { isPremium: false, expirationDate: null };
|
|
22
30
|
}
|
|
23
31
|
|
|
@@ -28,14 +36,14 @@ export const useSubscriptionStatus = (): SubscriptionStatusResult => {
|
|
|
28
36
|
return { isPremium: false, expirationDate: null };
|
|
29
37
|
}
|
|
30
38
|
},
|
|
31
|
-
enabled:
|
|
39
|
+
enabled: queryEnabled,
|
|
32
40
|
});
|
|
33
41
|
|
|
34
42
|
useEffect(() => {
|
|
35
|
-
if (!userId) return;
|
|
43
|
+
if (!isAuthenticated(userId)) return;
|
|
36
44
|
|
|
37
45
|
const unsubscribe = subscriptionEventBus.on(
|
|
38
|
-
SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED,
|
|
46
|
+
SUBSCRIPTION_EVENTS.PREMIUM_STATUS_CHANGED,
|
|
39
47
|
(event: { userId: string; isPremium: boolean }) => {
|
|
40
48
|
if (event.userId === userId) {
|
|
41
49
|
queryClient.invalidateQueries({
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isDefined } from "../../../shared/utils/typeGuards";
|
|
2
|
+
import type { UserCredits } from "../../credits/core/Credits";
|
|
3
|
+
|
|
4
|
+
export interface SyncState {
|
|
5
|
+
statusLoading: boolean;
|
|
6
|
+
creditsLoading: boolean;
|
|
7
|
+
subscriptionActive: boolean;
|
|
8
|
+
credits: UserCredits | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const isPremiumSyncPending = (state: SyncState): boolean => {
|
|
12
|
+
const { statusLoading, creditsLoading, subscriptionActive, credits } = state;
|
|
13
|
+
|
|
14
|
+
if (statusLoading || creditsLoading) return false;
|
|
15
|
+
|
|
16
|
+
if (!subscriptionActive) return false;
|
|
17
|
+
|
|
18
|
+
if (!isDefined(credits)) return false;
|
|
19
|
+
|
|
20
|
+
return !credits.isPremium;
|
|
21
|
+
};
|
|
@@ -1,73 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
* Number Utilities - Core Operations
|
|
3
|
-
* Basic numeric calculation functions
|
|
4
|
-
*/
|
|
1
|
+
import { canAffordAmount, calculateSafePercentage } from "../../domains/credits/utils/creditValidation";
|
|
5
2
|
|
|
6
|
-
/**
|
|
7
|
-
* Clamp a number between min and max values
|
|
8
|
-
*/
|
|
9
3
|
export function clamp(value: number, min: number, max: number): number {
|
|
10
4
|
return Math.min(Math.max(value, min), max);
|
|
11
5
|
}
|
|
12
6
|
|
|
13
|
-
/**
|
|
14
|
-
* Round a number to specified decimal places
|
|
15
|
-
*/
|
|
16
7
|
export function roundTo(value: number, decimals: number = 2): number {
|
|
17
8
|
const multiplier = Math.pow(10, decimals);
|
|
18
9
|
return Math.round(value * multiplier) / multiplier;
|
|
19
10
|
}
|
|
20
11
|
|
|
21
|
-
/**
|
|
22
|
-
* Calculate percentage
|
|
23
|
-
*/
|
|
24
12
|
export function calculatePercentage(value: number, total: number): number {
|
|
25
13
|
if (total === 0) return 0;
|
|
26
14
|
return (value / total) * 100;
|
|
27
15
|
}
|
|
28
16
|
|
|
29
|
-
/**
|
|
30
|
-
* Calculate percentage and clamp between 0-100
|
|
31
|
-
*/
|
|
32
17
|
export function calculatePercentageClamped(value: number, total: number): number {
|
|
33
18
|
return clamp(calculatePercentage(value, total), 0, 100);
|
|
34
19
|
}
|
|
35
20
|
|
|
36
|
-
/**
|
|
37
|
-
* Check if two numbers are approximately equal (within epsilon)
|
|
38
|
-
*/
|
|
39
21
|
export function isApproximatelyEqual(a: number, b: number, epsilon: number = 0.0001): boolean {
|
|
40
22
|
return Math.abs(a - b) < epsilon;
|
|
41
23
|
}
|
|
42
24
|
|
|
43
|
-
/**
|
|
44
|
-
* Safe division that returns 0 instead of NaN
|
|
45
|
-
*/
|
|
46
25
|
export function safeDivide(numerator: number, denominator: number): number {
|
|
47
26
|
if (denominator === 0) return 0;
|
|
48
27
|
return numerator / denominator;
|
|
49
28
|
}
|
|
50
29
|
|
|
51
|
-
/**
|
|
52
|
-
* Calculate remaining value after subtraction with floor at 0
|
|
53
|
-
*/
|
|
54
30
|
export function calculateRemaining(current: number, cost: number): number {
|
|
55
31
|
return Math.max(0, current - cost);
|
|
56
32
|
}
|
|
57
33
|
|
|
58
|
-
/**
|
|
59
|
-
* Check if user can afford a cost
|
|
60
|
-
*/
|
|
61
34
|
export function canAfford(balance: number | null | undefined, cost: number): boolean {
|
|
62
|
-
|
|
63
|
-
return balance >= cost;
|
|
35
|
+
return canAffordAmount(balance, cost);
|
|
64
36
|
}
|
|
65
37
|
|
|
66
|
-
/**
|
|
67
|
-
* Calculate credit percentage for UI display
|
|
68
|
-
*/
|
|
69
38
|
export function calculateCreditPercentage(current: number | null | undefined, max: number): number {
|
|
70
|
-
|
|
71
|
-
if (max <= 0) return 100;
|
|
72
|
-
return calculatePercentageClamped(current, max);
|
|
39
|
+
return calculateSafePercentage(current, max);
|
|
73
40
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { isAuthenticated } from "../../domains/subscription/utils/authGuards";
|
|
2
|
+
|
|
3
|
+
export const createUserQueryKey = <T extends readonly unknown[]>(
|
|
4
|
+
baseKey: T,
|
|
5
|
+
userId: string | null | undefined,
|
|
6
|
+
userSpecificKey: (userId: string) => readonly unknown[]
|
|
7
|
+
): readonly unknown[] => {
|
|
8
|
+
return isAuthenticated(userId) ? userSpecificKey(userId) : baseKey;
|
|
9
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const isDefined = <T>(value: T | null | undefined): value is T => {
|
|
2
|
+
return value !== null && value !== undefined;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export const isPositive = (value: number): boolean => {
|
|
6
|
+
return value > 0;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const isNonNegative = (value: number): boolean => {
|
|
10
|
+
return value >= 0;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const isValidNumber = (value: number | null | undefined): value is number => {
|
|
14
|
+
return isDefined(value) && !isNaN(value) && isFinite(value);
|
|
15
|
+
};
|