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