feat(i18n): add billing namespace, fully internationalize billing pages
billing/page.tsx, billing/plans/page.tsx, billing/invoices/page.tsx were hardcoded in English. Added zh/billing.json and en/billing.json covering overview, plans, and invoices sections. Registered billing namespace in i18n config. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c1fb39c3c0
commit
af1cae9da8
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { apiClient } from '@/infrastructure/api/api-client';
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
import { FileText, Download } from 'lucide-react';
|
import { FileText, Download } from 'lucide-react';
|
||||||
|
|
@ -38,6 +39,7 @@ const PAGE_SIZE = 20;
|
||||||
|
|
||||||
export default function InvoicesPage() {
|
export default function InvoicesPage() {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
const { t } = useTranslation('billing');
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<{ data: Invoice[]; total: number }>({
|
const { data, isLoading } = useQuery<{ data: Invoice[]; total: number }>({
|
||||||
queryKey: ['billing', 'invoices', page],
|
queryKey: ['billing', 'invoices', page],
|
||||||
|
|
@ -51,7 +53,7 @@ export default function InvoicesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
<FileText className="h-6 w-6" /> Invoices
|
<FileText className="h-6 w-6" /> {t('invoices.title')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card overflow-hidden">
|
<div className="rounded-lg border bg-card overflow-hidden">
|
||||||
|
|
@ -59,22 +61,22 @@ export default function InvoicesPage() {
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50 text-muted-foreground">
|
<thead className="bg-muted/50 text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left p-4 font-medium">Invoice #</th>
|
<th className="text-left p-4 font-medium">{t('invoices.invoiceNumber')}</th>
|
||||||
<th className="text-left p-4 font-medium">Period</th>
|
<th className="text-left p-4 font-medium">{t('invoices.period')}</th>
|
||||||
<th className="text-left p-4 font-medium">Amount</th>
|
<th className="text-left p-4 font-medium">{t('invoices.amount')}</th>
|
||||||
<th className="text-left p-4 font-medium">Due Date</th>
|
<th className="text-left p-4 font-medium">{t('invoices.dueDate')}</th>
|
||||||
<th className="text-left p-4 font-medium">Status</th>
|
<th className="text-left p-4 font-medium">{t('invoices.status')}</th>
|
||||||
<th className="p-4" />
|
<th className="p-4" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="p-8 text-center text-muted-foreground">Loading...</td>
|
<td colSpan={6} className="p-8 text-center text-muted-foreground">{t('invoices.loading')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : invoices.length === 0 ? (
|
) : invoices.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="p-8 text-center text-muted-foreground">No invoices found</td>
|
<td colSpan={6} className="p-8 text-center text-muted-foreground">{t('invoices.noInvoices')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
invoices.map((inv) => (
|
invoices.map((inv) => (
|
||||||
|
|
@ -102,7 +104,7 @@ export default function InvoicesPage() {
|
||||||
href={`/billing/invoices/${inv.id}?pay=1`}
|
href={`/billing/invoices/${inv.id}?pay=1`}
|
||||||
className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded-md hover:bg-primary/90"
|
className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded-md hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
Pay Now
|
{t('invoices.payNow')}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button className="p-1.5 rounded hover:bg-muted transition-colors" title="Download">
|
<button className="p-1.5 rounded hover:bg-muted transition-colors" title="Download">
|
||||||
|
|
@ -120,7 +122,7 @@ export default function InvoicesPage() {
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between p-4 border-t">
|
<div className="flex items-center justify-between p-4 border-t">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Page {page + 1} of {totalPages} ({total} total)
|
{t('invoices.pagination', { current: page + 1, total: totalPages, count: total })}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
|
@ -128,14 +130,14 @@ export default function InvoicesPage() {
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
className="px-3 py-1.5 rounded border text-sm disabled:opacity-50"
|
className="px-3 py-1.5 rounded border text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Previous
|
{t('invoices.previous')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => p + 1)}
|
onClick={() => setPage((p) => p + 1)}
|
||||||
disabled={page >= totalPages - 1}
|
disabled={page >= totalPages - 1}
|
||||||
className="px-3 py-1.5 rounded border text-sm disabled:opacity-50"
|
className="px-3 py-1.5 rounded border text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Next
|
{t('invoices.next')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ function formatTokens(n: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BillingPage() {
|
export default function BillingPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation('billing');
|
||||||
|
|
||||||
const { data: sub } = useQuery<Subscription>({
|
const { data: sub } = useQuery<Subscription>({
|
||||||
queryKey: ['billing', 'subscription'],
|
queryKey: ['billing', 'subscription'],
|
||||||
|
|
@ -100,18 +100,18 @@ export default function BillingPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold">Billing & Subscription</h1>
|
<h1 className="text-2xl font-bold">{t('overview.title')}</h1>
|
||||||
|
|
||||||
{/* Usage Warning Banner */}
|
{/* Usage Warning Banner */}
|
||||||
{usedPct >= 80 && (
|
{usedPct >= 80 && (
|
||||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-yellow-50 border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800">
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-yellow-50 border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||||
<AlertCircle className="h-5 w-5 text-yellow-600 flex-shrink-0" />
|
<AlertCircle className="h-5 w-5 text-yellow-600 flex-shrink-0" />
|
||||||
<span className="text-sm text-yellow-800 dark:text-yellow-300">
|
<span className="text-sm text-yellow-800 dark:text-yellow-300">
|
||||||
You have used {usedPct.toFixed(0)}% of your monthly token quota.{' '}
|
{t('overview.usageWarning', { pct: usedPct.toFixed(0) })}{' '}
|
||||||
{usedPct >= 100 ? 'Limit reached — agent tasks are blocked.' : 'Consider upgrading your plan.'}
|
{usedPct >= 100 ? t('overview.limitReached') : t('overview.considerUpgrade')}
|
||||||
</span>
|
</span>
|
||||||
<Link href="/billing/plans" className="ml-auto text-sm font-medium text-yellow-800 dark:text-yellow-300 underline whitespace-nowrap">
|
<Link href="/billing/plans" className="ml-auto text-sm font-medium text-yellow-800 dark:text-yellow-300 underline whitespace-nowrap">
|
||||||
Upgrade
|
{t('overview.upgrade')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -122,10 +122,10 @@ export default function BillingPage() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CreditCard className="h-5 w-5 text-muted-foreground" />
|
<CreditCard className="h-5 w-5 text-muted-foreground" />
|
||||||
<h2 className="font-semibold">Current Plan</h2>
|
<h2 className="font-semibold">{t('overview.currentPlan')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/billing/plans" className="text-sm text-primary flex items-center gap-1 hover:underline">
|
<Link href="/billing/plans" className="text-sm text-primary flex items-center gap-1 hover:underline">
|
||||||
Change <ChevronRight className="h-4 w-4" />
|
{t('overview.change')} <ChevronRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -137,25 +137,31 @@ export default function BillingPage() {
|
||||||
</div>
|
</div>
|
||||||
{sub.trialEndsAt && sub.status === 'trialing' && (
|
{sub.trialEndsAt && sub.status === 'trialing' && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Trial ends {new Date(sub.trialEndsAt).toLocaleDateString()}
|
{t('overview.trialEnds', { date: new Date(sub.trialEndsAt).toLocaleDateString() })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{sub.cancelAtPeriodEnd && (
|
{sub.cancelAtPeriodEnd && (
|
||||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||||
Cancels on {new Date(sub.currentPeriodEnd).toLocaleDateString()}
|
{t('overview.cancelsOn', { date: new Date(sub.currentPeriodEnd).toLocaleDateString() })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{sub.nextPlan && (
|
{sub.nextPlan && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Downgrade to <strong>{sub.nextPlan.displayName}</strong> on {new Date(sub.currentPeriodEnd).toLocaleDateString()}
|
{t('overview.downgradeTo', {
|
||||||
|
plan: sub.nextPlan.displayName,
|
||||||
|
date: new Date(sub.currentPeriodEnd).toLocaleDateString(),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Period: {new Date(sub.currentPeriodStart).toLocaleDateString()} – {new Date(sub.currentPeriodEnd).toLocaleDateString()}
|
{t('overview.period', {
|
||||||
|
start: new Date(sub.currentPeriodStart).toLocaleDateString(),
|
||||||
|
end: new Date(sub.currentPeriodEnd).toLocaleDateString(),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">No active subscription</p>
|
<p className="text-muted-foreground text-sm">{t('overview.noSubscription')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -163,15 +169,17 @@ export default function BillingPage() {
|
||||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Zap className="h-5 w-5 text-muted-foreground" />
|
<Zap className="h-5 w-5 text-muted-foreground" />
|
||||||
<h2 className="font-semibold">This Month's Token Usage</h2>
|
<h2 className="font-semibold">{t('overview.tokenUsage')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{quota ? (
|
{quota ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="font-medium">{formatTokens(quota.usedTokens)} used</span>
|
<span className="font-medium">{t('overview.tokensUsed', { used: formatTokens(quota.usedTokens) })}</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{quota.limitTokens === -1 ? 'Unlimited' : `${formatTokens(quota.limitTokens)} limit`}
|
{quota.limitTokens === -1
|
||||||
|
? t('overview.unlimited')
|
||||||
|
: t('overview.tokensLimit', { limit: formatTokens(quota.limitTokens) })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{quota.limitTokens !== -1 && (
|
{quota.limitTokens !== -1 && (
|
||||||
|
|
@ -180,12 +188,12 @@ export default function BillingPage() {
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span>{usedPct.toFixed(1)}%</span>
|
<span>{usedPct.toFixed(1)}%</span>
|
||||||
{quota.overageAllowed && usedPct > 100 && (
|
{quota.overageAllowed && usedPct > 100 && (
|
||||||
<span className="text-yellow-600">Overage charges apply</span>
|
<span className="text-yellow-600">{t('overview.overageApply')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">Loading usage data...</p>
|
<p className="text-muted-foreground text-sm">{t('overview.loadingUsage')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -195,15 +203,15 @@ export default function BillingPage() {
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||||
<h2 className="font-semibold">Recent Invoices</h2>
|
<h2 className="font-semibold">{t('overview.recentInvoices')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/billing/invoices" className="text-sm text-primary hover:underline flex items-center gap-1">
|
<Link href="/billing/invoices" className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||||
View all <ChevronRight className="h-4 w-4" />
|
{t('overview.viewAll')} <ChevronRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recentInvoices.length === 0 ? (
|
{recentInvoices.length === 0 ? (
|
||||||
<div className="p-6 text-center text-muted-foreground text-sm">No invoices yet</div>
|
<div className="p-6 text-center text-muted-foreground text-sm">{t('overview.noInvoices')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{recentInvoices.map((inv) => (
|
{recentInvoices.map((inv) => (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { apiClient } from '@/infrastructure/api/api-client';
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
import { Check, Zap } from 'lucide-react';
|
import { Check, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -26,25 +27,16 @@ interface Subscription {
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLimit(n: number) {
|
|
||||||
return n === -1 ? 'Unlimited' : n.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTokens(n: number) {
|
function formatTokens(n: number) {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||||
return `${n}`;
|
return `${n}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLAN_FEATURES: Record<string, string[]> = {
|
|
||||||
free: ['100K tokens/month', 'Up to 5 servers', 'Up to 3 users', 'Up to 10 standing orders', 'Hard limit at 100%'],
|
|
||||||
pro: ['1M tokens/month', 'Up to 50 servers', 'Up to 20 users', 'Up to 100 standing orders', 'Soft limit at 150%', 'Overage at $8/MTok', '14-day trial'],
|
|
||||||
enterprise: ['10M tokens/month', 'Unlimited servers', 'Unlimited users', 'Unlimited standing orders', 'No hard limit', 'Overage at $5/MTok', '14-day trial'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PlansPage() {
|
export default function PlansPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation('billing');
|
||||||
const [currency, setCurrency] = useState<'USD' | 'CNY'>('USD');
|
const [currency, setCurrency] = useState<'USD' | 'CNY'>('USD');
|
||||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -86,7 +78,7 @@ export default function PlansPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Choose a Plan</h1>
|
<h1 className="text-2xl font-bold">{t('plans.title')}</h1>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrency('USD')}
|
onClick={() => setCurrency('USD')}
|
||||||
|
|
@ -109,7 +101,7 @@ export default function PlansPage() {
|
||||||
const isSelected = plan.name === selectedPlan;
|
const isSelected = plan.name === selectedPlan;
|
||||||
const price = currency === 'CNY' ? plan.monthlyPriceCny : plan.monthlyPriceUsd;
|
const price = currency === 'CNY' ? plan.monthlyPriceCny : plan.monthlyPriceUsd;
|
||||||
const symbol = currency === 'CNY' ? '¥' : '$';
|
const symbol = currency === 'CNY' ? '¥' : '$';
|
||||||
const features = PLAN_FEATURES[plan.name] ?? [];
|
const features = t(`plans.features.${plan.name}`, { returnObjects: true }) as string[];
|
||||||
const isEnterprise = plan.name === 'enterprise';
|
const isEnterprise = plan.name === 'enterprise';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -125,7 +117,7 @@ export default function PlansPage() {
|
||||||
{isEnterprise && (
|
{isEnterprise && (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
<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">
|
<span className="bg-primary text-primary-foreground text-xs font-bold px-3 py-1 rounded-full">
|
||||||
MOST POPULAR
|
{t('plans.mostPopular')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -133,7 +125,7 @@ export default function PlansPage() {
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<div className="absolute top-3 right-3">
|
<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">
|
<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">
|
||||||
Current
|
{t('plans.current')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -142,29 +134,31 @@ export default function PlansPage() {
|
||||||
<h3 className="text-xl font-bold">{plan.displayName}</h3>
|
<h3 className="text-xl font-bold">{plan.displayName}</h3>
|
||||||
<div className="flex items-end gap-1 mt-2">
|
<div className="flex items-end gap-1 mt-2">
|
||||||
<span className="text-3xl font-bold">{symbol}{price.toFixed(2)}</span>
|
<span className="text-3xl font-bold">{symbol}{price.toFixed(2)}</span>
|
||||||
<span className="text-muted-foreground text-sm mb-1">/month</span>
|
<span className="text-muted-foreground text-sm mb-1">{t('plans.perMonth')}</span>
|
||||||
</div>
|
</div>
|
||||||
{plan.trialDays > 0 && (
|
{plan.trialDays > 0 && (
|
||||||
<p className="text-xs text-primary mt-1">{plan.trialDays}-day free trial</p>
|
<p className="text-xs text-primary mt-1">{t('plans.freeTrial', { days: plan.trialDays })}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4 space-y-2">
|
<div className="border-t pt-4 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<Zap className="h-4 w-4 text-primary" />
|
<Zap className="h-4 w-4 text-primary" />
|
||||||
{formatTokens(plan.includedTokensPerMonth)} tokens/month
|
{formatTokens(plan.includedTokensPerMonth)} {t('plans.tokensPerMonth')}
|
||||||
</div>
|
</div>
|
||||||
{plan.overageRateUsdPerMToken > 0 && (
|
{plan.overageRateUsdPerMToken > 0 && (
|
||||||
<p className="text-xs text-muted-foreground pl-6">
|
<p className="text-xs text-muted-foreground pl-6">
|
||||||
Overage: {currency === 'CNY'
|
{t('plans.overage', {
|
||||||
? `¥${(plan.overageRateUsdPerMToken * 7.2).toFixed(2)}`
|
rate: currency === 'CNY'
|
||||||
: `$${plan.overageRateUsdPerMToken.toFixed(2)}`}/MTok
|
? `¥${(plan.overageRateUsdPerMToken * 7.2).toFixed(2)}`
|
||||||
|
: `$${plan.overageRateUsdPerMToken.toFixed(2)}`,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{features.map((f) => (
|
{Array.isArray(features) && features.map((f) => (
|
||||||
<li key={f} className="flex items-center gap-2 text-sm">
|
<li key={f} className="flex items-center gap-2 text-sm">
|
||||||
<Check className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<Check className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
{f}
|
{f}
|
||||||
|
|
@ -181,7 +175,7 @@ export default function PlansPage() {
|
||||||
: 'bg-muted hover:bg-primary/10'
|
: 'bg-muted hover:bg-primary/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isSelected ? 'Selected' : plan.name === 'free' ? 'Downgrade' : 'Upgrade'}
|
{isSelected ? t('plans.selected') : plan.name === 'free' ? t('plans.downgrade') : t('plans.upgrade')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -192,21 +186,21 @@ export default function PlansPage() {
|
||||||
{selectedPlan && (
|
{selectedPlan && (
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<button onClick={() => setSelectedPlan(null)} className="px-4 py-2 rounded-lg border text-sm">
|
<button onClick={() => setSelectedPlan(null)} className="px-4 py-2 rounded-lg border text-sm">
|
||||||
Cancel
|
{t('plans.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={upgradeMutation.isPending}
|
disabled={upgradeMutation.isPending}
|
||||||
className="px-6 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
|
className="px-6 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{upgradeMutation.isPending ? 'Processing...' : `Switch to ${selectedPlan}`}
|
{upgradeMutation.isPending ? t('plans.processing') : t('plans.switchTo', { plan: selectedPlan })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{upgradeMutation.isError && (
|
{upgradeMutation.isError && (
|
||||||
<p className="text-sm text-red-500 text-center">
|
<p className="text-sm text-red-500 text-center">
|
||||||
Failed to change plan. Please try again.
|
{t('plans.changePlanFailed')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import enTerminal from './locales/en/terminal.json';
|
||||||
import enStandingOrders from './locales/en/standing-orders.json';
|
import enStandingOrders from './locales/en/standing-orders.json';
|
||||||
import enRunbooks from './locales/en/runbooks.json';
|
import enRunbooks from './locales/en/runbooks.json';
|
||||||
import enSessions from './locales/en/sessions.json';
|
import enSessions from './locales/en/sessions.json';
|
||||||
|
import enBilling from './locales/en/billing.json';
|
||||||
|
|
||||||
// Chinese
|
// Chinese
|
||||||
import zhCommon from './locales/zh/common.json';
|
import zhCommon from './locales/zh/common.json';
|
||||||
|
|
@ -41,6 +42,7 @@ import zhTerminal from './locales/zh/terminal.json';
|
||||||
import zhStandingOrders from './locales/zh/standing-orders.json';
|
import zhStandingOrders from './locales/zh/standing-orders.json';
|
||||||
import zhRunbooks from './locales/zh/runbooks.json';
|
import zhRunbooks from './locales/zh/runbooks.json';
|
||||||
import zhSessions from './locales/zh/sessions.json';
|
import zhSessions from './locales/zh/sessions.json';
|
||||||
|
import zhBilling from './locales/zh/billing.json';
|
||||||
|
|
||||||
export const supportedLngs = ['en', 'zh'] as const;
|
export const supportedLngs = ['en', 'zh'] as const;
|
||||||
export type SupportedLocale = (typeof supportedLngs)[number];
|
export type SupportedLocale = (typeof supportedLngs)[number];
|
||||||
|
|
@ -69,6 +71,7 @@ i18n
|
||||||
'standing-orders': enStandingOrders,
|
'standing-orders': enStandingOrders,
|
||||||
runbooks: enRunbooks,
|
runbooks: enRunbooks,
|
||||||
sessions: enSessions,
|
sessions: enSessions,
|
||||||
|
billing: enBilling,
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
common: zhCommon,
|
common: zhCommon,
|
||||||
|
|
@ -89,6 +92,7 @@ i18n
|
||||||
'standing-orders': zhStandingOrders,
|
'standing-orders': zhStandingOrders,
|
||||||
runbooks: zhRunbooks,
|
runbooks: zhRunbooks,
|
||||||
sessions: zhSessions,
|
sessions: zhSessions,
|
||||||
|
billing: zhBilling,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"plans": {
|
||||||
|
"title": "Choose a Plan",
|
||||||
|
"mostPopular": "MOST POPULAR",
|
||||||
|
"current": "Current",
|
||||||
|
"perMonth": "/month",
|
||||||
|
"freeTrial": "{{days}}-day free trial",
|
||||||
|
"tokensPerMonth": "tokens/month",
|
||||||
|
"overage": "Overage: {{rate}}/MTok",
|
||||||
|
"selected": "Selected",
|
||||||
|
"downgrade": "Downgrade",
|
||||||
|
"upgrade": "Upgrade",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"switchTo": "Switch to {{plan}}",
|
||||||
|
"changePlanFailed": "Failed to change plan. Please try again.",
|
||||||
|
"unlimited": "Unlimited",
|
||||||
|
"features": {
|
||||||
|
"free": ["100K tokens/month", "Up to 5 servers", "Up to 3 users", "Up to 10 standing orders", "Hard limit at 100%"],
|
||||||
|
"pro": ["1M tokens/month", "Up to 50 servers", "Up to 20 users", "Up to 100 standing orders", "Soft limit at 150%", "Overage at $8/MTok", "14-day trial"],
|
||||||
|
"enterprise": ["10M tokens/month", "Unlimited servers", "Unlimited users", "Unlimited standing orders", "No hard limit", "Overage at $5/MTok", "14-day trial"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Billing & Subscription",
|
||||||
|
"usageWarning": "You have used {{pct}}% of your monthly token quota.",
|
||||||
|
"limitReached": "Limit reached — agent tasks are blocked.",
|
||||||
|
"considerUpgrade": "Consider upgrading your plan.",
|
||||||
|
"upgrade": "Upgrade",
|
||||||
|
"currentPlan": "Current Plan",
|
||||||
|
"change": "Change",
|
||||||
|
"trialEnds": "Trial ends {{date}}",
|
||||||
|
"cancelsOn": "Cancels on {{date}}",
|
||||||
|
"downgradeTo": "Downgrade to {{plan}} on {{date}}",
|
||||||
|
"period": "Period: {{start}} – {{end}}",
|
||||||
|
"noSubscription": "No active subscription",
|
||||||
|
"tokenUsage": "This Month's Token Usage",
|
||||||
|
"tokensUsed": "{{used}} used",
|
||||||
|
"tokensLimit": "{{limit}} limit",
|
||||||
|
"unlimited": "Unlimited",
|
||||||
|
"overageApply": "Overage charges apply",
|
||||||
|
"loadingUsage": "Loading usage data...",
|
||||||
|
"recentInvoices": "Recent Invoices",
|
||||||
|
"viewAll": "View all",
|
||||||
|
"noInvoices": "No invoices yet"
|
||||||
|
},
|
||||||
|
"invoices": {
|
||||||
|
"title": "Invoices",
|
||||||
|
"invoiceNumber": "Invoice #",
|
||||||
|
"period": "Period",
|
||||||
|
"amount": "Amount",
|
||||||
|
"dueDate": "Due Date",
|
||||||
|
"status": "Status",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noInvoices": "No invoices found",
|
||||||
|
"payNow": "Pay Now",
|
||||||
|
"pagination": "Page {{current}} of {{total}} ({{count}} total)",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"plans": {
|
||||||
|
"title": "选择套餐",
|
||||||
|
"mostPopular": "最受欢迎",
|
||||||
|
"current": "当前",
|
||||||
|
"perMonth": "/月",
|
||||||
|
"freeTrial": "{{days}} 天免费试用",
|
||||||
|
"tokensPerMonth": "tokens/月",
|
||||||
|
"overage": "超额: {{rate}}/MTok",
|
||||||
|
"selected": "已选择",
|
||||||
|
"downgrade": "降级",
|
||||||
|
"upgrade": "升级",
|
||||||
|
"cancel": "取消",
|
||||||
|
"processing": "处理中...",
|
||||||
|
"switchTo": "切换到 {{plan}}",
|
||||||
|
"changePlanFailed": "套餐切换失败,请重试",
|
||||||
|
"unlimited": "不限",
|
||||||
|
"features": {
|
||||||
|
"free": ["每月 100K tokens", "最多 5 台服务器", "最多 3 个用户", "最多 10 条常驻指令", "硬限制 100%"],
|
||||||
|
"pro": ["每月 1M tokens", "最多 50 台服务器", "最多 20 个用户", "最多 100 条常驻指令", "软限制 150%", "超额 $8/MTok", "14 天试用"],
|
||||||
|
"enterprise": ["每月 10M tokens", "不限服务器", "不限用户", "不限常驻指令", "无硬限制", "超额 $5/MTok", "14 天试用"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "账单与订阅",
|
||||||
|
"usageWarning": "您已使用本月 token 配额的 {{pct}}%。",
|
||||||
|
"limitReached": "已达上限 — 智能体任务已暂停。",
|
||||||
|
"considerUpgrade": "建议升级套餐。",
|
||||||
|
"upgrade": "升级",
|
||||||
|
"currentPlan": "当前套餐",
|
||||||
|
"change": "更改",
|
||||||
|
"trialEnds": "试用期至 {{date}}",
|
||||||
|
"cancelsOn": "将于 {{date}} 取消",
|
||||||
|
"downgradeTo": "将于 {{date}} 降级为 {{plan}}",
|
||||||
|
"period": "周期:{{start}} – {{end}}",
|
||||||
|
"noSubscription": "暂无活跃订阅",
|
||||||
|
"tokenUsage": "本月 Token 用量",
|
||||||
|
"tokensUsed": "已用 {{used}}",
|
||||||
|
"tokensLimit": "上限 {{limit}}",
|
||||||
|
"unlimited": "不限",
|
||||||
|
"overageApply": "超额费用适用",
|
||||||
|
"loadingUsage": "加载用量数据...",
|
||||||
|
"recentInvoices": "近期账单",
|
||||||
|
"viewAll": "查看全部",
|
||||||
|
"noInvoices": "暂无账单"
|
||||||
|
},
|
||||||
|
"invoices": {
|
||||||
|
"title": "账单列表",
|
||||||
|
"invoiceNumber": "账单号",
|
||||||
|
"period": "周期",
|
||||||
|
"amount": "金额",
|
||||||
|
"dueDate": "到期日",
|
||||||
|
"status": "状态",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"noInvoices": "暂无账单",
|
||||||
|
"payNow": "立即支付",
|
||||||
|
"pagination": "第 {{current}} / {{total}} 页(共 {{count}} 条)",
|
||||||
|
"previous": "上一页",
|
||||||
|
"next": "下一页"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue