Merge remote-tracking branch 'origin/main' into cloudflare
This commit is contained in:
commit
18691030e7
@ -601,8 +601,7 @@
|
||||
"creditsAdded": "Credits have been added to your account",
|
||||
"viewTransactions": "View Credit Transactions",
|
||||
"retry": "Retry",
|
||||
"subscriptionCredits": "{credits} credits from subscription this month",
|
||||
"lifetimeCredits": "{credits} credits from lifetime plan this month",
|
||||
|
||||
"expiringCredits": "{credits} credits expiring on {date}"
|
||||
},
|
||||
"packages": {
|
||||
|
@ -601,8 +601,7 @@
|
||||
"creditsAdded": "积分已添加到您的账户",
|
||||
"viewTransactions": "查看积分记录",
|
||||
"retry": "重试",
|
||||
"subscriptionCredits": "本月订阅获得 {credits} 积分",
|
||||
"lifetimeCredits": "本月终身会员获得 {credits} 积分",
|
||||
|
||||
"expiringCredits": "{credits} 积分将在 {date} 过期"
|
||||
},
|
||||
"packages": {
|
||||
|
@ -79,6 +79,8 @@
|
||||
"@react-email/render": "1.0.5",
|
||||
"@stripe/stripe-js": "^5.6.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@tanstack/react-query-devtools": "^5.85.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
@ -140,6 +142,7 @@
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@opennextjs/cloudflare": "^1.6.5",
|
||||
"@tailwindcss/postcss": "^4.0.14",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/pg": "^8.11.11",
|
||||
|
655
pnpm-lock.yaml
generated
655
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,5 @@
|
||||
'use server';
|
||||
|
||||
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
|
||||
import { getDb } from '@/db';
|
||||
import { creditTransaction } from '@/db/schema';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
@ -9,7 +8,6 @@ import { addDays } from 'date-fns';
|
||||
import { and, eq, gte, isNotNull, lte, sql, sum } from 'drizzle-orm';
|
||||
|
||||
const CREDITS_EXPIRATION_DAYS = 31;
|
||||
const CREDITS_MONTHLY_DAYS = 31;
|
||||
|
||||
/**
|
||||
* Get credit statistics for a user
|
||||
@ -39,38 +37,6 @@ export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
|
||||
)
|
||||
);
|
||||
|
||||
// Get credits from subscription renewals (recent CREDITS_MONTHLY_DAYS days)
|
||||
const monthlyRefreshDaysAgo = addDays(new Date(), -CREDITS_MONTHLY_DAYS);
|
||||
const subscriptionCredits = await db
|
||||
.select({
|
||||
amount: sum(creditTransaction.amount),
|
||||
})
|
||||
.from(creditTransaction)
|
||||
.where(
|
||||
and(
|
||||
eq(creditTransaction.userId, userId),
|
||||
eq(
|
||||
creditTransaction.type,
|
||||
CREDIT_TRANSACTION_TYPE.SUBSCRIPTION_RENEWAL
|
||||
),
|
||||
gte(creditTransaction.createdAt, monthlyRefreshDaysAgo)
|
||||
)
|
||||
);
|
||||
|
||||
// Get credits from monthly lifetime distribution (recent CREDITS_MONTHLY_DAYS days)
|
||||
const lifetimeCredits = await db
|
||||
.select({
|
||||
amount: sum(creditTransaction.amount),
|
||||
})
|
||||
.from(creditTransaction)
|
||||
.where(
|
||||
and(
|
||||
eq(creditTransaction.userId, userId),
|
||||
eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY),
|
||||
gte(creditTransaction.createdAt, monthlyRefreshDaysAgo)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@ -78,12 +44,6 @@ export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
|
||||
amount: Number(expiringCredits[0]?.amount) || 0,
|
||||
earliestExpiration: expiringCredits[0]?.earliestExpiration || null,
|
||||
},
|
||||
subscriptionCredits: {
|
||||
amount: Number(subscriptionCredits[0]?.amount) || 0,
|
||||
},
|
||||
lifetimeCredits: {
|
||||
amount: Number(lifetimeCredits[0]?.amount) || 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCredits } from '@/hooks/use-credits';
|
||||
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits';
|
||||
import { CoinsIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@ -10,24 +10,28 @@ import { toast } from 'sonner';
|
||||
const CONSUME_CREDITS = 50;
|
||||
|
||||
export function ConsumeCreditCard() {
|
||||
const { consumeCredits, hasEnoughCredits, isLoading } = useCredits();
|
||||
const { data: balance = 0, isLoading: isLoadingBalance } = useCreditBalance();
|
||||
const consumeCreditsMutation = useConsumeCredits();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const hasEnoughCredits = (amount: number) => balance >= amount;
|
||||
|
||||
const handleConsume = async () => {
|
||||
if (!hasEnoughCredits(CONSUME_CREDITS)) {
|
||||
toast.error('Insufficient credits, please buy more credits.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const success = await consumeCredits(
|
||||
CONSUME_CREDITS,
|
||||
`AI Text Credit Consumption (${CONSUME_CREDITS} credits)`
|
||||
);
|
||||
setLoading(false);
|
||||
if (success) {
|
||||
try {
|
||||
await consumeCreditsMutation.mutateAsync({
|
||||
amount: CONSUME_CREDITS,
|
||||
description: `AI Text Credit Consumption (${CONSUME_CREDITS} credits)`,
|
||||
});
|
||||
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
|
||||
} else {
|
||||
} catch (error) {
|
||||
toast.error('Failed to consume credits, please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -40,7 +44,9 @@ export function ConsumeCreditCard() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleConsume}
|
||||
disabled={isLoading || loading}
|
||||
disabled={
|
||||
loading || isLoadingBalance || consumeCreditsMutation.isPending
|
||||
}
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<CoinsIcon className="size-4" />
|
||||
|
@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ActiveThemeProvider } from '@/components/layout/active-theme-provider';
|
||||
import { CreditsProvider } from '@/components/layout/credits-provider';
|
||||
import { PaymentProvider } from '@/components/layout/payment-provider';
|
||||
import { QueryProvider } from '@/components/providers/query-provider';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import type { Translations } from 'fumadocs-ui/i18n';
|
||||
@ -54,21 +53,19 @@ export function Providers({ children, locale }: ProvidersProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme={defaultMode}
|
||||
enableSystem={true}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ActiveThemeProvider>
|
||||
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
|
||||
<TooltipProvider>
|
||||
<PaymentProvider>
|
||||
<CreditsProvider>{children}</CreditsProvider>
|
||||
</PaymentProvider>
|
||||
</TooltipProvider>
|
||||
</RootProvider>
|
||||
</ActiveThemeProvider>
|
||||
</ThemeProvider>
|
||||
<QueryProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme={defaultMode}
|
||||
enableSystem={true}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ActiveThemeProvider>
|
||||
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</RootProvider>
|
||||
</ActiveThemeProvider>
|
||||
</ThemeProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
@ -21,13 +20,12 @@ import {
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { useBanUser, useUnbanUser } from '@/hooks/use-users';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { isDemoWebsite } from '@/lib/demo';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUsersStore } from '@/stores/users-store';
|
||||
import {
|
||||
CalendarIcon,
|
||||
Loader2Icon,
|
||||
@ -47,11 +45,13 @@ interface UserDetailViewerProps {
|
||||
export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [banReason, setBanReason] = useState(t('ban.defaultReason'));
|
||||
const [banExpiresAt, setBanExpiresAt] = useState<Date | undefined>();
|
||||
const triggerRefresh = useUsersStore((state) => state.triggerRefresh);
|
||||
|
||||
// TanStack Query mutations
|
||||
const banUserMutation = useBanUser();
|
||||
const unbanUserMutation = useUnbanUser();
|
||||
|
||||
// show fake data in demo website
|
||||
const isDemo = isDemoWebsite();
|
||||
@ -67,11 +67,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await authClient.admin.banUser({
|
||||
await banUserMutation.mutateAsync({
|
||||
userId: user.id,
|
||||
banReason,
|
||||
banExpiresIn: banExpiresAt
|
||||
@ -83,15 +82,11 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
// Reset form
|
||||
setBanReason('');
|
||||
setBanExpiresAt(undefined);
|
||||
// Trigger refresh
|
||||
triggerRefresh();
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error('Failed to ban user:', error);
|
||||
setError(error.message || t('ban.error'));
|
||||
toast.error(error.message || t('ban.error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -101,24 +96,19 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await authClient.admin.unbanUser({
|
||||
await unbanUserMutation.mutateAsync({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
toast.success(t('unban.success'));
|
||||
// Trigger refresh
|
||||
triggerRefresh();
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error('Failed to unban user:', error);
|
||||
setError(error.message || t('unban.error'));
|
||||
toast.error(error.message || t('unban.error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -166,7 +156,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
{user.role === 'admin' ? t('admin') : t('user')}
|
||||
</Badge>
|
||||
{/* email verified */}
|
||||
<Badge variant="outline" className="px-1.5 hover:bg-accent">
|
||||
{/* <Badge variant="outline" className="px-1.5 hover:bg-accent">
|
||||
{user.emailVerified ? (
|
||||
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
||||
) : (
|
||||
@ -175,7 +165,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
{user.emailVerified
|
||||
? t('email.verified')
|
||||
: t('email.unverified')}
|
||||
</Badge>
|
||||
</Badge> */}
|
||||
|
||||
{/* user banned */}
|
||||
<div className="flex items-center gap-2">
|
||||
@ -196,15 +186,23 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t('columns.email')}:
|
||||
</span>
|
||||
<span
|
||||
className="break-words cursor-pointer hover:bg-accent px-2 py-1 rounded border"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(user.email!);
|
||||
toast.success(t('emailCopied'));
|
||||
}}
|
||||
>
|
||||
{user.email}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm px-1.5 cursor-pointer hover:bg-accent"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(user.email);
|
||||
toast.success(t('emailCopied'));
|
||||
}}
|
||||
>
|
||||
{user.emailVerified ? (
|
||||
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
||||
) : (
|
||||
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
|
||||
)}
|
||||
{user.email}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -256,10 +254,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleUnban}
|
||||
disabled={isLoading || isDemo}
|
||||
disabled={unbanUserMutation.isPending || isDemo}
|
||||
className="mt-4 cursor-pointer"
|
||||
>
|
||||
{isLoading && (
|
||||
{unbanUserMutation.isPending && (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t('unban.button')}
|
||||
@ -315,10 +313,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={isLoading || !banReason || isDemo}
|
||||
disabled={banUserMutation.isPending || !banReason || isDemo}
|
||||
className="mt-4 cursor-pointer"
|
||||
>
|
||||
{isLoading && (
|
||||
{banUserMutation.isPending && (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t('ban.button')}
|
||||
|
@ -1,74 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { getUsersAction } from '@/actions/get-users';
|
||||
import { UsersTable } from '@/components/admin/users-table';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { useUsersStore } from '@/stores/users-store';
|
||||
import { useUsers } from '@/hooks/use-users';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function UsersPageClient() {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [data, setData] = useState<User[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
const refreshTrigger = useUsersStore((state) => state.refreshTrigger);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getUsersAction({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting,
|
||||
});
|
||||
|
||||
if (result?.data?.success) {
|
||||
setData(result.data.data?.items || []);
|
||||
setTotal(result.data.data?.total || 0);
|
||||
} else {
|
||||
const errorMessage = result?.data?.error || t('error');
|
||||
toast.error(errorMessage);
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
toast.error(t('error'));
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pageIndex, pageSize, search, sorting, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
const { data, isLoading } = useUsers(pageIndex, pageSize, search, sorting);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UsersTable
|
||||
data={data}
|
||||
total={total}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
loading={loading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSortingChange={setSorting}
|
||||
/>
|
||||
</>
|
||||
<UsersTable
|
||||
data={data?.items || []}
|
||||
total={data?.total || 0}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
loading={isLoading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSortingChange={setSorting}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Captcha } from '../shared/captcha';
|
||||
@ -56,6 +56,7 @@ export const LoginForm = ({
|
||||
const [success, setSuccess] = useState<string | undefined>('');
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const captchaRef = useRef<any>(null);
|
||||
|
||||
// Check if credential login is enabled
|
||||
const credentialLoginEnabled = websiteConfig.auth.enableCredentialLogin;
|
||||
@ -92,6 +93,15 @@ export const LoginForm = ({
|
||||
name: 'captchaToken',
|
||||
});
|
||||
|
||||
// Function to reset captcha
|
||||
const resetCaptcha = () => {
|
||||
form.setValue('captchaToken', '');
|
||||
// Try to reset the Turnstile widget if available
|
||||
if (captchaRef.current && typeof captchaRef.current.reset === 'function') {
|
||||
captchaRef.current.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
|
||||
// Validate captcha token if turnstile is enabled and site key is available
|
||||
if (captchaConfigured && values.captchaToken) {
|
||||
@ -107,6 +117,8 @@ export const LoginForm = ({
|
||||
console.error('login, captcha invalid:', values.captchaToken);
|
||||
const errorMessage = captchaResult?.data?.error || t('captchaInvalid');
|
||||
setError(errorMessage);
|
||||
setIsPending(false);
|
||||
resetCaptcha(); // Reset captcha on validation failure
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -139,6 +151,10 @@ export const LoginForm = ({
|
||||
onError: (ctx) => {
|
||||
console.error('login, error:', ctx.error);
|
||||
setError(`${ctx.error.status}: ${ctx.error.message}`);
|
||||
// Reset captcha on login error
|
||||
if (captchaConfigured) {
|
||||
resetCaptcha();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -237,6 +253,7 @@ export const LoginForm = ({
|
||||
<FormSuccess message={success} />
|
||||
{captchaConfigured && (
|
||||
<Captcha
|
||||
ref={captchaRef}
|
||||
onSuccess={(token) => form.setValue('captchaToken', token)}
|
||||
validationError={form.formState.errors.captchaToken?.message}
|
||||
/>
|
||||
|
@ -22,7 +22,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Captcha } from '../shared/captcha';
|
||||
@ -51,6 +51,7 @@ export const RegisterForm = ({
|
||||
const [success, setSuccess] = useState<string | undefined>('');
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const captchaRef = useRef<any>(null);
|
||||
|
||||
// Check if credential login is enabled
|
||||
const credentialLoginEnabled = websiteConfig.auth.enableCredentialLogin;
|
||||
@ -91,6 +92,15 @@ export const RegisterForm = ({
|
||||
name: 'captchaToken',
|
||||
});
|
||||
|
||||
// Function to reset captcha
|
||||
const resetCaptcha = () => {
|
||||
form.setValue('captchaToken', '');
|
||||
// Try to reset the Turnstile widget if available
|
||||
if (captchaRef.current && typeof captchaRef.current.reset === 'function') {
|
||||
captchaRef.current.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof RegisterSchema>) => {
|
||||
// Validate captcha token if turnstile is enabled and site key is available
|
||||
if (captchaConfigured && values.captchaToken) {
|
||||
@ -106,6 +116,8 @@ export const RegisterForm = ({
|
||||
console.error('register, captcha invalid:', values.captchaToken);
|
||||
const errorMessage = captchaResult?.data?.error || t('captchaInvalid');
|
||||
setError(errorMessage);
|
||||
setIsPending(false);
|
||||
resetCaptcha(); // Reset captcha on validation failure
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -148,6 +160,10 @@ export const RegisterForm = ({
|
||||
// sign up fail, display the error message
|
||||
console.error('register, error:', ctx.error);
|
||||
setError(`${ctx.error.status}: ${ctx.error.message}`);
|
||||
// Reset captcha on registration error
|
||||
if (captchaConfigured) {
|
||||
resetCaptcha();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -247,6 +263,7 @@ export const RegisterForm = ({
|
||||
<FormSuccess message={success} />
|
||||
{captchaConfigured && (
|
||||
<Captcha
|
||||
ref={captchaRef}
|
||||
onSuccess={(token) => form.setValue('captchaToken', token)}
|
||||
validationError={form.formState.errors.captchaToken?.message}
|
||||
/>
|
||||
|
@ -23,7 +23,6 @@ import { useLocalePathname, useLocaleRouter } from '@/i18n/navigation';
|
||||
import { LOCALES, routing } from '@/i18n/routing';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { usePaymentStore } from '@/stores/payment-store';
|
||||
import type { User } from 'better-auth';
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
@ -55,7 +54,6 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
||||
const pathname = useLocalePathname();
|
||||
const params = useParams();
|
||||
const { currentLocale, setCurrentLocale } = useLocaleStore();
|
||||
const { resetState } = usePaymentStore();
|
||||
const [, startTransition] = useTransition();
|
||||
const t = useTranslations();
|
||||
|
||||
@ -81,8 +79,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
console.log('sign out success');
|
||||
// Reset payment state on sign out
|
||||
resetState();
|
||||
// TanStack Query automatically handles cache invalidation on sign out
|
||||
router.replace('/');
|
||||
},
|
||||
onError: (error) => {
|
||||
@ -100,7 +97,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="cursor-pointer data-[state=open]:bg-sidebar-accent
|
||||
className="cursor-pointer data-[state=open]:bg-sidebar-accent
|
||||
data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<UserAvatar
|
||||
|
@ -9,8 +9,9 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { usePayment } from '@/hooks/use-payment';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { LocaleLink } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Routes } from '@/routes';
|
||||
import { SparklesIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@ -23,14 +24,16 @@ export function UpgradeCard() {
|
||||
|
||||
const t = useTranslations('Dashboard.upgrade');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { isLoading, currentPlan, subscription } = usePayment();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: paymentData, isLoading } = useCurrentPlan(session?.user?.id);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Don't show the upgrade card if the user has a lifetime membership or a subscription
|
||||
const isMember = currentPlan?.isLifetime || !!subscription;
|
||||
const isMember =
|
||||
paymentData?.currentPlan?.isLifetime || !!paymentData?.subscription;
|
||||
|
||||
if (!mounted || isLoading || isMember) {
|
||||
return null;
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useCredits } from '@/hooks/use-credits';
|
||||
import { useCreditBalance } from '@/hooks/use-credits';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { Routes } from '@/routes';
|
||||
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||
@ -15,8 +15,8 @@ export function CreditsBalanceButton() {
|
||||
|
||||
const router = useLocaleRouter();
|
||||
|
||||
// Use the new useCredits hook
|
||||
const { balance, isLoading } = useCredits();
|
||||
// Use TanStack Query hook for credit balance
|
||||
const { data: balance = 0, isLoading } = useCreditBalance();
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(Routes.SettingsCredits);
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useCredits } from '@/hooks/use-credits';
|
||||
import { useCreditBalance } from '@/hooks/use-credits';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { Routes } from '@/routes';
|
||||
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||
@ -16,8 +16,8 @@ export function CreditsBalanceMenu() {
|
||||
const t = useTranslations('Marketing.avatar');
|
||||
const router = useLocaleRouter();
|
||||
|
||||
// Use the new useCredits hook
|
||||
const { balance, isLoading } = useCredits();
|
||||
// Use TanStack Query hook for credit balance
|
||||
const { data: balance = 0, isLoading } = useCreditBalance();
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(Routes.SettingsCredits);
|
||||
|
@ -1,31 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
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.
|
||||
* This component is now simplified since TanStack Query handles data fetching automatically.
|
||||
* It's kept for potential future credits-related providers.
|
||||
* Only renders when credits are enabled in the website configuration.
|
||||
*/
|
||||
export function CreditsProvider({ children }: { children: React.ReactNode }) {
|
||||
// Only initialize credits store if credits are enabled
|
||||
// Only render when credits are enabled
|
||||
if (!websiteConfig.credits.enableCredits) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const { fetchCredits } = useCreditsStore();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
fetchCredits(session.user);
|
||||
}
|
||||
}, [session?.user, fetchCredits]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { usePaymentStore } from '@/stores/payment-store';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Payment provider component
|
||||
*
|
||||
* This component is responsible for initializing the payment state
|
||||
* by fetching the current user's subscription and payment information when the app loads.
|
||||
*/
|
||||
export function PaymentProvider({ children }: { children: React.ReactNode }) {
|
||||
const { fetchPayment } = usePaymentStore();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
fetchPayment(session.user);
|
||||
}
|
||||
}, [session?.user, fetchPayment]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
@ -13,7 +13,6 @@ import {
|
||||
import { getAvatarLinks } from '@/config/avatar-config';
|
||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { usePaymentStore } from '@/stores/payment-store';
|
||||
import type { User } from 'better-auth';
|
||||
import { LogOutIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@ -29,8 +28,6 @@ export function UserButtonMobile({ user }: UserButtonProps) {
|
||||
const avatarLinks = getAvatarLinks();
|
||||
const localeRouter = useLocaleRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { resetState } = usePaymentStore();
|
||||
|
||||
const closeDrawer = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
@ -40,8 +37,7 @@ export function UserButtonMobile({ user }: UserButtonProps) {
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
console.log('sign out success');
|
||||
// Reset payment state on sign out
|
||||
resetState();
|
||||
// TanStack Query automatically handles cache invalidation on sign out
|
||||
localeRouter.replace('/');
|
||||
},
|
||||
onError: (error) => {
|
||||
@ -64,7 +60,7 @@ export function UserButtonMobile({ user }: UserButtonProps) {
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay className="fixed inset-0 z-40 bg-background/50" />
|
||||
<DrawerContent
|
||||
className="fixed inset-x-0 bottom-0 z-50 mt-24
|
||||
className="fixed inset-x-0 bottom-0 z-50 mt-24
|
||||
overflow-hidden rounded-t-[10px] border bg-background px-3 text-sm"
|
||||
>
|
||||
<DrawerHeader>
|
||||
|
@ -12,7 +12,6 @@ import { getAvatarLinks } from '@/config/avatar-config';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { usePaymentStore } from '@/stores/payment-store';
|
||||
import type { User } from 'better-auth';
|
||||
import { LogOutIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@ -29,15 +28,12 @@ export function UserButton({ user }: UserButtonProps) {
|
||||
const avatarLinks = getAvatarLinks();
|
||||
const localeRouter = useLocaleRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { resetState } = usePaymentStore();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
console.log('sign out success');
|
||||
// Reset payment state on sign out
|
||||
resetState();
|
||||
// TanStack Query automatically handles cache invalidation on sign out
|
||||
localeRouter.replace('/');
|
||||
},
|
||||
onError: (error) => {
|
||||
|
36
src/components/providers/query-provider.tsx
Normal file
36
src/components/providers/query-provider.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface QueryProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function QueryProvider({ children }: QueryProviderProps) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - default stale time
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes - default garbage collection time
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
@ -14,7 +14,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { getPricePlans } from '@/config/price-config';
|
||||
import { useMounted } from '@/hooks/use-mounted';
|
||||
import { usePayment } from '@/hooks/use-payment';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
@ -33,34 +33,37 @@ export default function BillingCard() {
|
||||
const hasHandledSession = useRef(false);
|
||||
const mounted = useMounted();
|
||||
|
||||
const {
|
||||
isLoading: isLoadingPayment,
|
||||
error: loadPaymentError,
|
||||
subscription,
|
||||
currentPlan: currentPlanFromStore,
|
||||
fetchPayment,
|
||||
} = usePayment();
|
||||
|
||||
// Get user session for customer ID
|
||||
const { data: session, isPending: isLoadingSession } =
|
||||
authClient.useSession();
|
||||
const currentUser = session?.user;
|
||||
|
||||
// TanStack Query hook for current plan and subscription
|
||||
const {
|
||||
data: paymentData,
|
||||
isLoading: isLoadingPayment,
|
||||
error: loadPaymentError,
|
||||
refetch: refetchPayment,
|
||||
} = useCurrentPlan(currentUser?.id);
|
||||
|
||||
const currentPlan = paymentData?.currentPlan;
|
||||
const subscription = paymentData?.subscription;
|
||||
|
||||
// Get price plans with translations - must be called here to maintain hook order
|
||||
const pricePlans = getPricePlans();
|
||||
const plans = Object.values(pricePlans);
|
||||
|
||||
// Convert current plan from store to a plan with translations
|
||||
const currentPlan = currentPlanFromStore
|
||||
? plans.find((plan) => plan.id === currentPlanFromStore?.id)
|
||||
// Convert current plan to a plan with translations
|
||||
const currentPlanWithTranslations = currentPlan
|
||||
? plans.find((plan) => plan.id === currentPlan?.id)
|
||||
: null;
|
||||
const isFreePlan = currentPlan?.isFree || false;
|
||||
const isLifetimeMember = currentPlan?.isLifetime || false;
|
||||
const isFreePlan = currentPlanWithTranslations?.isFree || false;
|
||||
const isLifetimeMember = currentPlanWithTranslations?.isLifetime || false;
|
||||
|
||||
// Get subscription price details
|
||||
const currentPrice =
|
||||
subscription &&
|
||||
currentPlan?.prices.find(
|
||||
currentPlanWithTranslations?.prices.find(
|
||||
(price) => price.priceId === subscription?.priceId
|
||||
);
|
||||
|
||||
@ -77,8 +80,8 @@ export default function BillingCard() {
|
||||
// Retry payment data fetching
|
||||
const handleRetry = useCallback(() => {
|
||||
// console.log('handleRetry, refetch payment info');
|
||||
fetchPayment(true);
|
||||
}, [fetchPayment]);
|
||||
refetchPayment();
|
||||
}, [refetchPayment]);
|
||||
|
||||
// Check for payment success and show success message
|
||||
useEffect(() => {
|
||||
@ -132,7 +135,9 @@ export default function BillingCard() {
|
||||
<CardDescription>{t('currentPlan.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
<div className="text-destructive text-sm">{loadPaymentError}</div>
|
||||
<div className="text-destructive text-sm">
|
||||
{loadPaymentError?.message}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<Button
|
||||
@ -149,7 +154,7 @@ export default function BillingCard() {
|
||||
}
|
||||
|
||||
// currentPlan maybe null, so we need to check if it is null
|
||||
if (!currentPlan) {
|
||||
if (!currentPlanWithTranslations) {
|
||||
return (
|
||||
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
||||
<CardHeader>
|
||||
@ -187,7 +192,9 @@ export default function BillingCard() {
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
{/* Plan name and status */}
|
||||
<div className="flex items-center justify-start space-x-4">
|
||||
<div className="text-3xl font-medium">{currentPlan?.name}</div>
|
||||
<div className="text-3xl font-medium">
|
||||
{currentPlanWithTranslations?.name}
|
||||
</div>
|
||||
{subscription &&
|
||||
(subscription.status === 'trialing' ||
|
||||
subscription.status === 'active') && (
|
||||
|
@ -11,7 +11,8 @@ import {
|
||||
import { getCreditPackages } from '@/config/credits-config';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
||||
import { usePayment } from '@/hooks/use-payment';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { formatPrice } from '@/lib/formatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react';
|
||||
@ -31,7 +32,9 @@ export function CreditPackages() {
|
||||
|
||||
// Get current user and payment info
|
||||
const currentUser = useCurrentUser();
|
||||
const { currentPlan } = usePayment();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: paymentData } = useCurrentPlan(session?.user?.id);
|
||||
const currentPlan = paymentData?.currentPlan;
|
||||
|
||||
// Get credit packages with translations - must be called here to maintain hook order
|
||||
const creditPackages = Object.values(getCreditPackages()).filter(
|
||||
|
@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
|
||||
import type { CreditTransaction } from '@/components/settings/credits/credit-transactions-table';
|
||||
import { CreditTransactionsTable } from '@/components/settings/credits/credit-transactions-table';
|
||||
import { useCreditTransactions } from '@/hooks/use-credits';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Credit transactions component
|
||||
@ -16,57 +14,25 @@ export function CreditTransactions() {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [data, setData] = useState<CreditTransaction[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getCreditTransactionsAction({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting,
|
||||
});
|
||||
|
||||
if (result?.data?.success) {
|
||||
setData(result.data.data?.items || []);
|
||||
setTotal(result.data.data?.total || 0);
|
||||
} else {
|
||||
const errorMessage = result?.data?.error || t('error');
|
||||
toast.error(errorMessage);
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'CreditTransactions, fetch credit transactions error:',
|
||||
error
|
||||
);
|
||||
toast.error(t('error'));
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pageIndex, pageSize, search, sorting]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
const { data, isLoading } = useCreditTransactions(
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting
|
||||
);
|
||||
|
||||
return (
|
||||
<CreditTransactionsTable
|
||||
data={data}
|
||||
total={total}
|
||||
data={data?.items || []}
|
||||
total={data?.total || 0}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
loading={loading}
|
||||
loading={isLoading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
|
@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { getCreditStatsAction } from '@/actions/get-credit-stats';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@ -12,9 +11,8 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useCredits } from '@/hooks/use-credits';
|
||||
import { useCreditBalance, useCreditStats } from '@/hooks/use-credits';
|
||||
import { useMounted } from '@/hooks/use-mounted';
|
||||
import { usePayment } from '@/hooks/use-payment';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -22,7 +20,7 @@ import { Routes } from '@/routes';
|
||||
import { RefreshCwIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
@ -40,50 +38,36 @@ export default function CreditsBalanceCard() {
|
||||
const hasHandledSession = useRef(false);
|
||||
const mounted = useMounted();
|
||||
|
||||
// Use the credits hook to get balance
|
||||
// Use TanStack Query hooks for credits
|
||||
const {
|
||||
balance,
|
||||
data: balance = 0,
|
||||
isLoading: isLoadingBalance,
|
||||
error,
|
||||
fetchCredits,
|
||||
} = useCredits();
|
||||
error: balanceError,
|
||||
refetch: refetchBalance,
|
||||
} = useCreditBalance();
|
||||
|
||||
// Get payment info to check plan type
|
||||
const { currentPlan } = usePayment();
|
||||
// TanStack Query hook for credit statistics
|
||||
const {
|
||||
data: creditStats,
|
||||
isLoading: isLoadingStats,
|
||||
error: statsError,
|
||||
refetch: refetchStats,
|
||||
} = useCreditStats();
|
||||
|
||||
// State for credit statistics
|
||||
const [creditStats, setCreditStats] = useState<{
|
||||
expiringCredits: {
|
||||
amount: number;
|
||||
earliestExpiration: string | Date | null;
|
||||
};
|
||||
subscriptionCredits: { amount: number };
|
||||
lifetimeCredits: { amount: number };
|
||||
} | null>(null);
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||
// Handle payment success after credits purchase
|
||||
const handlePaymentSuccess = useCallback(async () => {
|
||||
// Use queueMicrotask to avoid React rendering conflicts
|
||||
queueMicrotask(() => {
|
||||
toast.success(t('creditsAdded'));
|
||||
});
|
||||
|
||||
// Fetch credit statistics
|
||||
const fetchCreditStats = useCallback(async () => {
|
||||
console.log('fetchCreditStats, fetch start');
|
||||
setIsLoadingStats(true);
|
||||
try {
|
||||
const result = await getCreditStatsAction();
|
||||
if (result?.data?.success && result.data.data) {
|
||||
setCreditStats(result.data.data);
|
||||
} else {
|
||||
console.error('fetchCreditStats, failed to fetch credit stats', result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('fetchCreditStats, error:', error);
|
||||
} finally {
|
||||
setIsLoadingStats(false);
|
||||
}
|
||||
}, []);
|
||||
// Wait for webhook to process (simplified approach)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Fetch stats on component mount
|
||||
useEffect(() => {
|
||||
fetchCreditStats();
|
||||
}, []);
|
||||
// Force refresh data
|
||||
refetchBalance();
|
||||
refetchStats();
|
||||
}, [t, refetchBalance, refetchStats]);
|
||||
|
||||
// Check for payment success and show success message
|
||||
useEffect(() => {
|
||||
@ -91,35 +75,25 @@ export default function CreditsBalanceCard() {
|
||||
if (sessionId && !hasHandledSession.current) {
|
||||
hasHandledSession.current = true;
|
||||
|
||||
setTimeout(() => {
|
||||
// Show success toast and refresh data after payment
|
||||
toast.success(t('creditsAdded'));
|
||||
|
||||
// Force refresh credits data to show updated balance
|
||||
fetchCredits(true);
|
||||
// Refresh credit stats
|
||||
fetchCreditStats();
|
||||
}, 0);
|
||||
|
||||
// Clean up URL parameters
|
||||
// Clean up URL parameters first
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('credits_session_id');
|
||||
localeRouter.replace(Routes.SettingsCredits + url.search);
|
||||
}
|
||||
}, [searchParams, localeRouter, fetchCredits, fetchCreditStats, t]);
|
||||
|
||||
// Retry all data fetching
|
||||
// Handle payment success
|
||||
handlePaymentSuccess();
|
||||
}
|
||||
}, [searchParams, localeRouter, handlePaymentSuccess]);
|
||||
|
||||
// Retry all data fetching using refetch methods
|
||||
const handleRetry = useCallback(() => {
|
||||
// console.log('handleRetry, refetch credits data');
|
||||
// Force refresh credits balance (ignore cache)
|
||||
fetchCredits(true);
|
||||
// Refresh credit stats
|
||||
fetchCreditStats();
|
||||
}, [fetchCredits, fetchCreditStats]);
|
||||
// Use refetch methods for immediate data refresh
|
||||
refetchBalance();
|
||||
refetchStats();
|
||||
}, [refetchBalance, refetchStats]);
|
||||
|
||||
// Render loading skeleton
|
||||
const isPageLoading = isLoadingBalance || isLoadingStats;
|
||||
if (!mounted || isPageLoading) {
|
||||
if (!mounted || isLoadingBalance || isLoadingStats) {
|
||||
return (
|
||||
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
||||
<CardHeader>
|
||||
@ -140,7 +114,7 @@ export default function CreditsBalanceCard() {
|
||||
}
|
||||
|
||||
// Render error state
|
||||
if (error) {
|
||||
if (balanceError || statsError) {
|
||||
return (
|
||||
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
||||
<CardHeader>
|
||||
@ -148,7 +122,9 @@ export default function CreditsBalanceCard() {
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
<div className="text-destructive text-sm">{error}</div>
|
||||
<div className="text-destructive text-sm">
|
||||
{balanceError?.message || statsError?.message}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<Button
|
||||
@ -184,44 +160,22 @@ export default function CreditsBalanceCard() {
|
||||
|
||||
{/* Balance information */}
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
{/* Plan-based credits info */}
|
||||
{!isLoadingStats && creditStats && (
|
||||
<>
|
||||
{/* Subscription credits (for paid plans) */}
|
||||
{!currentPlan?.isFree &&
|
||||
(creditStats.subscriptionCredits.amount > 0 ||
|
||||
creditStats.lifetimeCredits.amount > 0) && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span>
|
||||
{currentPlan?.isLifetime
|
||||
? t('lifetimeCredits', {
|
||||
credits: creditStats.lifetimeCredits.amount,
|
||||
})
|
||||
: t('subscriptionCredits', {
|
||||
credits: creditStats.subscriptionCredits.amount,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expiring credits warning */}
|
||||
{creditStats.expiringCredits.amount > 0 &&
|
||||
creditStats.expiringCredits.earliestExpiration && (
|
||||
<div className="flex items-center gap-2 text-amber-600">
|
||||
<span>
|
||||
{t('expiringCredits', {
|
||||
credits: creditStats.expiringCredits.amount,
|
||||
date: formatDate(
|
||||
new Date(
|
||||
creditStats.expiringCredits.earliestExpiration
|
||||
)
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Expiring credits warning */}
|
||||
{!isLoadingStats &&
|
||||
creditStats &&
|
||||
creditStats.expiringCredits.amount > 0 &&
|
||||
creditStats.expiringCredits.earliestExpiration && (
|
||||
<div className="flex items-center gap-2 text-amber-600">
|
||||
<span>
|
||||
{t('expiringCredits', {
|
||||
credits: creditStats.expiringCredits.amount,
|
||||
date: formatDate(
|
||||
new Date(creditStats.expiringCredits.earliestExpiration)
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="">
|
||||
|
@ -1,8 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { checkNewsletterStatusAction } from '@/actions/check-newsletter-status';
|
||||
import { subscribeNewsletterAction } from '@/actions/subscribe-newsletter';
|
||||
import { unsubscribeNewsletterAction } from '@/actions/unsubscribe-newsletter';
|
||||
import { FormError } from '@/components/shared/form-error';
|
||||
import {
|
||||
Card,
|
||||
@ -21,12 +18,17 @@ import {
|
||||
} from '@/components/ui/form';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import {
|
||||
useNewsletterStatus,
|
||||
useSubscribeNewsletter,
|
||||
useUnsubscribeNewsletter,
|
||||
} from '@/hooks/use-newsletter';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
@ -47,12 +49,19 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
||||
}
|
||||
|
||||
const t = useTranslations('Dashboard.settings.notification');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>('');
|
||||
const [isSubscriptionChecked, setIsSubscriptionChecked] = useState(false);
|
||||
const { data: session } = authClient.useSession();
|
||||
const currentUser = session?.user;
|
||||
|
||||
// TanStack Query hooks
|
||||
const {
|
||||
data: newsletterStatus,
|
||||
isLoading: isStatusLoading,
|
||||
error: statusError,
|
||||
} = useNewsletterStatus(currentUser?.email);
|
||||
|
||||
const subscribeMutation = useSubscribeNewsletter();
|
||||
const unsubscribeMutation = useUnsubscribeNewsletter();
|
||||
|
||||
// Create a schema for newsletter subscription
|
||||
const formSchema = z.object({
|
||||
subscribed: z.boolean(),
|
||||
@ -66,45 +75,12 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// Check subscription status on component mount
|
||||
// Update form when newsletter status changes
|
||||
useEffect(() => {
|
||||
const checkSubscriptionStatus = async () => {
|
||||
if (currentUser?.email) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Check if the user is already subscribed using server action
|
||||
const statusResult = await checkNewsletterStatusAction({
|
||||
email: currentUser.email,
|
||||
});
|
||||
|
||||
if (statusResult?.data?.success) {
|
||||
const isCurrentlySubscribed = statusResult.data.subscribed;
|
||||
setIsSubscriptionChecked(isCurrentlySubscribed);
|
||||
form.setValue('subscribed', isCurrentlySubscribed);
|
||||
} else {
|
||||
// Handle error from server action
|
||||
const errorMessage = statusResult?.data?.error;
|
||||
if (errorMessage) {
|
||||
console.error('check subscription status error:', errorMessage);
|
||||
setError(errorMessage);
|
||||
}
|
||||
// Default to not subscribed if there's an error
|
||||
setIsSubscriptionChecked(false);
|
||||
form.setValue('subscribed', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('check subscription status error:', error);
|
||||
// Default to not subscribed if there's an error
|
||||
setIsSubscriptionChecked(false);
|
||||
form.setValue('subscribed', false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkSubscriptionStatus();
|
||||
}, [currentUser?.email, form]);
|
||||
if (newsletterStatus) {
|
||||
form.setValue('subscribed', newsletterStatus.subscribed);
|
||||
}
|
||||
}, [newsletterStatus, form]);
|
||||
|
||||
// Check if user exists after all hooks are initialized
|
||||
if (!currentUser) {
|
||||
@ -114,59 +90,27 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
||||
// Handle checkbox change
|
||||
const handleSubscriptionChange = async (value: boolean) => {
|
||||
if (!currentUser.email) {
|
||||
setError(t('newsletter.emailRequired'));
|
||||
toast.error(t('newsletter.emailRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (value) {
|
||||
// Subscribe to newsletter using server action
|
||||
const subscribeResult = await subscribeNewsletterAction({
|
||||
email: currentUser.email,
|
||||
});
|
||||
|
||||
if (subscribeResult?.data?.success) {
|
||||
toast.success(t('newsletter.subscribeSuccess'));
|
||||
setIsSubscriptionChecked(true);
|
||||
form.setValue('subscribed', true);
|
||||
} else {
|
||||
const errorMessage =
|
||||
subscribeResult?.data?.error || t('newsletter.subscribeFail');
|
||||
toast.error(errorMessage);
|
||||
setError(errorMessage);
|
||||
// Reset checkbox if subscription failed
|
||||
form.setValue('subscribed', false);
|
||||
}
|
||||
// Subscribe to newsletter
|
||||
await subscribeMutation.mutateAsync(currentUser.email);
|
||||
toast.success(t('newsletter.subscribeSuccess'));
|
||||
} else {
|
||||
// Unsubscribe from newsletter using server action
|
||||
const unsubscribeResult = await unsubscribeNewsletterAction({
|
||||
email: currentUser.email,
|
||||
});
|
||||
|
||||
if (unsubscribeResult?.data?.success) {
|
||||
toast.success(t('newsletter.unsubscribeSuccess'));
|
||||
setIsSubscriptionChecked(false);
|
||||
form.setValue('subscribed', false);
|
||||
} else {
|
||||
const errorMessage =
|
||||
unsubscribeResult?.data?.error || t('newsletter.unsubscribeFail');
|
||||
toast.error(errorMessage);
|
||||
setError(errorMessage);
|
||||
// Reset checkbox if unsubscription failed
|
||||
form.setValue('subscribed', true);
|
||||
}
|
||||
// Unsubscribe from newsletter
|
||||
await unsubscribeMutation.mutateAsync(currentUser.email);
|
||||
toast.success(t('newsletter.unsubscribeSuccess'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('newsletter subscription error:', error);
|
||||
setError(t('newsletter.error'));
|
||||
toast.error(t('newsletter.error'));
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('newsletter.error');
|
||||
toast.error(errorMessage);
|
||||
// Reset form to previous state on error
|
||||
form.setValue('subscribed', isSubscriptionChecked);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
form.setValue('subscribed', newsletterStatus?.subscribed || false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -193,7 +137,9 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative flex items-center">
|
||||
{isLoading && (
|
||||
{(isStatusLoading ||
|
||||
subscribeMutation.isPending ||
|
||||
unsubscribeMutation.isPending) && (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin text-primary" />
|
||||
)}
|
||||
<Switch
|
||||
@ -202,8 +148,16 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
||||
field.onChange(checked);
|
||||
handleSubscriptionChange(checked);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
aria-readonly={isLoading}
|
||||
disabled={
|
||||
isStatusLoading ||
|
||||
subscribeMutation.isPending ||
|
||||
unsubscribeMutation.isPending
|
||||
}
|
||||
aria-readonly={
|
||||
isStatusLoading ||
|
||||
subscribeMutation.isPending ||
|
||||
unsubscribeMutation.isPending
|
||||
}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
@ -211,7 +165,13 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormError message={error} />
|
||||
<FormError
|
||||
message={
|
||||
statusError?.message ||
|
||||
subscribeMutation.error?.message ||
|
||||
unsubscribeMutation.error?.message
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-6 px-6 py-4 bg-background rounded-none">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
@ -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) {
|
||||
|
@ -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<ComponentProps<typeof Turnstile>, '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<any, Props>(
|
||||
({ 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 (
|
||||
<>
|
||||
<Turnstile
|
||||
ref={ref}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
language: locale,
|
||||
theme: theme.theme === 'dark' ? 'dark' : 'light',
|
||||
}}
|
||||
{...props}
|
||||
siteKey={siteKey}
|
||||
/>
|
||||
|
||||
{validationError && (
|
||||
<FormMessage className="text-red-500 mt-2">
|
||||
{validationError}
|
||||
</FormMessage>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<Turnstile
|
||||
options={{
|
||||
size: 'flexible',
|
||||
language: locale,
|
||||
theme: theme.theme === 'dark' ? 'dark' : 'light',
|
||||
}}
|
||||
{...props}
|
||||
siteKey={siteKey}
|
||||
/>
|
||||
|
||||
{validationError && (
|
||||
<FormMessage className="text-red-500 mt-2">
|
||||
{validationError}
|
||||
</FormMessage>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Captcha.displayName = 'Captcha';
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
44
src/hooks/use-auth.ts
Normal file
44
src/hooks/use-auth.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
77
src/hooks/use-newsletter.ts
Normal file
77
src/hooks/use-newsletter.ts
Normal file
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
@ -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<Subscription | null> => {
|
||||
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<boolean> => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
93
src/hooks/use-users.ts
Normal file
93
src/hooks/use-users.ts
Normal file
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
@ -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<void>;
|
||||
consumeCredits: (amount: number, description: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits store using Zustand
|
||||
* Manages the user's credit balance globally with caching and optimistic updates
|
||||
*/
|
||||
export const useCreditsStore = create<CreditsState>((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<boolean> 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;
|
||||
}
|
||||
},
|
||||
}));
|
@ -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<void>;
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment store using Zustand
|
||||
* Manages the user's payment and subscription data globally
|
||||
*/
|
||||
export const usePaymentStore = create<PaymentState>((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,
|
||||
});
|
||||
},
|
||||
}));
|
@ -1,12 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface UsersState {
|
||||
refreshTrigger: number;
|
||||
triggerRefresh: () => void;
|
||||
}
|
||||
|
||||
export const useUsersStore = create<UsersState>((set) => ({
|
||||
refreshTrigger: 0,
|
||||
triggerRefresh: () =>
|
||||
set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })),
|
||||
}));
|
Loading…
Reference in New Issue
Block a user