it0/it0-web-admin/src/app/(admin)/billing/plans/page.tsx

209 lines
7.5 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client';
import { Check, Zap } from 'lucide-react';
interface Plan {
id: string;
name: string;
displayName: string;
monthlyPriceUsd: number;
monthlyPriceCny: number;
includedTokensPerMonth: number;
overageRateUsdPerMToken: number;
maxServers: number;
maxUsers: number;
maxStandingOrders: number;
hardLimitPercent: number;
trialDays: number;
}
interface Subscription {
plan: { name: string } | null;
status: string;
}
function formatTokens(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return `${n}`;
}
export default function PlansPage() {
const queryClient = useQueryClient();
const router = useRouter();
const { t } = useTranslation('billing');
const [currency, setCurrency] = useState<'USD' | 'CNY'>('USD');
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const { data: plans = [] } = useQuery<Plan[]>({
queryKey: ['billing', 'plans'],
queryFn: () => apiClient('/api/v1/billing/plans'),
});
const { data: sub } = useQuery<Subscription>({
queryKey: ['billing', 'subscription'],
queryFn: () => apiClient('/api/v1/billing/subscription'),
retry: false,
});
const currentPlanName = sub?.plan?.name;
const upgradeMutation = useMutation({
mutationFn: (planName: string) =>
apiClient('/api/v1/billing/subscription/upgrade', {
method: 'POST',
body: { planName, currency },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['billing'] });
router.push('/billing');
},
});
const handleSelectPlan = (planName: string) => {
if (planName === currentPlanName) return;
setSelectedPlan(planName);
};
const handleConfirm = () => {
if (!selectedPlan) return;
upgradeMutation.mutate(selectedPlan);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('plans.title')}</h1>
<div className="flex items-center gap-2 text-sm">
<button
onClick={() => setCurrency('USD')}
className={`px-3 py-1 rounded-full ${currency === 'USD' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
>
USD
</button>
<button
onClick={() => setCurrency('CNY')}
className={`px-3 py-1 rounded-full ${currency === 'CNY' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
>
CNY
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{plans.map((plan) => {
const isCurrent = plan.name === currentPlanName;
const isSelected = plan.name === selectedPlan;
const price = currency === 'CNY' ? plan.monthlyPriceCny : plan.monthlyPriceUsd;
const symbol = currency === 'CNY' ? '¥' : '$';
const features = t(`plans.features.${plan.name}`, { returnObjects: true }) as string[];
const isEnterprise = plan.name === 'enterprise';
return (
<div
key={plan.id}
onClick={() => handleSelectPlan(plan.name)}
className={`relative rounded-xl border-2 p-6 cursor-pointer transition-all space-y-4 ${
isEnterprise ? 'border-primary' : 'border-border'
} ${isSelected ? 'ring-2 ring-primary ring-offset-2' : ''} ${
isCurrent ? 'opacity-70 cursor-default' : 'hover:border-primary/50'
}`}
>
{isEnterprise && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="bg-primary text-primary-foreground text-xs font-bold px-3 py-1 rounded-full">
{t('plans.mostPopular')}
</span>
</div>
)}
{isCurrent && (
<div className="absolute top-3 right-3">
<span className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 text-xs font-medium px-2 py-0.5 rounded-full">
{t('plans.current')}
</span>
</div>
)}
<div>
<h3 className="text-xl font-bold">{plan.displayName}</h3>
<div className="flex items-end gap-1 mt-2">
<span className="text-3xl font-bold">{symbol}{price.toFixed(2)}</span>
<span className="text-muted-foreground text-sm mb-1">{t('plans.perMonth')}</span>
</div>
{plan.trialDays > 0 && (
<p className="text-xs text-primary mt-1">{t('plans.freeTrial', { days: plan.trialDays })}</p>
)}
</div>
<div className="border-t pt-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Zap className="h-4 w-4 text-primary" />
{formatTokens(plan.includedTokensPerMonth)} {t('plans.tokensPerMonth')}
</div>
{plan.overageRateUsdPerMToken > 0 && (
<p className="text-xs text-muted-foreground pl-6">
{t('plans.overage', {
rate: currency === 'CNY'
? `¥${(plan.overageRateUsdPerMToken * 7.2).toFixed(2)}`
: `$${plan.overageRateUsdPerMToken.toFixed(2)}`,
})}
</p>
)}
</div>
<ul className="space-y-1.5">
{Array.isArray(features) && features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm">
<Check className="h-4 w-4 text-green-500 flex-shrink-0" />
{f}
</li>
))}
</ul>
{!isCurrent && (
<button
onClick={(e) => { e.stopPropagation(); handleSelectPlan(plan.name); }}
className={`w-full py-2 rounded-lg text-sm font-medium transition-colors ${
isSelected
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-primary/10'
}`}
>
{isSelected ? t('plans.selected') : plan.name === 'free' ? t('plans.downgrade') : t('plans.upgrade')}
</button>
)}
</div>
);
})}
</div>
{selectedPlan && (
<div className="flex justify-end gap-3 pt-4">
<button onClick={() => setSelectedPlan(null)} className="px-4 py-2 rounded-lg border text-sm">
{t('plans.cancel')}
</button>
<button
onClick={handleConfirm}
disabled={upgradeMutation.isPending}
className="px-6 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
>
{upgradeMutation.isPending ? t('plans.processing') : t('plans.switchTo', { plan: selectedPlan })}
</button>
</div>
)}
{upgradeMutation.isError && (
<p className="text-sm text-red-500 text-center">
{t('plans.changePlanFailed')}
</p>
)}
</div>
);
}