- {isLoading && (
+ {(isStatusLoading ||
+ subscribeMutation.isPending ||
+ unsubscribeMutation.isPending) && (
)}
@@ -211,7 +165,13 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
)}
/>
-
diff --git a/src/components/settings/security/password-card-wrapper.tsx b/src/components/settings/security/password-card-wrapper.tsx
index 497de96..a7f5b5f 100644
--- a/src/components/settings/security/password-card-wrapper.tsx
+++ b/src/components/settings/security/password-card-wrapper.tsx
@@ -10,10 +10,10 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
+import { useHasCredentialProvider } from '@/hooks/use-auth';
import { authClient } from '@/lib/auth-client';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
-import { useEffect, useState } from 'react';
/**
* PasswordCardWrapper renders either:
@@ -24,38 +24,15 @@ import { useEffect, useState } from 'react';
*/
export function PasswordCardWrapper() {
const { data: session } = authClient.useSession();
- const [hasCredentialProvider, setHasCredentialProvider] = useState(false);
- const [isLoading, setIsLoading] = useState(true);
+ const { hasCredentialProvider, isLoading, error } = useHasCredentialProvider(
+ session?.user?.id
+ );
- useEffect(() => {
- const checkCredentialProvider = async () => {
- if (!session?.user) {
- setIsLoading(false);
- return;
- }
-
- try {
- // Get the user's linked accounts
- const accounts = await authClient.listAccounts();
- // console.log('accounts', accounts);
-
- // Check if the response is successful and contains accounts data
- if ('data' in accounts && Array.isArray(accounts.data)) {
- // Check if any account has a credential provider (provider === 'credential')
- const hasCredential = accounts.data.some(
- (account) => account.provider === 'credential'
- );
- setHasCredentialProvider(hasCredential);
- }
- } catch (error) {
- console.error('Error checking credential provider:', error);
- } finally {
- setIsLoading(false);
- }
- };
-
- checkCredentialProvider();
- }, [session]);
+ // Handle error state
+ if (error) {
+ console.error('check credential provider error:', error);
+ return null;
+ }
// Don't render anything while loading
if (isLoading) {
diff --git a/src/components/shared/captcha.tsx b/src/components/shared/captcha.tsx
index 542a87a..3785eaf 100644
--- a/src/components/shared/captcha.tsx
+++ b/src/components/shared/captcha.tsx
@@ -5,7 +5,7 @@ import { websiteConfig } from '@/config/website';
import { useLocale } from 'next-intl';
import { useTheme } from 'next-themes';
import dynamic from 'next/dynamic';
-import type { ComponentProps } from 'react';
+import { type ComponentProps, forwardRef } from 'react';
const Turnstile = dynamic(
() => import('@marsidev/react-turnstile').then((mod) => mod.Turnstile),
@@ -21,41 +21,46 @@ type Props = Omit, 'siteKey'> & {
/**
* Captcha component for Cloudflare Turnstile
*/
-export const Captcha = ({ validationError, ...props }: Props) => {
- const turnstileEnabled = websiteConfig.features.enableTurnstileCaptcha;
- const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
+export const Captcha = forwardRef(
+ ({ validationError, ...props }, ref) => {
+ const turnstileEnabled = websiteConfig.features.enableTurnstileCaptcha;
+ const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
- // If turnstile is disabled in config, don't render anything
- if (!turnstileEnabled) {
- return null;
+ // If turnstile is disabled in config, don't render anything
+ if (!turnstileEnabled) {
+ return null;
+ }
+
+ // If turnstile is enabled but site key is missing, show error message
+ if (!siteKey) {
+ console.error('Captcha: NEXT_PUBLIC_TURNSTILE_SITE_KEY is not set');
+ return null;
+ }
+
+ const theme = useTheme();
+ const locale = useLocale();
+
+ return (
+ <>
+
+
+ {validationError && (
+
+ {validationError}
+
+ )}
+ >
+ );
}
+);
- // If turnstile is enabled but site key is missing, show error message
- if (!siteKey) {
- console.error('Captcha: NEXT_PUBLIC_TURNSTILE_SITE_KEY is not set');
- return null;
- }
-
- const theme = useTheme();
- const locale = useLocale();
-
- return (
- <>
-
-
- {validationError && (
-
- {validationError}
-
- )}
- >
- );
-};
+Captcha.displayName = 'Captcha';
diff --git a/src/credits/credits.ts b/src/credits/credits.ts
index d82b0a0..3d6e043 100644
--- a/src/credits/credits.ts
+++ b/src/credits/credits.ts
@@ -149,8 +149,6 @@ export async function addCredits({
console.error('addCredits, invalid expire days', userId, expireDays);
throw new Error('Invalid expire days');
}
- // Process expired credits first
- await processExpiredCredits(userId);
// Update user credit balance
const db = await getDb();
const current = await db
@@ -230,8 +228,6 @@ export async function consumeCredits({
console.error('consumeCredits, invalid amount', userId, amount);
throw new Error('Invalid amount');
}
- // Process expired credits first
- await processExpiredCredits(userId);
// Check balance
if (!(await hasEnoughCredits({ userId, requiredCredits: amount }))) {
console.error(
@@ -304,6 +300,7 @@ export async function consumeCredits({
/**
* Process expired credits
* @param userId - User ID
+ * @deprecated This function is no longer used, see distribute.ts instead
*/
export async function processExpiredCredits(userId: string) {
const now = new Date();
diff --git a/src/credits/distribute.ts b/src/credits/distribute.ts
index 4465e40..9c8f13d 100644
--- a/src/credits/distribute.ts
+++ b/src/credits/distribute.ts
@@ -4,7 +4,7 @@ import { creditTransaction, payment, user, userCredit } from '@/db/schema';
import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
import { PlanIntervals } from '@/payment/types';
import { addDays } from 'date-fns';
-import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
+import { and, eq, gt, inArray, isNull, lt, not, or, sql } from 'drizzle-orm';
import { CREDIT_TRANSACTION_TYPE } from './types';
/**
@@ -14,6 +14,11 @@ import { CREDIT_TRANSACTION_TYPE } from './types';
export async function distributeCreditsToAllUsers() {
console.log('>>> distribute credits start');
+ // Process expired credits first before distributing new credits
+ console.log('Processing expired credits before distribution...');
+ const expiredResult = await batchProcessExpiredCredits();
+ console.log('Expired credits processed:', expiredResult);
+
const db = await getDb();
// Get all users with their current active payments/subscriptions in a single query
@@ -602,3 +607,186 @@ export async function batchAddYearlyUsersMonthlyCredits(
`batchAddYearlyUsersMonthlyCredits completed, total processed: ${totalProcessedCount} users`
);
}
+
+/**
+ * Batch process expired credits for all users
+ * This function is designed to be called by a cron job
+ */
+export async function batchProcessExpiredCredits() {
+ console.log('>>> batch process expired credits start');
+
+ const db = await getDb();
+ const now = new Date();
+
+ // Get all users who have credit transactions that can expire
+ const usersWithExpirableCredits = await db
+ .selectDistinct({
+ userId: creditTransaction.userId,
+ })
+ .from(creditTransaction)
+ .where(
+ and(
+ // Exclude usage and expire records (these are consumption/expiration logs)
+ not(eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.USAGE)),
+ not(eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.EXPIRE)),
+ // Only include transactions with expirationDate set
+ not(isNull(creditTransaction.expirationDate)),
+ // Only include transactions not yet processed for expiration
+ isNull(creditTransaction.expirationDateProcessedAt),
+ // Only include transactions with remaining amount > 0
+ gt(creditTransaction.remainingAmount, 0),
+ // Only include expired transactions
+ lt(creditTransaction.expirationDate, now)
+ )
+ );
+
+ console.log(
+ 'batch process expired credits, users count:',
+ usersWithExpirableCredits.length
+ );
+
+ const usersCount = usersWithExpirableCredits.length;
+ let processedCount = 0;
+ let errorCount = 0;
+ let totalExpiredCredits = 0;
+
+ const batchSize = 100;
+
+ // Process users in batches
+ for (let i = 0; i < usersWithExpirableCredits.length; i += batchSize) {
+ const batch = usersWithExpirableCredits.slice(i, i + batchSize);
+ try {
+ const batchResult = await batchProcessExpiredCreditsForUsers(
+ batch.map((user) => user.userId)
+ );
+ processedCount += batchResult.processedCount;
+ totalExpiredCredits += batchResult.expiredCredits;
+ } catch (error) {
+ console.error(
+ `batchProcessExpiredCredits error for batch ${i / batchSize + 1}:`,
+ error
+ );
+ errorCount += batch.length;
+ }
+
+ // Log progress for large datasets
+ if (usersWithExpirableCredits.length > 1000) {
+ console.log(
+ `expired credits progress: ${Math.min(i + batchSize, usersWithExpirableCredits.length)}/${usersWithExpirableCredits.length}`
+ );
+ }
+ }
+
+ console.log(
+ `<<< batch process expired credits end, users: ${usersCount}, processed: ${processedCount}, errors: ${errorCount}, total expired credits: ${totalExpiredCredits}`
+ );
+ return { usersCount, processedCount, errorCount, totalExpiredCredits };
+}
+
+/**
+ * Batch process expired credits for a group of users
+ * @param userIds - Array of user IDs
+ */
+export async function batchProcessExpiredCreditsForUsers(userIds: string[]) {
+ if (userIds.length === 0) {
+ console.log('batchProcessExpiredCreditsForUsers, no users to process');
+ return { processedCount: 0, expiredCredits: 0 };
+ }
+
+ const db = await getDb();
+ const now = new Date();
+
+ let totalProcessedCount = 0;
+ let totalExpiredCredits = 0;
+
+ // Use transaction for data consistency
+ await db.transaction(async (tx) => {
+ for (const userId of userIds) {
+ // Get all credit transactions that can expire for this user
+ const transactions = await tx
+ .select()
+ .from(creditTransaction)
+ .where(
+ and(
+ eq(creditTransaction.userId, userId),
+ // Exclude usage and expire records (these are consumption/expiration logs)
+ not(eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.USAGE)),
+ not(eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.EXPIRE)),
+ // Only include transactions with expirationDate set
+ not(isNull(creditTransaction.expirationDate)),
+ // Only include transactions not yet processed for expiration
+ isNull(creditTransaction.expirationDateProcessedAt),
+ // Only include transactions with remaining amount > 0
+ gt(creditTransaction.remainingAmount, 0),
+ // Only include expired transactions
+ lt(creditTransaction.expirationDate, now)
+ )
+ );
+
+ let expiredTotal = 0;
+
+ // Process expired credit transactions
+ for (const transaction of transactions) {
+ const remain = transaction.remainingAmount || 0;
+ if (remain > 0) {
+ expiredTotal += remain;
+ await tx
+ .update(creditTransaction)
+ .set({
+ remainingAmount: 0,
+ expirationDateProcessedAt: now,
+ updatedAt: now,
+ })
+ .where(eq(creditTransaction.id, transaction.id));
+ }
+ }
+
+ if (expiredTotal > 0) {
+ // Deduct expired credits from balance
+ const current = await tx
+ .select()
+ .from(userCredit)
+ .where(eq(userCredit.userId, userId))
+ .limit(1);
+
+ const newBalance = Math.max(
+ 0,
+ (current[0]?.currentCredits || 0) - expiredTotal
+ );
+
+ await tx
+ .update(userCredit)
+ .set({ currentCredits: newBalance, updatedAt: now })
+ .where(eq(userCredit.userId, userId));
+
+ // Write expire record
+ await tx.insert(creditTransaction).values({
+ id: randomUUID(),
+ userId,
+ type: CREDIT_TRANSACTION_TYPE.EXPIRE,
+ amount: -expiredTotal,
+ remainingAmount: null,
+ description: `Expire credits: ${expiredTotal}`,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ totalExpiredCredits += expiredTotal;
+ console.log(
+ `batchProcessExpiredCreditsForUsers, ${expiredTotal} credits expired for user ${userId}`
+ );
+ }
+
+ totalProcessedCount++;
+ }
+ });
+
+ console.log(
+ `batchProcessExpiredCreditsForUsers, processed ${totalProcessedCount} users, total expired credits: ${totalExpiredCredits}`
+ );
+
+ return {
+ processedCount: totalProcessedCount,
+ expiredCredits: totalExpiredCredits,
+ };
+}
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
new file mode 100644
index 0000000..4ae3a40
--- /dev/null
+++ b/src/hooks/use-auth.ts
@@ -0,0 +1,44 @@
+import { authClient } from '@/lib/auth-client';
+import { useQuery } from '@tanstack/react-query';
+
+// Query keys
+export const userAccountsKeys = {
+ all: ['userAccounts'] as const,
+ list: (userId: string) => [...userAccountsKeys.all, 'list', userId] as const,
+};
+
+// Hook to fetch user accounts
+export function useUserAccounts(userId: string | undefined) {
+ return useQuery({
+ queryKey: userAccountsKeys.list(userId || ''),
+ queryFn: async () => {
+ if (!userId) {
+ throw new Error('User ID is required');
+ }
+
+ const accounts = await authClient.listAccounts();
+
+ // Check if the response is successful and contains accounts data
+ if ('data' in accounts && Array.isArray(accounts.data)) {
+ return accounts.data;
+ }
+
+ throw new Error('Failed to fetch user accounts');
+ },
+ enabled: !!userId,
+ });
+}
+
+// Hook to check if user has credential provider
+export function useHasCredentialProvider(userId: string | undefined) {
+ const { data: accounts, isLoading, error } = useUserAccounts(userId);
+
+ const hasCredentialProvider =
+ accounts?.some((account) => account.provider === 'credential') ?? false;
+
+ return {
+ hasCredentialProvider,
+ isLoading,
+ error,
+ };
+}
diff --git a/src/hooks/use-credits.ts b/src/hooks/use-credits.ts
index 2cfd0ee..7c4ed8c 100644
--- a/src/hooks/use-credits.ts
+++ b/src/hooks/use-credits.ts
@@ -1,66 +1,121 @@
-import { websiteConfig } from '@/config/website';
-import { authClient } from '@/lib/auth-client';
-import { useCreditsStore } from '@/stores/credits-store';
-import { useCallback, useEffect } from 'react';
+import { consumeCreditsAction } from '@/actions/consume-credits';
+import { getCreditBalanceAction } from '@/actions/get-credit-balance';
+import { getCreditStatsAction } from '@/actions/get-credit-stats';
+import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import type { SortingState } from '@tanstack/react-table';
-/**
- * Hook for accessing and managing credits state
- *
- * This hook provides access to the credits state and methods to manage it.
- * It also automatically fetches credits information when the user changes.
- * Only works when credits are enabled in the website configuration.
- */
-export function useCredits() {
- // Return default values if credits are disabled
- if (!websiteConfig.credits.enableCredits) {
- return {
- balance: 0,
- isLoading: false,
- error: null,
- fetchCredits: () => Promise.resolve(),
- consumeCredits: () => Promise.resolve(false),
- hasEnoughCredits: () => false,
- };
- }
+// Query keys
+export const creditsKeys = {
+ all: ['credits'] as const,
+ balance: () => [...creditsKeys.all, 'balance'] as const,
+ stats: () => [...creditsKeys.all, 'stats'] as const,
+ transactions: () => [...creditsKeys.all, 'transactions'] as const,
+ transactionsList: (filters: {
+ pageIndex: number;
+ pageSize: number;
+ search: string;
+ sorting: SortingState;
+ }) => [...creditsKeys.transactions(), filters] as const,
+};
- const {
- balance,
- isLoading,
- error,
- fetchCredits: fetchCreditsFromStore,
- consumeCredits,
- } = useCreditsStore();
-
- const { data: session } = authClient.useSession();
-
- const fetchCredits = useCallback(
- (force = false) => {
- const currentUser = session?.user;
- if (currentUser) {
- fetchCreditsFromStore(currentUser, force);
+// Hook to fetch credit balance
+export function useCreditBalance() {
+ return useQuery({
+ queryKey: creditsKeys.balance(),
+ queryFn: async () => {
+ console.log('Fetching credit balance...');
+ const result = await getCreditBalanceAction();
+ if (!result?.data?.success) {
+ throw new Error('Failed to fetch credit balance');
}
+ console.log('Credit balance fetched:', result.data.credits);
+ return result.data.credits || 0;
},
- [session?.user, fetchCreditsFromStore]
- );
-
- useEffect(() => {
- const currentUser = session?.user;
- if (currentUser) {
- fetchCreditsFromStore(currentUser);
- }
- }, [session?.user, fetchCreditsFromStore]);
-
- return {
- // State
- balance,
- isLoading,
- error,
-
- // Methods
- fetchCredits,
- consumeCredits,
-
- // Helper methods
- hasEnoughCredits: (amount: number) => balance >= amount,
- };
+ });
+}
+
+// Hook to fetch credit statistics
+export function useCreditStats() {
+ return useQuery({
+ queryKey: creditsKeys.stats(),
+ queryFn: async () => {
+ console.log('Fetching credit stats...');
+ const result = await getCreditStatsAction();
+ if (!result?.data?.success) {
+ throw new Error(result?.data?.error || 'Failed to fetch credit stats');
+ }
+ console.log('Credit stats fetched:', result.data.data);
+ return result.data.data;
+ },
+ });
+}
+
+// Hook to consume credits
+export function useConsumeCredits() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ amount,
+ description,
+ }: {
+ amount: number;
+ description: string;
+ }) => {
+ const result = await consumeCreditsAction({
+ amount,
+ description,
+ });
+ if (!result?.data?.success) {
+ throw new Error(result?.data?.error || 'Failed to consume credits');
+ }
+ return result.data;
+ },
+ onSuccess: () => {
+ // Invalidate credit balance and stats after consuming credits
+ queryClient.invalidateQueries({
+ queryKey: creditsKeys.balance(),
+ });
+ queryClient.invalidateQueries({
+ queryKey: creditsKeys.stats(),
+ });
+ },
+ });
+}
+
+// Hook to fetch credit transactions with pagination, search, and sorting
+export function useCreditTransactions(
+ pageIndex: number,
+ pageSize: number,
+ search: string,
+ sorting: SortingState
+) {
+ return useQuery({
+ queryKey: creditsKeys.transactionsList({
+ pageIndex,
+ pageSize,
+ search,
+ sorting,
+ }),
+ queryFn: async () => {
+ const result = await getCreditTransactionsAction({
+ pageIndex,
+ pageSize,
+ search,
+ sorting,
+ });
+
+ if (!result?.data?.success) {
+ throw new Error(
+ result?.data?.error || 'Failed to fetch credit transactions'
+ );
+ }
+
+ return {
+ items: result.data.data?.items || [],
+ total: result.data.data?.total || 0,
+ };
+ },
+ });
}
diff --git a/src/hooks/use-newsletter.ts b/src/hooks/use-newsletter.ts
new file mode 100644
index 0000000..d4f80ce
--- /dev/null
+++ b/src/hooks/use-newsletter.ts
@@ -0,0 +1,77 @@
+import { checkNewsletterStatusAction } from '@/actions/check-newsletter-status';
+import { subscribeNewsletterAction } from '@/actions/subscribe-newsletter';
+import { unsubscribeNewsletterAction } from '@/actions/unsubscribe-newsletter';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+// Query keys
+export const newsletterKeys = {
+ all: ['newsletter'] as const,
+ status: (email: string) => [...newsletterKeys.all, 'status', email] as const,
+};
+
+// Hook to check newsletter subscription status
+export function useNewsletterStatus(email: string | undefined) {
+ return useQuery({
+ queryKey: newsletterKeys.status(email || ''),
+ queryFn: async () => {
+ if (!email) {
+ throw new Error('Email is required');
+ }
+ const result = await checkNewsletterStatusAction({ email });
+ if (!result?.data?.success) {
+ throw new Error(
+ result?.data?.error || 'Failed to check newsletter status'
+ );
+ }
+ return result.data;
+ },
+ enabled: !!email,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ });
+}
+
+// Hook to subscribe to newsletter
+export function useSubscribeNewsletter() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (email: string) => {
+ const result = await subscribeNewsletterAction({ email });
+ if (!result?.data?.success) {
+ throw new Error(
+ result?.data?.error || 'Failed to subscribe to newsletter'
+ );
+ }
+ return result.data;
+ },
+ onSuccess: (_, email) => {
+ // Invalidate and refetch the newsletter status
+ queryClient.invalidateQueries({
+ queryKey: newsletterKeys.status(email),
+ });
+ },
+ });
+}
+
+// Hook to unsubscribe from newsletter
+export function useUnsubscribeNewsletter() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (email: string) => {
+ const result = await unsubscribeNewsletterAction({ email });
+ if (!result?.data?.success) {
+ throw new Error(
+ result?.data?.error || 'Failed to unsubscribe from newsletter'
+ );
+ }
+ return result.data;
+ },
+ onSuccess: (_, email) => {
+ // Invalidate and refetch the newsletter status
+ queryClient.invalidateQueries({
+ queryKey: newsletterKeys.status(email),
+ });
+ },
+ });
+}
diff --git a/src/hooks/use-payment.ts b/src/hooks/use-payment.ts
index 259c020..a721841 100644
--- a/src/hooks/use-payment.ts
+++ b/src/hooks/use-payment.ts
@@ -1,49 +1,107 @@
-import { authClient } from '@/lib/auth-client';
-import { usePaymentStore } from '@/stores/payment-store';
-import { useCallback, useEffect } from 'react';
+import { getActiveSubscriptionAction } from '@/actions/get-active-subscription';
+import { getLifetimeStatusAction } from '@/actions/get-lifetime-status';
+import { getAllPricePlans } from '@/lib/price-plan';
+import type { PricePlan, Subscription } from '@/payment/types';
+import { useQuery } from '@tanstack/react-query';
-/**
- * Hook for accessing and managing payment state
- *
- * This hook provides access to the payment state and methods to manage it.
- * It also automatically fetches payment information when the user changes.
- */
-export function usePayment() {
- const {
- currentPlan,
- subscription,
- isLoading,
- error,
- fetchPayment: fetchPaymentFromStore,
- } = usePaymentStore();
+// Query keys
+export const paymentKeys = {
+ all: ['payment'] as const,
+ subscription: (userId: string) =>
+ [...paymentKeys.all, 'subscription', userId] as const,
+ lifetime: (userId: string) =>
+ [...paymentKeys.all, 'lifetime', userId] as const,
+ currentPlan: (userId: string) =>
+ [...paymentKeys.all, 'currentPlan', userId] as const,
+};
- const { data: session } = authClient.useSession();
-
- const fetchPayment = useCallback(
- (force = false) => {
- const currentUser = session?.user;
- if (currentUser) {
- fetchPaymentFromStore(currentUser, force);
+// Hook to fetch active subscription
+export function useActiveSubscription(userId: string | undefined) {
+ return useQuery({
+ queryKey: paymentKeys.subscription(userId || ''),
+ queryFn: async (): Promise => {
+ if (!userId) {
+ throw new Error('User ID is required');
}
+ const result = await getActiveSubscriptionAction({ userId });
+ if (!result?.data?.success) {
+ throw new Error(result?.data?.error || 'Failed to fetch subscription');
+ }
+ return result.data.data || null;
},
- [session?.user, fetchPaymentFromStore]
- );
-
- useEffect(() => {
- const currentUser = session?.user;
- if (currentUser) {
- fetchPaymentFromStore(currentUser);
- }
- }, [session?.user, fetchPaymentFromStore]);
-
- return {
- // State
- currentPlan,
- subscription,
- isLoading,
- error,
-
- // Methods
- fetchPayment,
- };
+ enabled: !!userId,
+ });
+}
+
+// Hook to fetch lifetime status
+export function useLifetimeStatus(userId: string | undefined) {
+ return useQuery({
+ queryKey: paymentKeys.lifetime(userId || ''),
+ queryFn: async (): Promise => {
+ if (!userId) {
+ throw new Error('User ID is required');
+ }
+ const result = await getLifetimeStatusAction({ userId });
+ if (!result?.data?.success) {
+ throw new Error(
+ result?.data?.error || 'Failed to fetch lifetime status'
+ );
+ }
+ return result.data.isLifetimeMember || false;
+ },
+ enabled: !!userId,
+ });
+}
+
+// Hook to get current plan based on subscription and lifetime status
+export function useCurrentPlan(userId: string | undefined) {
+ const {
+ data: subscription,
+ isLoading: isLoadingSubscription,
+ error: subscriptionError,
+ } = useActiveSubscription(userId);
+ const {
+ data: isLifetimeMember,
+ isLoading: isLoadingLifetime,
+ error: lifetimeError,
+ } = useLifetimeStatus(userId);
+
+ return useQuery({
+ queryKey: paymentKeys.currentPlan(userId || ''),
+ queryFn: async (): Promise<{
+ currentPlan: PricePlan | null;
+ subscription: Subscription | null;
+ }> => {
+ const plans: PricePlan[] = getAllPricePlans();
+ const freePlan = plans.find((plan) => plan.isFree);
+ const lifetimePlan = plans.find((plan) => plan.isLifetime);
+
+ // If lifetime member, return lifetime plan
+ if (isLifetimeMember) {
+ return {
+ currentPlan: lifetimePlan || null,
+ subscription: null,
+ };
+ }
+
+ // If has active subscription, find the corresponding plan
+ if (subscription) {
+ const plan =
+ plans.find((p) =>
+ p.prices.find((price) => price.priceId === subscription.priceId)
+ ) || null;
+ return {
+ currentPlan: plan,
+ subscription,
+ };
+ }
+
+ // Default to free plan
+ return {
+ currentPlan: freePlan || null,
+ subscription: null,
+ };
+ },
+ enabled: !!userId && !isLoadingSubscription && !isLoadingLifetime,
+ });
}
diff --git a/src/hooks/use-users.ts b/src/hooks/use-users.ts
new file mode 100644
index 0000000..f983685
--- /dev/null
+++ b/src/hooks/use-users.ts
@@ -0,0 +1,93 @@
+import { getUsersAction } from '@/actions/get-users';
+import { authClient } from '@/lib/auth-client';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import type { SortingState } from '@tanstack/react-table';
+
+// Query keys
+export const usersKeys = {
+ all: ['users'] as const,
+ lists: () => [...usersKeys.all, 'lists'] as const,
+ list: (filters: {
+ pageIndex: number;
+ pageSize: number;
+ search: string;
+ sorting: SortingState;
+ }) => [...usersKeys.lists(), filters] as const,
+};
+
+// Hook to fetch users with pagination, search, and sorting
+export function useUsers(
+ pageIndex: number,
+ pageSize: number,
+ search: string,
+ sorting: SortingState
+) {
+ return useQuery({
+ queryKey: usersKeys.list({ pageIndex, pageSize, search, sorting }),
+ queryFn: async () => {
+ const result = await getUsersAction({
+ pageIndex,
+ pageSize,
+ search,
+ sorting,
+ });
+
+ if (!result?.data?.success) {
+ throw new Error(result?.data?.error || 'Failed to fetch users');
+ }
+
+ return {
+ items: result.data.data?.items || [],
+ total: result.data.data?.total || 0,
+ };
+ },
+ });
+}
+
+// Hook to ban user
+export function useBanUser() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ userId,
+ banReason,
+ banExpiresIn,
+ }: {
+ userId: string;
+ banReason: string;
+ banExpiresIn?: number;
+ }) => {
+ return authClient.admin.banUser({
+ userId,
+ banReason,
+ banExpiresIn,
+ });
+ },
+ onSuccess: () => {
+ // Invalidate all users queries to refresh the data
+ queryClient.invalidateQueries({
+ queryKey: usersKeys.all,
+ });
+ },
+ });
+}
+
+// Hook to unban user
+export function useUnbanUser() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ userId }: { userId: string }) => {
+ return authClient.admin.unbanUser({
+ userId,
+ });
+ },
+ onSuccess: () => {
+ // Invalidate all users queries to refresh the data
+ queryClient.invalidateQueries({
+ queryKey: usersKeys.all,
+ });
+ },
+ });
+}
diff --git a/src/stores/credits-store.ts b/src/stores/credits-store.ts
deleted file mode 100644
index 0f7325e..0000000
--- a/src/stores/credits-store.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-import { consumeCreditsAction } from '@/actions/consume-credits';
-import { getCreditBalanceAction } from '@/actions/get-credit-balance';
-import type { Session } from '@/lib/auth-types';
-import { create } from 'zustand';
-
-// Cache duration: 2 minutes (optimized for better UX)
-const CACHE_DURATION = 2 * 60 * 1000;
-
-/**
- * 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,
- force?: boolean
- ) => Promise;
- consumeCredits: (amount: number, description: string) => Promise;
-}
-
-/**
- * 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 optional cache bypass
- * @param user Current user from auth session
- * @param force Whether to force refresh and ignore cache
- */
- fetchCredits: async (user, force = false) => {
- // 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) unless force refresh
- if (!force) {
- const { lastFetchTime } = get();
- const now = Date.now();
- if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
- return; // Use cached data
- }
- }
-
- console.log(`fetchCredits, ${force ? 'force fetch' : 'fetch'} credits`);
- set({
- isLoading: true,
- error: null,
- // Clear cache if force refresh
- lastFetchTime: force ? null : get().lastFetchTime,
- });
-
- try {
- const result = await getCreditBalanceAction();
- if (result?.data?.success && result.data.credits !== undefined) {
- const newBalance = result.data.credits || 0;
- console.log('fetchCredits, set new balance', newBalance);
- set({
- balance: newBalance,
- isLoading: false,
- error: null,
- lastFetchTime: Date.now(),
- });
- } else {
- console.warn('fetchCredits, failed to fetch credit balance', result);
- set({
- error:
- (result?.data as any)?.error || 'Failed to fetch credit balance',
- isLoading: false,
- });
- }
- } catch (error) {
- console.error('fetchCredits, 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) {
- console.log('consumeCredits, insufficient credits', balance, amount);
- set({
- error: 'Insufficient credits',
- });
- 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
- console.warn('consumeCredits, reverting optimistic update');
- set({
- balance: balance, // Revert to original balance
- error: result?.data?.error || 'Failed to consume credits',
- isLoading: false,
- });
- return false;
- } catch (error) {
- console.error('consumeCredits, error:', error);
- // Revert optimistic update on error
- set({
- balance: balance, // Revert to original balance
- error: 'Failed to consume credits',
- isLoading: false,
- });
- return false;
- }
- },
-}));
diff --git a/src/stores/payment-store.ts b/src/stores/payment-store.ts
deleted file mode 100644
index ba8edd8..0000000
--- a/src/stores/payment-store.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { getActiveSubscriptionAction } from '@/actions/get-active-subscription';
-import { getLifetimeStatusAction } from '@/actions/get-lifetime-status';
-import type { Session } from '@/lib/auth-types';
-import { getAllPricePlans } from '@/lib/price-plan';
-import type { PricePlan, Subscription } from '@/payment/types';
-import { create } from 'zustand';
-
-// Cache duration: 2 minutes (optimized for better UX)
-const CACHE_DURATION = 2 * 60 * 1000;
-
-/**
- * Payment state interface
- */
-export interface PaymentState {
- // Current plan
- currentPlan: PricePlan | null;
- // Active subscription
- subscription: Subscription | null;
- // Loading state
- isLoading: boolean;
- // Error state
- error: string | null;
- // Last fetch timestamp to avoid frequent requests
- lastFetchTime: number | null;
-
- // Actions
- fetchPayment: (
- user: Session['user'] | null | undefined,
- force?: boolean
- ) => Promise;
- resetState: () => void;
-}
-
-/**
- * Payment store using Zustand
- * Manages the user's payment and subscription data globally
- */
-export const usePaymentStore = create((set, get) => ({
- // Initial state
- currentPlan: null,
- subscription: null,
- isLoading: false,
- error: null,
- lastFetchTime: null,
-
- /**
- * Fetch payment and subscription data for the current user
- * @param user Current user from auth session
- */
- fetchPayment: async (user, force = false) => {
- // Skip if already loading
- if (get().isLoading) return;
-
- // Skip if no user is provided
- if (!user) {
- set({
- currentPlan: null,
- subscription: null,
- error: null,
- lastFetchTime: null,
- });
- return;
- }
-
- // Check if we have recent data (within cache duration) unless force refresh
- if (!force) {
- const { lastFetchTime } = get();
- const now = Date.now();
- if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
- console.log('fetchPayment, use cached data');
- return; // Use cached data
- }
- }
-
- // Fetch subscription data
- set({ isLoading: true, error: null });
-
- // Get all price plans
- const plans: PricePlan[] = getAllPricePlans();
- const freePlan = plans.find((plan) => plan.isFree);
- const lifetimePlan = plans.find((plan) => plan.isLifetime);
-
- // Check if user is a lifetime member directly from the database
- let isLifetimeMember = false;
- try {
- const result = await getLifetimeStatusAction({ userId: user.id });
- if (result?.data?.success) {
- isLifetimeMember = result.data.isLifetimeMember || false;
- console.log('fetchPayment, lifetime status', isLifetimeMember);
- } else {
- console.warn(
- 'fetchPayment, lifetime status error',
- result?.data?.error
- );
- }
- } catch (error) {
- console.error('fetchPayment, lifetime status error:', error);
- }
-
- // If lifetime member, set the lifetime plan
- if (isLifetimeMember) {
- console.log('fetchPayment, set lifetime plan');
- set({
- currentPlan: lifetimePlan || null,
- subscription: null,
- isLoading: false,
- error: null,
- lastFetchTime: Date.now(),
- });
- return;
- }
-
- try {
- // Check if user has an active subscription
- const result = await getActiveSubscriptionAction({ userId: user.id });
- if (result?.data?.success) {
- const activeSubscription = result.data.data;
-
- // Set subscription state
- if (activeSubscription) {
- const plan =
- plans.find((p) =>
- p.prices.find(
- (price) => price.priceId === activeSubscription.priceId
- )
- ) || null;
- console.log('fetchPayment, subscription found, set pro plan');
- set({
- currentPlan: plan,
- subscription: activeSubscription,
- isLoading: false,
- error: null,
- lastFetchTime: Date.now(),
- });
- } else {
- // No subscription found - set to free plan
- console.log('fetchPayment, no subscription found, set free plan');
- set({
- currentPlan: freePlan || null,
- subscription: null,
- isLoading: false,
- error: null,
- lastFetchTime: Date.now(),
- });
- }
- } else {
- // Failed to fetch subscription
- console.error(
- 'fetchPayment, subscription for user failed',
- result?.data?.error
- );
- set({
- error: result?.data?.error || 'Failed to fetch payment data',
- isLoading: false,
- });
- }
- } catch (error) {
- console.error('fetchPayment, error:', error);
- set({
- error: 'Failed to fetch payment data',
- isLoading: false,
- });
- } finally {
- set({ isLoading: false });
- }
- },
-
- /**
- * Reset payment state
- */
- resetState: () => {
- set({
- currentPlan: null,
- subscription: null,
- isLoading: false,
- error: null,
- lastFetchTime: null,
- });
- },
-}));
diff --git a/src/stores/users-store.ts b/src/stores/users-store.ts
deleted file mode 100644
index e6b901e..0000000
--- a/src/stores/users-store.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { create } from 'zustand';
-
-interface UsersState {
- refreshTrigger: number;
- triggerRefresh: () => void;
-}
-
-export const useUsersStore = create((set) => ({
- refreshTrigger: 0,
- triggerRefresh: () =>
- set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })),
-}));