@umituz/react-native-subscription 2.14.86 → 2.14.88
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 +31 -4
- package/src/domains/wallet/domain/mappers/TransactionMapper.ts +38 -0
- package/src/domains/wallet/infrastructure/repositories/TransactionRepository.ts +5 -23
- package/src/infrastructure/mappers/CreditsMapper.ts +27 -0
- package/src/infrastructure/repositories/CreditsRepository.ts +11 -2
- package/src/presentation/components/details/CreditRow.tsx +39 -25
- package/src/presentation/components/details/DetailRow.tsx +19 -7
- package/src/presentation/hooks/useSubscription.ts +33 -71
- package/src/presentation/hooks/useSubscriptionDetails.ts +7 -31
- package/src/presentation/screens/components/CreditsList.tsx +2 -2
- package/src/presentation/screens/components/SubscriptionHeader.tsx +10 -44
- package/src/presentation/types/SubscriptionDetailTypes.ts +2 -2
- package/src/revenuecat/infrastructure/services/RevenueCatInitializer.ts +13 -11
- package/src/revenuecat/infrastructure/services/RevenueCatService.ts +15 -33
- package/src/revenuecat/infrastructure/services/ServiceStateManager.ts +1 -1
- package/src/presentation/screens/components/CreditItem.tsx +0 -95
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.88",
|
|
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",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@umituz/react-native-design-system": "latest",
|
|
36
36
|
"@umituz/react-native-firebase": "latest",
|
|
37
|
-
"@umituz/react-native-localization": "latest"
|
|
37
|
+
"@umituz/react-native-localization": "latest",
|
|
38
|
+
"@umituz/react-native-storage": "latest"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
41
|
"@tanstack/react-query": ">=5.0.0",
|
|
@@ -48,27 +49,53 @@
|
|
|
48
49
|
"react-native-safe-area-context": ">=5.0.0"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
52
|
+
"@expo/vector-icons": "^15.0.3",
|
|
53
|
+
"@gorhom/bottom-sheet": "^5.2.8",
|
|
54
|
+
"@react-native-async-storage/async-storage": "^2.2.0",
|
|
55
|
+
"@react-native-community/datetimepicker": "^8.6.0",
|
|
56
|
+
"@react-navigation/bottom-tabs": "^7.9.0",
|
|
57
|
+
"@react-navigation/native": "^7.1.26",
|
|
58
|
+
"@react-navigation/stack": "^7.6.13",
|
|
51
59
|
"@tanstack/react-query": "^5.0.0",
|
|
52
60
|
"@types/react": "~19.1.10",
|
|
53
61
|
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
|
54
62
|
"@typescript-eslint/parser": "^8.50.1",
|
|
55
63
|
"@umituz/react-native-auth": "*",
|
|
56
|
-
"@umituz/react-native-storage": "*",
|
|
57
64
|
"@umituz/react-native-design-system": "*",
|
|
65
|
+
"@umituz/react-native-filesystem": "^2.1.20",
|
|
58
66
|
"@umituz/react-native-firebase": "*",
|
|
67
|
+
"@umituz/react-native-haptics": "^1.0.5",
|
|
59
68
|
"@umituz/react-native-localization": "*",
|
|
69
|
+
"@umituz/react-native-storage": "*",
|
|
70
|
+
"@umituz/react-native-uuid": "^1.2.6",
|
|
60
71
|
"eslint": "^9.39.2",
|
|
61
72
|
"eslint-plugin-react": "^7.37.5",
|
|
62
73
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
74
|
+
"expo-apple-authentication": "^8.0.8",
|
|
75
|
+
"expo-application": "^7.0.8",
|
|
76
|
+
"expo-clipboard": "^8.0.8",
|
|
63
77
|
"expo-constants": "~16.0.0",
|
|
78
|
+
"expo-crypto": "^15.0.8",
|
|
79
|
+
"expo-device": "^8.0.10",
|
|
80
|
+
"expo-file-system": "^19.0.21",
|
|
81
|
+
"expo-haptics": "^15.0.8",
|
|
64
82
|
"expo-image": "~3.0.0",
|
|
65
83
|
"expo-linear-gradient": "~15.0.0",
|
|
84
|
+
"expo-localization": "^17.0.8",
|
|
85
|
+
"expo-sharing": "^14.0.8",
|
|
66
86
|
"firebase": "^11.0.0",
|
|
87
|
+
"i18next": "^25.7.3",
|
|
67
88
|
"react": "19.1.0",
|
|
89
|
+
"react-i18next": "^16.5.1",
|
|
68
90
|
"react-native": "0.81.5",
|
|
91
|
+
"react-native-gesture-handler": "^2.30.0",
|
|
69
92
|
"react-native-purchases": "^7.0.0",
|
|
93
|
+
"react-native-reanimated": "^4.2.1",
|
|
70
94
|
"react-native-safe-area-context": "^5.0.0",
|
|
71
|
-
"
|
|
95
|
+
"react-native-svg": "^15.15.1",
|
|
96
|
+
"rn-emoji-keyboard": "^1.7.0",
|
|
97
|
+
"typescript": "~5.9.2",
|
|
98
|
+
"zustand": "^5.0.9"
|
|
72
99
|
},
|
|
73
100
|
"publishConfig": {
|
|
74
101
|
"access": "public"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transaction Mapper
|
|
3
|
+
* Maps Firestore data to CreditLog entity
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { QueryDocumentSnapshot, DocumentData } from "firebase/firestore";
|
|
7
|
+
import type { CreditLog } from "../types/transaction.types";
|
|
8
|
+
|
|
9
|
+
export class TransactionMapper {
|
|
10
|
+
static toEntity(docSnap: QueryDocumentSnapshot<DocumentData>, defaultUserId: string): CreditLog {
|
|
11
|
+
const data = docSnap.data();
|
|
12
|
+
return {
|
|
13
|
+
id: docSnap.id,
|
|
14
|
+
userId: data.userId || defaultUserId,
|
|
15
|
+
change: data.change,
|
|
16
|
+
reason: data.reason,
|
|
17
|
+
feature: data.feature,
|
|
18
|
+
jobId: data.jobId,
|
|
19
|
+
packageId: data.packageId,
|
|
20
|
+
subscriptionPlan: data.subscriptionPlan,
|
|
21
|
+
description: data.description,
|
|
22
|
+
createdAt: data.createdAt?.toMillis?.() || Date.now(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static toFirestore(userId: string, change: number, reason: string, metadata?: Partial<CreditLog>) {
|
|
27
|
+
return {
|
|
28
|
+
userId,
|
|
29
|
+
change,
|
|
30
|
+
reason,
|
|
31
|
+
feature: metadata?.feature,
|
|
32
|
+
jobId: metadata?.jobId,
|
|
33
|
+
packageId: metadata?.packageId,
|
|
34
|
+
subscriptionPlan: metadata?.subscriptionPlan,
|
|
35
|
+
description: metadata?.description,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
TransactionResult,
|
|
26
26
|
TransactionReason,
|
|
27
27
|
} from "../../domain/types/transaction.types";
|
|
28
|
+
import { TransactionMapper } from "../../domain/mappers/TransactionMapper";
|
|
28
29
|
|
|
29
30
|
export class TransactionRepository extends BaseRepository {
|
|
30
31
|
private config: TransactionRepositoryConfig;
|
|
@@ -66,21 +67,9 @@ export class TransactionRepository extends BaseRepository {
|
|
|
66
67
|
const q = query(colRef, ...constraints);
|
|
67
68
|
const snapshot = await getDocs(q);
|
|
68
69
|
|
|
69
|
-
const transactions: CreditLog[] = snapshot.docs.map((docSnap) =>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
id: docSnap.id,
|
|
73
|
-
userId: data.userId || options.userId,
|
|
74
|
-
change: data.change,
|
|
75
|
-
reason: data.reason,
|
|
76
|
-
feature: data.feature,
|
|
77
|
-
jobId: data.jobId,
|
|
78
|
-
packageId: data.packageId,
|
|
79
|
-
subscriptionPlan: data.subscriptionPlan,
|
|
80
|
-
description: data.description,
|
|
81
|
-
createdAt: data.createdAt?.toMillis?.() || Date.now(),
|
|
82
|
-
};
|
|
83
|
-
});
|
|
70
|
+
const transactions: CreditLog[] = snapshot.docs.map((docSnap) =>
|
|
71
|
+
TransactionMapper.toEntity(docSnap, options.userId)
|
|
72
|
+
);
|
|
84
73
|
|
|
85
74
|
return { success: true, data: transactions };
|
|
86
75
|
} catch (error) {
|
|
@@ -112,14 +101,7 @@ export class TransactionRepository extends BaseRepository {
|
|
|
112
101
|
try {
|
|
113
102
|
const colRef = this.getCollectionRef(db, userId);
|
|
114
103
|
const docData = {
|
|
115
|
-
userId,
|
|
116
|
-
change,
|
|
117
|
-
reason,
|
|
118
|
-
feature: metadata?.feature,
|
|
119
|
-
jobId: metadata?.jobId,
|
|
120
|
-
packageId: metadata?.packageId,
|
|
121
|
-
subscriptionPlan: metadata?.subscriptionPlan,
|
|
122
|
-
description: metadata?.description,
|
|
104
|
+
...TransactionMapper.toFirestore(userId, change, reason, metadata),
|
|
123
105
|
createdAt: serverTimestamp(),
|
|
124
106
|
};
|
|
125
107
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credits Mapper
|
|
3
|
+
* Maps Firestore data to UserCredits entity and vice-versa.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { UserCredits } from "../../domain/entities/Credits";
|
|
7
|
+
import type { UserCreditsDocumentRead } from "../models/UserCreditsDocument";
|
|
8
|
+
|
|
9
|
+
export class CreditsMapper {
|
|
10
|
+
static toEntity(snapData: UserCreditsDocumentRead): UserCredits {
|
|
11
|
+
return {
|
|
12
|
+
textCredits: snapData.textCredits,
|
|
13
|
+
imageCredits: snapData.imageCredits,
|
|
14
|
+
purchasedAt: snapData.purchasedAt?.toDate?.() || new Date(),
|
|
15
|
+
lastUpdatedAt: snapData.lastUpdatedAt?.toDate?.() || new Date(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static toFirestore(data: Partial<UserCredits>): Record<string, any> {
|
|
20
|
+
return {
|
|
21
|
+
textCredits: data.textCredits,
|
|
22
|
+
imageCredits: data.imageCredits,
|
|
23
|
+
// Timestamps are usually handled by serverTimestamp() in repos,
|
|
24
|
+
// but we can map them if needed.
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -10,6 +10,8 @@ import { initializeCreditsTransaction } from "../services/CreditsInitializer";
|
|
|
10
10
|
import { detectPackageType } from "../../utils/packageTypeDetector";
|
|
11
11
|
import { getCreditAllocation } from "../../utils/creditMapper";
|
|
12
12
|
|
|
13
|
+
import { CreditsMapper } from "../mappers/CreditsMapper";
|
|
14
|
+
|
|
13
15
|
export class CreditsRepository extends BaseRepository {
|
|
14
16
|
constructor(private config: CreditsConfig) { super(); }
|
|
15
17
|
|
|
@@ -26,7 +28,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
26
28
|
const snap = await getDoc(this.getRef(db, userId));
|
|
27
29
|
if (!snap.exists()) return { success: true, data: undefined };
|
|
28
30
|
const d = snap.data() as UserCreditsDocumentRead;
|
|
29
|
-
return { success: true, data:
|
|
31
|
+
return { success: true, data: CreditsMapper.toEntity(d) };
|
|
30
32
|
} catch (e: any) { return { success: false, error: { message: e.message, code: "FETCH_ERR" } }; }
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -44,7 +46,14 @@ export class CreditsRepository extends BaseRepository {
|
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
const res = await initializeCreditsTransaction(db, this.getRef(db, userId), cfg, purchaseId);
|
|
47
|
-
return {
|
|
49
|
+
return {
|
|
50
|
+
success: true,
|
|
51
|
+
data: CreditsMapper.toEntity({
|
|
52
|
+
...res,
|
|
53
|
+
purchasedAt: { toDate: () => new Date() } as any,
|
|
54
|
+
lastUpdatedAt: { toDate: () => new Date() } as any,
|
|
55
|
+
})
|
|
56
|
+
};
|
|
48
57
|
} catch (e: any) { return { success: false, error: { message: e.message, code: "INIT_ERR" } }; }
|
|
49
58
|
}
|
|
50
59
|
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Credit Row Component
|
|
3
|
-
* Displays credit information with progress bar
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import React from "react";
|
|
1
|
+
import React, { useMemo } from "react";
|
|
7
2
|
import { View, StyleSheet } from "react-native";
|
|
8
3
|
import { useAppDesignTokens, AtomicText } from "@umituz/react-native-design-system";
|
|
9
4
|
|
|
@@ -18,60 +13,79 @@ export const CreditRow: React.FC<CreditRowProps> = ({
|
|
|
18
13
|
label,
|
|
19
14
|
current,
|
|
20
15
|
total,
|
|
21
|
-
remainingLabel
|
|
16
|
+
remainingLabel,
|
|
22
17
|
}) => {
|
|
23
18
|
const tokens = useAppDesignTokens();
|
|
24
19
|
const percentage = total > 0 ? (current / total) * 100 : 0;
|
|
25
|
-
|
|
20
|
+
|
|
21
|
+
// Progress color based on percentage
|
|
22
|
+
const progressColor = useMemo(() => {
|
|
23
|
+
if (percentage <= 20) return tokens.colors.error;
|
|
24
|
+
if (percentage <= 50) return tokens.colors.warning;
|
|
25
|
+
return tokens.colors.success;
|
|
26
|
+
}, [percentage, tokens.colors]);
|
|
26
27
|
|
|
27
28
|
return (
|
|
28
29
|
<View style={styles.container}>
|
|
29
30
|
<View style={styles.header}>
|
|
30
|
-
<AtomicText type="
|
|
31
|
+
<AtomicText type="bodyMedium" style={[styles.label, { color: tokens.colors.textPrimary }]}>
|
|
31
32
|
{label}
|
|
32
33
|
</AtomicText>
|
|
33
|
-
<
|
|
34
|
-
type="
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
>
|
|
39
|
-
{current} / {total} {remainingLabel}
|
|
40
|
-
</AtomicText>
|
|
34
|
+
<View style={[styles.badge, { backgroundColor: tokens.colors.surfaceSecondary }]}>
|
|
35
|
+
<AtomicText type="labelSmall" style={[styles.count, { color: progressColor }]}>
|
|
36
|
+
{current} / {total}
|
|
37
|
+
</AtomicText>
|
|
38
|
+
</View>
|
|
41
39
|
</View>
|
|
42
|
-
<View
|
|
43
|
-
style={[styles.progressBar, { backgroundColor: tokens.colors.surfaceSecondary }]}
|
|
44
|
-
>
|
|
40
|
+
<View style={[styles.progressBar, { backgroundColor: tokens.colors.surfaceSecondary }]}>
|
|
45
41
|
<View
|
|
46
42
|
style={[
|
|
47
43
|
styles.progressFill,
|
|
48
44
|
{
|
|
49
45
|
width: `${percentage}%`,
|
|
50
|
-
backgroundColor:
|
|
46
|
+
backgroundColor: progressColor,
|
|
51
47
|
},
|
|
52
48
|
]}
|
|
53
49
|
/>
|
|
54
50
|
</View>
|
|
51
|
+
{remainingLabel && (
|
|
52
|
+
<AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }}>
|
|
53
|
+
{current} {remainingLabel}
|
|
54
|
+
</AtomicText>
|
|
55
|
+
)}
|
|
55
56
|
</View>
|
|
56
57
|
);
|
|
57
58
|
};
|
|
58
59
|
|
|
59
60
|
const styles = StyleSheet.create({
|
|
60
61
|
container: {
|
|
61
|
-
gap:
|
|
62
|
+
gap: 8,
|
|
63
|
+
marginVertical: 4,
|
|
62
64
|
},
|
|
63
65
|
header: {
|
|
64
66
|
flexDirection: "row",
|
|
65
67
|
justifyContent: "space-between",
|
|
66
68
|
alignItems: "center",
|
|
67
69
|
},
|
|
70
|
+
label: {
|
|
71
|
+
fontWeight: "500",
|
|
72
|
+
},
|
|
73
|
+
badge: {
|
|
74
|
+
paddingHorizontal: 8,
|
|
75
|
+
paddingVertical: 2,
|
|
76
|
+
borderRadius: 6,
|
|
77
|
+
},
|
|
78
|
+
count: {
|
|
79
|
+
fontWeight: "600",
|
|
80
|
+
},
|
|
68
81
|
progressBar: {
|
|
69
|
-
height:
|
|
70
|
-
borderRadius:
|
|
82
|
+
height: 8,
|
|
83
|
+
borderRadius: 4,
|
|
71
84
|
overflow: "hidden",
|
|
72
85
|
},
|
|
73
86
|
progressFill: {
|
|
74
87
|
height: "100%",
|
|
75
|
-
borderRadius:
|
|
88
|
+
borderRadius: 4,
|
|
76
89
|
},
|
|
77
90
|
});
|
|
91
|
+
|
|
@@ -4,33 +4,45 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import React from "react";
|
|
7
|
-
import { View, StyleSheet } from "react-native";
|
|
7
|
+
import { View, StyleSheet, type ViewStyle, type TextStyle } from "react-native";
|
|
8
8
|
import { useAppDesignTokens, AtomicText } from "@umituz/react-native-design-system";
|
|
9
9
|
|
|
10
10
|
export interface DetailRowProps {
|
|
11
11
|
label: string;
|
|
12
12
|
value: string;
|
|
13
13
|
highlight?: boolean;
|
|
14
|
+
style?: ViewStyle;
|
|
15
|
+
labelStyle?: TextStyle;
|
|
16
|
+
valueStyle?: TextStyle;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
export const DetailRow: React.FC<DetailRowProps> = ({
|
|
17
20
|
label,
|
|
18
21
|
value,
|
|
19
22
|
highlight = false,
|
|
23
|
+
style,
|
|
24
|
+
labelStyle,
|
|
25
|
+
valueStyle,
|
|
20
26
|
}) => {
|
|
21
27
|
const tokens = useAppDesignTokens();
|
|
22
28
|
|
|
23
29
|
return (
|
|
24
|
-
<View style={styles.container}>
|
|
25
|
-
<AtomicText
|
|
30
|
+
<View style={[styles.container, style]}>
|
|
31
|
+
<AtomicText
|
|
32
|
+
type="bodyMedium"
|
|
33
|
+
style={[{ color: tokens.colors.textSecondary }, labelStyle]}
|
|
34
|
+
>
|
|
26
35
|
{label}
|
|
27
36
|
</AtomicText>
|
|
28
37
|
<AtomicText
|
|
29
38
|
type="bodyMedium"
|
|
30
|
-
style={
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
style={[
|
|
40
|
+
{
|
|
41
|
+
color: highlight ? tokens.colors.warning : tokens.colors.textPrimary,
|
|
42
|
+
fontWeight: "500",
|
|
43
|
+
},
|
|
44
|
+
valueStyle,
|
|
45
|
+
]}
|
|
34
46
|
>
|
|
35
47
|
{value}
|
|
36
48
|
</AtomicText>
|
|
@@ -48,98 +48,60 @@ export function useSubscription(): UseSubscriptionResult {
|
|
|
48
48
|
const [loading, setLoading] = useState(false);
|
|
49
49
|
const [error, setError] = useState<string | null>(null);
|
|
50
50
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
const performOperation = useCallback(async (
|
|
52
|
+
userId: string,
|
|
53
|
+
operation: () => Promise<SubscriptionStatus | null | void>
|
|
54
|
+
) => {
|
|
55
|
+
const errorMsg = validateUserId(userId);
|
|
56
|
+
if (errorMsg) {
|
|
57
|
+
setError(errorMsg);
|
|
55
58
|
return;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
60
|
-
setError(
|
|
61
|
+
const check = checkSubscriptionService();
|
|
62
|
+
if (!check.success) {
|
|
63
|
+
setError(check.error || "Service error");
|
|
61
64
|
return;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
await executeSubscriptionOperation(
|
|
65
|
-
|
|
68
|
+
operation,
|
|
66
69
|
setLoading,
|
|
67
70
|
setError,
|
|
68
|
-
(result) => setStatus(result)
|
|
71
|
+
(result) => { if (result) setStatus(result as SubscriptionStatus); }
|
|
69
72
|
);
|
|
70
73
|
}, []);
|
|
71
74
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
75
|
+
const loadStatus = useCallback((userId: string) =>
|
|
76
|
+
performOperation(userId, () => {
|
|
77
|
+
const { service } = checkSubscriptionService();
|
|
78
|
+
|
|
79
|
+
return service!.getSubscriptionStatus(userId);
|
|
80
|
+
}), [performOperation]);
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
if (!serviceCheck.success) {
|
|
81
|
-
setError(serviceCheck.error || "Service error");
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
await executeSubscriptionOperation(
|
|
86
|
-
() => serviceCheck.service!.getSubscriptionStatus(userId),
|
|
87
|
-
setLoading,
|
|
88
|
-
setError,
|
|
89
|
-
(result) => setStatus(result)
|
|
90
|
-
);
|
|
91
|
-
}, []);
|
|
82
|
+
const refreshStatus = loadStatus;
|
|
92
83
|
|
|
93
84
|
const activateSubscription = useCallback(
|
|
94
|
-
|
|
95
|
-
const validationError = validateUserId(userId);
|
|
96
|
-
if (validationError) {
|
|
97
|
-
setError(validationError);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
85
|
+
(userId: string, productId: string, expiresAt: string | null) => {
|
|
101
86
|
if (!productId) {
|
|
102
87
|
setError("Product ID is required");
|
|
103
|
-
return;
|
|
88
|
+
return Promise.resolve();
|
|
104
89
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
await executeSubscriptionOperation(
|
|
113
|
-
() =>
|
|
114
|
-
serviceCheck.service!.activateSubscription(userId, productId, expiresAt),
|
|
115
|
-
setLoading,
|
|
116
|
-
setError,
|
|
117
|
-
(result) => setStatus(result)
|
|
118
|
-
);
|
|
90
|
+
return performOperation(userId, () => {
|
|
91
|
+
const { service } = checkSubscriptionService();
|
|
92
|
+
|
|
93
|
+
return service!.activateSubscription(userId, productId, expiresAt).then(res => res ?? undefined);
|
|
94
|
+
});
|
|
119
95
|
},
|
|
120
|
-
[]
|
|
96
|
+
[performOperation]
|
|
121
97
|
);
|
|
122
98
|
|
|
123
|
-
const deactivateSubscription = useCallback(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const serviceCheck = checkSubscriptionService();
|
|
131
|
-
if (!serviceCheck.success) {
|
|
132
|
-
setError(serviceCheck.error || "Service error");
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
await executeSubscriptionOperation(
|
|
137
|
-
() => serviceCheck.service!.deactivateSubscription(userId),
|
|
138
|
-
setLoading,
|
|
139
|
-
setError,
|
|
140
|
-
(result) => setStatus(result)
|
|
141
|
-
);
|
|
142
|
-
}, []);
|
|
99
|
+
const deactivateSubscription = useCallback((userId: string) =>
|
|
100
|
+
performOperation(userId, () => {
|
|
101
|
+
const { service } = checkSubscriptionService();
|
|
102
|
+
|
|
103
|
+
return service!.deactivateSubscription(userId).then(res => res ?? undefined);
|
|
104
|
+
}), [performOperation]);
|
|
143
105
|
|
|
144
106
|
const isPremium = isSubscriptionValid(status);
|
|
145
107
|
|
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useSubscriptionDetails Hook
|
|
3
|
-
* Provides formatted subscription details for display
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import { useMemo } from "react";
|
|
7
2
|
import type { SubscriptionStatus } from "../../domain/entities/SubscriptionStatus";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
isSubscriptionExpired,
|
|
11
|
-
} from "../../utils/dateValidationUtils";
|
|
3
|
+
import { isSubscriptionExpired } from "../../utils/dateValidationUtils";
|
|
4
|
+
import { formatDateForLocale, calculateDaysRemaining } from "../utils/subscriptionDateUtils";
|
|
12
5
|
|
|
13
6
|
export interface SubscriptionDetails {
|
|
14
7
|
/** Raw subscription status */
|
|
@@ -34,24 +27,6 @@ interface UseSubscriptionDetailsParams {
|
|
|
34
27
|
locale?: string;
|
|
35
28
|
}
|
|
36
29
|
|
|
37
|
-
/**
|
|
38
|
-
* Format date to localized string
|
|
39
|
-
*/
|
|
40
|
-
function formatDate(dateString: string | null, locale: string): string | null {
|
|
41
|
-
if (!dateString) return null;
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
const date = new Date(dateString);
|
|
45
|
-
return date.toLocaleDateString(locale, {
|
|
46
|
-
year: "numeric",
|
|
47
|
-
month: "long",
|
|
48
|
-
day: "numeric",
|
|
49
|
-
});
|
|
50
|
-
} catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
30
|
/**
|
|
56
31
|
* Hook for formatted subscription details
|
|
57
32
|
*/
|
|
@@ -76,7 +51,7 @@ export function useSubscriptionDetails(
|
|
|
76
51
|
|
|
77
52
|
const isExpired = isSubscriptionExpired(status);
|
|
78
53
|
const isLifetime = status.isPremium && !status.expiresAt;
|
|
79
|
-
const
|
|
54
|
+
const daysRemainingValue = calculateDaysRemaining(status.expiresAt ?? null);
|
|
80
55
|
const isPremium = status.isPremium && !isExpired;
|
|
81
56
|
|
|
82
57
|
let statusKey: "active" | "expired" | "none" = "none";
|
|
@@ -89,10 +64,11 @@ export function useSubscriptionDetails(
|
|
|
89
64
|
isPremium,
|
|
90
65
|
isExpired,
|
|
91
66
|
isLifetime,
|
|
92
|
-
daysRemaining,
|
|
93
|
-
formattedExpirationDate:
|
|
94
|
-
formattedPurchaseDate:
|
|
67
|
+
daysRemaining: daysRemainingValue,
|
|
68
|
+
formattedExpirationDate: formatDateForLocale(status.expiresAt ?? null, locale),
|
|
69
|
+
formattedPurchaseDate: formatDateForLocale(status.purchasedAt ?? null, locale),
|
|
95
70
|
statusKey,
|
|
96
71
|
};
|
|
97
72
|
}, [status, locale]);
|
|
98
73
|
}
|
|
74
|
+
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import React, { useMemo } from "react";
|
|
7
7
|
import { View, StyleSheet } from "react-native";
|
|
8
8
|
import { useAppDesignTokens, AtomicText } from "@umituz/react-native-design-system";
|
|
9
|
-
import {
|
|
9
|
+
import { CreditRow } from "../../components/details/CreditRow";
|
|
10
10
|
import type { CreditsListProps } from "../../types/SubscriptionDetailTypes";
|
|
11
11
|
|
|
12
12
|
export const CreditsList: React.FC<CreditsListProps> = ({
|
|
@@ -59,7 +59,7 @@ export const CreditsList: React.FC<CreditsListProps> = ({
|
|
|
59
59
|
)}
|
|
60
60
|
<View style={styles.list}>
|
|
61
61
|
{credits.map((credit) => (
|
|
62
|
-
<
|
|
62
|
+
<CreditRow
|
|
63
63
|
key={credit.id}
|
|
64
64
|
label={credit.label}
|
|
65
65
|
current={credit.current}
|
|
@@ -7,6 +7,7 @@ import React, { useMemo } from "react";
|
|
|
7
7
|
import { View, StyleSheet } from "react-native";
|
|
8
8
|
import { useAppDesignTokens, AtomicText } from "@umituz/react-native-design-system";
|
|
9
9
|
import { PremiumStatusBadge } from "../../components/details/PremiumStatusBadge";
|
|
10
|
+
import { DetailRow } from "../../components/details/DetailRow";
|
|
10
11
|
import type { SubscriptionHeaderProps } from "../../types/SubscriptionDetailTypes";
|
|
11
12
|
|
|
12
13
|
export const SubscriptionHeader: React.FC<SubscriptionHeaderProps> = ({
|
|
@@ -90,8 +91,9 @@ export const SubscriptionHeader: React.FC<SubscriptionHeaderProps> = ({
|
|
|
90
91
|
<DetailRow
|
|
91
92
|
label={translations.statusLabel}
|
|
92
93
|
value={translations.lifetimeLabel}
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
style={styles.row}
|
|
95
|
+
labelStyle={styles.label}
|
|
96
|
+
valueStyle={styles.value}
|
|
95
97
|
/>
|
|
96
98
|
) : (
|
|
97
99
|
<>
|
|
@@ -100,16 +102,18 @@ export const SubscriptionHeader: React.FC<SubscriptionHeaderProps> = ({
|
|
|
100
102
|
label={translations.expiresLabel}
|
|
101
103
|
value={expirationDate}
|
|
102
104
|
highlight={showExpiring}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
style={styles.row}
|
|
106
|
+
labelStyle={styles.label}
|
|
107
|
+
valueStyle={styles.value}
|
|
105
108
|
/>
|
|
106
109
|
)}
|
|
107
110
|
{purchaseDate && (
|
|
108
111
|
<DetailRow
|
|
109
112
|
label={translations.purchasedLabel}
|
|
110
113
|
value={purchaseDate}
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
style={styles.row}
|
|
115
|
+
labelStyle={styles.label}
|
|
116
|
+
valueStyle={styles.value}
|
|
113
117
|
/>
|
|
114
118
|
)}
|
|
115
119
|
</>
|
|
@@ -119,41 +123,3 @@ export const SubscriptionHeader: React.FC<SubscriptionHeaderProps> = ({
|
|
|
119
123
|
</View>
|
|
120
124
|
);
|
|
121
125
|
};
|
|
122
|
-
|
|
123
|
-
interface DetailRowProps {
|
|
124
|
-
label: string;
|
|
125
|
-
value: string;
|
|
126
|
-
highlight?: boolean;
|
|
127
|
-
tokens: ReturnType<typeof useAppDesignTokens>;
|
|
128
|
-
styles: {
|
|
129
|
-
row: object;
|
|
130
|
-
label: object;
|
|
131
|
-
value: object;
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const DetailRow: React.FC<DetailRowProps> = ({
|
|
136
|
-
label,
|
|
137
|
-
value,
|
|
138
|
-
highlight,
|
|
139
|
-
tokens,
|
|
140
|
-
styles,
|
|
141
|
-
}) => (
|
|
142
|
-
<View style={styles.row}>
|
|
143
|
-
<AtomicText
|
|
144
|
-
type="bodyMedium"
|
|
145
|
-
style={[styles.label, { color: tokens.colors.textSecondary }]}
|
|
146
|
-
>
|
|
147
|
-
{label}
|
|
148
|
-
</AtomicText>
|
|
149
|
-
<AtomicText
|
|
150
|
-
type="bodyMedium"
|
|
151
|
-
style={[
|
|
152
|
-
styles.value,
|
|
153
|
-
{ color: highlight ? tokens.colors.warning : tokens.colors.textPrimary },
|
|
154
|
-
]}
|
|
155
|
-
>
|
|
156
|
-
{value}
|
|
157
|
-
</AtomicText>
|
|
158
|
-
</View>
|
|
159
|
-
);
|
|
@@ -105,8 +105,8 @@ export interface CreditsListProps {
|
|
|
105
105
|
remainingLabel?: string;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/** Props for credit
|
|
109
|
-
export interface
|
|
108
|
+
/** Props for credit row component */
|
|
109
|
+
export interface CreditRowProps {
|
|
110
110
|
label: string;
|
|
111
111
|
current: number;
|
|
112
112
|
total: number;
|
|
@@ -45,6 +45,16 @@ function configureLogHandler(): void {
|
|
|
45
45
|
isLogHandlerConfigured = true;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function buildSuccessResult(
|
|
49
|
+
deps: InitializerDeps,
|
|
50
|
+
customerInfo: any,
|
|
51
|
+
offerings: any
|
|
52
|
+
): InitializeResult {
|
|
53
|
+
const entitlementId = deps.config.entitlementIdentifier;
|
|
54
|
+
const hasPremium = !!customerInfo.entitlements.active[entitlementId];
|
|
55
|
+
return { success: true, offering: offerings.current, hasPremium };
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
export async function initializeSDK(
|
|
49
59
|
deps: InitializerDeps,
|
|
50
60
|
userId: string,
|
|
@@ -57,9 +67,7 @@ export async function initializeSDK(
|
|
|
57
67
|
Purchases.getCustomerInfo(),
|
|
58
68
|
Purchases.getOfferings(),
|
|
59
69
|
]);
|
|
60
|
-
|
|
61
|
-
const hasPremium = !!customerInfo.entitlements.active[entitlementId];
|
|
62
|
-
return { success: true, offering: offerings.current, hasPremium };
|
|
70
|
+
return buildSuccessResult(deps, customerInfo, offerings);
|
|
63
71
|
} catch {
|
|
64
72
|
return { success: false, offering: null, hasPremium: false };
|
|
65
73
|
}
|
|
@@ -82,10 +90,7 @@ export async function initializeSDK(
|
|
|
82
90
|
deps.setCurrentUserId(userId);
|
|
83
91
|
|
|
84
92
|
const offerings = await Purchases.getOfferings();
|
|
85
|
-
|
|
86
|
-
const hasPremium = !!customerInfo.entitlements.active[entitlementId];
|
|
87
|
-
|
|
88
|
-
return { success: true, offering: offerings.current, hasPremium };
|
|
93
|
+
return buildSuccessResult(deps, customerInfo, offerings);
|
|
89
94
|
} catch {
|
|
90
95
|
return { success: false, offering: null, hasPremium: false };
|
|
91
96
|
}
|
|
@@ -114,10 +119,7 @@ export async function initializeSDK(
|
|
|
114
119
|
Purchases.getOfferings(),
|
|
115
120
|
]);
|
|
116
121
|
|
|
117
|
-
|
|
118
|
-
const hasPremium = !!customerInfo.entitlements.active[entitlementId];
|
|
119
|
-
|
|
120
|
-
return { success: true, offering: offerings.current, hasPremium };
|
|
122
|
+
return buildSuccessResult(deps, customerInfo, offerings);
|
|
121
123
|
} catch (error) {
|
|
122
124
|
getErrorMessage(error, "RevenueCat init failed");
|
|
123
125
|
return { success: false, offering: null, hasPremium: false };
|
|
@@ -51,6 +51,17 @@ export class RevenueCatService implements IRevenueCatService {
|
|
|
51
51
|
return this.stateManager.getCurrentUserId();
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
private getSDKParams() {
|
|
55
|
+
return {
|
|
56
|
+
config: this.stateManager.getConfig(),
|
|
57
|
+
isUsingTestStore: () => this.isUsingTestStore(),
|
|
58
|
+
isInitialized: () => this.isInitialized(),
|
|
59
|
+
getCurrentUserId: () => this.stateManager.getCurrentUserId(),
|
|
60
|
+
setInitialized: (value: boolean) => this.stateManager.setInitialized(value),
|
|
61
|
+
setCurrentUserId: (id: string | null) => this.stateManager.setCurrentUserId(id),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
async initialize(userId: string, apiKey?: string): Promise<InitializeResult> {
|
|
55
66
|
if (this.isInitialized() && this.getCurrentUserId() === userId) {
|
|
56
67
|
return {
|
|
@@ -60,18 +71,7 @@ export class RevenueCatService implements IRevenueCatService {
|
|
|
60
71
|
};
|
|
61
72
|
}
|
|
62
73
|
|
|
63
|
-
const result = await initializeSDK(
|
|
64
|
-
{
|
|
65
|
-
config: this.stateManager.getConfig(),
|
|
66
|
-
isUsingTestStore: () => this.isUsingTestStore(),
|
|
67
|
-
isInitialized: () => this.isInitialized(),
|
|
68
|
-
getCurrentUserId: () => this.stateManager.getCurrentUserId(),
|
|
69
|
-
setInitialized: (value) => this.stateManager.setInitialized(value),
|
|
70
|
-
setCurrentUserId: (id) => this.stateManager.setCurrentUserId(id),
|
|
71
|
-
},
|
|
72
|
-
userId,
|
|
73
|
-
apiKey
|
|
74
|
-
);
|
|
74
|
+
const result = await initializeSDK(this.getSDKParams(), userId, apiKey);
|
|
75
75
|
|
|
76
76
|
if (result.success) {
|
|
77
77
|
this.listenerManager.setUserId(userId);
|
|
@@ -82,36 +82,18 @@ export class RevenueCatService implements IRevenueCatService {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
async fetchOfferings(): Promise<PurchasesOffering | null> {
|
|
85
|
-
return fetchOfferings(
|
|
86
|
-
isInitialized: () => this.isInitialized(),
|
|
87
|
-
isUsingTestStore: () => this.isUsingTestStore(),
|
|
88
|
-
});
|
|
85
|
+
return fetchOfferings(this.getSDKParams());
|
|
89
86
|
}
|
|
90
87
|
|
|
91
88
|
async purchasePackage(
|
|
92
89
|
pkg: PurchasesPackage,
|
|
93
90
|
userId: string
|
|
94
91
|
): Promise<PurchaseResult> {
|
|
95
|
-
return handlePurchase(
|
|
96
|
-
{
|
|
97
|
-
config: this.stateManager.getConfig(),
|
|
98
|
-
isInitialized: () => this.isInitialized(),
|
|
99
|
-
isUsingTestStore: () => this.isUsingTestStore(),
|
|
100
|
-
},
|
|
101
|
-
pkg,
|
|
102
|
-
userId
|
|
103
|
-
);
|
|
92
|
+
return handlePurchase(this.getSDKParams(), pkg, userId);
|
|
104
93
|
}
|
|
105
94
|
|
|
106
95
|
async restorePurchases(userId: string): Promise<RestoreResult> {
|
|
107
|
-
return handleRestore(
|
|
108
|
-
{
|
|
109
|
-
config: this.stateManager.getConfig(),
|
|
110
|
-
isInitialized: () => this.isInitialized(),
|
|
111
|
-
isUsingTestStore: () => this.isUsingTestStore(),
|
|
112
|
-
},
|
|
113
|
-
userId
|
|
114
|
-
);
|
|
96
|
+
return handleRestore(this.getSDKParams(), userId);
|
|
115
97
|
}
|
|
116
98
|
|
|
117
99
|
async getCustomerInfo(): Promise<CustomerInfo | null> {
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credit Item Component
|
|
3
|
-
* Displays individual credit usage with progress bar
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import React, { useMemo } from "react";
|
|
7
|
-
import { View, StyleSheet } from "react-native";
|
|
8
|
-
import { useAppDesignTokens, AtomicText } from "@umituz/react-native-design-system";
|
|
9
|
-
import type { CreditItemProps } from "../../types/SubscriptionDetailTypes";
|
|
10
|
-
|
|
11
|
-
export const CreditItem: React.FC<CreditItemProps> = ({
|
|
12
|
-
label,
|
|
13
|
-
current,
|
|
14
|
-
total,
|
|
15
|
-
remainingLabel,
|
|
16
|
-
}) => {
|
|
17
|
-
const tokens = useAppDesignTokens();
|
|
18
|
-
const percentage = total > 0 ? (current / total) * 100 : 0;
|
|
19
|
-
const isLow = percentage <= 20;
|
|
20
|
-
const isMedium = percentage > 20 && percentage <= 50;
|
|
21
|
-
|
|
22
|
-
const progressColor = useMemo(() => {
|
|
23
|
-
if (isLow) return tokens.colors.error;
|
|
24
|
-
if (isMedium) return tokens.colors.warning;
|
|
25
|
-
return tokens.colors.success;
|
|
26
|
-
}, [isLow, isMedium, tokens.colors]);
|
|
27
|
-
|
|
28
|
-
const styles = useMemo(
|
|
29
|
-
() =>
|
|
30
|
-
StyleSheet.create({
|
|
31
|
-
container: {
|
|
32
|
-
gap: tokens.spacing.sm,
|
|
33
|
-
},
|
|
34
|
-
header: {
|
|
35
|
-
flexDirection: "row",
|
|
36
|
-
justifyContent: "space-between",
|
|
37
|
-
alignItems: "center",
|
|
38
|
-
},
|
|
39
|
-
label: {
|
|
40
|
-
fontWeight: "500",
|
|
41
|
-
},
|
|
42
|
-
badge: {
|
|
43
|
-
paddingHorizontal: tokens.spacing.md,
|
|
44
|
-
paddingVertical: tokens.spacing.xs,
|
|
45
|
-
borderRadius: tokens.radius.md,
|
|
46
|
-
backgroundColor: tokens.colors.surfaceSecondary,
|
|
47
|
-
},
|
|
48
|
-
count: {
|
|
49
|
-
fontWeight: "600",
|
|
50
|
-
},
|
|
51
|
-
progressBar: {
|
|
52
|
-
height: 8,
|
|
53
|
-
borderRadius: tokens.radius.xs,
|
|
54
|
-
overflow: "hidden",
|
|
55
|
-
backgroundColor: tokens.colors.surfaceSecondary,
|
|
56
|
-
},
|
|
57
|
-
progressFill: {
|
|
58
|
-
height: "100%",
|
|
59
|
-
borderRadius: tokens.radius.xs,
|
|
60
|
-
width: `${percentage}%`,
|
|
61
|
-
backgroundColor: progressColor,
|
|
62
|
-
},
|
|
63
|
-
}),
|
|
64
|
-
[tokens, percentage, progressColor]
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
return (
|
|
68
|
-
<View style={styles.container}>
|
|
69
|
-
<View style={styles.header}>
|
|
70
|
-
<AtomicText
|
|
71
|
-
type="bodyMedium"
|
|
72
|
-
style={[styles.label, { color: tokens.colors.textPrimary }]}
|
|
73
|
-
>
|
|
74
|
-
{label}
|
|
75
|
-
</AtomicText>
|
|
76
|
-
<View style={styles.badge}>
|
|
77
|
-
<AtomicText
|
|
78
|
-
type="labelSmall"
|
|
79
|
-
style={[styles.count, { color: progressColor }]}
|
|
80
|
-
>
|
|
81
|
-
{current} / {total}
|
|
82
|
-
</AtomicText>
|
|
83
|
-
</View>
|
|
84
|
-
</View>
|
|
85
|
-
<View style={styles.progressBar}>
|
|
86
|
-
<View style={styles.progressFill} />
|
|
87
|
-
</View>
|
|
88
|
-
{remainingLabel && (
|
|
89
|
-
<AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }}>
|
|
90
|
-
{current} {remainingLabel}
|
|
91
|
-
</AtomicText>
|
|
92
|
-
)}
|
|
93
|
-
</View>
|
|
94
|
-
);
|
|
95
|
-
};
|