209 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|