Merge pull request #81 from MkSaaSHQ/dev/tanstack-query
feat: add tanstack query to optimize performance
This commit is contained in:
commit
8cc16a898c
177
TANSTACK_QUERY_REFACTOR.md
Normal file
177
TANSTACK_QUERY_REFACTOR.md
Normal 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 类型检查通过
|
||||
- ✅ 构建成功
|
||||
- ✅ 代码格式化通过
|
@ -78,6 +78,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",
|
||||
@ -137,6 +139,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@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",
|
||||
|
664
pnpm-lock.yaml
generated
664
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@
|
||||
|
||||
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCredits } from '@/hooks/use-credits';
|
||||
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits';
|
||||
import { CoinsIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@ -10,24 +10,28 @@ import { toast } from 'sonner';
|
||||
const CONSUME_CREDITS = 50;
|
||||
|
||||
export function ConsumeCreditCard() {
|
||||
const { consumeCredits, hasEnoughCredits, isLoading } = useCredits();
|
||||
const { data: balance = 0, isLoading: isLoadingBalance } = useCreditBalance();
|
||||
const consumeCreditsMutation = useConsumeCredits();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const hasEnoughCredits = (amount: number) => balance >= amount;
|
||||
|
||||
const handleConsume = async () => {
|
||||
if (!hasEnoughCredits(CONSUME_CREDITS)) {
|
||||
toast.error('Insufficient credits, please buy more credits.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const success = await consumeCredits(
|
||||
CONSUME_CREDITS,
|
||||
`AI Text Credit Consumption (${CONSUME_CREDITS} credits)`
|
||||
);
|
||||
setLoading(false);
|
||||
if (success) {
|
||||
try {
|
||||
await consumeCreditsMutation.mutateAsync({
|
||||
amount: CONSUME_CREDITS,
|
||||
description: `AI Text Credit Consumption (${CONSUME_CREDITS} credits)`,
|
||||
});
|
||||
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
|
||||
} else {
|
||||
} catch (error) {
|
||||
toast.error('Failed to consume credits, please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -40,7 +44,9 @@ export function ConsumeCreditCard() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleConsume}
|
||||
disabled={isLoading || loading}
|
||||
disabled={
|
||||
loading || isLoadingBalance || consumeCreditsMutation.isPending
|
||||
}
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<CoinsIcon className="size-4" />
|
||||
|
@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ActiveThemeProvider } from '@/components/layout/active-theme-provider';
|
||||
import { CreditsProvider } from '@/components/layout/credits-provider';
|
||||
import { PaymentProvider } from '@/components/layout/payment-provider';
|
||||
import { QueryProvider } from '@/components/providers/query-provider';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import type { Translations } from 'fumadocs-ui/i18n';
|
||||
@ -54,21 +53,19 @@ export function Providers({ children, locale }: ProvidersProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme={defaultMode}
|
||||
enableSystem={true}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ActiveThemeProvider>
|
||||
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
|
||||
<TooltipProvider>
|
||||
<PaymentProvider>
|
||||
<CreditsProvider>{children}</CreditsProvider>
|
||||
</PaymentProvider>
|
||||
</TooltipProvider>
|
||||
</RootProvider>
|
||||
</ActiveThemeProvider>
|
||||
</ThemeProvider>
|
||||
<QueryProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme={defaultMode}
|
||||
enableSystem={true}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ActiveThemeProvider>
|
||||
<RootProvider theme={theme} i18n={{ locale, locales, translations }}>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</RootProvider>
|
||||
</ActiveThemeProvider>
|
||||
</ThemeProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
@ -21,13 +20,12 @@ import {
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { useBanUser, useUnbanUser } from '@/hooks/use-users';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { isDemoWebsite } from '@/lib/demo';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
import { getStripeDashboardCustomerUrl } from '@/lib/urls/urls';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUsersStore } from '@/stores/users-store';
|
||||
import {
|
||||
CalendarIcon,
|
||||
Loader2Icon,
|
||||
@ -47,11 +45,13 @@ interface UserDetailViewerProps {
|
||||
export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const isMobile = useIsMobile();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [banReason, setBanReason] = useState(t('ban.defaultReason'));
|
||||
const [banExpiresAt, setBanExpiresAt] = useState<Date | undefined>();
|
||||
const triggerRefresh = useUsersStore((state) => state.triggerRefresh);
|
||||
|
||||
// TanStack Query mutations
|
||||
const banUserMutation = useBanUser();
|
||||
const unbanUserMutation = useUnbanUser();
|
||||
|
||||
// show fake data in demo website
|
||||
const isDemo = isDemoWebsite();
|
||||
@ -67,11 +67,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await authClient.admin.banUser({
|
||||
await banUserMutation.mutateAsync({
|
||||
userId: user.id,
|
||||
banReason,
|
||||
banExpiresIn: banExpiresAt
|
||||
@ -83,15 +82,11 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
// Reset form
|
||||
setBanReason('');
|
||||
setBanExpiresAt(undefined);
|
||||
// Trigger refresh
|
||||
triggerRefresh();
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error('Failed to ban user:', error);
|
||||
setError(error.message || t('ban.error'));
|
||||
toast.error(error.message || t('ban.error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -101,24 +96,19 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await authClient.admin.unbanUser({
|
||||
await unbanUserMutation.mutateAsync({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
toast.success(t('unban.success'));
|
||||
// Trigger refresh
|
||||
triggerRefresh();
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error('Failed to unban user:', error);
|
||||
setError(error.message || t('unban.error'));
|
||||
toast.error(error.message || t('unban.error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -166,7 +156,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
{user.role === 'admin' ? t('admin') : t('user')}
|
||||
</Badge>
|
||||
{/* email verified */}
|
||||
<Badge variant="outline" className="px-1.5 hover:bg-accent">
|
||||
{/* <Badge variant="outline" className="px-1.5 hover:bg-accent">
|
||||
{user.emailVerified ? (
|
||||
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
||||
) : (
|
||||
@ -175,7 +165,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
{user.emailVerified
|
||||
? t('email.verified')
|
||||
: t('email.unverified')}
|
||||
</Badge>
|
||||
</Badge> */}
|
||||
|
||||
{/* user banned */}
|
||||
<div className="flex items-center gap-2">
|
||||
@ -196,15 +186,23 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t('columns.email')}:
|
||||
</span>
|
||||
<span
|
||||
className="break-words cursor-pointer hover:bg-accent px-2 py-1 rounded border"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(user.email!);
|
||||
toast.success(t('emailCopied'));
|
||||
}}
|
||||
>
|
||||
{user.email}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm px-1.5 cursor-pointer hover:bg-accent"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(user.email);
|
||||
toast.success(t('emailCopied'));
|
||||
}}
|
||||
>
|
||||
{user.emailVerified ? (
|
||||
<MailCheckIcon className="stroke-green-500 dark:stroke-green-400" />
|
||||
) : (
|
||||
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
|
||||
)}
|
||||
{user.email}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -256,10 +254,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleUnban}
|
||||
disabled={isLoading || isDemo}
|
||||
disabled={unbanUserMutation.isPending || isDemo}
|
||||
className="mt-4 cursor-pointer"
|
||||
>
|
||||
{isLoading && (
|
||||
{unbanUserMutation.isPending && (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t('unban.button')}
|
||||
@ -315,10 +313,10 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={isLoading || !banReason || isDemo}
|
||||
disabled={banUserMutation.isPending || !banReason || isDemo}
|
||||
className="mt-4 cursor-pointer"
|
||||
>
|
||||
{isLoading && (
|
||||
{banUserMutation.isPending && (
|
||||
<Loader2Icon className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t('ban.button')}
|
||||
|
@ -1,74 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { getUsersAction } from '@/actions/get-users';
|
||||
import { UsersTable } from '@/components/admin/users-table';
|
||||
import type { User } from '@/lib/auth-types';
|
||||
import { useUsersStore } from '@/stores/users-store';
|
||||
import { useUsers } from '@/hooks/use-users';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function UsersPageClient() {
|
||||
const t = useTranslations('Dashboard.admin.users');
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [data, setData] = useState<User[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
const refreshTrigger = useUsersStore((state) => state.refreshTrigger);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getUsersAction({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting,
|
||||
});
|
||||
|
||||
if (result?.data?.success) {
|
||||
setData(result.data.data?.items || []);
|
||||
setTotal(result.data.data?.total || 0);
|
||||
} else {
|
||||
const errorMessage = result?.data?.error || t('error');
|
||||
toast.error(errorMessage);
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
toast.error(t('error'));
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pageIndex, pageSize, search, sorting, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
const { data, isLoading } = useUsers(pageIndex, pageSize, search, sorting);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UsersTable
|
||||
data={data}
|
||||
total={total}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
loading={loading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSortingChange={setSorting}
|
||||
/>
|
||||
</>
|
||||
<UsersTable
|
||||
data={data?.items || []}
|
||||
total={data?.total || 0}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
loading={isLoading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSortingChange={setSorting}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -23,7 +23,6 @@ import { useLocalePathname, useLocaleRouter } from '@/i18n/navigation';
|
||||
import { LOCALES, routing } from '@/i18n/routing';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { usePaymentStore } from '@/stores/payment-store';
|
||||
import type { User } from 'better-auth';
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
@ -55,7 +54,6 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
||||
const pathname = useLocalePathname();
|
||||
const params = useParams();
|
||||
const { currentLocale, setCurrentLocale } = useLocaleStore();
|
||||
const { resetState } = usePaymentStore();
|
||||
const [, startTransition] = useTransition();
|
||||
const t = useTranslations();
|
||||
|
||||
@ -81,8 +79,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
console.log('sign out success');
|
||||
// Reset payment state on sign out
|
||||
resetState();
|
||||
// TanStack Query automatically handles cache invalidation on sign out
|
||||
router.replace('/');
|
||||
},
|
||||
onError: (error) => {
|
||||
@ -100,7 +97,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="cursor-pointer data-[state=open]:bg-sidebar-accent
|
||||
className="cursor-pointer data-[state=open]:bg-sidebar-accent
|
||||
data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<UserAvatar
|
||||
|
@ -9,8 +9,9 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { usePayment } from '@/hooks/use-payment';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { LocaleLink } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Routes } from '@/routes';
|
||||
import { SparklesIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@ -23,14 +24,16 @@ export function UpgradeCard() {
|
||||
|
||||
const t = useTranslations('Dashboard.upgrade');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { isLoading, currentPlan, subscription } = usePayment();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: paymentData, isLoading } = useCurrentPlan(session?.user?.id);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Don't show the upgrade card if the user has a lifetime membership or a subscription
|
||||
const isMember = currentPlan?.isLifetime || !!subscription;
|
||||
const isMember =
|
||||
paymentData?.currentPlan?.isLifetime || !!paymentData?.subscription;
|
||||
|
||||
if (!mounted || isLoading || isMember) {
|
||||
return null;
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useCredits } from '@/hooks/use-credits';
|
||||
import { useCreditBalance } from '@/hooks/use-credits';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { Routes } from '@/routes';
|
||||
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||
@ -15,8 +15,8 @@ export function CreditsBalanceButton() {
|
||||
|
||||
const router = useLocaleRouter();
|
||||
|
||||
// Use the new useCredits hook
|
||||
const { balance, isLoading } = useCredits();
|
||||
// Use TanStack Query hook for credit balance
|
||||
const { data: balance = 0, isLoading } = useCreditBalance();
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(Routes.SettingsCredits);
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useCredits } from '@/hooks/use-credits';
|
||||
import { useCreditBalance } from '@/hooks/use-credits';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { Routes } from '@/routes';
|
||||
import { CoinsIcon, Loader2Icon } from 'lucide-react';
|
||||
@ -16,8 +16,8 @@ export function CreditsBalanceMenu() {
|
||||
const t = useTranslations('Marketing.avatar');
|
||||
const router = useLocaleRouter();
|
||||
|
||||
// Use the new useCredits hook
|
||||
const { balance, isLoading } = useCredits();
|
||||
// Use TanStack Query hook for credit balance
|
||||
const { data: balance = 0, isLoading } = useCreditBalance();
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(Routes.SettingsCredits);
|
||||
|
@ -1,31 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { useCreditsStore } from '@/stores/credits-store';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Credits Provider Component
|
||||
*
|
||||
* This component initializes the credits store when the user is authenticated
|
||||
* and handles cleanup when the user logs out.
|
||||
* This component is now simplified since TanStack Query handles data fetching automatically.
|
||||
* It's kept for potential future credits-related providers.
|
||||
* Only renders when credits are enabled in the website configuration.
|
||||
*/
|
||||
export function CreditsProvider({ children }: { children: React.ReactNode }) {
|
||||
// Only initialize credits store if credits are enabled
|
||||
// Only render when credits are enabled
|
||||
if (!websiteConfig.credits.enableCredits) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const { fetchCredits } = useCreditsStore();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
fetchCredits(session.user);
|
||||
}
|
||||
}, [session?.user, fetchCredits]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { usePaymentStore } from '@/stores/payment-store';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Payment provider component
|
||||
*
|
||||
* This component is responsible for initializing the payment state
|
||||
* by fetching the current user's subscription and payment information when the app loads.
|
||||
*/
|
||||
export function PaymentProvider({ children }: { children: React.ReactNode }) {
|
||||
const { fetchPayment } = usePaymentStore();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
fetchPayment(session.user);
|
||||
}
|
||||
}, [session?.user, fetchPayment]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
@ -13,7 +13,6 @@ import {
|
||||
import { getAvatarLinks } from '@/config/avatar-config';
|
||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { usePaymentStore } from '@/stores/payment-store';
|
||||
import type { User } from 'better-auth';
|
||||
import { LogOutIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@ -29,8 +28,6 @@ export function UserButtonMobile({ user }: UserButtonProps) {
|
||||
const avatarLinks = getAvatarLinks();
|
||||
const localeRouter = useLocaleRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { resetState } = usePaymentStore();
|
||||
|
||||
const closeDrawer = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
@ -40,8 +37,7 @@ export function UserButtonMobile({ user }: UserButtonProps) {
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
console.log('sign out success');
|
||||
// Reset payment state on sign out
|
||||
resetState();
|
||||
// TanStack Query automatically handles cache invalidation on sign out
|
||||
localeRouter.replace('/');
|
||||
},
|
||||
onError: (error) => {
|
||||
@ -64,7 +60,7 @@ export function UserButtonMobile({ user }: UserButtonProps) {
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay className="fixed inset-0 z-40 bg-background/50" />
|
||||
<DrawerContent
|
||||
className="fixed inset-x-0 bottom-0 z-50 mt-24
|
||||
className="fixed inset-x-0 bottom-0 z-50 mt-24
|
||||
overflow-hidden rounded-t-[10px] border bg-background px-3 text-sm"
|
||||
>
|
||||
<DrawerHeader>
|
||||
|
@ -12,7 +12,6 @@ import { getAvatarLinks } from '@/config/avatar-config';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { usePaymentStore } from '@/stores/payment-store';
|
||||
import type { User } from 'better-auth';
|
||||
import { LogOutIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@ -29,15 +28,12 @@ export function UserButton({ user }: UserButtonProps) {
|
||||
const avatarLinks = getAvatarLinks();
|
||||
const localeRouter = useLocaleRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { resetState } = usePaymentStore();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
console.log('sign out success');
|
||||
// Reset payment state on sign out
|
||||
resetState();
|
||||
// TanStack Query automatically handles cache invalidation on sign out
|
||||
localeRouter.replace('/');
|
||||
},
|
||||
onError: (error) => {
|
||||
|
36
src/components/providers/query-provider.tsx
Normal file
36
src/components/providers/query-provider.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface QueryProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function QueryProvider({ children }: QueryProviderProps) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - default stale time
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes - default garbage collection time
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
@ -14,7 +14,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { getPricePlans } from '@/config/price-config';
|
||||
import { useMounted } from '@/hooks/use-mounted';
|
||||
import { usePayment } from '@/hooks/use-payment';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { formatDate } from '@/lib/formatter';
|
||||
@ -33,34 +33,37 @@ export default function BillingCard() {
|
||||
const hasHandledSession = useRef(false);
|
||||
const mounted = useMounted();
|
||||
|
||||
const {
|
||||
isLoading: isLoadingPayment,
|
||||
error: loadPaymentError,
|
||||
subscription,
|
||||
currentPlan: currentPlanFromStore,
|
||||
fetchPayment,
|
||||
} = usePayment();
|
||||
|
||||
// Get user session for customer ID
|
||||
const { data: session, isPending: isLoadingSession } =
|
||||
authClient.useSession();
|
||||
const currentUser = session?.user;
|
||||
|
||||
// TanStack Query hook for current plan and subscription
|
||||
const {
|
||||
data: paymentData,
|
||||
isLoading: isLoadingPayment,
|
||||
error: loadPaymentError,
|
||||
refetch: refetchPayment,
|
||||
} = useCurrentPlan(currentUser?.id);
|
||||
|
||||
const currentPlan = paymentData?.currentPlan;
|
||||
const subscription = paymentData?.subscription;
|
||||
|
||||
// Get price plans with translations - must be called here to maintain hook order
|
||||
const pricePlans = getPricePlans();
|
||||
const plans = Object.values(pricePlans);
|
||||
|
||||
// Convert current plan from store to a plan with translations
|
||||
const currentPlan = currentPlanFromStore
|
||||
? plans.find((plan) => plan.id === currentPlanFromStore?.id)
|
||||
// Convert current plan to a plan with translations
|
||||
const currentPlanWithTranslations = currentPlan
|
||||
? plans.find((plan) => plan.id === currentPlan?.id)
|
||||
: null;
|
||||
const isFreePlan = currentPlan?.isFree || false;
|
||||
const isLifetimeMember = currentPlan?.isLifetime || false;
|
||||
const isFreePlan = currentPlanWithTranslations?.isFree || false;
|
||||
const isLifetimeMember = currentPlanWithTranslations?.isLifetime || false;
|
||||
|
||||
// Get subscription price details
|
||||
const currentPrice =
|
||||
subscription &&
|
||||
currentPlan?.prices.find(
|
||||
currentPlanWithTranslations?.prices.find(
|
||||
(price) => price.priceId === subscription?.priceId
|
||||
);
|
||||
|
||||
@ -77,8 +80,8 @@ export default function BillingCard() {
|
||||
// Retry payment data fetching
|
||||
const handleRetry = useCallback(() => {
|
||||
// console.log('handleRetry, refetch payment info');
|
||||
fetchPayment(true);
|
||||
}, [fetchPayment]);
|
||||
refetchPayment();
|
||||
}, [refetchPayment]);
|
||||
|
||||
// Check for payment success and show success message
|
||||
useEffect(() => {
|
||||
@ -132,7 +135,9 @@ export default function BillingCard() {
|
||||
<CardDescription>{t('currentPlan.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
<div className="text-destructive text-sm">{loadPaymentError}</div>
|
||||
<div className="text-destructive text-sm">
|
||||
{loadPaymentError?.message}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-2 px-6 py-4 flex justify-end items-center bg-background rounded-none">
|
||||
<Button
|
||||
@ -149,7 +154,7 @@ export default function BillingCard() {
|
||||
}
|
||||
|
||||
// currentPlan maybe null, so we need to check if it is null
|
||||
if (!currentPlan) {
|
||||
if (!currentPlanWithTranslations) {
|
||||
return (
|
||||
<Card className={cn('w-full overflow-hidden pt-6 pb-0 flex flex-col')}>
|
||||
<CardHeader>
|
||||
@ -187,7 +192,9 @@ export default function BillingCard() {
|
||||
<CardContent className="space-y-4 flex-1">
|
||||
{/* Plan name and status */}
|
||||
<div className="flex items-center justify-start space-x-4">
|
||||
<div className="text-3xl font-medium">{currentPlan?.name}</div>
|
||||
<div className="text-3xl font-medium">
|
||||
{currentPlanWithTranslations?.name}
|
||||
</div>
|
||||
{subscription &&
|
||||
(subscription.status === 'trialing' ||
|
||||
subscription.status === 'active') && (
|
||||
|
@ -11,7 +11,8 @@ import {
|
||||
import { getCreditPackages } from '@/config/credits-config';
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { useCurrentUser } from '@/hooks/use-current-user';
|
||||
import { usePayment } from '@/hooks/use-payment';
|
||||
import { useCurrentPlan } from '@/hooks/use-payment';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { formatPrice } from '@/lib/formatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CircleCheckBigIcon, CoinsIcon } from 'lucide-react';
|
||||
@ -31,7 +32,9 @@ export function CreditPackages() {
|
||||
|
||||
// Get current user and payment info
|
||||
const currentUser = useCurrentUser();
|
||||
const { currentPlan } = usePayment();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: paymentData } = useCurrentPlan(session?.user?.id);
|
||||
const currentPlan = paymentData?.currentPlan;
|
||||
|
||||
// Get credit packages with translations - must be called here to maintain hook order
|
||||
const creditPackages = Object.values(getCreditPackages()).filter(
|
||||
|
@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
|
||||
import type { CreditTransaction } from '@/components/settings/credits/credit-transactions-table';
|
||||
import { CreditTransactionsTable } from '@/components/settings/credits/credit-transactions-table';
|
||||
import { useCreditTransactions } from '@/hooks/use-credits';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Credit transactions component
|
||||
@ -16,57 +14,25 @@ export function CreditTransactions() {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [data, setData] = useState<CreditTransaction[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'createdAt', desc: true },
|
||||
]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getCreditTransactionsAction({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting,
|
||||
});
|
||||
|
||||
if (result?.data?.success) {
|
||||
setData(result.data.data?.items || []);
|
||||
setTotal(result.data.data?.total || 0);
|
||||
} else {
|
||||
const errorMessage = result?.data?.error || t('error');
|
||||
toast.error(errorMessage);
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'CreditTransactions, fetch credit transactions error:',
|
||||
error
|
||||
);
|
||||
toast.error(t('error'));
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pageIndex, pageSize, search, sorting]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
const { data, isLoading } = useCreditTransactions(
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting
|
||||
);
|
||||
|
||||
return (
|
||||
<CreditTransactionsTable
|
||||
data={data}
|
||||
total={total}
|
||||
data={data?.items || []}
|
||||
total={data?.total || 0}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
loading={loading}
|
||||
loading={isLoading}
|
||||
onSearch={setSearch}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={setPageSize}
|
||||
|
@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { getCreditStatsAction } from '@/actions/get-credit-stats';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@ -12,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
|
||||
|
@ -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">
|
||||
|
@ -1,66 +1,121 @@
|
||||
import { websiteConfig } from '@/config/website';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { useCreditsStore } from '@/stores/credits-store';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { consumeCreditsAction } from '@/actions/consume-credits';
|
||||
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
|
||||
import { getCreditStatsAction } from '@/actions/get-credit-stats';
|
||||
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
|
||||
/**
|
||||
* Hook for accessing and managing credits state
|
||||
*
|
||||
* This hook provides access to the credits state and methods to manage it.
|
||||
* It also automatically fetches credits information when the user changes.
|
||||
* Only works when credits are enabled in the website configuration.
|
||||
*/
|
||||
export function useCredits() {
|
||||
// Return default values if credits are disabled
|
||||
if (!websiteConfig.credits.enableCredits) {
|
||||
return {
|
||||
balance: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
fetchCredits: () => Promise.resolve(),
|
||||
consumeCredits: () => Promise.resolve(false),
|
||||
hasEnoughCredits: () => false,
|
||||
};
|
||||
}
|
||||
// Query keys
|
||||
export const creditsKeys = {
|
||||
all: ['credits'] as const,
|
||||
balance: () => [...creditsKeys.all, 'balance'] as const,
|
||||
stats: () => [...creditsKeys.all, 'stats'] as const,
|
||||
transactions: () => [...creditsKeys.all, 'transactions'] as const,
|
||||
transactionsList: (filters: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
sorting: SortingState;
|
||||
}) => [...creditsKeys.transactions(), filters] as const,
|
||||
};
|
||||
|
||||
const {
|
||||
balance,
|
||||
isLoading,
|
||||
error,
|
||||
fetchCredits: fetchCreditsFromStore,
|
||||
consumeCredits,
|
||||
} = useCreditsStore();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const fetchCredits = useCallback(
|
||||
(force = false) => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
fetchCreditsFromStore(currentUser, force);
|
||||
// Hook to fetch credit balance
|
||||
export function useCreditBalance() {
|
||||
return useQuery({
|
||||
queryKey: creditsKeys.balance(),
|
||||
queryFn: async () => {
|
||||
console.log('Fetching credit balance...');
|
||||
const result = await getCreditBalanceAction();
|
||||
if (!result?.data?.success) {
|
||||
throw new Error('Failed to fetch credit balance');
|
||||
}
|
||||
console.log('Credit balance fetched:', result.data.credits);
|
||||
return result.data.credits || 0;
|
||||
},
|
||||
[session?.user, fetchCreditsFromStore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
fetchCreditsFromStore(currentUser);
|
||||
}
|
||||
}, [session?.user, fetchCreditsFromStore]);
|
||||
|
||||
return {
|
||||
// State
|
||||
balance,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Methods
|
||||
fetchCredits,
|
||||
consumeCredits,
|
||||
|
||||
// Helper methods
|
||||
hasEnoughCredits: (amount: number) => balance >= amount,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to fetch credit statistics
|
||||
export function useCreditStats() {
|
||||
return useQuery({
|
||||
queryKey: creditsKeys.stats(),
|
||||
queryFn: async () => {
|
||||
console.log('Fetching credit stats...');
|
||||
const result = await getCreditStatsAction();
|
||||
if (!result?.data?.success) {
|
||||
throw new Error(result?.data?.error || 'Failed to fetch credit stats');
|
||||
}
|
||||
console.log('Credit stats fetched:', result.data.data);
|
||||
return result.data.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to consume credits
|
||||
export function useConsumeCredits() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
amount,
|
||||
description,
|
||||
}: {
|
||||
amount: number;
|
||||
description: string;
|
||||
}) => {
|
||||
const result = await consumeCreditsAction({
|
||||
amount,
|
||||
description,
|
||||
});
|
||||
if (!result?.data?.success) {
|
||||
throw new Error(result?.data?.error || 'Failed to consume credits');
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate credit balance and stats after consuming credits
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: creditsKeys.balance(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: creditsKeys.stats(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to fetch credit transactions with pagination, search, and sorting
|
||||
export function useCreditTransactions(
|
||||
pageIndex: number,
|
||||
pageSize: number,
|
||||
search: string,
|
||||
sorting: SortingState
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: creditsKeys.transactionsList({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
const result = await getCreditTransactionsAction({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting,
|
||||
});
|
||||
|
||||
if (!result?.data?.success) {
|
||||
throw new Error(
|
||||
result?.data?.error || 'Failed to fetch credit transactions'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
items: result.data.data?.items || [],
|
||||
total: result.data.data?.total || 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
77
src/hooks/use-newsletter.ts
Normal file
77
src/hooks/use-newsletter.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { checkNewsletterStatusAction } from '@/actions/check-newsletter-status';
|
||||
import { subscribeNewsletterAction } from '@/actions/subscribe-newsletter';
|
||||
import { unsubscribeNewsletterAction } from '@/actions/unsubscribe-newsletter';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
// Query keys
|
||||
export const newsletterKeys = {
|
||||
all: ['newsletter'] as const,
|
||||
status: (email: string) => [...newsletterKeys.all, 'status', email] as const,
|
||||
};
|
||||
|
||||
// Hook to check newsletter subscription status
|
||||
export function useNewsletterStatus(email: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: newsletterKeys.status(email || ''),
|
||||
queryFn: async () => {
|
||||
if (!email) {
|
||||
throw new Error('Email is required');
|
||||
}
|
||||
const result = await checkNewsletterStatusAction({ email });
|
||||
if (!result?.data?.success) {
|
||||
throw new Error(
|
||||
result?.data?.error || 'Failed to check newsletter status'
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: !!email,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to subscribe to newsletter
|
||||
export function useSubscribeNewsletter() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (email: string) => {
|
||||
const result = await subscribeNewsletterAction({ email });
|
||||
if (!result?.data?.success) {
|
||||
throw new Error(
|
||||
result?.data?.error || 'Failed to subscribe to newsletter'
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: (_, email) => {
|
||||
// Invalidate and refetch the newsletter status
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: newsletterKeys.status(email),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to unsubscribe from newsletter
|
||||
export function useUnsubscribeNewsletter() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (email: string) => {
|
||||
const result = await unsubscribeNewsletterAction({ email });
|
||||
if (!result?.data?.success) {
|
||||
throw new Error(
|
||||
result?.data?.error || 'Failed to unsubscribe from newsletter'
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: (_, email) => {
|
||||
// Invalidate and refetch the newsletter status
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: newsletterKeys.status(email),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
@ -1,49 +1,107 @@
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { usePaymentStore } from '@/stores/payment-store';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { getActiveSubscriptionAction } from '@/actions/get-active-subscription';
|
||||
import { getLifetimeStatusAction } from '@/actions/get-lifetime-status';
|
||||
import { getAllPricePlans } from '@/lib/price-plan';
|
||||
import type { PricePlan, Subscription } from '@/payment/types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* Hook for accessing and managing payment state
|
||||
*
|
||||
* This hook provides access to the payment state and methods to manage it.
|
||||
* It also automatically fetches payment information when the user changes.
|
||||
*/
|
||||
export function usePayment() {
|
||||
const {
|
||||
currentPlan,
|
||||
subscription,
|
||||
isLoading,
|
||||
error,
|
||||
fetchPayment: fetchPaymentFromStore,
|
||||
} = usePaymentStore();
|
||||
// Query keys
|
||||
export const paymentKeys = {
|
||||
all: ['payment'] as const,
|
||||
subscription: (userId: string) =>
|
||||
[...paymentKeys.all, 'subscription', userId] as const,
|
||||
lifetime: (userId: string) =>
|
||||
[...paymentKeys.all, 'lifetime', userId] as const,
|
||||
currentPlan: (userId: string) =>
|
||||
[...paymentKeys.all, 'currentPlan', userId] as const,
|
||||
};
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const fetchPayment = useCallback(
|
||||
(force = false) => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
fetchPaymentFromStore(currentUser, force);
|
||||
// Hook to fetch active subscription
|
||||
export function useActiveSubscription(userId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: paymentKeys.subscription(userId || ''),
|
||||
queryFn: async (): Promise<Subscription | null> => {
|
||||
if (!userId) {
|
||||
throw new Error('User ID is required');
|
||||
}
|
||||
const result = await getActiveSubscriptionAction({ userId });
|
||||
if (!result?.data?.success) {
|
||||
throw new Error(result?.data?.error || 'Failed to fetch subscription');
|
||||
}
|
||||
return result.data.data || null;
|
||||
},
|
||||
[session?.user, fetchPaymentFromStore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentUser = session?.user;
|
||||
if (currentUser) {
|
||||
fetchPaymentFromStore(currentUser);
|
||||
}
|
||||
}, [session?.user, fetchPaymentFromStore]);
|
||||
|
||||
return {
|
||||
// State
|
||||
currentPlan,
|
||||
subscription,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Methods
|
||||
fetchPayment,
|
||||
};
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to fetch lifetime status
|
||||
export function useLifetimeStatus(userId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: paymentKeys.lifetime(userId || ''),
|
||||
queryFn: async (): Promise<boolean> => {
|
||||
if (!userId) {
|
||||
throw new Error('User ID is required');
|
||||
}
|
||||
const result = await getLifetimeStatusAction({ userId });
|
||||
if (!result?.data?.success) {
|
||||
throw new Error(
|
||||
result?.data?.error || 'Failed to fetch lifetime status'
|
||||
);
|
||||
}
|
||||
return result.data.isLifetimeMember || false;
|
||||
},
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to get current plan based on subscription and lifetime status
|
||||
export function useCurrentPlan(userId: string | undefined) {
|
||||
const {
|
||||
data: subscription,
|
||||
isLoading: isLoadingSubscription,
|
||||
error: subscriptionError,
|
||||
} = useActiveSubscription(userId);
|
||||
const {
|
||||
data: isLifetimeMember,
|
||||
isLoading: isLoadingLifetime,
|
||||
error: lifetimeError,
|
||||
} = useLifetimeStatus(userId);
|
||||
|
||||
return useQuery({
|
||||
queryKey: paymentKeys.currentPlan(userId || ''),
|
||||
queryFn: async (): Promise<{
|
||||
currentPlan: PricePlan | null;
|
||||
subscription: Subscription | null;
|
||||
}> => {
|
||||
const plans: PricePlan[] = getAllPricePlans();
|
||||
const freePlan = plans.find((plan) => plan.isFree);
|
||||
const lifetimePlan = plans.find((plan) => plan.isLifetime);
|
||||
|
||||
// If lifetime member, return lifetime plan
|
||||
if (isLifetimeMember) {
|
||||
return {
|
||||
currentPlan: lifetimePlan || null,
|
||||
subscription: null,
|
||||
};
|
||||
}
|
||||
|
||||
// If has active subscription, find the corresponding plan
|
||||
if (subscription) {
|
||||
const plan =
|
||||
plans.find((p) =>
|
||||
p.prices.find((price) => price.priceId === subscription.priceId)
|
||||
) || null;
|
||||
return {
|
||||
currentPlan: plan,
|
||||
subscription,
|
||||
};
|
||||
}
|
||||
|
||||
// Default to free plan
|
||||
return {
|
||||
currentPlan: freePlan || null,
|
||||
subscription: null,
|
||||
};
|
||||
},
|
||||
enabled: !!userId && !isLoadingSubscription && !isLoadingLifetime,
|
||||
});
|
||||
}
|
||||
|
93
src/hooks/use-users.ts
Normal file
93
src/hooks/use-users.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { getUsersAction } from '@/actions/get-users';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
|
||||
// Query keys
|
||||
export const usersKeys = {
|
||||
all: ['users'] as const,
|
||||
lists: () => [...usersKeys.all, 'lists'] as const,
|
||||
list: (filters: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
sorting: SortingState;
|
||||
}) => [...usersKeys.lists(), filters] as const,
|
||||
};
|
||||
|
||||
// Hook to fetch users with pagination, search, and sorting
|
||||
export function useUsers(
|
||||
pageIndex: number,
|
||||
pageSize: number,
|
||||
search: string,
|
||||
sorting: SortingState
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: usersKeys.list({ pageIndex, pageSize, search, sorting }),
|
||||
queryFn: async () => {
|
||||
const result = await getUsersAction({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
search,
|
||||
sorting,
|
||||
});
|
||||
|
||||
if (!result?.data?.success) {
|
||||
throw new Error(result?.data?.error || 'Failed to fetch users');
|
||||
}
|
||||
|
||||
return {
|
||||
items: result.data.data?.items || [],
|
||||
total: result.data.data?.total || 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to ban user
|
||||
export function useBanUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
userId,
|
||||
banReason,
|
||||
banExpiresIn,
|
||||
}: {
|
||||
userId: string;
|
||||
banReason: string;
|
||||
banExpiresIn?: number;
|
||||
}) => {
|
||||
return authClient.admin.banUser({
|
||||
userId,
|
||||
banReason,
|
||||
banExpiresIn,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate all users queries to refresh the data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: usersKeys.all,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to unban user
|
||||
export function useUnbanUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId }: { userId: string }) => {
|
||||
return authClient.admin.unbanUser({
|
||||
userId,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate all users queries to refresh the data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: usersKeys.all,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
@ -1,163 +0,0 @@
|
||||
import { consumeCreditsAction } from '@/actions/consume-credits';
|
||||
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
|
||||
import type { Session } from '@/lib/auth-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Cache duration: 2 minutes (optimized for better UX)
|
||||
const CACHE_DURATION = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Credits state interface
|
||||
*/
|
||||
export interface CreditsState {
|
||||
// Current credit balance
|
||||
balance: number;
|
||||
// Loading state
|
||||
isLoading: boolean;
|
||||
// Error state
|
||||
error: string | null;
|
||||
// Last fetch timestamp to avoid frequent requests
|
||||
lastFetchTime: number | null;
|
||||
|
||||
// Actions
|
||||
fetchCredits: (
|
||||
user: Session['user'] | null | undefined,
|
||||
force?: boolean
|
||||
) => Promise<void>;
|
||||
consumeCredits: (amount: number, description: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits store using Zustand
|
||||
* Manages the user's credit balance globally with caching and optimistic updates
|
||||
*/
|
||||
export const useCreditsStore = create<CreditsState>((set, get) => ({
|
||||
// Initial state
|
||||
balance: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
|
||||
/**
|
||||
* Fetch credit balance for the current user with optional cache bypass
|
||||
* @param user Current user from auth session
|
||||
* @param force Whether to force refresh and ignore cache
|
||||
*/
|
||||
fetchCredits: async (user, force = false) => {
|
||||
// Skip if already loading
|
||||
if (get().isLoading) return;
|
||||
|
||||
// Skip if no user is provided
|
||||
if (!user) {
|
||||
set({
|
||||
balance: 0,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have recent data (within cache duration) unless force refresh
|
||||
if (!force) {
|
||||
const { lastFetchTime } = get();
|
||||
const now = Date.now();
|
||||
if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
|
||||
return; // Use cached data
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`fetchCredits, ${force ? 'force fetch' : 'fetch'} credits`);
|
||||
set({
|
||||
isLoading: true,
|
||||
error: null,
|
||||
// Clear cache if force refresh
|
||||
lastFetchTime: force ? null : get().lastFetchTime,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await getCreditBalanceAction();
|
||||
if (result?.data?.success && result.data.credits !== undefined) {
|
||||
const newBalance = result.data.credits || 0;
|
||||
console.log('fetchCredits, set new balance', newBalance);
|
||||
set({
|
||||
balance: newBalance,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
} else {
|
||||
console.warn('fetchCredits, failed to fetch credit balance', result);
|
||||
set({
|
||||
error:
|
||||
(result?.data as any)?.error || 'Failed to fetch credit balance',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('fetchCredits, error:', error);
|
||||
set({
|
||||
error: 'Failed to fetch credit balance',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Consume credits with optimistic updates
|
||||
* @param amount Amount of credits to consume
|
||||
* @param description Description for the transaction
|
||||
* @returns Promise<boolean> Success status
|
||||
*/
|
||||
consumeCredits: async (amount: number, description: string) => {
|
||||
const { balance } = get();
|
||||
|
||||
// Check if we have enough credits
|
||||
if (balance < amount) {
|
||||
console.log('consumeCredits, insufficient credits', balance, amount);
|
||||
set({
|
||||
error: 'Insufficient credits',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Optimistically update the balance
|
||||
set({
|
||||
balance: balance - amount,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await consumeCreditsAction({
|
||||
amount,
|
||||
description,
|
||||
});
|
||||
|
||||
if (result?.data?.success) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Revert optimistic update on failure
|
||||
console.warn('consumeCredits, reverting optimistic update');
|
||||
set({
|
||||
balance: balance, // Revert to original balance
|
||||
error: result?.data?.error || 'Failed to consume credits',
|
||||
isLoading: false,
|
||||
});
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('consumeCredits, error:', error);
|
||||
// Revert optimistic update on error
|
||||
set({
|
||||
balance: balance, // Revert to original balance
|
||||
error: 'Failed to consume credits',
|
||||
isLoading: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
@ -1,180 +0,0 @@
|
||||
import { getActiveSubscriptionAction } from '@/actions/get-active-subscription';
|
||||
import { getLifetimeStatusAction } from '@/actions/get-lifetime-status';
|
||||
import type { Session } from '@/lib/auth-types';
|
||||
import { getAllPricePlans } from '@/lib/price-plan';
|
||||
import type { PricePlan, Subscription } from '@/payment/types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Cache duration: 2 minutes (optimized for better UX)
|
||||
const CACHE_DURATION = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Payment state interface
|
||||
*/
|
||||
export interface PaymentState {
|
||||
// Current plan
|
||||
currentPlan: PricePlan | null;
|
||||
// Active subscription
|
||||
subscription: Subscription | null;
|
||||
// Loading state
|
||||
isLoading: boolean;
|
||||
// Error state
|
||||
error: string | null;
|
||||
// Last fetch timestamp to avoid frequent requests
|
||||
lastFetchTime: number | null;
|
||||
|
||||
// Actions
|
||||
fetchPayment: (
|
||||
user: Session['user'] | null | undefined,
|
||||
force?: boolean
|
||||
) => Promise<void>;
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment store using Zustand
|
||||
* Manages the user's payment and subscription data globally
|
||||
*/
|
||||
export const usePaymentStore = create<PaymentState>((set, get) => ({
|
||||
// Initial state
|
||||
currentPlan: null,
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
|
||||
/**
|
||||
* Fetch payment and subscription data for the current user
|
||||
* @param user Current user from auth session
|
||||
*/
|
||||
fetchPayment: async (user, force = false) => {
|
||||
// Skip if already loading
|
||||
if (get().isLoading) return;
|
||||
|
||||
// Skip if no user is provided
|
||||
if (!user) {
|
||||
set({
|
||||
currentPlan: null,
|
||||
subscription: null,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have recent data (within cache duration) unless force refresh
|
||||
if (!force) {
|
||||
const { lastFetchTime } = get();
|
||||
const now = Date.now();
|
||||
if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
|
||||
console.log('fetchPayment, use cached data');
|
||||
return; // Use cached data
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch subscription data
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
// Get all price plans
|
||||
const plans: PricePlan[] = getAllPricePlans();
|
||||
const freePlan = plans.find((plan) => plan.isFree);
|
||||
const lifetimePlan = plans.find((plan) => plan.isLifetime);
|
||||
|
||||
// Check if user is a lifetime member directly from the database
|
||||
let isLifetimeMember = false;
|
||||
try {
|
||||
const result = await getLifetimeStatusAction({ userId: user.id });
|
||||
if (result?.data?.success) {
|
||||
isLifetimeMember = result.data.isLifetimeMember || false;
|
||||
console.log('fetchPayment, lifetime status', isLifetimeMember);
|
||||
} else {
|
||||
console.warn(
|
||||
'fetchPayment, lifetime status error',
|
||||
result?.data?.error
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('fetchPayment, lifetime status error:', error);
|
||||
}
|
||||
|
||||
// If lifetime member, set the lifetime plan
|
||||
if (isLifetimeMember) {
|
||||
console.log('fetchPayment, set lifetime plan');
|
||||
set({
|
||||
currentPlan: lifetimePlan || null,
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user has an active subscription
|
||||
const result = await getActiveSubscriptionAction({ userId: user.id });
|
||||
if (result?.data?.success) {
|
||||
const activeSubscription = result.data.data;
|
||||
|
||||
// Set subscription state
|
||||
if (activeSubscription) {
|
||||
const plan =
|
||||
plans.find((p) =>
|
||||
p.prices.find(
|
||||
(price) => price.priceId === activeSubscription.priceId
|
||||
)
|
||||
) || null;
|
||||
console.log('fetchPayment, subscription found, set pro plan');
|
||||
set({
|
||||
currentPlan: plan,
|
||||
subscription: activeSubscription,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
} else {
|
||||
// No subscription found - set to free plan
|
||||
console.log('fetchPayment, no subscription found, set free plan');
|
||||
set({
|
||||
currentPlan: freePlan || null,
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: Date.now(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Failed to fetch subscription
|
||||
console.error(
|
||||
'fetchPayment, subscription for user failed',
|
||||
result?.data?.error
|
||||
);
|
||||
set({
|
||||
error: result?.data?.error || 'Failed to fetch payment data',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('fetchPayment, error:', error);
|
||||
set({
|
||||
error: 'Failed to fetch payment data',
|
||||
isLoading: false,
|
||||
});
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset payment state
|
||||
*/
|
||||
resetState: () => {
|
||||
set({
|
||||
currentPlan: null,
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
});
|
||||
},
|
||||
}));
|
@ -1,12 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface UsersState {
|
||||
refreshTrigger: number;
|
||||
triggerRefresh: () => void;
|
||||
}
|
||||
|
||||
export const useUsersStore = create<UsersState>((set) => ({
|
||||
refreshTrigger: 0,
|
||||
triggerRefresh: () =>
|
||||
set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })),
|
||||
}));
|
Loading…
Reference in New Issue
Block a user