@umituz/react-native-subscription 1.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "1.7.0",
4
- "description": "Subscription management and paywall UI for React Native apps",
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
@@ -164,3 +164,70 @@ 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";
@@ -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,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
+ };