diff --git a/src/app/[locale]/providers.tsx b/src/app/[locale]/providers.tsx index 82192f0..40f290e 100644 --- a/src/app/[locale]/providers.tsx +++ b/src/app/[locale]/providers.tsx @@ -4,6 +4,7 @@ import { ActiveThemeProvider } from '@/components/layout/active-theme-provider'; import { PaymentProvider } from '@/components/layout/payment-provider'; import { TooltipProvider } from '@/components/ui/tooltip'; import { websiteConfig } from '@/config/website'; +import { CreditsProvider } from '@/providers/credits-provider'; import type { Translations } from 'fumadocs-ui/i18n'; import { RootProvider } from 'fumadocs-ui/provider'; import { useTranslations } from 'next-intl'; @@ -25,6 +26,7 @@ interface ProvidersProps { * - RootProvider: Provides the root provider for Fumadocs UI. * - TooltipProvider: Provides the tooltip to the app. * - PaymentProvider: Provides the payment state to the app. + * - CreditsProvider: Provides the credits state to the app. */ export function Providers({ children, locale }: ProvidersProps) { const theme = useTheme(); @@ -61,7 +63,9 @@ export function Providers({ children, locale }: ProvidersProps) { - {children} + + {children} + diff --git a/src/providers/credits-provider.tsx b/src/providers/credits-provider.tsx new file mode 100644 index 0000000..3ac8ca5 --- /dev/null +++ b/src/providers/credits-provider.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useCurrentUser } from '@/hooks/use-current-user'; +import { useCreditsStore } from '@/stores/credits-store'; +import { useEffect } from 'react'; + +/** + * Credits Provider Component + * + * This component initializes the credits store when the user is authenticated + * and handles cleanup when the user logs out. + */ +export function CreditsProvider({ children }: { children: React.ReactNode }) { + const user = useCurrentUser(); + const { fetchCredits, resetState } = useCreditsStore(); + + useEffect(() => { + if (user) { + // User is logged in, fetch their credits + fetchCredits(user); + } else { + // User is logged out, reset the credits state + resetState(); + } + }, [user, fetchCredits, resetState]); + + return <>{children}; +} diff --git a/src/stores/credits-store.ts b/src/stores/credits-store.ts new file mode 100644 index 0000000..7c39180 --- /dev/null +++ b/src/stores/credits-store.ts @@ -0,0 +1,212 @@ +import { consumeCreditsAction } from '@/actions/consume-credits'; +import { getCreditBalanceAction } from '@/actions/get-credit-balance'; +import type { Session } from '@/lib/auth-types'; +import { create } from 'zustand'; + +/** + * Credits state interface + */ +export interface CreditsState { + // Current credit balance + balance: number; + // Loading state + isLoading: boolean; + // Error state + error: string | null; + // Last fetch timestamp to avoid frequent requests + lastFetchTime: number | null; + + // Actions + fetchCredits: (user: Session['user'] | null | undefined) => Promise; + consumeCredits: (amount: number, description: string) => Promise; + refreshCredits: (user: Session['user'] | null | undefined) => Promise; + resetState: () => void; + // For optimistic updates + updateBalanceOptimistically: (amount: number) => void; +} + +// Cache duration: 30 seconds +const CACHE_DURATION = 30 * 1000; + +/** + * Credits store using Zustand + * Manages the user's credit balance globally with caching and optimistic updates + */ +export const useCreditsStore = create((set, get) => ({ + // Initial state + balance: 0, + isLoading: false, + error: null, + lastFetchTime: null, + + /** + * Fetch credit balance for the current user with caching + * @param user Current user from auth session + */ + fetchCredits: async (user) => { + // Skip if already loading + if (get().isLoading) return; + + // Skip if no user is provided + if (!user) { + set({ + balance: 0, + error: null, + lastFetchTime: null, + }); + return; + } + + // Check if we have recent data (within cache duration) + const { lastFetchTime } = get(); + const now = Date.now(); + if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) { + return; // Use cached data + } + + set({ isLoading: true, error: null }); + + try { + const result = await getCreditBalanceAction(); + + if (result?.data?.success) { + set({ + balance: result.data.credits || 0, + isLoading: false, + error: null, + lastFetchTime: now, + }); + } else { + set({ + error: result?.data?.error || 'Failed to fetch credit balance', + isLoading: false, + }); + } + } catch (error) { + console.error('fetch credits error:', error); + set({ + error: 'Failed to fetch credit balance', + isLoading: false, + }); + } + }, + + /** + * Consume credits with optimistic updates + * @param amount Amount of credits to consume + * @param description Description for the transaction + * @returns Promise Success status + */ + consumeCredits: async (amount: number, description: string) => { + const { balance } = get(); + + // Check if we have enough credits + if (balance < amount) { + set({ + error: `Insufficient credits. You need ${amount} credits but only have ${balance}.`, + }); + return false; + } + + // Optimistically update the balance + set({ + balance: balance - amount, + error: null, + isLoading: true, + }); + + try { + const result = await consumeCreditsAction({ + amount, + description, + }); + + if (result?.data?.success) { + set({ + isLoading: false, + error: null, + }); + return true; + } + + // Revert optimistic update on failure + set({ + balance: balance, // Revert to original balance + error: result?.data?.error || 'Failed to consume credits', + isLoading: false, + }); + return false; + } catch (error) { + console.error('consume credits error:', error); + // Revert optimistic update on error + set({ + balance: balance, // Revert to original balance + error: 'Failed to consume credits', + isLoading: false, + }); + return false; + } + }, + + /** + * Force refresh credit balance (ignores cache) + * @param user Current user from auth session + */ + refreshCredits: async (user) => { + if (!user) return; + + set({ + isLoading: true, + error: null, + lastFetchTime: null, // Clear cache to force refresh + }); + + try { + const result = await getCreditBalanceAction(); + + if (result?.data?.success) { + set({ + balance: result.data.credits || 0, + isLoading: false, + error: null, + lastFetchTime: Date.now(), + }); + } else { + set({ + error: result?.data?.error || 'Failed to fetch credit balance', + isLoading: false, + }); + } + } catch (error) { + console.error('refresh credits error:', error); + set({ + error: 'Failed to fetch credit balance', + isLoading: false, + }); + } + }, + + /** + * Update balance optimistically (for external credit additions) + * @param amount Amount to add to current balance + */ + updateBalanceOptimistically: (amount: number) => { + const { balance } = get(); + set({ + balance: balance + amount, + lastFetchTime: null, // Clear cache to fetch fresh data next time + }); + }, + + /** + * Reset credits state + */ + resetState: () => { + set({ + balance: 0, + isLoading: false, + error: null, + lastFetchTime: null, + }); + }, +}));