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",
|
"creditsAdded": "Credits have been added to your account",
|
||||||
"viewTransactions": "View Credit Transactions",
|
"viewTransactions": "View Credit Transactions",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"subscriptionCredits": "{credits} credits from subscription this month",
|
|
||||||
"lifetimeCredits": "{credits} credits from lifetime plan this month",
|
|
||||||
"expiringCredits": "{credits} credits expiring on {date}"
|
"expiringCredits": "{credits} credits expiring on {date}"
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
@ -601,8 +601,7 @@
|
|||||||
"creditsAdded": "积分已添加到您的账户",
|
"creditsAdded": "积分已添加到您的账户",
|
||||||
"viewTransactions": "查看积分记录",
|
"viewTransactions": "查看积分记录",
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"subscriptionCredits": "本月订阅获得 {credits} 积分",
|
|
||||||
"lifetimeCredits": "本月终身会员获得 {credits} 积分",
|
|
||||||
"expiringCredits": "{credits} 积分将在 {date} 过期"
|
"expiringCredits": "{credits} 积分将在 {date} 过期"
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
@ -79,6 +79,8 @@
|
|||||||
"@react-email/render": "1.0.5",
|
"@react-email/render": "1.0.5",
|
||||||
"@stripe/stripe-js": "^5.6.0",
|
"@stripe/stripe-js": "^5.6.0",
|
||||||
"@tabler/icons-react": "^3.31.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",
|
"@tanstack/react-table": "^8.21.2",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@vercel/analytics": "^1.5.0",
|
"@vercel/analytics": "^1.5.0",
|
||||||
@ -140,6 +142,7 @@
|
|||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
"@opennextjs/cloudflare": "^1.6.5",
|
"@opennextjs/cloudflare": "^1.6.5",
|
||||||
"@tailwindcss/postcss": "^4.0.14",
|
"@tailwindcss/postcss": "^4.0.14",
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "^20.19.0",
|
"@types/node": "^20.19.0",
|
||||||
"@types/pg": "^8.11.11",
|
"@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';
|
'use server';
|
||||||
|
|
||||||
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
|
|
||||||
import { getDb } from '@/db';
|
import { getDb } from '@/db';
|
||||||
import { creditTransaction } from '@/db/schema';
|
import { creditTransaction } from '@/db/schema';
|
||||||
import type { User } from '@/lib/auth-types';
|
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';
|
import { and, eq, gte, isNotNull, lte, sql, sum } from 'drizzle-orm';
|
||||||
|
|
||||||
const CREDITS_EXPIRATION_DAYS = 31;
|
const CREDITS_EXPIRATION_DAYS = 31;
|
||||||
const CREDITS_MONTHLY_DAYS = 31;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get credit statistics for a user
|
* 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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@ -78,12 +44,6 @@ export const getCreditStatsAction = userActionClient.action(async ({ ctx }) => {
|
|||||||
amount: Number(expiringCredits[0]?.amount) || 0,
|
amount: Number(expiringCredits[0]?.amount) || 0,
|
||||||
earliestExpiration: expiringCredits[0]?.earliestExpiration || null,
|
earliestExpiration: expiringCredits[0]?.earliestExpiration || null,
|
||||||
},
|
},
|
||||||
subscriptionCredits: {
|
|
||||||
amount: Number(subscriptionCredits[0]?.amount) || 0,
|
|
||||||
},
|
|
||||||
lifetimeCredits: {
|
|
||||||
amount: Number(lifetimeCredits[0]?.amount) || 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
|
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
|
||||||
import { Button } from '@/components/ui/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 { CoinsIcon } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -10,24 +10,28 @@ import { toast } from 'sonner';
|
|||||||
const CONSUME_CREDITS = 50;
|
const CONSUME_CREDITS = 50;
|
||||||
|
|
||||||
export function ConsumeCreditCard() {
|
export function ConsumeCreditCard() {
|
||||||
const { consumeCredits, hasEnoughCredits, isLoading } = useCredits();
|
const { data: balance = 0, isLoading: isLoadingBalance } = useCreditBalance();
|
||||||
|
const consumeCreditsMutation = useConsumeCredits();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const hasEnoughCredits = (amount: number) => balance >= amount;
|
||||||
|
|
||||||
const handleConsume = async () => {
|
const handleConsume = async () => {
|
||||||
if (!hasEnoughCredits(CONSUME_CREDITS)) {
|
if (!hasEnoughCredits(CONSUME_CREDITS)) {
|
||||||
toast.error('Insufficient credits, please buy more credits.');
|
toast.error('Insufficient credits, please buy more credits.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const success = await consumeCredits(
|
try {
|
||||||
CONSUME_CREDITS,
|
await consumeCreditsMutation.mutateAsync({
|
||||||
`AI Text Credit Consumption (${CONSUME_CREDITS} credits)`
|
amount: CONSUME_CREDITS,
|
||||||
);
|
description: `AI Text Credit Consumption (${CONSUME_CREDITS} credits)`,
|
||||||
setLoading(false);
|
});
|
||||||
if (success) {
|
|
||||||
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
|
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
|
||||||
} else {
|
} catch (error) {
|
||||||
toast.error('Failed to consume credits, please try again later.');
|
toast.error('Failed to consume credits, please try again later.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -40,7 +44,9 @@ export function ConsumeCreditCard() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleConsume}
|
onClick={handleConsume}
|
||||||
disabled={isLoading || loading}
|
disabled={
|
||||||
|
loading || isLoadingBalance || consumeCreditsMutation.isPending
|
||||||
|
}
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
>
|
>
|
||||||
<CoinsIcon className="size-4" />
|
<CoinsIcon className="size-4" />
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ActiveThemeProvider } from '@/components/layout/active-theme-provider';
|
import { ActiveThemeProvider } from '@/components/layout/active-theme-provider';
|
||||||
import { CreditsProvider } from '@/components/layout/credits-provider';
|
import { QueryProvider } from '@/components/providers/query-provider';
|
||||||
import { PaymentProvider } from '@/components/layout/payment-provider';
|
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import type { Translations } from 'fumadocs-ui/i18n';
|
import type { Translations } from 'fumadocs-ui/i18n';
|
||||||
@ -54,21 +53,19 @@ export function Providers({ children, locale }: ProvidersProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
<QueryProvider>
|
||||||
attribute="class"
|
<ThemeProvider
|
||||||
defaultTheme={defaultMode}
|
attribute="class"
|
||||||
enableSystem={true}
|
defaultTheme={defaultMode}
|
||||||
disableTransitionOnChange
|
enableSystem={true}
|
||||||
>
|
disableTransitionOnChange
|
||||||
<ActiveThemeProvider>
|
>
|
||||||
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
|
<ActiveThemeProvider>
|
||||||
<TooltipProvider>
|
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
|
||||||
<PaymentProvider>
|
<TooltipProvider>{children}</TooltipProvider>
|
||||||
<CreditsProvider>{children}</CreditsProvider>
|
</RootProvider>
|
||||||
</PaymentProvider>
|
</ActiveThemeProvider>
|
||||||
</TooltipProvider>
|
</ThemeProvider>
|
||||||
</RootProvider>
|
</QueryProvider>
|
||||||
</ActiveThemeProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,6 @@ import {
|
|||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
DrawerDescription,
|
|
||||||
DrawerFooter,
|
DrawerFooter,
|
||||||
DrawerHeader,
|
DrawerHeader,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
@ -21,13 +20,12 @@ import {
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
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 type { User } from '@/lib/auth-types';
|
||||||
import { isDemoWebsite } from '@/lib/demo';
|
import { isDemoWebsite } from '@/lib/demo';
|
||||||
import { formatDate } from '@/lib/formatter';
|
import { formatDate } from '@/lib/formatter';
|
||||||
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
|
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useUsersStore } from '@/stores/users-store';
|
|
||||||
import {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
@ -47,11 +45,13 @@ interface UserDetailViewerProps {
|
|||||||
export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||||
const t = useTranslations('Dashboard.admin.users');
|
const t = useTranslations('Dashboard.admin.users');
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | undefined>();
|
const [error, setError] = useState<string | undefined>();
|
||||||
const [banReason, setBanReason] = useState(t('ban.defaultReason'));
|
const [banReason, setBanReason] = useState(t('ban.defaultReason'));
|
||||||
const [banExpiresAt, setBanExpiresAt] = useState<Date | undefined>();
|
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
|
// show fake data in demo website
|
||||||
const isDemo = isDemoWebsite();
|
const isDemo = isDemoWebsite();
|
||||||
@ -67,11 +67,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authClient.admin.banUser({
|
await banUserMutation.mutateAsync({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
banReason,
|
banReason,
|
||||||
banExpiresIn: banExpiresAt
|
banExpiresIn: banExpiresAt
|
||||||
@ -83,15 +82,11 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
|||||||
// Reset form
|
// Reset form
|
||||||
setBanReason('');
|
setBanReason('');
|
||||||
setBanExpiresAt(undefined);
|
setBanExpiresAt(undefined);
|
||||||
// Trigger refresh
|
|
||||||
triggerRefresh();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
console.error('Failed to ban user:', error);
|
console.error('Failed to ban user:', error);
|
||||||
setError(error.message || t('ban.error'));
|
setError(error.message || t('ban.error'));
|
||||||
toast.error(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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authClient.admin.unbanUser({
|
await unbanUserMutation.mutateAsync({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(t('unban.success'));
|
toast.success(t('unban.success'));
|
||||||
// Trigger refresh
|
|
||||||
triggerRefresh();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
console.error('Failed to unban user:', error);
|
console.error('Failed to unban user:', error);
|
||||||
setError(error.message || t('unban.error'));
|
setError(error.message || t('unban.error'));
|
||||||
toast.error(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')}
|
{user.role === 'admin' ? t('admin') : t('user')}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* email verified */}
|
{/* email verified */}
|
||||||
<Badge variant="outline" className="px-1.5 hover:bg-accent">
|
{/* <Badge variant="outline" className="px-1.5 hover:bg-accent">
|
||||||
{user.emailVerified ? (
|
{user.emailVerified ? (
|
||||||
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
||||||
) : (
|
) : (
|
||||||
@ -175,7 +165,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
|||||||
{user.emailVerified
|
{user.emailVerified
|
||||||
? t('email.verified')
|
? t('email.verified')
|
||||||
: t('email.unverified')}
|
: t('email.unverified')}
|
||||||
</Badge>
|
</Badge> */}
|
||||||
|
|
||||||
{/* user banned */}
|
{/* user banned */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -196,15 +186,23 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
|||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{t('columns.email')}:
|
{t('columns.email')}:
|
||||||
</span>
|
</span>
|
||||||
<span
|
<div className="flex items-center gap-2">
|
||||||
className="break-words cursor-pointer hover:bg-accent px-2 py-1 rounded border"
|
<Badge
|
||||||
onClick={() => {
|
variant="outline"
|
||||||
navigator.clipboard.writeText(user.email!);
|
className="text-sm px-1.5 cursor-pointer hover:bg-accent"
|
||||||
toast.success(t('emailCopied'));
|
onClick={() => {
|
||||||
}}
|
navigator.clipboard.writeText(user.email);
|
||||||
>
|
toast.success(t('emailCopied'));
|
||||||
{user.email}
|
}}
|
||||||
</span>
|
>
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -256,10 +254,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleUnban}
|
onClick={handleUnban}
|
||||||
disabled={isLoading || isDemo}
|
disabled={unbanUserMutation.isPending || isDemo}
|
||||||
className="mt-4 cursor-pointer"
|
className="mt-4 cursor-pointer"
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{unbanUserMutation.isPending && (
|
||||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{t('unban.button')}
|
{t('unban.button')}
|
||||||
@ -315,10 +313,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={isLoading || !banReason || isDemo}
|
disabled={banUserMutation.isPending || !banReason || isDemo}
|
||||||
className="mt-4 cursor-pointer"
|
className="mt-4 cursor-pointer"
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{banUserMutation.isPending && (
|
||||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{t('ban.button')}
|
{t('ban.button')}
|
||||||
|
@ -1,74 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getUsersAction } from '@/actions/get-users';
|
|
||||||
import { UsersTable } from '@/components/admin/users-table';
|
import { UsersTable } from '@/components/admin/users-table';
|
||||||
import type { User } from '@/lib/auth-types';
|
import { useUsers } from '@/hooks/use-users';
|
||||||
import { useUsersStore } from '@/stores/users-store';
|
|
||||||
import type { SortingState } from '@tanstack/react-table';
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export function UsersPageClient() {
|
export function UsersPageClient() {
|
||||||
const t = useTranslations('Dashboard.admin.users');
|
const t = useTranslations('Dashboard.admin.users');
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [data, setData] = useState<User[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: 'createdAt', desc: true },
|
{ id: 'createdAt', desc: true },
|
||||||
]);
|
]);
|
||||||
const refreshTrigger = useUsersStore((state) => state.refreshTrigger);
|
|
||||||
|
|
||||||
const fetchUsers = useCallback(async () => {
|
const { data, isLoading } = useUsers(pageIndex, pageSize, search, sorting);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UsersTable
|
||||||
<UsersTable
|
data={data?.items || []}
|
||||||
data={data}
|
total={data?.total || 0}
|
||||||
total={total}
|
pageIndex={pageIndex}
|
||||||
pageIndex={pageIndex}
|
pageSize={pageSize}
|
||||||
pageSize={pageSize}
|
search={search}
|
||||||
search={search}
|
loading={isLoading}
|
||||||
loading={loading}
|
onSearch={setSearch}
|
||||||
onSearch={setSearch}
|
onPageChange={setPageIndex}
|
||||||
onPageChange={setPageIndex}
|
onPageSizeChange={setPageSize}
|
||||||
onPageSizeChange={setPageSize}
|
onSortingChange={setSorting}
|
||||||
onSortingChange={setSorting}
|
/>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Captcha } from '../shared/captcha';
|
import { Captcha } from '../shared/captcha';
|
||||||
@ -56,6 +56,7 @@ export const LoginForm = ({
|
|||||||
const [success, setSuccess] = useState<string | undefined>('');
|
const [success, setSuccess] = useState<string | undefined>('');
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const captchaRef = useRef<any>(null);
|
||||||
|
|
||||||
// Check if credential login is enabled
|
// Check if credential login is enabled
|
||||||
const credentialLoginEnabled = websiteConfig.auth.enableCredentialLogin;
|
const credentialLoginEnabled = websiteConfig.auth.enableCredentialLogin;
|
||||||
@ -92,6 +93,15 @@ export const LoginForm = ({
|
|||||||
name: 'captchaToken',
|
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>) => {
|
const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
|
||||||
// Validate captcha token if turnstile is enabled and site key is available
|
// Validate captcha token if turnstile is enabled and site key is available
|
||||||
if (captchaConfigured && values.captchaToken) {
|
if (captchaConfigured && values.captchaToken) {
|
||||||
@ -107,6 +117,8 @@ export const LoginForm = ({
|
|||||||
console.error('login, captcha invalid:', values.captchaToken);
|
console.error('login, captcha invalid:', values.captchaToken);
|
||||||
const errorMessage = captchaResult?.data?.error || t('captchaInvalid');
|
const errorMessage = captchaResult?.data?.error || t('captchaInvalid');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
|
setIsPending(false);
|
||||||
|
resetCaptcha(); // Reset captcha on validation failure
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -139,6 +151,10 @@ export const LoginForm = ({
|
|||||||
onError: (ctx) => {
|
onError: (ctx) => {
|
||||||
console.error('login, error:', ctx.error);
|
console.error('login, error:', ctx.error);
|
||||||
setError(`${ctx.error.status}: ${ctx.error.message}`);
|
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} />
|
<FormSuccess message={success} />
|
||||||
{captchaConfigured && (
|
{captchaConfigured && (
|
||||||
<Captcha
|
<Captcha
|
||||||
|
ref={captchaRef}
|
||||||
onSuccess={(token) => form.setValue('captchaToken', token)}
|
onSuccess={(token) => form.setValue('captchaToken', token)}
|
||||||
validationError={form.formState.errors.captchaToken?.message}
|
validationError={form.formState.errors.captchaToken?.message}
|
||||||
/>
|
/>
|
||||||
|
@ -22,7 +22,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
import { EyeIcon, EyeOffIcon, Loader2Icon } from 'lucide-react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Captcha } from '../shared/captcha';
|
import { Captcha } from '../shared/captcha';
|
||||||
@ -51,6 +51,7 @@ export const RegisterForm = ({
|
|||||||
const [success, setSuccess] = useState<string | undefined>('');
|
const [success, setSuccess] = useState<string | undefined>('');
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const captchaRef = useRef<any>(null);
|
||||||
|
|
||||||
// Check if credential login is enabled
|
// Check if credential login is enabled
|
||||||
const credentialLoginEnabled = websiteConfig.auth.enableCredentialLogin;
|
const credentialLoginEnabled = websiteConfig.auth.enableCredentialLogin;
|
||||||
@ -91,6 +92,15 @@ export const RegisterForm = ({
|
|||||||
name: 'captchaToken',
|
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>) => {
|
const onSubmit = async (values: z.infer<typeof RegisterSchema>) => {
|
||||||
// Validate captcha token if turnstile is enabled and site key is available
|
// Validate captcha token if turnstile is enabled and site key is available
|
||||||
if (captchaConfigured && values.captchaToken) {
|
if (captchaConfigured && values.captchaToken) {
|
||||||
@ -106,6 +116,8 @@ export const RegisterForm = ({
|
|||||||
console.error('register, captcha invalid:', values.captchaToken);
|
console.error('register, captcha invalid:', values.captchaToken);
|
||||||
const errorMessage = captchaResult?.data?.error || t('captchaInvalid');
|
const errorMessage = captchaResult?.data?.error || t('captchaInvalid');
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
|
setIsPending(false);
|
||||||
|
resetCaptcha(); // Reset captcha on validation failure
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -148,6 +160,10 @@ export const RegisterForm = ({
|
|||||||
// sign up fail, display the error message
|
// sign up fail, display the error message
|
||||||
console.error('register, error:', ctx.error);
|
console.error('register, error:', ctx.error);
|
||||||
setError(`${ctx.error.status}: ${ctx.error.message}`);
|
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} />
|
<FormSuccess message={success} />
|
||||||
{captchaConfigured && (
|
{captchaConfigured && (
|
||||||
<Captcha
|
<Captcha
|
||||||
|
ref={captchaRef}
|
||||||
onSuccess={(token) => form.setValue('captchaToken', token)}
|
onSuccess={(token) => form.setValue('captchaToken', token)}
|
||||||
validationError={form.formState.errors.captchaToken?.message}
|
validationError={form.formState.errors.captchaToken?.message}
|
||||||
/>
|
/>
|
||||||
|
@ -23,7 +23,6 @@ import { useLocalePathname, useLocaleRouter } from '@/i18n/navigation';
|
|||||||
import { LOCALES, routing } from '@/i18n/routing';
|
import { LOCALES, routing } from '@/i18n/routing';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { useLocaleStore } from '@/stores/locale-store';
|
import { useLocaleStore } from '@/stores/locale-store';
|
||||||
import { usePaymentStore } from '@/stores/payment-store';
|
|
||||||
import type { User } from 'better-auth';
|
import type { User } from 'better-auth';
|
||||||
import {
|
import {
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
@ -55,7 +54,6 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
|||||||
const pathname = useLocalePathname();
|
const pathname = useLocalePathname();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { currentLocale, setCurrentLocale } = useLocaleStore();
|
const { currentLocale, setCurrentLocale } = useLocaleStore();
|
||||||
const { resetState } = usePaymentStore();
|
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@ -81,8 +79,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
|||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log('sign out success');
|
console.log('sign out success');
|
||||||
// Reset payment state on sign out
|
// TanStack Query automatically handles cache invalidation on sign out
|
||||||
resetState();
|
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -100,7 +97,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
size="lg"
|
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"
|
data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
|
@ -9,8 +9,9 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { usePayment } from '@/hooks/use-payment';
|
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||||
import { LocaleLink } from '@/i18n/navigation';
|
import { LocaleLink } from '@/i18n/navigation';
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { Routes } from '@/routes';
|
import { Routes } from '@/routes';
|
||||||
import { SparklesIcon } from 'lucide-react';
|
import { SparklesIcon } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
@ -23,14 +24,16 @@ export function UpgradeCard() {
|
|||||||
|
|
||||||
const t = useTranslations('Dashboard.upgrade');
|
const t = useTranslations('Dashboard.upgrade');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { isLoading, currentPlan, subscription } = usePayment();
|
const { data: session } = authClient.useSession();
|
||||||
|
const { data: paymentData, isLoading } = useCurrentPlan(session?.user?.id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Don't show the upgrade card if the user has a lifetime membership or a subscription
|
// 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) {
|
if (!mounted || isLoading || isMember) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { useCredits } from '@/hooks/use-credits';
|
import { useCreditBalance } from '@/hooks/use-credits';
|
||||||
import { useLocaleRouter } from '@/i18n/navigation';
|
import { useLocaleRouter } from '@/i18n/navigation';
|
||||||
import { Routes } from '@/routes';
|
import { Routes } from '@/routes';
|
||||||
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||||
@ -15,8 +15,8 @@ export function CreditsBalanceButton() {
|
|||||||
|
|
||||||
const router = useLocaleRouter();
|
const router = useLocaleRouter();
|
||||||
|
|
||||||
// Use the new useCredits hook
|
// Use TanStack Query hook for credit balance
|
||||||
const { balance, isLoading } = useCredits();
|
const { data: balance = 0, isLoading } = useCreditBalance();
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
router.push(Routes.SettingsCredits);
|
router.push(Routes.SettingsCredits);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { useCredits } from '@/hooks/use-credits';
|
import { useCreditBalance } from '@/hooks/use-credits';
|
||||||
import { useLocaleRouter } from '@/i18n/navigation';
|
import { useLocaleRouter } from '@/i18n/navigation';
|
||||||
import { Routes } from '@/routes';
|
import { Routes } from '@/routes';
|
||||||
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||||
@ -16,8 +16,8 @@ export function CreditsBalanceMenu() {
|
|||||||
const t = useTranslations('Marketing.avatar');
|
const t = useTranslations('Marketing.avatar');
|
||||||
const router = useLocaleRouter();
|
const router = useLocaleRouter();
|
||||||
|
|
||||||
// Use the new useCredits hook
|
// Use TanStack Query hook for credit balance
|
||||||
const { balance, isLoading } = useCredits();
|
const { data: balance = 0, isLoading } = useCreditBalance();
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
router.push(Routes.SettingsCredits);
|
router.push(Routes.SettingsCredits);
|
||||||
|
@ -1,31 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { authClient } from '@/lib/auth-client';
|
|
||||||
import { useCreditsStore } from '@/stores/credits-store';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Credits Provider Component
|
* Credits Provider Component
|
||||||
*
|
*
|
||||||
* This component initializes the credits store when the user is authenticated
|
* This component is now simplified since TanStack Query handles data fetching automatically.
|
||||||
* and handles cleanup when the user logs out.
|
* It's kept for potential future credits-related providers.
|
||||||
* Only renders when credits are enabled in the website configuration.
|
* Only renders when credits are enabled in the website configuration.
|
||||||
*/
|
*/
|
||||||
export function CreditsProvider({ children }: { children: React.ReactNode }) {
|
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) {
|
if (!websiteConfig.credits.enableCredits) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fetchCredits } = useCreditsStore();
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (session?.user) {
|
|
||||||
fetchCredits(session.user);
|
|
||||||
}
|
|
||||||
}, [session?.user, fetchCredits]);
|
|
||||||
|
|
||||||
return <>{children}</>;
|
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 { getAvatarLinks } from '@/config/avatar-config';
|
||||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { usePaymentStore } from '@/stores/payment-store';
|
|
||||||
import type { User } from 'better-auth';
|
import type { User } from 'better-auth';
|
||||||
import { LogOutIcon } from 'lucide-react';
|
import { LogOutIcon } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
@ -29,8 +28,6 @@ export function UserButtonMobile({ user }: UserButtonProps) {
|
|||||||
const avatarLinks = getAvatarLinks();
|
const avatarLinks = getAvatarLinks();
|
||||||
const localeRouter = useLocaleRouter();
|
const localeRouter = useLocaleRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { resetState } = usePaymentStore();
|
|
||||||
|
|
||||||
const closeDrawer = () => {
|
const closeDrawer = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
@ -40,8 +37,7 @@ export function UserButtonMobile({ user }: UserButtonProps) {
|
|||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log('sign out success');
|
console.log('sign out success');
|
||||||
// Reset payment state on sign out
|
// TanStack Query automatically handles cache invalidation on sign out
|
||||||
resetState();
|
|
||||||
localeRouter.replace('/');
|
localeRouter.replace('/');
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -64,7 +60,7 @@ export function UserButtonMobile({ user }: UserButtonProps) {
|
|||||||
<DrawerPortal>
|
<DrawerPortal>
|
||||||
<DrawerOverlay className="fixed inset-0 z-40 bg-background/50" />
|
<DrawerOverlay className="fixed inset-0 z-40 bg-background/50" />
|
||||||
<DrawerContent
|
<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"
|
overflow-hidden rounded-t-[10px] border bg-background px-3 text-sm"
|
||||||
>
|
>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
|
@ -12,7 +12,6 @@ import { getAvatarLinks } from '@/config/avatar-config';
|
|||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { useLocaleRouter } from '@/i18n/navigation';
|
import { useLocaleRouter } from '@/i18n/navigation';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { usePaymentStore } from '@/stores/payment-store';
|
|
||||||
import type { User } from 'better-auth';
|
import type { User } from 'better-auth';
|
||||||
import { LogOutIcon } from 'lucide-react';
|
import { LogOutIcon } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
@ -29,15 +28,12 @@ export function UserButton({ user }: UserButtonProps) {
|
|||||||
const avatarLinks = getAvatarLinks();
|
const avatarLinks = getAvatarLinks();
|
||||||
const localeRouter = useLocaleRouter();
|
const localeRouter = useLocaleRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { resetState } = usePaymentStore();
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
console.log('sign out success');
|
console.log('sign out success');
|
||||||
// Reset payment state on sign out
|
// TanStack Query automatically handles cache invalidation on sign out
|
||||||
resetState();
|
|
||||||
localeRouter.replace('/');
|
localeRouter.replace('/');
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { getPricePlans } from '@/config/price-config';
|
import { getPricePlans } from '@/config/price-config';
|
||||||
import { useMounted } from '@/hooks/use-mounted';
|
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 { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { formatDate } from '@/lib/formatter';
|
import { formatDate } from '@/lib/formatter';
|
||||||
@ -33,34 +33,37 @@ export default function BillingCard() {
|
|||||||
const hasHandledSession = useRef(false);
|
const hasHandledSession = useRef(false);
|
||||||
const mounted = useMounted();
|
const mounted = useMounted();
|
||||||
|
|
||||||
const {
|
|
||||||
isLoading: isLoadingPayment,
|
|
||||||
error: loadPaymentError,
|
|
||||||
subscription,
|
|
||||||
currentPlan: currentPlanFromStore,
|
|
||||||
fetchPayment,
|
|
||||||
} = usePayment();
|
|
||||||
|
|
||||||
// Get user session for customer ID
|
// Get user session for customer ID
|
||||||
const { data: session, isPending: isLoadingSession } =
|
const { data: session, isPending: isLoadingSession } =
|
||||||
authClient.useSession();
|
authClient.useSession();
|
||||||
const currentUser = session?.user;
|
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
|
// Get price plans with translations - must be called here to maintain hook order
|
||||||
const pricePlans = getPricePlans();
|
const pricePlans = getPricePlans();
|
||||||
const plans = Object.values(pricePlans);
|
const plans = Object.values(pricePlans);
|
||||||
|
|
||||||
// Convert current plan from store to a plan with translations
|
// Convert current plan to a plan with translations
|
||||||
const currentPlan = currentPlanFromStore
|
const currentPlanWithTranslations = currentPlan
|
||||||
? plans.find((plan) => plan.id === currentPlanFromStore?.id)
|
? plans.find((plan) => plan.id === currentPlan?.id)
|
||||||
: null;
|
: null;
|
||||||
const isFreePlan = currentPlan?.isFree || false;
|
const isFreePlan = currentPlanWithTranslations?.isFree || false;
|
||||||
const isLifetimeMember = currentPlan?.isLifetime || false;
|
const isLifetimeMember = currentPlanWithTranslations?.isLifetime || false;
|
||||||
|
|
||||||
// Get subscription price details
|
// Get subscription price details
|
||||||
const currentPrice =
|
const currentPrice =
|
||||||
subscription &&
|
subscription &&
|
||||||
currentPlan?.prices.find(
|
currentPlanWithTranslations?.prices.find(
|
||||||
(price) => price.priceId === subscription?.priceId
|
(price) => price.priceId === subscription?.priceId
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -77,8 +80,8 @@ export default function BillingCard() {
|
|||||||
// Retry payment data fetching
|
// Retry payment data fetching
|
||||||
const handleRetry = useCallback(() => {
|
const handleRetry = useCallback(() => {
|
||||||
// console.log('handleRetry, refetch payment info');
|
// console.log('handleRetry, refetch payment info');
|
||||||
fetchPayment(true);
|
refetchPayment();
|
||||||
}, [fetchPayment]);
|
}, [refetchPayment]);
|
||||||
|
|
||||||
// Check for payment success and show success message
|
// Check for payment success and show success message
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -132,7 +135,9 @@ export default function BillingCard() {
|
|||||||
<CardDescription>{t('currentPlan.description')}</CardDescription>
|
<CardDescription>{t('currentPlan.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 flex-1">
|
<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>
|
</CardContent>
|
||||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||||
<Button
|
<Button
|
||||||
@ -149,7 +154,7 @@ export default function BillingCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// currentPlan maybe null, so we need to check if it is null
|
// currentPlan maybe null, so we need to check if it is null
|
||||||
if (!currentPlan) {
|
if (!currentPlanWithTranslations) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -187,7 +192,9 @@ export default function BillingCard() {
|
|||||||
<CardContent className="space-y-4 flex-1">
|
<CardContent className="space-y-4 flex-1">
|
||||||
{/* Plan name and status */}
|
{/* Plan name and status */}
|
||||||
<div className="flex items-center justify-start space-x-4">
|
<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 &&
|
||||||
(subscription.status === 'trialing' ||
|
(subscription.status === 'trialing' ||
|
||||||
subscription.status === 'active') && (
|
subscription.status === 'active') && (
|
||||||
|
@ -11,7 +11,8 @@ import {
|
|||||||
import { getCreditPackages } from '@/config/credits-config';
|
import { getCreditPackages } from '@/config/credits-config';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
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 { formatPrice } from '@/lib/formatter';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react';
|
import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react';
|
||||||
@ -31,7 +32,9 @@ export function CreditPackages() {
|
|||||||
|
|
||||||
// Get current user and payment info
|
// Get current user and payment info
|
||||||
const currentUser = useCurrentUser();
|
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
|
// Get credit packages with translations - must be called here to maintain hook order
|
||||||
const creditPackages = Object.values(getCreditPackages()).filter(
|
const creditPackages = Object.values(getCreditPackages()).filter(
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
'use client';
|
'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 { CreditTransactionsTable } from '@/components/settings/credits/credit-transactions-table';
|
||||||
|
import { useCreditTransactions } from '@/hooks/use-credits';
|
||||||
import type { SortingState } from '@tanstack/react-table';
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Credit transactions component
|
* Credit transactions component
|
||||||
@ -16,57 +14,25 @@ export function CreditTransactions() {
|
|||||||
const [pageIndex, setPageIndex] = useState(0);
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [data, setData] = useState<CreditTransaction[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: 'createdAt', desc: true },
|
{ id: 'createdAt', desc: true },
|
||||||
]);
|
]);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const { data, isLoading } = useCreditTransactions(
|
||||||
setLoading(true);
|
pageIndex,
|
||||||
try {
|
pageSize,
|
||||||
const result = await getCreditTransactionsAction({
|
search,
|
||||||
pageIndex,
|
sorting
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreditTransactionsTable
|
<CreditTransactionsTable
|
||||||
data={data}
|
data={data?.items || []}
|
||||||
total={total}
|
total={data?.total || 0}
|
||||||
pageIndex={pageIndex}
|
pageIndex={pageIndex}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
search={search}
|
search={search}
|
||||||
loading={loading}
|
loading={isLoading}
|
||||||
onSearch={setSearch}
|
onSearch={setSearch}
|
||||||
onPageChange={setPageIndex}
|
onPageChange={setPageIndex}
|
||||||
onPageSizeChange={setPageSize}
|
onPageSizeChange={setPageSize}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getCreditStatsAction } from '@/actions/get-credit-stats';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -12,9 +11,8 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { websiteConfig } from '@/config/website';
|
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 { useMounted } from '@/hooks/use-mounted';
|
||||||
import { usePayment } from '@/hooks/use-payment';
|
|
||||||
import { useLocaleRouter } from '@/i18n/navigation';
|
import { useLocaleRouter } from '@/i18n/navigation';
|
||||||
import { formatDate } from '@/lib/formatter';
|
import { formatDate } from '@/lib/formatter';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -22,7 +20,7 @@ import { Routes } from '@/routes';
|
|||||||
import { RefreshCwIcon } from 'lucide-react';
|
import { RefreshCwIcon } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -40,50 +38,36 @@ export default function CreditsBalanceCard() {
|
|||||||
const hasHandledSession = useRef(false);
|
const hasHandledSession = useRef(false);
|
||||||
const mounted = useMounted();
|
const mounted = useMounted();
|
||||||
|
|
||||||
// Use the credits hook to get balance
|
// Use TanStack Query hooks for credits
|
||||||
const {
|
const {
|
||||||
balance,
|
data: balance = 0,
|
||||||
isLoading: isLoadingBalance,
|
isLoading: isLoadingBalance,
|
||||||
error,
|
error: balanceError,
|
||||||
fetchCredits,
|
refetch: refetchBalance,
|
||||||
} = useCredits();
|
} = useCreditBalance();
|
||||||
|
|
||||||
// Get payment info to check plan type
|
// TanStack Query hook for credit statistics
|
||||||
const { currentPlan } = usePayment();
|
const {
|
||||||
|
data: creditStats,
|
||||||
|
isLoading: isLoadingStats,
|
||||||
|
error: statsError,
|
||||||
|
refetch: refetchStats,
|
||||||
|
} = useCreditStats();
|
||||||
|
|
||||||
// State for credit statistics
|
// Handle payment success after credits purchase
|
||||||
const [creditStats, setCreditStats] = useState<{
|
const handlePaymentSuccess = useCallback(async () => {
|
||||||
expiringCredits: {
|
// Use queueMicrotask to avoid React rendering conflicts
|
||||||
amount: number;
|
queueMicrotask(() => {
|
||||||
earliestExpiration: string | Date | null;
|
toast.success(t('creditsAdded'));
|
||||||
};
|
});
|
||||||
subscriptionCredits: { amount: number };
|
|
||||||
lifetimeCredits: { amount: number };
|
|
||||||
} | null>(null);
|
|
||||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
|
||||||
|
|
||||||
// Fetch credit statistics
|
// Wait for webhook to process (simplified approach)
|
||||||
const fetchCreditStats = useCallback(async () => {
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch stats on component mount
|
// Force refresh data
|
||||||
useEffect(() => {
|
refetchBalance();
|
||||||
fetchCreditStats();
|
refetchStats();
|
||||||
}, []);
|
}, [t, refetchBalance, refetchStats]);
|
||||||
|
|
||||||
// Check for payment success and show success message
|
// Check for payment success and show success message
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -91,35 +75,25 @@ export default function CreditsBalanceCard() {
|
|||||||
if (sessionId && !hasHandledSession.current) {
|
if (sessionId && !hasHandledSession.current) {
|
||||||
hasHandledSession.current = true;
|
hasHandledSession.current = true;
|
||||||
|
|
||||||
setTimeout(() => {
|
// Clean up URL parameters first
|
||||||
// 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
|
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.delete('credits_session_id');
|
url.searchParams.delete('credits_session_id');
|
||||||
localeRouter.replace(Routes.SettingsCredits + url.search);
|
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(() => {
|
const handleRetry = useCallback(() => {
|
||||||
// console.log('handleRetry, refetch credits data');
|
// Use refetch methods for immediate data refresh
|
||||||
// Force refresh credits balance (ignore cache)
|
refetchBalance();
|
||||||
fetchCredits(true);
|
refetchStats();
|
||||||
// Refresh credit stats
|
}, [refetchBalance, refetchStats]);
|
||||||
fetchCreditStats();
|
|
||||||
}, [fetchCredits, fetchCreditStats]);
|
|
||||||
|
|
||||||
// Render loading skeleton
|
// Render loading skeleton
|
||||||
const isPageLoading = isLoadingBalance || isLoadingStats;
|
if (!mounted || isLoadingBalance || isLoadingStats) {
|
||||||
if (!mounted || isPageLoading) {
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -140,7 +114,7 @@ export default function CreditsBalanceCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render error state
|
// Render error state
|
||||||
if (error) {
|
if (balanceError || statsError) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -148,7 +122,9 @@ export default function CreditsBalanceCard() {
|
|||||||
<CardDescription>{t('description')}</CardDescription>
|
<CardDescription>{t('description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 flex-1">
|
<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>
|
</CardContent>
|
||||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||||
<Button
|
<Button
|
||||||
@ -184,44 +160,22 @@ export default function CreditsBalanceCard() {
|
|||||||
|
|
||||||
{/* Balance information */}
|
{/* Balance information */}
|
||||||
<div className="text-sm text-muted-foreground space-y-2">
|
<div className="text-sm text-muted-foreground space-y-2">
|
||||||
{/* Plan-based credits info */}
|
{/* Expiring credits warning */}
|
||||||
{!isLoadingStats && creditStats && (
|
{!isLoadingStats &&
|
||||||
<>
|
creditStats &&
|
||||||
{/* Subscription credits (for paid plans) */}
|
creditStats.expiringCredits.amount > 0 &&
|
||||||
{!currentPlan?.isFree &&
|
creditStats.expiringCredits.earliestExpiration && (
|
||||||
(creditStats.subscriptionCredits.amount > 0 ||
|
<div className="flex items-center gap-2 text-amber-600">
|
||||||
creditStats.lifetimeCredits.amount > 0) && (
|
<span>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
{t('expiringCredits', {
|
||||||
<span>
|
credits: creditStats.expiringCredits.amount,
|
||||||
{currentPlan?.isLifetime
|
date: formatDate(
|
||||||
? t('lifetimeCredits', {
|
new Date(creditStats.expiringCredits.earliestExpiration)
|
||||||
credits: creditStats.lifetimeCredits.amount,
|
),
|
||||||
})
|
})}
|
||||||
: t('subscriptionCredits', {
|
</span>
|
||||||
credits: creditStats.subscriptionCredits.amount,
|
</div>
|
||||||
})}
|
)}
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="">
|
<CardFooter className="">
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
'use client';
|
'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 { FormError } from '@/components/shared/form-error';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -21,12 +18,17 @@ import {
|
|||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
|
import {
|
||||||
|
useNewsletterStatus,
|
||||||
|
useSubscribeNewsletter,
|
||||||
|
useUnsubscribeNewsletter,
|
||||||
|
} from '@/hooks/use-newsletter';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Loader2Icon } from 'lucide-react';
|
import { Loader2Icon } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -47,12 +49,19 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t = useTranslations('Dashboard.settings.notification');
|
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 { data: session } = authClient.useSession();
|
||||||
const currentUser = session?.user;
|
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
|
// Create a schema for newsletter subscription
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
subscribed: z.boolean(),
|
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(() => {
|
useEffect(() => {
|
||||||
const checkSubscriptionStatus = async () => {
|
if (newsletterStatus) {
|
||||||
if (currentUser?.email) {
|
form.setValue('subscribed', newsletterStatus.subscribed);
|
||||||
try {
|
}
|
||||||
setIsLoading(true);
|
}, [newsletterStatus, form]);
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Check if user exists after all hooks are initialized
|
// Check if user exists after all hooks are initialized
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
@ -114,59 +90,27 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
|||||||
// Handle checkbox change
|
// Handle checkbox change
|
||||||
const handleSubscriptionChange = async (value: boolean) => {
|
const handleSubscriptionChange = async (value: boolean) => {
|
||||||
if (!currentUser.email) {
|
if (!currentUser.email) {
|
||||||
setError(t('newsletter.emailRequired'));
|
toast.error(t('newsletter.emailRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (value) {
|
if (value) {
|
||||||
// Subscribe to newsletter using server action
|
// Subscribe to newsletter
|
||||||
const subscribeResult = await subscribeNewsletterAction({
|
await subscribeMutation.mutateAsync(currentUser.email);
|
||||||
email: currentUser.email,
|
toast.success(t('newsletter.subscribeSuccess'));
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Unsubscribe from newsletter using server action
|
// Unsubscribe from newsletter
|
||||||
const unsubscribeResult = await unsubscribeNewsletterAction({
|
await unsubscribeMutation.mutateAsync(currentUser.email);
|
||||||
email: currentUser.email,
|
toast.success(t('newsletter.unsubscribeSuccess'));
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('newsletter subscription error:', error);
|
console.error('newsletter subscription error:', error);
|
||||||
setError(t('newsletter.error'));
|
const errorMessage =
|
||||||
toast.error(t('newsletter.error'));
|
error instanceof Error ? error.message : t('newsletter.error');
|
||||||
|
toast.error(errorMessage);
|
||||||
// Reset form to previous state on error
|
// Reset form to previous state on error
|
||||||
form.setValue('subscribed', isSubscriptionChecked);
|
form.setValue('subscribed', newsletterStatus?.subscribed || false);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -193,7 +137,9 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
{isLoading && (
|
{(isStatusLoading ||
|
||||||
|
subscribeMutation.isPending ||
|
||||||
|
unsubscribeMutation.isPending) && (
|
||||||
<Loader2Icon className="mr-2 size-4 animate-spin text-primary" />
|
<Loader2Icon className="mr-2 size-4 animate-spin text-primary" />
|
||||||
)}
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
@ -202,8 +148,16 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
|||||||
field.onChange(checked);
|
field.onChange(checked);
|
||||||
handleSubscriptionChange(checked);
|
handleSubscriptionChange(checked);
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={
|
||||||
aria-readonly={isLoading}
|
isStatusLoading ||
|
||||||
|
subscribeMutation.isPending ||
|
||||||
|
unsubscribeMutation.isPending
|
||||||
|
}
|
||||||
|
aria-readonly={
|
||||||
|
isStatusLoading ||
|
||||||
|
subscribeMutation.isPending ||
|
||||||
|
unsubscribeMutation.isPending
|
||||||
|
}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -211,7 +165,13 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormError message={error} />
|
<FormError
|
||||||
|
message={
|
||||||
|
statusError?.message ||
|
||||||
|
subscribeMutation.error?.message ||
|
||||||
|
unsubscribeMutation.error?.message
|
||||||
|
}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="mt-6 px-6 py-4 bg-background rounded-none">
|
<CardFooter className="mt-6 px-6 py-4 bg-background rounded-none">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
@ -10,10 +10,10 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useHasCredentialProvider } from '@/hooks/use-auth';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PasswordCardWrapper renders either:
|
* PasswordCardWrapper renders either:
|
||||||
@ -24,38 +24,15 @@ import { useEffect, useState } from 'react';
|
|||||||
*/
|
*/
|
||||||
export function PasswordCardWrapper() {
|
export function PasswordCardWrapper() {
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
const [hasCredentialProvider, setHasCredentialProvider] = useState(false);
|
const { hasCredentialProvider, isLoading, error } = useHasCredentialProvider(
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
session?.user?.id
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// Handle error state
|
||||||
const checkCredentialProvider = async () => {
|
if (error) {
|
||||||
if (!session?.user) {
|
console.error('check credential provider error:', error);
|
||||||
setIsLoading(false);
|
return null;
|
||||||
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]);
|
|
||||||
|
|
||||||
// Don't render anything while loading
|
// Don't render anything while loading
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
@ -5,7 +5,7 @@ import { websiteConfig } from '@/config/website';
|
|||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import type { ComponentProps } from 'react';
|
import { type ComponentProps, forwardRef } from 'react';
|
||||||
|
|
||||||
const Turnstile = dynamic(
|
const Turnstile = dynamic(
|
||||||
() => import('@marsidev/react-turnstile').then((mod) => mod.Turnstile),
|
() => import('@marsidev/react-turnstile').then((mod) => mod.Turnstile),
|
||||||
@ -21,41 +21,46 @@ type Props = Omit<ComponentProps<typeof Turnstile>, 'siteKey'> & {
|
|||||||
/**
|
/**
|
||||||
* Captcha component for Cloudflare Turnstile
|
* Captcha component for Cloudflare Turnstile
|
||||||
*/
|
*/
|
||||||
export const Captcha = ({ validationError, ...props }: Props) => {
|
export const Captcha = forwardRef<any, Props>(
|
||||||
const turnstileEnabled = websiteConfig.features.enableTurnstileCaptcha;
|
({ validationError, ...props }, ref) => {
|
||||||
const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
|
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 turnstile is disabled in config, don't render anything
|
||||||
if (!turnstileEnabled) {
|
if (!turnstileEnabled) {
|
||||||
return null;
|
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
|
Captcha.displayName = 'Captcha';
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
@ -149,8 +149,6 @@ export async function addCredits({
|
|||||||
console.error('addCredits, invalid expire days', userId, expireDays);
|
console.error('addCredits, invalid expire days', userId, expireDays);
|
||||||
throw new Error('Invalid expire days');
|
throw new Error('Invalid expire days');
|
||||||
}
|
}
|
||||||
// Process expired credits first
|
|
||||||
await processExpiredCredits(userId);
|
|
||||||
// Update user credit balance
|
// Update user credit balance
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const current = await db
|
const current = await db
|
||||||
@ -230,8 +228,6 @@ export async function consumeCredits({
|
|||||||
console.error('consumeCredits, invalid amount', userId, amount);
|
console.error('consumeCredits, invalid amount', userId, amount);
|
||||||
throw new Error('Invalid amount');
|
throw new Error('Invalid amount');
|
||||||
}
|
}
|
||||||
// Process expired credits first
|
|
||||||
await processExpiredCredits(userId);
|
|
||||||
// Check balance
|
// Check balance
|
||||||
if (!(await hasEnoughCredits({ userId, requiredCredits: amount }))) {
|
if (!(await hasEnoughCredits({ userId, requiredCredits: amount }))) {
|
||||||
console.error(
|
console.error(
|
||||||
@ -304,6 +300,7 @@ export async function consumeCredits({
|
|||||||
/**
|
/**
|
||||||
* Process expired credits
|
* Process expired credits
|
||||||
* @param userId - User ID
|
* @param userId - User ID
|
||||||
|
* @deprecated This function is no longer used, see distribute.ts instead
|
||||||
*/
|
*/
|
||||||
export async function processExpiredCredits(userId: string) {
|
export async function processExpiredCredits(userId: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
@ -4,7 +4,7 @@ import { creditTransaction, payment, user, userCredit } from '@/db/schema';
|
|||||||
import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
|
import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan';
|
||||||
import { PlanIntervals } from '@/payment/types';
|
import { PlanIntervals } from '@/payment/types';
|
||||||
import { addDays } from 'date-fns';
|
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';
|
import { CREDIT_TRANSACTION_TYPE } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -14,6 +14,11 @@ import { CREDIT_TRANSACTION_TYPE } from './types';
|
|||||||
export async function distributeCreditsToAllUsers() {
|
export async function distributeCreditsToAllUsers() {
|
||||||
console.log('>>> distribute credits start');
|
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();
|
const db = await getDb();
|
||||||
|
|
||||||
// Get all users with their current active payments/subscriptions in a single query
|
// 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`
|
`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 { consumeCreditsAction } from '@/actions/consume-credits';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
|
||||||
import { useCreditsStore } from '@/stores/credits-store';
|
import { getCreditStatsAction } from '@/actions/get-credit-stats';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
/**
|
// Query keys
|
||||||
* Hook for accessing and managing credits state
|
export const creditsKeys = {
|
||||||
*
|
all: ['credits'] as const,
|
||||||
* This hook provides access to the credits state and methods to manage it.
|
balance: () => [...creditsKeys.all, 'balance'] as const,
|
||||||
* It also automatically fetches credits information when the user changes.
|
stats: () => [...creditsKeys.all, 'stats'] as const,
|
||||||
* Only works when credits are enabled in the website configuration.
|
transactions: () => [...creditsKeys.all, 'transactions'] as const,
|
||||||
*/
|
transactionsList: (filters: {
|
||||||
export function useCredits() {
|
pageIndex: number;
|
||||||
// Return default values if credits are disabled
|
pageSize: number;
|
||||||
if (!websiteConfig.credits.enableCredits) {
|
search: string;
|
||||||
return {
|
sorting: SortingState;
|
||||||
balance: 0,
|
}) => [...creditsKeys.transactions(), filters] as const,
|
||||||
isLoading: false,
|
};
|
||||||
error: null,
|
|
||||||
fetchCredits: () => Promise.resolve(),
|
|
||||||
consumeCredits: () => Promise.resolve(false),
|
|
||||||
hasEnoughCredits: () => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
// Hook to fetch credit balance
|
||||||
balance,
|
export function useCreditBalance() {
|
||||||
isLoading,
|
return useQuery({
|
||||||
error,
|
queryKey: creditsKeys.balance(),
|
||||||
fetchCredits: fetchCreditsFromStore,
|
queryFn: async () => {
|
||||||
consumeCredits,
|
console.log('Fetching credit balance...');
|
||||||
} = useCreditsStore();
|
const result = await getCreditBalanceAction();
|
||||||
|
if (!result?.data?.success) {
|
||||||
const { data: session } = authClient.useSession();
|
throw new Error('Failed to fetch credit balance');
|
||||||
|
|
||||||
const fetchCredits = useCallback(
|
|
||||||
(force = false) => {
|
|
||||||
const currentUser = session?.user;
|
|
||||||
if (currentUser) {
|
|
||||||
fetchCreditsFromStore(currentUser, force);
|
|
||||||
}
|
}
|
||||||
|
console.log('Credit balance fetched:', result.data.credits);
|
||||||
|
return result.data.credits || 0;
|
||||||
},
|
},
|
||||||
[session?.user, fetchCreditsFromStore]
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
// Hook to fetch credit statistics
|
||||||
const currentUser = session?.user;
|
export function useCreditStats() {
|
||||||
if (currentUser) {
|
return useQuery({
|
||||||
fetchCreditsFromStore(currentUser);
|
queryKey: creditsKeys.stats(),
|
||||||
}
|
queryFn: async () => {
|
||||||
}, [session?.user, fetchCreditsFromStore]);
|
console.log('Fetching credit stats...');
|
||||||
|
const result = await getCreditStatsAction();
|
||||||
return {
|
if (!result?.data?.success) {
|
||||||
// State
|
throw new Error(result?.data?.error || 'Failed to fetch credit stats');
|
||||||
balance,
|
}
|
||||||
isLoading,
|
console.log('Credit stats fetched:', result.data.data);
|
||||||
error,
|
return result.data.data;
|
||||||
|
},
|
||||||
// Methods
|
});
|
||||||
fetchCredits,
|
}
|
||||||
consumeCredits,
|
|
||||||
|
// Hook to consume credits
|
||||||
// Helper methods
|
export function useConsumeCredits() {
|
||||||
hasEnoughCredits: (amount: number) => balance >= amount,
|
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 { getActiveSubscriptionAction } from '@/actions/get-active-subscription';
|
||||||
import { usePaymentStore } from '@/stores/payment-store';
|
import { getLifetimeStatusAction } from '@/actions/get-lifetime-status';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { getAllPricePlans } from '@/lib/price-plan';
|
||||||
|
import type { PricePlan, Subscription } from '@/payment/types';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
/**
|
// Query keys
|
||||||
* Hook for accessing and managing payment state
|
export const paymentKeys = {
|
||||||
*
|
all: ['payment'] as const,
|
||||||
* This hook provides access to the payment state and methods to manage it.
|
subscription: (userId: string) =>
|
||||||
* It also automatically fetches payment information when the user changes.
|
[...paymentKeys.all, 'subscription', userId] as const,
|
||||||
*/
|
lifetime: (userId: string) =>
|
||||||
export function usePayment() {
|
[...paymentKeys.all, 'lifetime', userId] as const,
|
||||||
const {
|
currentPlan: (userId: string) =>
|
||||||
currentPlan,
|
[...paymentKeys.all, 'currentPlan', userId] as const,
|
||||||
subscription,
|
};
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
fetchPayment: fetchPaymentFromStore,
|
|
||||||
} = usePaymentStore();
|
|
||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
// Hook to fetch active subscription
|
||||||
|
export function useActiveSubscription(userId: string | undefined) {
|
||||||
const fetchPayment = useCallback(
|
return useQuery({
|
||||||
(force = false) => {
|
queryKey: paymentKeys.subscription(userId || ''),
|
||||||
const currentUser = session?.user;
|
queryFn: async (): Promise<Subscription | null> => {
|
||||||
if (currentUser) {
|
if (!userId) {
|
||||||
fetchPaymentFromStore(currentUser, force);
|
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]
|
enabled: !!userId,
|
||||||
);
|
});
|
||||||
|
}
|
||||||
useEffect(() => {
|
|
||||||
const currentUser = session?.user;
|
// Hook to fetch lifetime status
|
||||||
if (currentUser) {
|
export function useLifetimeStatus(userId: string | undefined) {
|
||||||
fetchPaymentFromStore(currentUser);
|
return useQuery({
|
||||||
}
|
queryKey: paymentKeys.lifetime(userId || ''),
|
||||||
}, [session?.user, fetchPaymentFromStore]);
|
queryFn: async (): Promise<boolean> => {
|
||||||
|
if (!userId) {
|
||||||
return {
|
throw new Error('User ID is required');
|
||||||
// State
|
}
|
||||||
currentPlan,
|
const result = await getLifetimeStatusAction({ userId });
|
||||||
subscription,
|
if (!result?.data?.success) {
|
||||||
isLoading,
|
throw new Error(
|
||||||
error,
|
result?.data?.error || 'Failed to fetch lifetime status'
|
||||||
|
);
|
||||||
// Methods
|
}
|
||||||
fetchPayment,
|
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