@umituz/react-native-subscription 1.6.0 → 1.8.0
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 +11 -4
- package/src/domain/entities/Credits.ts +45 -0
- package/src/index.ts +77 -0
- package/src/infrastructure/repositories/CreditsRepository.ts +247 -0
- package/src/presentation/components/sections/SubscriptionSection.tsx +65 -0
- package/src/presentation/context/CreditsContext.ts +42 -0
- package/src/presentation/hooks/useCredits.ts +97 -0
- package/src/presentation/hooks/useDeductCredit.ts +146 -0
- package/src/presentation/hooks/usePremiumWithCredits.ts +48 -0
- package/src/presentation/providers/CreditsProvider.tsx +38 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Subscription management
|
|
3
|
+
"version": "1.8.0",
|
|
4
|
+
"description": "Subscription management, paywall UI and credits system for React Native apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"subscription",
|
|
14
14
|
"premium",
|
|
15
15
|
"paywall",
|
|
16
|
+
"credits",
|
|
16
17
|
"in-app-purchase",
|
|
17
18
|
"iap",
|
|
18
19
|
"revenuecat",
|
|
@@ -30,15 +31,21 @@
|
|
|
30
31
|
"react-native": ">=0.74.0",
|
|
31
32
|
"react-native-purchases": ">=8.0.0",
|
|
32
33
|
"react-native-safe-area-context": ">=4.0.0",
|
|
34
|
+
"@tanstack/react-query": ">=5.0.0",
|
|
35
|
+
"@umituz/react-native-firestore": "*",
|
|
33
36
|
"@umituz/react-native-design-system-atoms": "*",
|
|
34
37
|
"@umituz/react-native-design-system-theme": "*",
|
|
35
|
-
"@umituz/react-native-legal": "*"
|
|
38
|
+
"@umituz/react-native-legal": "*",
|
|
39
|
+
"firebase": ">=10.0.0"
|
|
36
40
|
},
|
|
37
41
|
"devDependencies": {
|
|
38
42
|
"@types/react": "~19.1.0",
|
|
39
43
|
"typescript": "~5.9.2",
|
|
40
44
|
"react-native": "~0.76.0",
|
|
41
|
-
"@umituz/react-native-design-system-theme": "latest"
|
|
45
|
+
"@umituz/react-native-design-system-theme": "latest",
|
|
46
|
+
"@umituz/react-native-firestore": "latest",
|
|
47
|
+
"@tanstack/react-query": "^5.0.0",
|
|
48
|
+
"firebase": "^11.0.0"
|
|
42
49
|
},
|
|
43
50
|
"publishConfig": {
|
|
44
51
|
"access": "public"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credits Domain Entities
|
|
3
|
+
*
|
|
4
|
+
* Generic credit system types for subscription-based apps.
|
|
5
|
+
* Designed to be used across hundreds of apps with configurable limits.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type CreditType = "text" | "image";
|
|
9
|
+
|
|
10
|
+
export interface UserCredits {
|
|
11
|
+
textCredits: number;
|
|
12
|
+
imageCredits: number;
|
|
13
|
+
purchasedAt: Date;
|
|
14
|
+
lastUpdatedAt: Date;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CreditsConfig {
|
|
18
|
+
collectionName: string;
|
|
19
|
+
textCreditLimit: number;
|
|
20
|
+
imageCreditLimit: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CreditsResult<T = UserCredits> {
|
|
24
|
+
success: boolean;
|
|
25
|
+
data?: T;
|
|
26
|
+
error?: {
|
|
27
|
+
message: string;
|
|
28
|
+
code: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DeductCreditsResult {
|
|
33
|
+
success: boolean;
|
|
34
|
+
remainingCredits?: number;
|
|
35
|
+
error?: {
|
|
36
|
+
message: string;
|
|
37
|
+
code: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DEFAULT_CREDITS_CONFIG: CreditsConfig = {
|
|
42
|
+
collectionName: "user_credits",
|
|
43
|
+
textCreditLimit: 1000,
|
|
44
|
+
imageCreditLimit: 100,
|
|
45
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -108,6 +108,16 @@ export {
|
|
|
108
108
|
type SubscriptionStatusType,
|
|
109
109
|
} from "./presentation/components/details/PremiumStatusBadge";
|
|
110
110
|
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// PRESENTATION LAYER - Settings Section Component
|
|
113
|
+
// =============================================================================
|
|
114
|
+
|
|
115
|
+
export {
|
|
116
|
+
SubscriptionSection,
|
|
117
|
+
type SubscriptionSectionProps,
|
|
118
|
+
type SubscriptionSectionConfig,
|
|
119
|
+
} from "./presentation/components/sections/SubscriptionSection";
|
|
120
|
+
|
|
111
121
|
// =============================================================================
|
|
112
122
|
// UTILS - Date & Price
|
|
113
123
|
// =============================================================================
|
|
@@ -154,3 +164,70 @@ export {
|
|
|
154
164
|
validateIsPremium,
|
|
155
165
|
validateFetcher,
|
|
156
166
|
} from "./utils/validation";
|
|
167
|
+
|
|
168
|
+
// =============================================================================
|
|
169
|
+
// CREDITS SYSTEM - Domain Entities
|
|
170
|
+
// =============================================================================
|
|
171
|
+
|
|
172
|
+
export type {
|
|
173
|
+
CreditType,
|
|
174
|
+
UserCredits,
|
|
175
|
+
CreditsConfig,
|
|
176
|
+
CreditsResult,
|
|
177
|
+
DeductCreditsResult,
|
|
178
|
+
} from "./domain/entities/Credits";
|
|
179
|
+
|
|
180
|
+
export { DEFAULT_CREDITS_CONFIG } from "./domain/entities/Credits";
|
|
181
|
+
|
|
182
|
+
// =============================================================================
|
|
183
|
+
// CREDITS SYSTEM - Repository
|
|
184
|
+
// =============================================================================
|
|
185
|
+
|
|
186
|
+
export {
|
|
187
|
+
CreditsRepository,
|
|
188
|
+
createCreditsRepository,
|
|
189
|
+
} from "./infrastructure/repositories/CreditsRepository";
|
|
190
|
+
|
|
191
|
+
// =============================================================================
|
|
192
|
+
// CREDITS SYSTEM - Context & Provider
|
|
193
|
+
// =============================================================================
|
|
194
|
+
|
|
195
|
+
export {
|
|
196
|
+
CreditsContext,
|
|
197
|
+
useCreditsContext,
|
|
198
|
+
useCreditsConfig,
|
|
199
|
+
useCreditsRepository,
|
|
200
|
+
type CreditsContextValue,
|
|
201
|
+
} from "./presentation/context/CreditsContext";
|
|
202
|
+
|
|
203
|
+
export {
|
|
204
|
+
CreditsProvider,
|
|
205
|
+
type CreditsProviderProps,
|
|
206
|
+
} from "./presentation/providers/CreditsProvider";
|
|
207
|
+
|
|
208
|
+
// =============================================================================
|
|
209
|
+
// CREDITS SYSTEM - Hooks
|
|
210
|
+
// =============================================================================
|
|
211
|
+
|
|
212
|
+
export {
|
|
213
|
+
useCredits,
|
|
214
|
+
useHasCredits,
|
|
215
|
+
creditsQueryKeys,
|
|
216
|
+
type UseCreditsParams,
|
|
217
|
+
type UseCreditsResult,
|
|
218
|
+
} from "./presentation/hooks/useCredits";
|
|
219
|
+
|
|
220
|
+
export {
|
|
221
|
+
useDeductCredit,
|
|
222
|
+
useInitializeCredits,
|
|
223
|
+
type UseDeductCreditParams,
|
|
224
|
+
type UseDeductCreditResult,
|
|
225
|
+
type UseInitializeCreditsParams,
|
|
226
|
+
type UseInitializeCreditsResult,
|
|
227
|
+
} from "./presentation/hooks/useDeductCredit";
|
|
228
|
+
|
|
229
|
+
export {
|
|
230
|
+
usePremiumWithCredits,
|
|
231
|
+
type UsePremiumWithCreditsParams,
|
|
232
|
+
type UsePremiumWithCreditsResult,
|
|
233
|
+
} from "./presentation/hooks/usePremiumWithCredits";
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credits Repository
|
|
3
|
+
*
|
|
4
|
+
* Firestore operations for user credits management.
|
|
5
|
+
* Extends BaseRepository from @umituz/react-native-firestore.
|
|
6
|
+
*
|
|
7
|
+
* Generic and reusable - accepts config from main app.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
doc,
|
|
12
|
+
getDoc,
|
|
13
|
+
runTransaction,
|
|
14
|
+
serverTimestamp,
|
|
15
|
+
} from "firebase/firestore";
|
|
16
|
+
import { BaseRepository, getFirestore } from "@umituz/react-native-firestore";
|
|
17
|
+
import type {
|
|
18
|
+
CreditType,
|
|
19
|
+
UserCredits,
|
|
20
|
+
CreditsConfig,
|
|
21
|
+
CreditsResult,
|
|
22
|
+
DeductCreditsResult,
|
|
23
|
+
} from "../../domain/entities/Credits";
|
|
24
|
+
|
|
25
|
+
interface FirestoreTimestamp {
|
|
26
|
+
toDate: () => Date;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface UserCreditsDocument {
|
|
30
|
+
textCredits: number;
|
|
31
|
+
imageCredits: number;
|
|
32
|
+
purchasedAt: FirestoreTimestamp;
|
|
33
|
+
lastUpdatedAt: FirestoreTimestamp;
|
|
34
|
+
lastPurchaseAt?: FirestoreTimestamp;
|
|
35
|
+
processedPurchases?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CreditsRepository extends BaseRepository {
|
|
39
|
+
private config: CreditsConfig;
|
|
40
|
+
|
|
41
|
+
constructor(config: CreditsConfig) {
|
|
42
|
+
super();
|
|
43
|
+
this.config = config;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async getCredits(userId: string): Promise<CreditsResult> {
|
|
47
|
+
const db = this.getDb();
|
|
48
|
+
if (!db) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
error: { message: "Database not available", code: "DB_NOT_AVAILABLE" },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const creditsRef = doc(db, this.config.collectionName, userId);
|
|
57
|
+
const snapshot = await getDoc(creditsRef);
|
|
58
|
+
|
|
59
|
+
if (!snapshot.exists()) {
|
|
60
|
+
return { success: true, data: undefined };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const data = snapshot.data() as UserCreditsDocument;
|
|
64
|
+
return {
|
|
65
|
+
success: true,
|
|
66
|
+
data: {
|
|
67
|
+
textCredits: data.textCredits,
|
|
68
|
+
imageCredits: data.imageCredits,
|
|
69
|
+
purchasedAt: data.purchasedAt?.toDate?.() || new Date(),
|
|
70
|
+
lastUpdatedAt: data.lastUpdatedAt?.toDate?.() || new Date(),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
success: false,
|
|
76
|
+
error: {
|
|
77
|
+
message:
|
|
78
|
+
error instanceof Error ? error.message : "Failed to get credits",
|
|
79
|
+
code: "FETCH_FAILED",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async initializeCredits(
|
|
86
|
+
userId: string,
|
|
87
|
+
purchaseId?: string
|
|
88
|
+
): Promise<CreditsResult> {
|
|
89
|
+
const db = getFirestore();
|
|
90
|
+
if (!db) {
|
|
91
|
+
return {
|
|
92
|
+
success: false,
|
|
93
|
+
error: { message: "Database not available", code: "INIT_FAILED" },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const creditsRef = doc(db, this.config.collectionName, userId);
|
|
99
|
+
|
|
100
|
+
const result = await runTransaction(db, async (transaction) => {
|
|
101
|
+
const creditsDoc = await transaction.get(creditsRef);
|
|
102
|
+
const now = serverTimestamp();
|
|
103
|
+
|
|
104
|
+
let newTextCredits = this.config.textCreditLimit;
|
|
105
|
+
let newImageCredits = this.config.imageCreditLimit;
|
|
106
|
+
let purchasedAt = now;
|
|
107
|
+
let processedPurchases: string[] = [];
|
|
108
|
+
|
|
109
|
+
if (creditsDoc.exists()) {
|
|
110
|
+
const existing = creditsDoc.data() as UserCreditsDocument;
|
|
111
|
+
processedPurchases = existing.processedPurchases || [];
|
|
112
|
+
|
|
113
|
+
if (purchaseId && processedPurchases.includes(purchaseId)) {
|
|
114
|
+
return {
|
|
115
|
+
textCredits: existing.textCredits,
|
|
116
|
+
imageCredits: existing.imageCredits,
|
|
117
|
+
alreadyProcessed: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
newTextCredits =
|
|
122
|
+
(existing.textCredits || 0) + this.config.textCreditLimit;
|
|
123
|
+
newImageCredits =
|
|
124
|
+
(existing.imageCredits || 0) + this.config.imageCreditLimit;
|
|
125
|
+
purchasedAt = existing.purchasedAt || now;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (purchaseId) {
|
|
129
|
+
processedPurchases = [...processedPurchases, purchaseId].slice(-10);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
transaction.set(creditsRef, {
|
|
133
|
+
textCredits: newTextCredits,
|
|
134
|
+
imageCredits: newImageCredits,
|
|
135
|
+
purchasedAt,
|
|
136
|
+
lastUpdatedAt: now,
|
|
137
|
+
lastPurchaseAt: now,
|
|
138
|
+
processedPurchases,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return { textCredits: newTextCredits, imageCredits: newImageCredits };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
success: true,
|
|
146
|
+
data: {
|
|
147
|
+
textCredits: result.textCredits,
|
|
148
|
+
imageCredits: result.imageCredits,
|
|
149
|
+
purchasedAt: new Date(),
|
|
150
|
+
lastUpdatedAt: new Date(),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return {
|
|
155
|
+
success: false,
|
|
156
|
+
error: {
|
|
157
|
+
message:
|
|
158
|
+
error instanceof Error
|
|
159
|
+
? error.message
|
|
160
|
+
: "Failed to initialize credits",
|
|
161
|
+
code: "INIT_FAILED",
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async deductCredit(
|
|
168
|
+
userId: string,
|
|
169
|
+
creditType: CreditType
|
|
170
|
+
): Promise<DeductCreditsResult> {
|
|
171
|
+
const db = getFirestore();
|
|
172
|
+
if (!db) {
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
error: { message: "Database not available", code: "DEDUCT_FAILED" },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const creditsRef = doc(db, this.config.collectionName, userId);
|
|
181
|
+
const fieldName = creditType === "text" ? "textCredits" : "imageCredits";
|
|
182
|
+
|
|
183
|
+
const newCredits = await runTransaction(db, async (transaction) => {
|
|
184
|
+
const creditsDoc = await transaction.get(creditsRef);
|
|
185
|
+
|
|
186
|
+
if (!creditsDoc.exists()) {
|
|
187
|
+
throw new Error("NO_CREDITS");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const currentCredits = creditsDoc.data()[fieldName] as number;
|
|
191
|
+
|
|
192
|
+
if (currentCredits <= 0) {
|
|
193
|
+
throw new Error("CREDITS_EXHAUSTED");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const updatedCredits = currentCredits - 1;
|
|
197
|
+
transaction.update(creditsRef, {
|
|
198
|
+
[fieldName]: updatedCredits,
|
|
199
|
+
lastUpdatedAt: serverTimestamp(),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return updatedCredits;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return { success: true, remainingCredits: newCredits };
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const errorMessage =
|
|
208
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
209
|
+
|
|
210
|
+
if (errorMessage === "NO_CREDITS") {
|
|
211
|
+
return {
|
|
212
|
+
success: false,
|
|
213
|
+
error: { message: "No credits found", code: "NO_CREDITS" },
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (errorMessage === "CREDITS_EXHAUSTED") {
|
|
218
|
+
return {
|
|
219
|
+
success: false,
|
|
220
|
+
error: { message: "Credits exhausted", code: "CREDITS_EXHAUSTED" },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
error: { message: errorMessage, code: "DEDUCT_FAILED" },
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async hasCredits(userId: string, creditType: CreditType): Promise<boolean> {
|
|
232
|
+
const result = await this.getCredits(userId);
|
|
233
|
+
if (!result.success || !result.data) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const credits =
|
|
238
|
+
creditType === "text" ? result.data.textCredits : result.data.imageCredits;
|
|
239
|
+
return credits > 0;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const createCreditsRepository = (
|
|
244
|
+
config: CreditsConfig
|
|
245
|
+
): CreditsRepository => {
|
|
246
|
+
return new CreditsRepository(config);
|
|
247
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription Section for Settings Screen
|
|
3
|
+
* Generic component that renders subscription/premium details
|
|
4
|
+
* App passes all data via config props (credits, translations, handlers)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { View } from "react-native";
|
|
9
|
+
import type { StyleProp, ViewStyle } from "react-native";
|
|
10
|
+
import {
|
|
11
|
+
PremiumDetailsCard,
|
|
12
|
+
type CreditInfo,
|
|
13
|
+
type PremiumDetailsTranslations,
|
|
14
|
+
} from "../details/PremiumDetailsCard";
|
|
15
|
+
import type { SubscriptionStatusType } from "../details/PremiumStatusBadge";
|
|
16
|
+
|
|
17
|
+
export interface SubscriptionSectionConfig {
|
|
18
|
+
/** Subscription status type */
|
|
19
|
+
statusType: SubscriptionStatusType;
|
|
20
|
+
/** Whether user has premium */
|
|
21
|
+
isPremium: boolean;
|
|
22
|
+
/** Formatted expiration date string */
|
|
23
|
+
expirationDate?: string | null;
|
|
24
|
+
/** Formatted purchase date string */
|
|
25
|
+
purchaseDate?: string | null;
|
|
26
|
+
/** Whether subscription is lifetime */
|
|
27
|
+
isLifetime?: boolean;
|
|
28
|
+
/** Days remaining until expiration */
|
|
29
|
+
daysRemaining?: number | null;
|
|
30
|
+
/** Credit info array (app-specific, passed from app) */
|
|
31
|
+
credits?: CreditInfo[];
|
|
32
|
+
/** All translations (app must provide these) */
|
|
33
|
+
translations: PremiumDetailsTranslations;
|
|
34
|
+
/** Handler for manage subscription button */
|
|
35
|
+
onManageSubscription?: () => void;
|
|
36
|
+
/** Handler for upgrade button */
|
|
37
|
+
onUpgrade?: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SubscriptionSectionProps {
|
|
41
|
+
config: SubscriptionSectionConfig;
|
|
42
|
+
containerStyle?: StyleProp<ViewStyle>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const SubscriptionSection: React.FC<SubscriptionSectionProps> = ({
|
|
46
|
+
config,
|
|
47
|
+
containerStyle,
|
|
48
|
+
}) => {
|
|
49
|
+
return (
|
|
50
|
+
<View style={containerStyle}>
|
|
51
|
+
<PremiumDetailsCard
|
|
52
|
+
statusType={config.statusType}
|
|
53
|
+
isPremium={config.isPremium}
|
|
54
|
+
expirationDate={config.expirationDate}
|
|
55
|
+
purchaseDate={config.purchaseDate}
|
|
56
|
+
isLifetime={config.isLifetime}
|
|
57
|
+
daysRemaining={config.daysRemaining}
|
|
58
|
+
credits={config.credits}
|
|
59
|
+
translations={config.translations}
|
|
60
|
+
onManageSubscription={config.onManageSubscription}
|
|
61
|
+
onUpgrade={config.onUpgrade}
|
|
62
|
+
/>
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credits Context
|
|
3
|
+
*
|
|
4
|
+
* React context for credits configuration.
|
|
5
|
+
* Allows main app to provide credit limits and collection name.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createContext, useContext } from "react";
|
|
9
|
+
import type { CreditsConfig } from "../../domain/entities/Credits";
|
|
10
|
+
import { DEFAULT_CREDITS_CONFIG } from "../../domain/entities/Credits";
|
|
11
|
+
import type { CreditsRepository } from "../../infrastructure/repositories/CreditsRepository";
|
|
12
|
+
|
|
13
|
+
export interface CreditsContextValue {
|
|
14
|
+
config: CreditsConfig;
|
|
15
|
+
repository: CreditsRepository | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const CreditsContext = createContext<CreditsContextValue>({
|
|
19
|
+
config: DEFAULT_CREDITS_CONFIG,
|
|
20
|
+
repository: null,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const useCreditsContext = (): CreditsContextValue => {
|
|
24
|
+
const context = useContext(CreditsContext);
|
|
25
|
+
if (!context.repository) {
|
|
26
|
+
throw new Error("CreditsProvider must be used to provide credits config");
|
|
27
|
+
}
|
|
28
|
+
return context;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const useCreditsConfig = (): CreditsConfig => {
|
|
32
|
+
const { config } = useContext(CreditsContext);
|
|
33
|
+
return config;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const useCreditsRepository = (): CreditsRepository => {
|
|
37
|
+
const { repository } = useContext(CreditsContext);
|
|
38
|
+
if (!repository) {
|
|
39
|
+
throw new Error("CreditsProvider must be used to provide repository");
|
|
40
|
+
}
|
|
41
|
+
return repository;
|
|
42
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCredits Hook
|
|
3
|
+
*
|
|
4
|
+
* TanStack Query hook for fetching user credits.
|
|
5
|
+
* Generic and reusable - uses config from CreditsProvider.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useQuery } from "@tanstack/react-query";
|
|
9
|
+
import type { UserCredits, CreditType } from "../../domain/entities/Credits";
|
|
10
|
+
import {
|
|
11
|
+
useCreditsRepository,
|
|
12
|
+
useCreditsConfig,
|
|
13
|
+
} from "../context/CreditsContext";
|
|
14
|
+
|
|
15
|
+
const CACHE_CONFIG = {
|
|
16
|
+
staleTime: 30 * 1000,
|
|
17
|
+
gcTime: 5 * 60 * 1000,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const creditsQueryKeys = {
|
|
21
|
+
all: ["credits"] as const,
|
|
22
|
+
user: (userId: string) => ["credits", userId] as const,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface UseCreditsParams {
|
|
26
|
+
userId: string | undefined;
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UseCreditsResult {
|
|
31
|
+
credits: UserCredits | null;
|
|
32
|
+
isLoading: boolean;
|
|
33
|
+
error: Error | null;
|
|
34
|
+
hasTextCredits: boolean;
|
|
35
|
+
hasImageCredits: boolean;
|
|
36
|
+
textCreditsPercent: number;
|
|
37
|
+
imageCreditsPercent: number;
|
|
38
|
+
refetch: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const useCredits = ({
|
|
42
|
+
userId,
|
|
43
|
+
enabled = true,
|
|
44
|
+
}: UseCreditsParams): UseCreditsResult => {
|
|
45
|
+
const repository = useCreditsRepository();
|
|
46
|
+
const config = useCreditsConfig();
|
|
47
|
+
|
|
48
|
+
const { data, isLoading, error, refetch } = useQuery({
|
|
49
|
+
queryKey: creditsQueryKeys.user(userId ?? ""),
|
|
50
|
+
queryFn: async () => {
|
|
51
|
+
if (!userId) return null;
|
|
52
|
+
const result = await repository.getCredits(userId);
|
|
53
|
+
if (!result.success) {
|
|
54
|
+
throw new Error(result.error?.message || "Failed to fetch credits");
|
|
55
|
+
}
|
|
56
|
+
return result.data || null;
|
|
57
|
+
},
|
|
58
|
+
enabled: enabled && !!userId,
|
|
59
|
+
staleTime: CACHE_CONFIG.staleTime,
|
|
60
|
+
gcTime: CACHE_CONFIG.gcTime,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const credits = data ?? null;
|
|
64
|
+
const hasTextCredits = (credits?.textCredits ?? 0) > 0;
|
|
65
|
+
const hasImageCredits = (credits?.imageCredits ?? 0) > 0;
|
|
66
|
+
|
|
67
|
+
const textCreditsPercent = credits
|
|
68
|
+
? Math.round((credits.textCredits / config.textCreditLimit) * 100)
|
|
69
|
+
: 0;
|
|
70
|
+
|
|
71
|
+
const imageCreditsPercent = credits
|
|
72
|
+
? Math.round((credits.imageCredits / config.imageCreditLimit) * 100)
|
|
73
|
+
: 0;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
credits,
|
|
77
|
+
isLoading,
|
|
78
|
+
error: error as Error | null,
|
|
79
|
+
hasTextCredits,
|
|
80
|
+
hasImageCredits,
|
|
81
|
+
textCreditsPercent,
|
|
82
|
+
imageCreditsPercent,
|
|
83
|
+
refetch,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const useHasCredits = (
|
|
88
|
+
userId: string | undefined,
|
|
89
|
+
creditType: CreditType
|
|
90
|
+
): boolean => {
|
|
91
|
+
const { credits } = useCredits({ userId });
|
|
92
|
+
if (!credits) return false;
|
|
93
|
+
|
|
94
|
+
return creditType === "text"
|
|
95
|
+
? credits.textCredits > 0
|
|
96
|
+
: credits.imageCredits > 0;
|
|
97
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDeductCredit Hook
|
|
3
|
+
*
|
|
4
|
+
* TanStack Query mutation hook for deducting credits.
|
|
5
|
+
* Generic and reusable - uses config from CreditsProvider.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
9
|
+
import type { CreditType, UserCredits } from "../../domain/entities/Credits";
|
|
10
|
+
import { useCreditsRepository } from "../context/CreditsContext";
|
|
11
|
+
import { creditsQueryKeys } from "./useCredits";
|
|
12
|
+
|
|
13
|
+
export interface UseDeductCreditParams {
|
|
14
|
+
userId: string | undefined;
|
|
15
|
+
onCreditsExhausted?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UseDeductCreditResult {
|
|
19
|
+
deductCredit: (creditType: CreditType) => Promise<boolean>;
|
|
20
|
+
isDeducting: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const useDeductCredit = ({
|
|
24
|
+
userId,
|
|
25
|
+
onCreditsExhausted,
|
|
26
|
+
}: UseDeductCreditParams): UseDeductCreditResult => {
|
|
27
|
+
const repository = useCreditsRepository();
|
|
28
|
+
const queryClient = useQueryClient();
|
|
29
|
+
|
|
30
|
+
const mutation = useMutation({
|
|
31
|
+
mutationFn: async (creditType: CreditType) => {
|
|
32
|
+
if (!userId) {
|
|
33
|
+
throw new Error("User not authenticated");
|
|
34
|
+
}
|
|
35
|
+
return repository.deductCredit(userId, creditType);
|
|
36
|
+
},
|
|
37
|
+
onMutate: async (creditType: CreditType) => {
|
|
38
|
+
if (!userId) return;
|
|
39
|
+
|
|
40
|
+
await queryClient.cancelQueries({
|
|
41
|
+
queryKey: creditsQueryKeys.user(userId),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const previousCredits = queryClient.getQueryData<UserCredits>(
|
|
45
|
+
creditsQueryKeys.user(userId)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
queryClient.setQueryData<UserCredits | null>(
|
|
49
|
+
creditsQueryKeys.user(userId),
|
|
50
|
+
(old) => {
|
|
51
|
+
if (!old) return old;
|
|
52
|
+
const fieldName =
|
|
53
|
+
creditType === "text" ? "textCredits" : "imageCredits";
|
|
54
|
+
return {
|
|
55
|
+
...old,
|
|
56
|
+
[fieldName]: Math.max(0, old[fieldName] - 1),
|
|
57
|
+
lastUpdatedAt: new Date(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return { previousCredits };
|
|
63
|
+
},
|
|
64
|
+
onError: (_err, _creditType, context) => {
|
|
65
|
+
if (userId && context?.previousCredits) {
|
|
66
|
+
queryClient.setQueryData(
|
|
67
|
+
creditsQueryKeys.user(userId),
|
|
68
|
+
context.previousCredits
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
onSettled: () => {
|
|
73
|
+
if (userId) {
|
|
74
|
+
queryClient.invalidateQueries({
|
|
75
|
+
queryKey: creditsQueryKeys.user(userId),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const deductCredit = async (creditType: CreditType): Promise<boolean> => {
|
|
82
|
+
try {
|
|
83
|
+
const result = await mutation.mutateAsync(creditType);
|
|
84
|
+
|
|
85
|
+
if (!result.success) {
|
|
86
|
+
if (result.error?.code === "CREDITS_EXHAUSTED") {
|
|
87
|
+
onCreditsExhausted?.();
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
deductCredit,
|
|
100
|
+
isDeducting: mutation.isPending,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export interface UseInitializeCreditsParams {
|
|
105
|
+
userId: string | undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface UseInitializeCreditsResult {
|
|
109
|
+
initializeCredits: (purchaseId?: string) => Promise<boolean>;
|
|
110
|
+
isInitializing: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const useInitializeCredits = ({
|
|
114
|
+
userId,
|
|
115
|
+
}: UseInitializeCreditsParams): UseInitializeCreditsResult => {
|
|
116
|
+
const repository = useCreditsRepository();
|
|
117
|
+
const queryClient = useQueryClient();
|
|
118
|
+
|
|
119
|
+
const mutation = useMutation({
|
|
120
|
+
mutationFn: async (purchaseId?: string) => {
|
|
121
|
+
if (!userId) {
|
|
122
|
+
throw new Error("User not authenticated");
|
|
123
|
+
}
|
|
124
|
+
return repository.initializeCredits(userId, purchaseId);
|
|
125
|
+
},
|
|
126
|
+
onSuccess: (result) => {
|
|
127
|
+
if (userId && result.success && result.data) {
|
|
128
|
+
queryClient.setQueryData(creditsQueryKeys.user(userId), result.data);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const initializeCredits = async (purchaseId?: string): Promise<boolean> => {
|
|
134
|
+
try {
|
|
135
|
+
const result = await mutation.mutateAsync(purchaseId);
|
|
136
|
+
return result.success;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
initializeCredits,
|
|
144
|
+
isInitializing: mutation.isPending,
|
|
145
|
+
};
|
|
146
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePremiumWithCredits Hook
|
|
3
|
+
*
|
|
4
|
+
* Combined hook for premium subscription with credits system.
|
|
5
|
+
* Ensures premium users always have credits initialized.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useCallback } from "react";
|
|
9
|
+
import { useCredits, type UseCreditsResult } from "./useCredits";
|
|
10
|
+
import { useInitializeCredits } from "./useDeductCredit";
|
|
11
|
+
|
|
12
|
+
export interface UsePremiumWithCreditsParams {
|
|
13
|
+
userId: string | undefined;
|
|
14
|
+
isPremium: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UsePremiumWithCreditsResult extends UseCreditsResult {
|
|
18
|
+
ensureCreditsInitialized: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const usePremiumWithCredits = ({
|
|
22
|
+
userId,
|
|
23
|
+
isPremium,
|
|
24
|
+
}: UsePremiumWithCreditsParams): UsePremiumWithCreditsResult => {
|
|
25
|
+
const creditsResult = useCredits({ userId });
|
|
26
|
+
const { initializeCredits, isInitializing } = useInitializeCredits({
|
|
27
|
+
userId,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const ensureCreditsInitialized = useCallback(async () => {
|
|
31
|
+
if (!userId || !isPremium) return;
|
|
32
|
+
if (creditsResult.credits) return;
|
|
33
|
+
if (isInitializing) return;
|
|
34
|
+
|
|
35
|
+
await initializeCredits();
|
|
36
|
+
}, [userId, isPremium, creditsResult.credits, isInitializing, initializeCredits]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (isPremium && userId && !creditsResult.credits && !creditsResult.isLoading) {
|
|
40
|
+
ensureCreditsInitialized();
|
|
41
|
+
}
|
|
42
|
+
}, [isPremium, userId, creditsResult.credits, creditsResult.isLoading, ensureCreditsInitialized]);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
...creditsResult,
|
|
46
|
+
ensureCreditsInitialized,
|
|
47
|
+
};
|
|
48
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credits Provider
|
|
3
|
+
*
|
|
4
|
+
* React provider for credits configuration.
|
|
5
|
+
* Main app uses this to configure credit limits.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useMemo } from "react";
|
|
9
|
+
import type { CreditsConfig } from "../../domain/entities/Credits";
|
|
10
|
+
import { DEFAULT_CREDITS_CONFIG } from "../../domain/entities/Credits";
|
|
11
|
+
import {
|
|
12
|
+
CreditsContext,
|
|
13
|
+
type CreditsContextValue,
|
|
14
|
+
} from "../context/CreditsContext";
|
|
15
|
+
import { createCreditsRepository } from "../../infrastructure/repositories/CreditsRepository";
|
|
16
|
+
|
|
17
|
+
export interface CreditsProviderProps {
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
config?: Partial<CreditsConfig>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const CreditsProvider: React.FC<CreditsProviderProps> = ({
|
|
23
|
+
children,
|
|
24
|
+
config,
|
|
25
|
+
}) => {
|
|
26
|
+
const value = useMemo<CreditsContextValue>(() => {
|
|
27
|
+
const mergedConfig: CreditsConfig = {
|
|
28
|
+
...DEFAULT_CREDITS_CONFIG,
|
|
29
|
+
...config,
|
|
30
|
+
};
|
|
31
|
+
const repository = createCreditsRepository(mergedConfig);
|
|
32
|
+
return { config: mergedConfig, repository };
|
|
33
|
+
}, [config]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<CreditsContext.Provider value={value}>{children}</CreditsContext.Provider>
|
|
37
|
+
);
|
|
38
|
+
};
|