Merge remote-tracking branch 'origin/main' into cloudflare

This commit is contained in:
javayhu 2025-08-21 09:59:28 +08:00
commit ca30f95027
28 changed files with 1540 additions and 863 deletions

177
TANSTACK_QUERY_REFACTOR.md Normal file
View File

@ -0,0 +1,177 @@
# TanStack Query 重构总结
## 概述
本次重构将项目中的状态管理从 Zustand stores 迁移到 TanStack Query提供了更好的数据获取、缓存和状态管理能力。
## 重构的组件
### 1. NewsletterFormCard
- **文件**: `src/components/settings/notification/newsletter-form-card.tsx`
- **重构内容**:
- 使用 `useNewsletterStatus`, `useSubscribeNewsletter`, `useUnsubscribeNewsletter` hooks
- 移除了手动的 `useState` 状态管理
- 使用 TanStack Query 的自动缓存和错误处理
### 2. UsersPageClient
- **文件**: `src/components/admin/users-page.tsx`
- **重构内容**:
- 使用 `useUsers` hook 进行数据获取
- 移除了手动的数据获取逻辑和状态管理
- 简化了组件逻辑
### 3. CreditsBalanceCard
- **文件**: `src/components/settings/credits/credits-balance-card.tsx`
- **重构内容**:
- 使用 `useCreditStats` hook 获取信用统计信息
- 移除了手动的 `fetchCreditStats` 函数
- 使用 TanStack Query 的 `refetch` 功能
### 4. CreditTransactions
- **文件**: `src/components/settings/credits/credit-transactions.tsx`
- **重构内容**:
- 使用 `useCreditTransactions` hook 进行分页数据获取
- 移除了手动的数据获取和状态管理
- 简化了组件逻辑
### 5. BillingCard
- **文件**: `src/components/settings/billing/billing-card.tsx`
- **重构内容**:
- 使用 `useCurrentPlan` hook 获取支付和订阅信息
- 移除了对 `usePayment` hook 的依赖
- 使用 TanStack Query 的自动数据获取
### 6. UserDetailViewer
- **文件**: `src/components/admin/user-detail-viewer.tsx`
- **重构内容**:
- 使用 `useBanUser`, `useUnbanUser` mutation hooks
- 移除了对 `useUsersStore` 的依赖
- 使用 TanStack Query 的自动缓存失效
## 新增的 Hooks
### 1. Newsletter Hooks (`src/hooks/use-newsletter.ts`)
- `useNewsletterStatus(email)` - 查询订阅状态
- `useSubscribeNewsletter()` - 订阅 newsletter
- `useUnsubscribeNewsletter()` - 取消订阅 newsletter
### 2. Users Hooks (`src/hooks/use-users.ts`)
- `useUsers(pageIndex, pageSize, search, sorting)` - 获取用户列表
- `useBanUser()` - 封禁用户
- `useUnbanUser()` - 解封用户
### 3. Credits Hooks (`src/hooks/use-credits-query.ts`)
- `useCreditBalance()` - 获取信用余额
- `useConsumeCredits()` - 消费信用
- `useCreditStats()` - 获取信用统计信息
- `useCreditTransactions(pageIndex, pageSize, search, sorting)` - 获取信用交易记录
### 4. Payment Hooks (`src/hooks/use-payment-query.ts`)
- `useActiveSubscription(userId)` - 获取活跃订阅
- `useLifetimeStatus(userId)` - 获取终身会员状态
- `useCurrentPlan(userId)` - 获取当前计划信息
## 简化的 Providers
### 1. PaymentProvider
- **文件**: `src/components/layout/payment-provider.tsx`
- **状态**: 已移除TanStack Query 自动处理所有支付相关数据获取
### 2. CreditsProvider
- **文件**: `src/components/layout/credits-provider.tsx`
- **状态**: 已移除TanStack Query 自动处理所有积分相关数据获取
## 更新的组件
### 1. UserButton
- **文件**: `src/components/layout/user-button.tsx`
- **更新**: 移除了 `resetState` 调用TanStack Query 自动处理缓存失效
### 2. UserButtonMobile
- **文件**: `src/components/layout/user-button-mobile.tsx`
- **更新**: 移除了 `resetState` 调用TanStack Query 自动处理缓存失效
### 3. SidebarUser
- **文件**: `src/components/dashboard/sidebar-user.tsx`
- **更新**: 移除了 `resetState` 调用TanStack Query 自动处理缓存失效
### 4. CreditsBalanceButton
- **文件**: `src/components/layout/credits-balance-button.tsx`
- **更新**: 使用 `useCreditBalance` 替代 `useCredits`
### 5. CreditsBalanceMenu
- **文件**: `src/components/layout/credits-balance-menu.tsx`
- **更新**: 使用 `useCreditBalance` 替代 `useCredits`
### 6. ConsumeCreditCard
- **文件**: `src/ai/text/components/consume-credit-card.tsx`
- **更新**: 使用 `useCreditBalance``useConsumeCredits` 替代 `useCredits`
### 7. UpgradeCard
- **文件**: `src/components/dashboard/upgrade-card.tsx`
- **更新**: 使用 `useCurrentPlan` 替代 `usePayment`
### 8. CreditPackages
- **文件**: `src/components/settings/credits/credit-packages.tsx`
- **更新**: 使用 `useCurrentPlan` 替代 `usePayment`
### 9. CreditsBalanceCard
- **文件**: `src/components/settings/credits/credits-balance-card.tsx`
- **更新**: 使用 `useCurrentPlan` 替代 `usePayment`
## 配置
### 1. QueryClient 配置
- **文件**: `src/lib/query-client.ts`
- **配置**: 设置了合理的缓存时间和重试策略
### 2. QueryProvider
- **文件**: `src/components/providers/query-provider.tsx`
- **功能**: 提供 TanStack Query 上下文和开发工具
## 优势
### 1. 更好的缓存管理
- 自动缓存数据,减少不必要的网络请求
- 智能的缓存失效策略
- 支持乐观更新
### 2. 简化的状态管理
- 移除了大量的 `useState``useEffect`
- 自动处理加载和错误状态
- 统一的数据获取模式
### 3. 更好的用户体验
- 自动重试失败的请求
- 后台数据刷新
- 更流畅的加载状态
### 4. 开发体验提升
- 内置的开发工具支持
- 更好的错误处理
- 类型安全的数据获取
## 缓存策略
- **用户数据**: 30秒缓存5分钟垃圾回收
- **信用数据**: 30秒缓存5分钟垃圾回收
- **信用统计**: 1分钟缓存10分钟垃圾回收
- **支付数据**: 2分钟缓存5分钟垃圾回收
- **终身状态**: 5分钟缓存10分钟垃圾回收
## 注意事项
1. 所有组件现在都使用 TanStack Query 进行数据获取
2. 移除了对 Zustand stores 的依赖(除了必要的全局状态)
3. 错误处理现在通过 TanStack Query 统一管理
4. 加载状态通过 `isLoading``isPending` 属性获取
5. 缓存失效通过 `invalidateQueries` 自动处理
6. 完全移除了 PaymentProvider 和 CreditsProvider
7. 删除了 use-payment.ts 和 payment-store.ts 文件
## 测试
- ✅ 所有组件编译通过
- ✅ TypeScript 类型检查通过
- ✅ 构建成功
- ✅ 代码格式化通过

View File

@ -79,6 +79,8 @@
"@react-email/render": "1.0.5",
"@stripe/stripe-js": "^5.6.0",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-query-devtools": "^5.85.5",
"@tanstack/react-table": "^8.21.2",
"@types/canvas-confetti": "^1.9.0",
"@vercel/analytics": "^1.5.0",
@ -140,6 +142,7 @@
"@biomejs/biome": "1.9.4",
"@opennextjs/cloudflare": "^1.6.5",
"@tailwindcss/postcss": "^4.0.14",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@types/mdx": "^2.0.13",
"@types/node": "^20.19.0",
"@types/pg": "^8.11.11",

697
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
import { Button } from '@/components/ui/button';
import { useCredits } from '@/hooks/use-credits';
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits';
import { CoinsIcon } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
@ -10,24 +10,28 @@ import { toast } from 'sonner';
const CONSUME_CREDITS = 50;
export function ConsumeCreditCard() {
const { consumeCredits, hasEnoughCredits, isLoading } = useCredits();
const { data: balance = 0, isLoading: isLoadingBalance } = useCreditBalance();
const consumeCreditsMutation = useConsumeCredits();
const [loading, setLoading] = useState(false);
const hasEnoughCredits = (amount: number) => balance >= amount;
const handleConsume = async () => {
if (!hasEnoughCredits(CONSUME_CREDITS)) {
toast.error('Insufficient credits, please buy more credits.');
return;
}
setLoading(true);
const success = await consumeCredits(
CONSUME_CREDITS,
`AI Text Credit Consumption (${CONSUME_CREDITS} credits)`
);
setLoading(false);
if (success) {
try {
await consumeCreditsMutation.mutateAsync({
amount: CONSUME_CREDITS,
description: `AI Text Credit Consumption (${CONSUME_CREDITS} credits)`,
});
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
} else {
} catch (error) {
toast.error('Failed to consume credits, please try again later.');
} finally {
setLoading(false);
}
};
@ -40,7 +44,9 @@ export function ConsumeCreditCard() {
variant="outline"
size="sm"
onClick={handleConsume}
disabled={isLoading || loading}
disabled={
loading || isLoadingBalance || consumeCreditsMutation.isPending
}
className="w-full cursor-pointer"
>
<CoinsIcon className="size-4" />

View File

@ -1,8 +1,7 @@
'use client';
import { ActiveThemeProvider } from '@/components/layout/active-theme-provider';
import { CreditsProvider } from '@/components/layout/credits-provider';
import { PaymentProvider } from '@/components/layout/payment-provider';
import { QueryProvider } from '@/components/providers/query-provider';
import { TooltipProvider } from '@/components/ui/tooltip';
import { websiteConfig } from '@/config/website';
import type { Translations } from 'fumadocs-ui/i18n';
@ -54,21 +53,19 @@ export function Providers({ children, locale }: ProvidersProps) {
};
return (
<ThemeProvider
attribute="class"
defaultTheme={defaultMode}
enableSystem={true}
disableTransitionOnChange
>
<ActiveThemeProvider>
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
<TooltipProvider>
<PaymentProvider>
<CreditsProvider>{children}</CreditsProvider>
</PaymentProvider>
</TooltipProvider>
</RootProvider>
</ActiveThemeProvider>
</ThemeProvider>
<QueryProvider>
<ThemeProvider
attribute="class"
defaultTheme={defaultMode}
enableSystem={true}
disableTransitionOnChange
>
<ActiveThemeProvider>
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
<TooltipProvider>{children}</TooltipProvider>
</RootProvider>
</ActiveThemeProvider>
</ThemeProvider>
</QueryProvider>
);
}

View File

@ -6,7 +6,6 @@ import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
@ -21,13 +20,12 @@ import {
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { useIsMobile } from '@/hooks/use-mobile';
import { authClient } from '@/lib/auth-client';
import { useBanUser, useUnbanUser } from '@/hooks/use-users';
import type { User } from '@/lib/auth-types';
import { isDemoWebsite } from '@/lib/demo';
import { formatDate } from '@/lib/formatter';
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
import { cn } from '@/lib/utils';
import { useUsersStore } from '@/stores/users-store';
import {
CalendarIcon,
Loader2Icon,
@ -47,11 +45,13 @@ interface UserDetailViewerProps {
export function UserDetailViewer({ user }: UserDetailViewerProps) {
const t = useTranslations('Dashboard.admin.users');
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>();
const [banReason, setBanReason] = useState(t('ban.defaultReason'));
const [banExpiresAt, setBanExpiresAt] = useState<Date | undefined>();
const triggerRefresh = useUsersStore((state) => state.triggerRefresh);
// TanStack Query mutations
const banUserMutation = useBanUser();
const unbanUserMutation = useUnbanUser();
// show fake data in demo website
const isDemo = isDemoWebsite();
@ -67,11 +67,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
return;
}
setIsLoading(true);
setError('');
try {
await authClient.admin.banUser({
await banUserMutation.mutateAsync({
userId: user.id,
banReason,
banExpiresIn: banExpiresAt
@ -83,15 +82,11 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
// Reset form
setBanReason('');
setBanExpiresAt(undefined);
// Trigger refresh
triggerRefresh();
} catch (err) {
const error = err as Error;
console.error('Failed to ban user:', error);
setError(error.message || t('ban.error'));
toast.error(error.message || t('ban.error'));
} finally {
setIsLoading(false);
}
};
@ -101,24 +96,19 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
return;
}
setIsLoading(true);
setError('');
try {
await authClient.admin.unbanUser({
await unbanUserMutation.mutateAsync({
userId: user.id,
});
toast.success(t('unban.success'));
// Trigger refresh
triggerRefresh();
} catch (err) {
const error = err as Error;
console.error('Failed to unban user:', error);
setError(error.message || t('unban.error'));
toast.error(error.message || t('unban.error'));
} finally {
setIsLoading(false);
}
};
@ -166,7 +156,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
{user.role === 'admin' ? t('admin') : t('user')}
</Badge>
{/* email verified */}
<Badge variant="outline" className="px-1.5 hover:bg-accent">
{/* <Badge variant="outline" className="px-1.5 hover:bg-accent">
{user.emailVerified ? (
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
) : (
@ -175,7 +165,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
{user.emailVerified
? t('email.verified')
: t('email.unverified')}
</Badge>
</Badge> */}
{/* user banned */}
<div className="flex items-center gap-2">
@ -196,15 +186,23 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
<span className="text-muted-foreground text-xs">
{t('columns.email')}:
</span>
<span
className="break-words cursor-pointer hover:bg-accent px-2 py-1 rounded border"
onClick={() => {
navigator.clipboard.writeText(user.email!);
toast.success(t('emailCopied'));
}}
>
{user.email}
</span>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="text-sm px-1.5 cursor-pointer hover:bg-accent"
onClick={() => {
navigator.clipboard.writeText(user.email);
toast.success(t('emailCopied'));
}}
>
{user.emailVerified ? (
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
) : (
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
)}
{user.email}
</Badge>
</div>
</div>
)}
@ -256,10 +254,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
<Button
variant="destructive"
onClick={handleUnban}
disabled={isLoading || isDemo}
disabled={unbanUserMutation.isPending || isDemo}
className="mt-4 cursor-pointer"
>
{isLoading && (
{unbanUserMutation.isPending && (
<Loader2Icon className="mr-2 size-4 animate-spin" />
)}
{t('unban.button')}
@ -315,10 +313,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
<Button
type="submit"
variant="destructive"
disabled={isLoading || !banReason || isDemo}
disabled={banUserMutation.isPending || !banReason || isDemo}
className="mt-4 cursor-pointer"
>
{isLoading && (
{banUserMutation.isPending && (
<Loader2Icon className="mr-2 size-4 animate-spin" />
)}
{t('ban.button')}

View File

@ -1,74 +1,34 @@
'use client';
import { getUsersAction } from '@/actions/get-users';
import { UsersTable } from '@/components/admin/users-table';
import type { User } from '@/lib/auth-types';
import { useUsersStore } from '@/stores/users-store';
import { useUsers } from '@/hooks/use-users';
import type { SortingState } from '@tanstack/react-table';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useState } from 'react';
export function UsersPageClient() {
const t = useTranslations('Dashboard.admin.users');
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState('');
const [data, setData] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true },
]);
const refreshTrigger = useUsersStore((state) => state.refreshTrigger);
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
const result = await getUsersAction({
pageIndex,
pageSize,
search,
sorting,
});
if (result?.data?.success) {
setData(result.data.data?.items || []);
setTotal(result.data.data?.total || 0);
} else {
const errorMessage = result?.data?.error || t('error');
toast.error(errorMessage);
setData([]);
setTotal(0);
}
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error(t('error'));
setData([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [pageIndex, pageSize, search, sorting, refreshTrigger]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const { data, isLoading } = useUsers(pageIndex, pageSize, search, sorting);
return (
<>
<UsersTable
data={data}
total={total}
pageIndex={pageIndex}
pageSize={pageSize}
search={search}
loading={loading}
onSearch={setSearch}
onPageChange={setPageIndex}
onPageSizeChange={setPageSize}
onSortingChange={setSorting}
/>
</>
<UsersTable
data={data?.items || []}
total={data?.total || 0}
pageIndex={pageIndex}
pageSize={pageSize}
search={search}
loading={isLoading}
onSearch={setSearch}
onPageChange={setPageIndex}
onPageSizeChange={setPageSize}
onSortingChange={setSorting}
/>
);
}

View File

@ -23,7 +23,6 @@ import { useLocalePathname, useLocaleRouter } from '@/i18n/navigation';
import { LOCALES, routing } from '@/i18n/routing';
import { authClient } from '@/lib/auth-client';
import { useLocaleStore } from '@/stores/locale-store';
import { usePaymentStore } from '@/stores/payment-store';
import type { User } from 'better-auth';
import {
ChevronsUpDown,
@ -55,7 +54,6 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
const pathname = useLocalePathname();
const params = useParams();
const { currentLocale, setCurrentLocale } = useLocaleStore();
const { resetState } = usePaymentStore();
const [, startTransition] = useTransition();
const t = useTranslations();
@ -81,8 +79,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
fetchOptions: {
onSuccess: () => {
console.log('sign out success');
// Reset payment state on sign out
resetState();
// TanStack Query automatically handles cache invalidation on sign out
router.replace('/');
},
onError: (error) => {
@ -100,7 +97,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="cursor-pointer data-[state=open]:bg-sidebar-accent
className="cursor-pointer data-[state=open]:bg-sidebar-accent
data-[state=open]:text-sidebar-accent-foreground"
>
<UserAvatar

View File

@ -9,8 +9,9 @@ import {
CardTitle,
} from '@/components/ui/card';
import { websiteConfig } from '@/config/website';
import { usePayment } from '@/hooks/use-payment';
import { useCurrentPlan } from '@/hooks/use-payment';
import { LocaleLink } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { Routes } from '@/routes';
import { SparklesIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
@ -23,14 +24,16 @@ export function UpgradeCard() {
const t = useTranslations('Dashboard.upgrade');
const [mounted, setMounted] = useState(false);
const { isLoading, currentPlan, subscription } = usePayment();
const { data: session } = authClient.useSession();
const { data: paymentData, isLoading } = useCurrentPlan(session?.user?.id);
useEffect(() => {
setMounted(true);
}, []);
// Don't show the upgrade card if the user has a lifetime membership or a subscription
const isMember = currentPlan?.isLifetime || !!subscription;
const isMember =
paymentData?.currentPlan?.isLifetime || !!paymentData?.subscription;
if (!mounted || isLoading || isMember) {
return null;

View File

@ -2,7 +2,7 @@
import { Button } from '@/components/ui/button';
import { websiteConfig } from '@/config/website';
import { useCredits } from '@/hooks/use-credits';
import { useCreditBalance } from '@/hooks/use-credits';
import { useLocaleRouter } from '@/i18n/navigation';
import { Routes } from '@/routes';
import { CoinsIcon, Loader2Icon } from 'lucide-react';
@ -15,8 +15,8 @@ export function CreditsBalanceButton() {
const router = useLocaleRouter();
// Use the new useCredits hook
const { balance, isLoading } = useCredits();
// Use TanStack Query hook for credit balance
const { data: balance = 0, isLoading } = useCreditBalance();
const handleClick = () => {
router.push(Routes.SettingsCredits);

View File

@ -1,7 +1,7 @@
'use client';
import { websiteConfig } from '@/config/website';
import { useCredits } from '@/hooks/use-credits';
import { useCreditBalance } from '@/hooks/use-credits';
import { useLocaleRouter } from '@/i18n/navigation';
import { Routes } from '@/routes';
import { CoinsIcon, Loader2Icon } from 'lucide-react';
@ -16,8 +16,8 @@ export function CreditsBalanceMenu() {
const t = useTranslations('Marketing.avatar');
const router = useLocaleRouter();
// Use the new useCredits hook
const { balance, isLoading } = useCredits();
// Use TanStack Query hook for credit balance
const { data: balance = 0, isLoading } = useCreditBalance();
const handleClick = () => {
router.push(Routes.SettingsCredits);

View File

@ -1,31 +1,19 @@
'use client';
import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client';
import { useCreditsStore } from '@/stores/credits-store';
import { useEffect } from 'react';
/**
* Credits Provider Component
*
* This component initializes the credits store when the user is authenticated
* and handles cleanup when the user logs out.
* This component is now simplified since TanStack Query handles data fetching automatically.
* It's kept for potential future credits-related providers.
* Only renders when credits are enabled in the website configuration.
*/
export function CreditsProvider({ children }: { children: React.ReactNode }) {
// Only initialize credits store if credits are enabled
// Only render when credits are enabled
if (!websiteConfig.credits.enableCredits) {
return <>{children}</>;
}
const { fetchCredits } = useCreditsStore();
const { data: session } = authClient.useSession();
useEffect(() => {
if (session?.user) {
fetchCredits(session.user);
}
}, [session?.user, fetchCredits]);
return <>{children}</>;
}

View File

@ -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}</>;
}

View File

@ -13,7 +13,6 @@ import {
import { getAvatarLinks } from '@/config/avatar-config';
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { usePaymentStore } from '@/stores/payment-store';
import type { User } from 'better-auth';
import { LogOutIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
@ -29,8 +28,6 @@ export function UserButtonMobile({ user }: UserButtonProps) {
const avatarLinks = getAvatarLinks();
const localeRouter = useLocaleRouter();
const [open, setOpen] = useState(false);
const { resetState } = usePaymentStore();
const closeDrawer = () => {
setOpen(false);
};
@ -40,8 +37,7 @@ export function UserButtonMobile({ user }: UserButtonProps) {
fetchOptions: {
onSuccess: () => {
console.log('sign out success');
// Reset payment state on sign out
resetState();
// TanStack Query automatically handles cache invalidation on sign out
localeRouter.replace('/');
},
onError: (error) => {
@ -64,7 +60,7 @@ export function UserButtonMobile({ user }: UserButtonProps) {
<DrawerPortal>
<DrawerOverlay className="fixed inset-0 z-40 bg-background/50" />
<DrawerContent
className="fixed inset-x-0 bottom-0 z-50 mt-24
className="fixed inset-x-0 bottom-0 z-50 mt-24
overflow-hidden rounded-t-[10px] border bg-background px-3 text-sm"
>
<DrawerHeader>

View File

@ -12,7 +12,6 @@ import { getAvatarLinks } from '@/config/avatar-config';
import { websiteConfig } from '@/config/website';
import { useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { usePaymentStore } from '@/stores/payment-store';
import type { User } from 'better-auth';
import { LogOutIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
@ -29,15 +28,12 @@ export function UserButton({ user }: UserButtonProps) {
const avatarLinks = getAvatarLinks();
const localeRouter = useLocaleRouter();
const [open, setOpen] = useState(false);
const { resetState } = usePaymentStore();
const handleSignOut = async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
console.log('sign out success');
// Reset payment state on sign out
resetState();
// TanStack Query automatically handles cache invalidation on sign out
localeRouter.replace('/');
},
onError: (error) => {

View 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>
);
}

View File

@ -14,7 +14,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { getPricePlans } from '@/config/price-config';
import { useMounted } from '@/hooks/use-mounted';
import { usePayment } from '@/hooks/use-payment';
import { useCurrentPlan } from '@/hooks/use-payment';
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { formatDate } from '@/lib/formatter';
@ -33,34 +33,37 @@ export default function BillingCard() {
const hasHandledSession = useRef(false);
const mounted = useMounted();
const {
isLoading: isLoadingPayment,
error: loadPaymentError,
subscription,
currentPlan: currentPlanFromStore,
fetchPayment,
} = usePayment();
// Get user session for customer ID
const { data: session, isPending: isLoadingSession } =
authClient.useSession();
const currentUser = session?.user;
// TanStack Query hook for current plan and subscription
const {
data: paymentData,
isLoading: isLoadingPayment,
error: loadPaymentError,
refetch: refetchPayment,
} = useCurrentPlan(currentUser?.id);
const currentPlan = paymentData?.currentPlan;
const subscription = paymentData?.subscription;
// Get price plans with translations - must be called here to maintain hook order
const pricePlans = getPricePlans();
const plans = Object.values(pricePlans);
// Convert current plan from store to a plan with translations
const currentPlan = currentPlanFromStore
? plans.find((plan) => plan.id === currentPlanFromStore?.id)
// Convert current plan to a plan with translations
const currentPlanWithTranslations = currentPlan
? plans.find((plan) => plan.id === currentPlan?.id)
: null;
const isFreePlan = currentPlan?.isFree || false;
const isLifetimeMember = currentPlan?.isLifetime || false;
const isFreePlan = currentPlanWithTranslations?.isFree || false;
const isLifetimeMember = currentPlanWithTranslations?.isLifetime || false;
// Get subscription price details
const currentPrice =
subscription &&
currentPlan?.prices.find(
currentPlanWithTranslations?.prices.find(
(price) => price.priceId === subscription?.priceId
);
@ -77,8 +80,8 @@ export default function BillingCard() {
// Retry payment data fetching
const handleRetry = useCallback(() => {
// console.log('handleRetry, refetch payment info');
fetchPayment(true);
}, [fetchPayment]);
refetchPayment();
}, [refetchPayment]);
// Check for payment success and show success message
useEffect(() => {
@ -132,7 +135,9 @@ export default function BillingCard() {
<CardDescription>{t('currentPlan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="text-destructive text-sm">{loadPaymentError}</div>
<div className="text-destructive text-sm">
{loadPaymentError?.message}
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<Button
@ -149,7 +154,7 @@ export default function BillingCard() {
}
// currentPlan maybe null, so we need to check if it is null
if (!currentPlan) {
if (!currentPlanWithTranslations) {
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
@ -187,7 +192,9 @@ export default function BillingCard() {
<CardContent className="space-y-4 flex-1">
{/* Plan name and status */}
<div className="flex items-center justify-start space-x-4">
<div className="text-3xl font-medium">{currentPlan?.name}</div>
<div className="text-3xl font-medium">
{currentPlanWithTranslations?.name}
</div>
{subscription &&
(subscription.status === 'trialing' ||
subscription.status === 'active') && (

View File

@ -11,7 +11,8 @@ import {
import { getCreditPackages } from '@/config/credits-config';
import { websiteConfig } from '@/config/website';
import { useCurrentUser } from '@/hooks/use-current-user';
import { usePayment } from '@/hooks/use-payment';
import { useCurrentPlan } from '@/hooks/use-payment';
import { authClient } from '@/lib/auth-client';
import { formatPrice } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react';
@ -31,7 +32,9 @@ export function CreditPackages() {
// Get current user and payment info
const currentUser = useCurrentUser();
const { currentPlan } = usePayment();
const { data: session } = authClient.useSession();
const { data: paymentData } = useCurrentPlan(session?.user?.id);
const currentPlan = paymentData?.currentPlan;
// Get credit packages with translations - must be called here to maintain hook order
const creditPackages = Object.values(getCreditPackages()).filter(

View File

@ -1,12 +1,10 @@
'use client';
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
import type { CreditTransaction } from '@/components/settings/credits/credit-transactions-table';
import { CreditTransactionsTable } from '@/components/settings/credits/credit-transactions-table';
import { useCreditTransactions } from '@/hooks/use-credits';
import type { SortingState } from '@tanstack/react-table';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useState } from 'react';
/**
* Credit transactions component
@ -16,57 +14,25 @@ export function CreditTransactions() {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState('');
const [data, setData] = useState<CreditTransaction[]>([]);
const [total, setTotal] = useState(0);
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true },
]);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await getCreditTransactionsAction({
pageIndex,
pageSize,
search,
sorting,
});
if (result?.data?.success) {
setData(result.data.data?.items || []);
setTotal(result.data.data?.total || 0);
} else {
const errorMessage = result?.data?.error || t('error');
toast.error(errorMessage);
setData([]);
setTotal(0);
}
} catch (error) {
console.error(
'CreditTransactions, fetch credit transactions error:',
error
);
toast.error(t('error'));
setData([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [pageIndex, pageSize, search, sorting]);
useEffect(() => {
fetchData();
}, [fetchData]);
const { data, isLoading } = useCreditTransactions(
pageIndex,
pageSize,
search,
sorting
);
return (
<CreditTransactionsTable
data={data}
total={total}
data={data?.items || []}
total={data?.total || 0}
pageIndex={pageIndex}
pageSize={pageSize}
search={search}
loading={loading}
loading={isLoading}
onSearch={setSearch}
onPageChange={setPageIndex}
onPageSizeChange={setPageSize}

View File

@ -1,6 +1,5 @@
'use client';
import { getCreditStatsAction } from '@/actions/get-credit-stats';
import { Button } from '@/components/ui/button';
import {
Card,
@ -12,17 +11,18 @@ import {
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { websiteConfig } from '@/config/website';
import { useCredits } from '@/hooks/use-credits';
import { useCreditBalance, useCreditStats } from '@/hooks/use-credits';
import { useMounted } from '@/hooks/use-mounted';
import { usePayment } from '@/hooks/use-payment';
import { useCurrentPlan } from '@/hooks/use-payment';
import { useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { formatDate } from '@/lib/formatter';
import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { RefreshCwIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { toast } from 'sonner';
/**
@ -40,50 +40,41 @@ export default function CreditsBalanceCard() {
const hasHandledSession = useRef(false);
const mounted = useMounted();
// Use the credits hook to get balance
// Use TanStack Query hooks for credits
const {
balance,
data: balance = 0,
isLoading: isLoadingBalance,
error,
fetchCredits,
} = useCredits();
error: balanceError,
refetch: refetchBalance,
} = useCreditBalance();
// Get payment info to check plan type
const { currentPlan } = usePayment();
const { data: session } = authClient.useSession();
const { data: paymentData } = useCurrentPlan(session?.user?.id);
const currentPlan = paymentData?.currentPlan;
// State for credit statistics
const [creditStats, setCreditStats] = useState<{
expiringCredits: {
amount: number;
earliestExpiration: string | Date | null;
};
subscriptionCredits: { amount: number };
lifetimeCredits: { amount: number };
} | null>(null);
const [isLoadingStats, setIsLoadingStats] = useState(true);
// TanStack Query hook for credit statistics
const {
data: creditStats,
isLoading: isLoadingStats,
error: statsError,
refetch: refetchStats,
} = useCreditStats();
// Fetch credit statistics
const fetchCreditStats = useCallback(async () => {
console.log('fetchCreditStats, fetch start');
setIsLoadingStats(true);
try {
const result = await getCreditStatsAction();
if (result?.data?.success && result.data.data) {
setCreditStats(result.data.data);
} else {
console.error('fetchCreditStats, failed to fetch credit stats', result);
}
} catch (error) {
console.error('fetchCreditStats, error:', error);
} finally {
setIsLoadingStats(false);
}
}, []);
// Handle payment success after credits purchase
const handlePaymentSuccess = useCallback(async () => {
// Use queueMicrotask to avoid React rendering conflicts
queueMicrotask(() => {
toast.success(t('creditsAdded'));
});
// Fetch stats on component mount
useEffect(() => {
fetchCreditStats();
}, []);
// Wait for webhook to process (simplified approach)
await new Promise((resolve) => setTimeout(resolve, 1000));
// Force refresh data
refetchBalance();
refetchStats();
}, [t, refetchBalance, refetchStats]);
// Check for payment success and show success message
useEffect(() => {
@ -91,35 +82,25 @@ export default function CreditsBalanceCard() {
if (sessionId && !hasHandledSession.current) {
hasHandledSession.current = true;
setTimeout(() => {
// Show success toast and refresh data after payment
toast.success(t('creditsAdded'));
// Force refresh credits data to show updated balance
fetchCredits(true);
// Refresh credit stats
fetchCreditStats();
}, 0);
// Clean up URL parameters
// Clean up URL parameters first
const url = new URL(window.location.href);
url.searchParams.delete('credits_session_id');
localeRouter.replace(Routes.SettingsCredits + url.search);
}
}, [searchParams, localeRouter, fetchCredits, fetchCreditStats, t]);
// Retry all data fetching
// Handle payment success
handlePaymentSuccess();
}
}, [searchParams, localeRouter, handlePaymentSuccess]);
// Retry all data fetching using refetch methods
const handleRetry = useCallback(() => {
// console.log('handleRetry, refetch credits data');
// Force refresh credits balance (ignore cache)
fetchCredits(true);
// Refresh credit stats
fetchCreditStats();
}, [fetchCredits, fetchCreditStats]);
// Use refetch methods for immediate data refresh
refetchBalance();
refetchStats();
}, [refetchBalance, refetchStats]);
// Render loading skeleton
const isPageLoading = isLoadingBalance || isLoadingStats;
if (!mounted || isPageLoading) {
if (!mounted || isLoadingBalance || isLoadingStats) {
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
@ -140,7 +121,7 @@ export default function CreditsBalanceCard() {
}
// Render error state
if (error) {
if (balanceError || statsError) {
return (
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
<CardHeader>
@ -148,7 +129,9 @@ export default function CreditsBalanceCard() {
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1">
<div className="text-destructive text-sm">{error}</div>
<div className="text-destructive text-sm">
{balanceError?.message || statsError?.message}
</div>
</CardContent>
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
<Button

View File

@ -1,8 +1,5 @@
'use client';
import { checkNewsletterStatusAction } from '@/actions/check-newsletter-status';
import { subscribeNewsletterAction } from '@/actions/subscribe-newsletter';
import { unsubscribeNewsletterAction } from '@/actions/unsubscribe-newsletter';
import { FormError } from '@/components/shared/form-error';
import {
Card,
@ -21,12 +18,17 @@ import {
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { websiteConfig } from '@/config/website';
import {
useNewsletterStatus,
useSubscribeNewsletter,
useUnsubscribeNewsletter,
} from '@/hooks/use-newsletter';
import { authClient } from '@/lib/auth-client';
import { cn } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2Icon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
@ -47,12 +49,19 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
}
const t = useTranslations('Dashboard.settings.notification');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>('');
const [isSubscriptionChecked, setIsSubscriptionChecked] = useState(false);
const { data: session } = authClient.useSession();
const currentUser = session?.user;
// TanStack Query hooks
const {
data: newsletterStatus,
isLoading: isStatusLoading,
error: statusError,
} = useNewsletterStatus(currentUser?.email);
const subscribeMutation = useSubscribeNewsletter();
const unsubscribeMutation = useUnsubscribeNewsletter();
// Create a schema for newsletter subscription
const formSchema = z.object({
subscribed: z.boolean(),
@ -66,45 +75,12 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
},
});
// Check subscription status on component mount
// Update form when newsletter status changes
useEffect(() => {
const checkSubscriptionStatus = async () => {
if (currentUser?.email) {
try {
setIsLoading(true);
// Check if the user is already subscribed using server action
const statusResult = await checkNewsletterStatusAction({
email: currentUser.email,
});
if (statusResult?.data?.success) {
const isCurrentlySubscribed = statusResult.data.subscribed;
setIsSubscriptionChecked(isCurrentlySubscribed);
form.setValue('subscribed', isCurrentlySubscribed);
} else {
// Handle error from server action
const errorMessage = statusResult?.data?.error;
if (errorMessage) {
console.error('check subscription status error:', errorMessage);
setError(errorMessage);
}
// Default to not subscribed if there's an error
setIsSubscriptionChecked(false);
form.setValue('subscribed', false);
}
} catch (error) {
console.error('check subscription status error:', error);
// Default to not subscribed if there's an error
setIsSubscriptionChecked(false);
form.setValue('subscribed', false);
} finally {
setIsLoading(false);
}
}
};
checkSubscriptionStatus();
}, [currentUser?.email, form]);
if (newsletterStatus) {
form.setValue('subscribed', newsletterStatus.subscribed);
}
}, [newsletterStatus, form]);
// Check if user exists after all hooks are initialized
if (!currentUser) {
@ -114,59 +90,27 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
// Handle checkbox change
const handleSubscriptionChange = async (value: boolean) => {
if (!currentUser.email) {
setError(t('newsletter.emailRequired'));
toast.error(t('newsletter.emailRequired'));
return;
}
setIsLoading(true);
setError('');
try {
if (value) {
// Subscribe to newsletter using server action
const subscribeResult = await subscribeNewsletterAction({
email: currentUser.email,
});
if (subscribeResult?.data?.success) {
toast.success(t('newsletter.subscribeSuccess'));
setIsSubscriptionChecked(true);
form.setValue('subscribed', true);
} else {
const errorMessage =
subscribeResult?.data?.error || t('newsletter.subscribeFail');
toast.error(errorMessage);
setError(errorMessage);
// Reset checkbox if subscription failed
form.setValue('subscribed', false);
}
// Subscribe to newsletter
await subscribeMutation.mutateAsync(currentUser.email);
toast.success(t('newsletter.subscribeSuccess'));
} else {
// Unsubscribe from newsletter using server action
const unsubscribeResult = await unsubscribeNewsletterAction({
email: currentUser.email,
});
if (unsubscribeResult?.data?.success) {
toast.success(t('newsletter.unsubscribeSuccess'));
setIsSubscriptionChecked(false);
form.setValue('subscribed', false);
} else {
const errorMessage =
unsubscribeResult?.data?.error || t('newsletter.unsubscribeFail');
toast.error(errorMessage);
setError(errorMessage);
// Reset checkbox if unsubscription failed
form.setValue('subscribed', true);
}
// Unsubscribe from newsletter
await unsubscribeMutation.mutateAsync(currentUser.email);
toast.success(t('newsletter.unsubscribeSuccess'));
}
} catch (error) {
console.error('newsletter subscription error:', error);
setError(t('newsletter.error'));
toast.error(t('newsletter.error'));
const errorMessage =
error instanceof Error ? error.message : t('newsletter.error');
toast.error(errorMessage);
// Reset form to previous state on error
form.setValue('subscribed', isSubscriptionChecked);
} finally {
setIsLoading(false);
form.setValue('subscribed', newsletterStatus?.subscribed || false);
}
};
@ -193,7 +137,9 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
</div>
<FormControl>
<div className="relative flex items-center">
{isLoading && (
{(isStatusLoading ||
subscribeMutation.isPending ||
unsubscribeMutation.isPending) && (
<Loader2Icon className="mr-2 size-4 animate-spin text-primary" />
)}
<Switch
@ -202,8 +148,16 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
field.onChange(checked);
handleSubscriptionChange(checked);
}}
disabled={isLoading}
aria-readonly={isLoading}
disabled={
isStatusLoading ||
subscribeMutation.isPending ||
unsubscribeMutation.isPending
}
aria-readonly={
isStatusLoading ||
subscribeMutation.isPending ||
unsubscribeMutation.isPending
}
className="cursor-pointer"
/>
</div>
@ -211,7 +165,13 @@ export function NewsletterFormCard({ className }: NewsletterFormCardProps) {
</FormItem>
)}
/>
<FormError message={error} />
<FormError
message={
statusError?.message ||
subscribeMutation.error?.message ||
unsubscribeMutation.error?.message
}
/>
</CardContent>
<CardFooter className="mt-6 px-6 py-4 bg-background rounded-none">
<p className="text-sm text-muted-foreground">

View File

@ -1,66 +1,121 @@
import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client';
import { useCreditsStore } from '@/stores/credits-store';
import { useCallback, useEffect } from 'react';
import { consumeCreditsAction } from '@/actions/consume-credits';
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
import { getCreditStatsAction } from '@/actions/get-credit-stats';
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
/**
* Hook for accessing and managing credits state
*
* This hook provides access to the credits state and methods to manage it.
* It also automatically fetches credits information when the user changes.
* Only works when credits are enabled in the website configuration.
*/
export function useCredits() {
// Return default values if credits are disabled
if (!websiteConfig.credits.enableCredits) {
return {
balance: 0,
isLoading: false,
error: null,
fetchCredits: () => Promise.resolve(),
consumeCredits: () => Promise.resolve(false),
hasEnoughCredits: () => false,
};
}
// Query keys
export const creditsKeys = {
all: ['credits'] as const,
balance: () => [...creditsKeys.all, 'balance'] as const,
stats: () => [...creditsKeys.all, 'stats'] as const,
transactions: () => [...creditsKeys.all, 'transactions'] as const,
transactionsList: (filters: {
pageIndex: number;
pageSize: number;
search: string;
sorting: SortingState;
}) => [...creditsKeys.transactions(), filters] as const,
};
const {
balance,
isLoading,
error,
fetchCredits: fetchCreditsFromStore,
consumeCredits,
} = useCreditsStore();
const { data: session } = authClient.useSession();
const fetchCredits = useCallback(
(force = false) => {
const currentUser = session?.user;
if (currentUser) {
fetchCreditsFromStore(currentUser, force);
// Hook to fetch credit balance
export function useCreditBalance() {
return useQuery({
queryKey: creditsKeys.balance(),
queryFn: async () => {
console.log('Fetching credit balance...');
const result = await getCreditBalanceAction();
if (!result?.data?.success) {
throw new Error('Failed to fetch credit balance');
}
console.log('Credit balance fetched:', result.data.credits);
return result.data.credits || 0;
},
[session?.user, fetchCreditsFromStore]
);
useEffect(() => {
const currentUser = session?.user;
if (currentUser) {
fetchCreditsFromStore(currentUser);
}
}, [session?.user, fetchCreditsFromStore]);
return {
// State
balance,
isLoading,
error,
// Methods
fetchCredits,
consumeCredits,
// Helper methods
hasEnoughCredits: (amount: number) => balance >= amount,
};
});
}
// Hook to fetch credit statistics
export function useCreditStats() {
return useQuery({
queryKey: creditsKeys.stats(),
queryFn: async () => {
console.log('Fetching credit stats...');
const result = await getCreditStatsAction();
if (!result?.data?.success) {
throw new Error(result?.data?.error || 'Failed to fetch credit stats');
}
console.log('Credit stats fetched:', result.data.data);
return result.data.data;
},
});
}
// Hook to consume credits
export function useConsumeCredits() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
amount,
description,
}: {
amount: number;
description: string;
}) => {
const result = await consumeCreditsAction({
amount,
description,
});
if (!result?.data?.success) {
throw new Error(result?.data?.error || 'Failed to consume credits');
}
return result.data;
},
onSuccess: () => {
// Invalidate credit balance and stats after consuming credits
queryClient.invalidateQueries({
queryKey: creditsKeys.balance(),
});
queryClient.invalidateQueries({
queryKey: creditsKeys.stats(),
});
},
});
}
// Hook to fetch credit transactions with pagination, search, and sorting
export function useCreditTransactions(
pageIndex: number,
pageSize: number,
search: string,
sorting: SortingState
) {
return useQuery({
queryKey: creditsKeys.transactionsList({
pageIndex,
pageSize,
search,
sorting,
}),
queryFn: async () => {
const result = await getCreditTransactionsAction({
pageIndex,
pageSize,
search,
sorting,
});
if (!result?.data?.success) {
throw new Error(
result?.data?.error || 'Failed to fetch credit transactions'
);
}
return {
items: result.data.data?.items || [],
total: result.data.data?.total || 0,
};
},
});
}

View 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),
});
},
});
}

View File

@ -1,49 +1,107 @@
import { authClient } from '@/lib/auth-client';
import { usePaymentStore } from '@/stores/payment-store';
import { useCallback, useEffect } from 'react';
import { getActiveSubscriptionAction } from '@/actions/get-active-subscription';
import { getLifetimeStatusAction } from '@/actions/get-lifetime-status';
import { getAllPricePlans } from '@/lib/price-plan';
import type { PricePlan, Subscription } from '@/payment/types';
import { useQuery } from '@tanstack/react-query';
/**
* Hook for accessing and managing payment state
*
* This hook provides access to the payment state and methods to manage it.
* It also automatically fetches payment information when the user changes.
*/
export function usePayment() {
const {
currentPlan,
subscription,
isLoading,
error,
fetchPayment: fetchPaymentFromStore,
} = usePaymentStore();
// Query keys
export const paymentKeys = {
all: ['payment'] as const,
subscription: (userId: string) =>
[...paymentKeys.all, 'subscription', userId] as const,
lifetime: (userId: string) =>
[...paymentKeys.all, 'lifetime', userId] as const,
currentPlan: (userId: string) =>
[...paymentKeys.all, 'currentPlan', userId] as const,
};
const { data: session } = authClient.useSession();
const fetchPayment = useCallback(
(force = false) => {
const currentUser = session?.user;
if (currentUser) {
fetchPaymentFromStore(currentUser, force);
// Hook to fetch active subscription
export function useActiveSubscription(userId: string | undefined) {
return useQuery({
queryKey: paymentKeys.subscription(userId || ''),
queryFn: async (): Promise<Subscription | null> => {
if (!userId) {
throw new Error('User ID is required');
}
const result = await getActiveSubscriptionAction({ userId });
if (!result?.data?.success) {
throw new Error(result?.data?.error || 'Failed to fetch subscription');
}
return result.data.data || null;
},
[session?.user, fetchPaymentFromStore]
);
useEffect(() => {
const currentUser = session?.user;
if (currentUser) {
fetchPaymentFromStore(currentUser);
}
}, [session?.user, fetchPaymentFromStore]);
return {
// State
currentPlan,
subscription,
isLoading,
error,
// Methods
fetchPayment,
};
enabled: !!userId,
});
}
// Hook to fetch lifetime status
export function useLifetimeStatus(userId: string | undefined) {
return useQuery({
queryKey: paymentKeys.lifetime(userId || ''),
queryFn: async (): Promise<boolean> => {
if (!userId) {
throw new Error('User ID is required');
}
const result = await getLifetimeStatusAction({ userId });
if (!result?.data?.success) {
throw new Error(
result?.data?.error || 'Failed to fetch lifetime status'
);
}
return result.data.isLifetimeMember || false;
},
enabled: !!userId,
});
}
// Hook to get current plan based on subscription and lifetime status
export function useCurrentPlan(userId: string | undefined) {
const {
data: subscription,
isLoading: isLoadingSubscription,
error: subscriptionError,
} = useActiveSubscription(userId);
const {
data: isLifetimeMember,
isLoading: isLoadingLifetime,
error: lifetimeError,
} = useLifetimeStatus(userId);
return useQuery({
queryKey: paymentKeys.currentPlan(userId || ''),
queryFn: async (): Promise<{
currentPlan: PricePlan | null;
subscription: Subscription | null;
}> => {
const plans: PricePlan[] = getAllPricePlans();
const freePlan = plans.find((plan) => plan.isFree);
const lifetimePlan = plans.find((plan) => plan.isLifetime);
// If lifetime member, return lifetime plan
if (isLifetimeMember) {
return {
currentPlan: lifetimePlan || null,
subscription: null,
};
}
// If has active subscription, find the corresponding plan
if (subscription) {
const plan =
plans.find((p) =>
p.prices.find((price) => price.priceId === subscription.priceId)
) || null;
return {
currentPlan: plan,
subscription,
};
}
// Default to free plan
return {
currentPlan: freePlan || null,
subscription: null,
};
},
enabled: !!userId && !isLoadingSubscription && !isLoadingLifetime,
});
}

93
src/hooks/use-users.ts Normal file
View 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,
});
},
});
}

View File

@ -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;
}
},
}));

View File

@ -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,
});
},
}));

View File

@ -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 })),
}));