feat(billing): add CNY overage rate field and auto-detect currency from locale
- Add overage_rate_fen_per_m_token_cny column (migration 006) - Plan entity and seed updated with CNY overage rates (Pro ¥58, Enterprise ¥36) - upsertSeedPlans now updates existing plans (not insert-only) - Plan controller exposes overageRateCnyPerMToken - Frontend: currency auto-selects from i18n locale (zh→CNY, en→USD) - Frontend: Intl.NumberFormat for proper currency formatting - Currency toggle redesigned as pill selector Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
af1cae9da8
commit
60cf49432e
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -15,6 +15,7 @@ interface Plan {
|
|||
monthlyPriceCny: number;
|
||||
includedTokensPerMonth: number;
|
||||
overageRateUsdPerMToken: number;
|
||||
overageRateCnyPerMToken: number;
|
||||
maxServers: number;
|
||||
maxUsers: number;
|
||||
maxStandingOrders: number;
|
||||
|
|
@ -27,6 +28,16 @@ interface Subscription {
|
|||
status: string;
|
||||
}
|
||||
|
||||
type Currency = 'USD' | 'CNY';
|
||||
|
||||
function formatCurrency(amount: number, currency: Currency): string {
|
||||
return new Intl.NumberFormat(currency === 'CNY' ? 'zh-CN' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
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`;
|
||||
|
|
@ -36,8 +47,17 @@ function formatTokens(n: number) {
|
|||
export default function PlansPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('billing');
|
||||
const [currency, setCurrency] = useState<'USD' | 'CNY'>('USD');
|
||||
const { t, i18n } = useTranslation('billing');
|
||||
|
||||
// Default currency based on locale: zh → CNY, others → USD
|
||||
const defaultCurrency: Currency = i18n.language === 'zh' ? 'CNY' : 'USD';
|
||||
const [currency, setCurrency] = useState<Currency>(defaultCurrency);
|
||||
|
||||
// Sync currency when language changes
|
||||
useEffect(() => {
|
||||
setCurrency(i18n.language === 'zh' ? 'CNY' : 'USD');
|
||||
}, [i18n.language]);
|
||||
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
|
||||
const { data: plans = [] } = useQuery<Plan[]>({
|
||||
|
|
@ -79,19 +99,20 @@ export default function PlansPage() {
|
|||
<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">
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-lg text-sm">
|
||||
{(['USD', 'CNY'] as Currency[]).map((c) => (
|
||||
<button
|
||||
onClick={() => setCurrency('USD')}
|
||||
className={`px-3 py-1 rounded-full ${currency === 'USD' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
|
||||
key={c}
|
||||
onClick={() => setCurrency(c)}
|
||||
className={`px-3 py-1 rounded-md transition-colors ${
|
||||
currency === c
|
||||
? 'bg-background shadow text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
USD
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrency('CNY')}
|
||||
className={`px-3 py-1 rounded-full ${currency === 'CNY' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
|
||||
>
|
||||
CNY
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -100,7 +121,7 @@ export default function PlansPage() {
|
|||
const isCurrent = plan.name === currentPlanName;
|
||||
const isSelected = plan.name === selectedPlan;
|
||||
const price = currency === 'CNY' ? plan.monthlyPriceCny : plan.monthlyPriceUsd;
|
||||
const symbol = currency === 'CNY' ? '¥' : '$';
|
||||
const overageRate = currency === 'CNY' ? plan.overageRateCnyPerMToken : plan.overageRateUsdPerMToken;
|
||||
const features = t(`plans.features.${plan.name}`, { returnObjects: true }) as string[];
|
||||
const isEnterprise = plan.name === 'enterprise';
|
||||
|
||||
|
|
@ -133,7 +154,7 @@ export default function PlansPage() {
|
|||
<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-3xl font-bold">{formatCurrency(price, currency)}</span>
|
||||
<span className="text-muted-foreground text-sm mb-1">{t('plans.perMonth')}</span>
|
||||
</div>
|
||||
{plan.trialDays > 0 && (
|
||||
|
|
@ -146,13 +167,9 @@ export default function PlansPage() {
|
|||
<Zap className="h-4 w-4 text-primary" />
|
||||
{formatTokens(plan.includedTokensPerMonth)} {t('plans.tokensPerMonth')}
|
||||
</div>
|
||||
{plan.overageRateUsdPerMToken > 0 && (
|
||||
{overageRate > 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)}`,
|
||||
})}
|
||||
{t('plans.overage', { rate: formatCurrency(overageRate, currency) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ export class Plan {
|
|||
includedTokens!: number; // tokens per month included in plan
|
||||
|
||||
@Column({ name: 'overage_rate_cents_per_m_token', type: 'int', default: 0 })
|
||||
overageRateCentsPerMTokenUsd!: number; // price per 1M overage tokens in cents
|
||||
overageRateCentsPerMTokenUsd!: number; // price per 1M overage tokens in USD cents
|
||||
|
||||
@Column({ name: 'overage_rate_fen_per_m_token_cny', type: 'int', default: 0 })
|
||||
overageRateFenPerMTokenCny!: number; // price per 1M overage tokens in CNY fen
|
||||
|
||||
@Column({ name: 'max_servers', type: 'int', default: 5 })
|
||||
maxServers!: number; // -1 = unlimited
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export class PlanRepository {
|
|||
const existing = await this.repo.findOne({ where: { name: p.name } });
|
||||
if (!existing) {
|
||||
await this.repo.save(this.repo.create(p));
|
||||
} else {
|
||||
await this.repo.save(Object.assign(existing, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const SEED_PLANS = [
|
|||
monthlyPriceFenCny: 0,
|
||||
includedTokens: 100_000,
|
||||
overageRateCentsPerMTokenUsd: 0,
|
||||
overageRateFenPerMTokenCny: 0,
|
||||
maxServers: 5,
|
||||
maxUsers: 3,
|
||||
maxStandingOrders: 10,
|
||||
|
|
@ -23,6 +24,7 @@ const SEED_PLANS = [
|
|||
monthlyPriceFenCny: 34900, // ¥349.00
|
||||
includedTokens: 1_000_000,
|
||||
overageRateCentsPerMTokenUsd: 800, // $8.00 per MTok
|
||||
overageRateFenPerMTokenCny: 5800, // ¥58.00 per MTok
|
||||
maxServers: 50,
|
||||
maxUsers: 20,
|
||||
maxStandingOrders: 100,
|
||||
|
|
@ -37,6 +39,7 @@ const SEED_PLANS = [
|
|||
monthlyPriceFenCny: 139900, // ¥1399.00
|
||||
includedTokens: 10_000_000,
|
||||
overageRateCentsPerMTokenUsd: 500, // $5.00 per MTok
|
||||
overageRateFenPerMTokenCny: 3600, // ¥36.00 per MTok
|
||||
maxServers: -1,
|
||||
maxUsers: -1,
|
||||
maxStandingOrders: -1,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export class PlanController {
|
|||
monthlyPriceCny: p.monthlyPriceFenCny / 100,
|
||||
includedTokensPerMonth: p.includedTokens,
|
||||
overageRateUsdPerMToken: p.overageRateCentsPerMTokenUsd / 100,
|
||||
overageRateCnyPerMToken: p.overageRateFenPerMTokenCny / 100,
|
||||
maxServers: p.maxServers,
|
||||
maxUsers: p.maxUsers,
|
||||
maxStandingOrders: p.maxStandingOrders,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
-- Add CNY overage rate to billing_plans
|
||||
ALTER TABLE billing_plans
|
||||
ADD COLUMN IF NOT EXISTS overage_rate_fen_per_m_token_cny INTEGER NOT NULL DEFAULT 0;
|
||||
Loading…
Reference in New Issue