@umituz/react-native-subscription 1.7.0 → 1.8.1
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 +84 -0
- package/src/infrastructure/repositories/CreditsRepository.ts +247 -0
- package/src/presentation/context/CreditsContext.ts +42 -0
- package/src/presentation/hooks/useCreditChecker.ts +41 -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/src/utils/creditChecker.ts +79 -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.1",
|
|
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
|
@@ -164,3 +164,87 @@ export {
|
|
|
164
164
|
validateIsPremium,
|
|
165
165
|
validateFetcher,
|
|
166
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";
|
|
234
|
+
|
|
235
|
+
export {
|
|
236
|
+
useCreditChecker,
|
|
237
|
+
type UseCreditCheckerParams,
|
|
238
|
+
type UseCreditCheckerResult,
|
|
239
|
+
} from "./presentation/hooks/useCreditChecker";
|
|
240
|
+
|
|
241
|
+
// =============================================================================
|
|
242
|
+
// CREDITS SYSTEM - Utilities
|
|
243
|
+
// =============================================================================
|
|
244
|
+
|
|
245
|
+
export {
|
|
246
|
+
createCreditChecker,
|
|
247
|
+
type CreditCheckResult,
|
|
248
|
+
type CreditCheckerConfig,
|
|
249
|
+
type CreditChecker,
|
|
250
|
+
} from "./utils/creditChecker";
|
|
@@ -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,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,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCreditChecker Hook
|
|
3
|
+
*
|
|
4
|
+
* Provides credit checking utilities using context repository.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useMemo } from "react";
|
|
8
|
+
import type { CreditType } from "../../domain/entities/Credits";
|
|
9
|
+
import { useCreditsRepository } from "../context/CreditsContext";
|
|
10
|
+
import {
|
|
11
|
+
createCreditChecker,
|
|
12
|
+
type CreditCheckResult,
|
|
13
|
+
} from "../../utils/creditChecker";
|
|
14
|
+
|
|
15
|
+
export interface UseCreditCheckerParams {
|
|
16
|
+
getCreditType: (operationType: string) => CreditType;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseCreditCheckerResult {
|
|
20
|
+
checkCreditsAvailable: (
|
|
21
|
+
userId: string | undefined,
|
|
22
|
+
operationType: string
|
|
23
|
+
) => Promise<CreditCheckResult>;
|
|
24
|
+
deductCreditsAfterSuccess: (
|
|
25
|
+
userId: string | undefined,
|
|
26
|
+
creditType: CreditType
|
|
27
|
+
) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const useCreditChecker = ({
|
|
31
|
+
getCreditType,
|
|
32
|
+
}: UseCreditCheckerParams): UseCreditCheckerResult => {
|
|
33
|
+
const repository = useCreditsRepository();
|
|
34
|
+
|
|
35
|
+
const checker = useMemo(
|
|
36
|
+
() => createCreditChecker({ repository, getCreditType }),
|
|
37
|
+
[repository, getCreditType]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return checker;
|
|
41
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credit Checker Utility
|
|
3
|
+
*
|
|
4
|
+
* Validates credit availability before operations.
|
|
5
|
+
* Generic - works with any generation type mapping.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CreditType } from "../domain/entities/Credits";
|
|
9
|
+
import type { CreditsRepository } from "../infrastructure/repositories/CreditsRepository";
|
|
10
|
+
|
|
11
|
+
export interface CreditCheckResult {
|
|
12
|
+
success: boolean;
|
|
13
|
+
error?: string;
|
|
14
|
+
creditType?: CreditType;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CreditCheckerConfig {
|
|
18
|
+
repository: CreditsRepository;
|
|
19
|
+
getCreditType: (operationType: string) => CreditType;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const createCreditChecker = (config: CreditCheckerConfig) => {
|
|
23
|
+
const { repository, getCreditType } = config;
|
|
24
|
+
|
|
25
|
+
const checkCreditsAvailable = async (
|
|
26
|
+
userId: string | undefined,
|
|
27
|
+
operationType: string
|
|
28
|
+
): Promise<CreditCheckResult> => {
|
|
29
|
+
if (!userId) {
|
|
30
|
+
return { success: false, error: "anonymous_user_blocked" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const creditType = getCreditType(operationType);
|
|
34
|
+
const hasCreditsAvailable = await repository.hasCredits(userId, creditType);
|
|
35
|
+
|
|
36
|
+
if (!hasCreditsAvailable) {
|
|
37
|
+
return {
|
|
38
|
+
success: false,
|
|
39
|
+
error:
|
|
40
|
+
creditType === "image"
|
|
41
|
+
? "credits_exhausted_image"
|
|
42
|
+
: "credits_exhausted_text",
|
|
43
|
+
creditType,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { success: true, creditType };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const deductCreditsAfterSuccess = async (
|
|
51
|
+
userId: string | undefined,
|
|
52
|
+
creditType: CreditType
|
|
53
|
+
): Promise<void> => {
|
|
54
|
+
if (!userId) return;
|
|
55
|
+
|
|
56
|
+
const maxRetries = 3;
|
|
57
|
+
let lastError: Error | null = null;
|
|
58
|
+
|
|
59
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
60
|
+
const result = await repository.deductCredit(userId, creditType);
|
|
61
|
+
if (result.success) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
lastError = new Error(result.error?.message || "Deduction failed");
|
|
65
|
+
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (lastError) {
|
|
69
|
+
throw lastError;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
checkCreditsAvailable,
|
|
75
|
+
deductCreditsAfterSuccess,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type CreditChecker = ReturnType<typeof createCreditChecker>;
|