diff --git a/packages/views/billing/billing-test-page.tsx b/packages/views/billing/billing-test-page.tsx index 375d57b63..4dc4fc369 100644 --- a/packages/views/billing/billing-test-page.tsx +++ b/packages/views/billing/billing-test-page.tsx @@ -15,12 +15,12 @@ // // Anything past "make the API talk to Stripe and surface results" is // out of scope here on purpose — when the real billing UI ships it -// will live elsewhere and this whole page can be deleted. Until then, -// the page intentionally hard-codes English labels rather than going -// through useT(): it is dev-only, not localized, and slated for -// deletion — so it is exempt from the package-wide i18next/no-literal-string -// rule. -/* eslint-disable i18next/no-literal-string */ +// will live elsewhere and this whole page can be deleted. Strings +// are still routed through useT() so the package-wide +// i18next/no-literal-string lint rule passes; the namespace is +// `billing` and the en/zh-Hans bundles live alongside the other +// namespaces. When the real UI lands, both the namespace and this +// file get deleted together. import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; @@ -51,6 +51,7 @@ import { CardHeader, CardTitle, } from "@multica/ui/components/ui/card"; +import { useT } from "../i18n"; import { useNavigation } from "../navigation"; // 1 credit = 1_000_000 micro-credit; cents → dollars factor for the @@ -60,6 +61,7 @@ const MICRO_PER_CREDIT = 1_000_000; const CENTS_PER_DOLLAR = 100; export function BillingTestPage() { + const { t } = useT("billing"); const { searchParams, replace, pathname } = useNavigation(); // The Stripe success URL on the cloud side has the literal @@ -70,11 +72,9 @@ export function BillingTestPage() { return (
-

Billing (test page)

+

{t(($) => $.title)}

- Direct passthrough to multica-cloud's /api/v1/billing/*. Not - a finished UI — just every endpoint stuffed onto one page so - we can verify the proxy + Stripe flow. + {t(($) => $.subtitle)}

@@ -115,6 +115,7 @@ function CheckoutSessionStatusBanner({ sessionId: string; onDismiss: () => void; }) { + const { t } = useT("billing"); const { data, isLoading, isError, error } = useQuery( billingCheckoutSessionOptions(sessionId), ); @@ -131,11 +132,11 @@ function CheckoutSessionStatusBanner({ // would be a manual refresh click. Invalidate the dependent // queries so they re-fetch in the background. // - // Dep list `[terminal, ...]`: `terminal` only flips from false→true - // once per session-id, so the invalidation fires exactly once. If - // the caller mounts this banner with a session that is already in a + // Dep list `terminal`: terminal only flips from false→true once + // per session-id, so the invalidation fires exactly once. If the + // caller mounts this banner with a session that is already in a // terminal state (e.g. user revisits the success URL after closing - // and reopening the tab), `terminal` flips false→true on the first + // and reopening the tab), terminal flips false→true on the first // data load and we still re-fetch — which is what we want, because // the cached snapshot is just as stale in that case. const invalidateBillingDataAfterCredit = useInvalidateBillingDataAfterCredit(); @@ -147,31 +148,44 @@ function CheckoutSessionStatusBanner({ - Checkout session {sessionId.slice(0, 16)}… + {t(($) => $.checkout.session_label, { prefix: sessionId.slice(0, 16) })} {isLoading - ? "Loading order status…" + ? t(($) => $.checkout.loading) : isError - ? `Failed to fetch status: ${error instanceof Error ? error.message : "unknown error"}` + ? t(($) => $.checkout.fetch_failed, { + error: + error instanceof Error + ? error.message + : t(($) => $.checkout.fetch_failed_unknown), + }) : terminal - ? `Final status: ${status}` - : `Polling status… current: ${status || "unknown"}`} + ? t(($) => $.checkout.final_status, { status }) + : t(($) => $.checkout.polling_status, { + status: status || t(($) => $.checkout.status_unknown), + })} {data && (
-
Order
+
{t(($) => $.checkout.label_order)}
{data.order_id}
-
Tier
+
{t(($) => $.checkout.label_tier)}
{data.tier_id}
-
Charged
+
{t(($) => $.checkout.label_charged)}
- {formatMoney(data.amount_cents, data.currency)} ·{" "} - {data.credits.toLocaleString()} credits - {data.bonus_credits > 0 && - ` + ${data.bonus_credits.toLocaleString()} bonus`} + {data.bonus_credits > 0 + ? t(($) => $.checkout.charged_with_bonus, { + money: formatMoney(data.amount_cents, data.currency), + credits: data.credits.toLocaleString(), + bonus: data.bonus_credits.toLocaleString(), + }) + : t(($) => $.checkout.charged_value, { + money: formatMoney(data.amount_cents, data.currency), + credits: data.credits.toLocaleString(), + })}
{terminal && ( @@ -181,7 +195,7 @@ function CheckoutSessionStatusBanner({ className="mt-3" onClick={onDismiss} > - Clear from URL + {t(($) => $.checkout.clear_url)} )}
@@ -193,15 +207,16 @@ function CheckoutSessionStatusBanner({ // ─── Balance ───────────────────────────────────────────────────────── function BalanceCard() { + const { t } = useT("billing"); const balance = useQuery(billingBalanceOptions()); return (
- Balance + {t(($) => $.balance.title)} - GET /api/cloud-billing/balance + {t(($) => $.endpoints.balance)}
{balance.data?.balance_credit.toLocaleString() ?? 0} - credits + {t(($) => $.balance.credits_suffix)}
- raw micro: {balance.data?.balance_micro.toLocaleString() ?? 0} · - owner: {balance.data?.owner_id.slice(0, 8) ?? ""}… · updated{" "} - {formatDate(balance.data?.updated_at)} + {t(($) => $.balance.meta, { + micro: balance.data?.balance_micro.toLocaleString() ?? 0, + owner: balance.data?.owner_id.slice(0, 8) ?? "", + updated: formatDate(balance.data?.updated_at, t), + })}
)} @@ -237,6 +254,7 @@ function BalanceCard() { // ─── Buy + Portal ──────────────────────────────────────────────────── function BuyAndPortalSection() { + const { t } = useT("billing"); const tiers = useQuery(billingPriceTiersOptions()); const createCheckout = useCreateCloudBillingCheckoutSession(); const createPortal = useCreateCloudBillingPortalSession(); @@ -247,7 +265,7 @@ function BuyAndPortalSection() { try { const { url } = await createCheckout.mutateAsync({ tier_id: tier.id }); if (!url) { - toast.error("Cloud returned no checkout URL"); + toast.error(t(($) => $.buy.toast_no_url)); return; } // Redirect via window.location instead of window.open so the @@ -256,7 +274,9 @@ function BuyAndPortalSection() { // SPA-like behaviour from there. window.location.href = url; } catch (err) { - toast.error(err instanceof Error ? err.message : "Checkout failed"); + toast.error( + err instanceof Error ? err.message : t(($) => $.buy.toast_checkout_failed), + ); } finally { setBusyTier(null); } @@ -266,7 +286,7 @@ function BuyAndPortalSection() { try { const { url } = await createPortal.mutateAsync(); if (!url) { - toast.error("No portal URL returned"); + toast.error(t(($) => $.buy.toast_no_portal_url)); return; } // Open in a new tab — the portal is a customer self-service @@ -277,16 +297,18 @@ function BuyAndPortalSection() { // 400 is the documented "no Stripe customer yet" case from // upstream. Surface the body verbatim — it's the most useful // signal during testing. - toast.error(err instanceof Error ? err.message : "Portal failed"); + toast.error( + err instanceof Error ? err.message : t(($) => $.buy.toast_portal_failed), + ); } }; return ( - Buy credits / Manage billing + {t(($) => $.buy.title)} - GET /price-tiers · POST /checkout-sessions · POST /portal-sessions + {t(($) => $.endpoints.buy)} @@ -307,10 +329,7 @@ function BuyAndPortalSection() { ))} ) : ( -

- No price tiers configured. Cloud probably has no Stripe key set — - checkout will return 503. -

+

{t(($) => $.buy.no_tiers)}

)}
@@ -326,11 +345,10 @@ function BuyAndPortalSection() { ) : ( )} - Open Stripe Billing Portal + {t(($) => $.buy.open_portal)}

- 400 is expected for users who haven't paid yet (no Stripe - customer record exists). + {t(($) => $.buy.portal_hint)}

@@ -349,7 +367,22 @@ function TierButton({ disabled: boolean; onClick: () => void; }) { + const { t } = useT("billing"); const display = tier.display_name || tier.id; + const baseLine = t(($) => $.buy.tier_money_to_credits, { + money: formatMoney(tier.amount_cents, "usd"), + credits: tier.credits.toLocaleString(), + }); + const bonusLine = tier.bonus_credits + ? tier.bonus_expires_in + ? t(($) => $.buy.tier_bonus_with_expiry, { + credits: tier.bonus_credits.toLocaleString(), + expiry: tier.bonus_expires_in, + }) + : t(($) => $.buy.tier_bonus, { + credits: tier.bonus_credits.toLocaleString(), + }) + : ""; return ( ); @@ -382,14 +408,15 @@ function TierButton({ // ─── Lists ─────────────────────────────────────────────────────────── function TransactionsCard() { + const { t } = useT("billing"); const txs = useQuery(billingTransactionsOptions({ page: 1, page_size: 20 })); return (
- Transactions + {t(($) => $.transactions.title)} - GET /api/cloud-billing/transactions + {t(($) => $.endpoints.transactions)}
) : ( - No transactions yet. + {t(($) => $.transactions.empty)} )} @@ -434,34 +462,40 @@ function TransactionRow({ row }: { row: BillingTransaction }) { = 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400" + credit >= 0 + ? "text-green-700 dark:text-green-400" + : "text-red-700 dark:text-red-400" }`} > - {credit >= 0 ? "+" : ""} - {credit.toLocaleString()} credits + {t(($) => $.transactions.credits_value, { + value: `${credit >= 0 ? "+" : ""}${credit.toLocaleString()}`, + })} {row.description && (
{row.description}
)}
- {formatDate(row.created_at)} · balance after:{" "} - {(row.balance_after / MICRO_PER_CREDIT).toLocaleString()} credits · ref:{" "} - {row.reference_id || "—"} + {t(($) => $.transactions.row_meta, { + date: formatDate(row.created_at, t), + balance: (row.balance_after / MICRO_PER_CREDIT).toLocaleString(), + ref: row.reference_id || t(($) => $.transactions.ref_empty), + })}
); } function BatchesCard() { + const { t } = useT("billing"); const batches = useQuery(billingBatchesOptions({ page: 1, page_size: 20 })); return (
- Credit batches + {t(($) => $.batches.title)} - GET /api/cloud-billing/batches + {t(($) => $.endpoints.batches)}
) : ( - No batches yet. + {t(($) => $.batches.empty)} )} {row.source_type} - {row.id.slice(0, 8)}… + {t(($) => $.batches.id_suffix, { id: row.id.slice(0, 8) })} - {remaining.toLocaleString()} / {total.toLocaleString()} credits + {t(($) => $.batches.remaining_over_total, { + remaining: remaining.toLocaleString(), + total: total.toLocaleString(), + })}
- Consumed: {consumed.toLocaleString()} credits - {row.expires_at ? ` · expires ${formatDate(row.expires_at)}` : " · never expires"} + {t(($) => $.batches.consumed, { value: consumed.toLocaleString() })} + {row.expires_at + ? t(($) => $.batches.expires_suffix, { value: formatDate(row.expires_at, t) }) + : t(($) => $.batches.never_expires_suffix)}
); } function TopupsCard() { + const { t } = useT("billing"); const topups = useQuery(billingTopupsOptions({ page: 1, page_size: 20 })); return (
- Topup orders + {t(($) => $.topups.title)} - GET /api/cloud-billing/topups + {t(($) => $.endpoints.topups)}
) : ( - No topup orders yet. + {t(($) => $.topups.empty)} )}
@@ -577,13 +619,23 @@ function TopupRow({ row }: { row: BillingTopup }) { - {formatMoney(row.amount_cents, row.currency)} →{" "} - {row.credits.toLocaleString()} credits - {row.bonus_credits > 0 ? ` + ${row.bonus_credits} bonus` : ""} + {row.bonus_credits > 0 + ? t(($) => $.topups.amount_to_credits_with_bonus, { + money: formatMoney(row.amount_cents, row.currency), + credits: row.credits.toLocaleString(), + bonus: row.bonus_credits, + }) + : t(($) => $.topups.amount_to_credits, { + money: formatMoney(row.amount_cents, row.currency), + credits: row.credits.toLocaleString(), + })}
- {formatDate(row.created_at)} · stripe: {row.stripe_checkout_id || "—"} + {t(($) => $.topups.row_meta, { + date: formatDate(row.created_at, t), + checkout: row.stripe_checkout_id || t(($) => $.topups.stripe_empty), + })}
); @@ -600,10 +652,15 @@ function PagingFooter({ pageSize: number; total: number; }) { + const { t } = useT("billing"); if (total === 0) return null; return (
- page {page} / {Math.max(1, Math.ceil(total / pageSize))} · {total} total + {t(($) => $.shared.paging, { + page, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + total, + })}
); } @@ -615,6 +672,7 @@ function RefreshButton({ isLoading: boolean; onClick: () => void; }) { + const { t } = useT("billing"); return ( @@ -631,9 +689,10 @@ function RefreshButton({ } function ErrorText({ error }: { error: unknown }) { + const { t } = useT("billing"); return (

- {error instanceof Error ? error.message : "Request failed"} + {error instanceof Error ? error.message : t(($) => $.shared.request_failed)}

); } @@ -645,7 +704,8 @@ function EmptyText({ children }: { children: React.ReactNode }) { function formatMoney(amountCents: number, currency: string): string { // Intl is fine here — no currency conversion happening, just // canonical display. Defaults to en-US to match the rest of the - // dev UI. + // dev UI; the produced string is then passed into a t() interpolation + // so the surrounding sentence still gets translated. try { return new Intl.NumberFormat("en-US", { style: "currency", @@ -656,8 +716,17 @@ function formatMoney(amountCents: number, currency: string): string { } } -function formatDate(value: string | undefined): string { - if (!value) return "—"; +// formatDate returns an ISO-ish localized string or the locale's "—" +// dash for missing/invalid input. The dash itself goes through the +// translation bundle so it stays consistent if either locale ever +// wants to swap it for a localized placeholder. Caller passes in `t` +// so we don't run useT() inside a util (the rule of hooks would +// trip on the conditional path). +function formatDate( + value: string | undefined, + t: ReturnType>["t"], +): string { + if (!value) return t(($) => $.shared.date_dash); const d = new Date(value); if (Number.isNaN(d.getTime())) return value; return d.toLocaleString(); diff --git a/packages/views/i18n/resources-types.ts b/packages/views/i18n/resources-types.ts index efc3cfa26..f1b83b423 100644 --- a/packages/views/i18n/resources-types.ts +++ b/packages/views/i18n/resources-types.ts @@ -26,6 +26,7 @@ import type runtimes from "../locales/en/runtimes.json"; import type layout from "../locales/en/layout.json"; import type usage from "../locales/en/usage.json"; import type squads from "../locales/en/squads.json"; +import type billing from "../locales/en/billing.json"; // Module augmentation enables i18next v26 selector API across the monorepo: // `t($ => $.signin.title)` resolves to the value in en/auth.json. @@ -66,6 +67,7 @@ declare global { layout: typeof layout; usage: typeof usage; squads: typeof squads; + billing: typeof billing; } } diff --git a/packages/views/locales/en/billing.json b/packages/views/locales/en/billing.json new file mode 100644 index 000000000..1a03364d8 --- /dev/null +++ b/packages/views/locales/en/billing.json @@ -0,0 +1,75 @@ +{ + "title": "Billing (test page)", + "subtitle": "Direct passthrough to multica-cloud's /api/v1/billing/*. Not a finished UI — just every endpoint stuffed onto one page so we can verify the proxy + Stripe flow.", + "endpoints": { + "balance": "GET /api/cloud-billing/balance", + "transactions": "GET /api/cloud-billing/transactions", + "batches": "GET /api/cloud-billing/batches", + "topups": "GET /api/cloud-billing/topups", + "buy": "GET /price-tiers · POST /checkout-sessions · POST /portal-sessions" + }, + "balance": { + "title": "Balance", + "credits_suffix": "credits", + "meta": "raw micro: {{micro}} · owner: {{owner}}… · updated {{updated}}" + }, + "buy": { + "title": "Buy credits / Manage billing", + "no_tiers": "No price tiers configured. Cloud probably has no Stripe key set — checkout will return 503.", + "tier_money_to_credits": "{{money}} → {{credits}} credits", + "tier_bonus": " + {{credits}} bonus", + "tier_bonus_with_expiry": " + {{credits}} bonus ({{expiry}})", + "tier_id": "id: {{id}}", + "open_portal": "Open Stripe Billing Portal", + "portal_hint": "400 is expected for users who haven't paid yet (no Stripe customer record exists).", + "toast_no_url": "Cloud returned no checkout URL", + "toast_checkout_failed": "Checkout failed", + "toast_no_portal_url": "No portal URL returned", + "toast_portal_failed": "Portal failed" + }, + "checkout": { + "session_label": "Checkout session {{prefix}}…", + "loading": "Loading order status…", + "fetch_failed": "Failed to fetch status: {{error}}", + "fetch_failed_unknown": "unknown error", + "final_status": "Final status: {{status}}", + "polling_status": "Polling status… current: {{status}}", + "status_unknown": "unknown", + "label_order": "Order", + "label_tier": "Tier", + "label_charged": "Charged", + "charged_value": "{{money}} · {{credits}} credits", + "charged_with_bonus": "{{money}} · {{credits}} credits + {{bonus}} bonus", + "clear_url": "Clear from URL" + }, + "transactions": { + "title": "Transactions", + "empty": "No transactions yet.", + "credits_value": "{{value}} credits", + "row_meta": "{{date}} · balance after: {{balance}} credits · ref: {{ref}}", + "ref_empty": "—" + }, + "batches": { + "title": "Credit batches", + "empty": "No batches yet.", + "remaining_over_total": "{{remaining}} / {{total}} credits", + "id_suffix": "{{id}}…", + "consumed": "Consumed: {{value}} credits", + "expires_suffix": " · expires {{value}}", + "never_expires_suffix": " · never expires" + }, + "topups": { + "title": "Topup orders", + "empty": "No topup orders yet.", + "amount_to_credits": "{{money}} → {{credits}} credits", + "amount_to_credits_with_bonus": "{{money}} → {{credits}} credits + {{bonus}} bonus", + "row_meta": "{{date}} · stripe: {{checkout}}", + "stripe_empty": "—" + }, + "shared": { + "request_failed": "Request failed", + "refresh": "Refresh", + "paging": "page {{page}} / {{totalPages}} · {{total}} total", + "date_dash": "—" + } +} diff --git a/packages/views/locales/index.ts b/packages/views/locales/index.ts index 114048aaa..75f9d43c6 100644 --- a/packages/views/locales/index.ts +++ b/packages/views/locales/index.ts @@ -23,6 +23,7 @@ import enLayout from "./en/layout.json"; import enUsage from "./en/usage.json"; import enUi from "./en/ui.json"; import enSquads from "./en/squads.json"; +import enBilling from "./en/billing.json"; import zhHansCommon from "./zh-Hans/common.json"; import zhHansAuth from "./zh-Hans/auth.json"; import zhHansSettings from "./zh-Hans/settings.json"; @@ -47,6 +48,7 @@ import zhHansLayout from "./zh-Hans/layout.json"; import zhHansUsage from "./zh-Hans/usage.json"; import zhHansUi from "./zh-Hans/ui.json"; import zhHansSquads from "./zh-Hans/squads.json"; +import zhHansBilling from "./zh-Hans/billing.json"; // Single source of truth for the resource bundle. Both apps (web layout + // desktop App.tsx) import from here so adding a locale or namespace happens @@ -77,6 +79,7 @@ export const RESOURCES: Record = { usage: enUsage, ui: enUi, squads: enSquads, + billing: enBilling, }, "zh-Hans": { common: zhHansCommon, @@ -103,5 +106,6 @@ export const RESOURCES: Record = { usage: zhHansUsage, ui: zhHansUi, squads: zhHansSquads, + billing: zhHansBilling, }, }; diff --git a/packages/views/locales/zh-Hans/billing.json b/packages/views/locales/zh-Hans/billing.json new file mode 100644 index 000000000..3a213bde4 --- /dev/null +++ b/packages/views/locales/zh-Hans/billing.json @@ -0,0 +1,75 @@ +{ + "title": "账单(测试页)", + "subtitle": "直接透传 multica-cloud 的 /api/v1/billing/*。不是正式 UI——只是把所有端点塞到一个页面方便验证 Stripe 流程。", + "endpoints": { + "balance": "GET /api/cloud-billing/balance", + "transactions": "GET /api/cloud-billing/transactions", + "batches": "GET /api/cloud-billing/batches", + "topups": "GET /api/cloud-billing/topups", + "buy": "GET /price-tiers · POST /checkout-sessions · POST /portal-sessions" + }, + "balance": { + "title": "余额", + "credits_suffix": "credits", + "meta": "raw micro: {{micro}} · owner: {{owner}}… · updated {{updated}}" + }, + "buy": { + "title": "购买 credits / 管理账单", + "no_tiers": "未配置任何价格档位。Cloud 那边应该没有配 Stripe key——checkout 会返回 503。", + "tier_money_to_credits": "{{money}} → {{credits}} credits", + "tier_bonus": " + 赠送 {{credits}}", + "tier_bonus_with_expiry": " + 赠送 {{credits}}({{expiry}})", + "tier_id": "id: {{id}}", + "open_portal": "打开 Stripe Billing Portal", + "portal_hint": "对于尚未付款的用户预期返回 400(Stripe 还没建 customer 记录)。", + "toast_no_url": "Cloud 没有返回 checkout URL", + "toast_checkout_failed": "Checkout 失败", + "toast_no_portal_url": "没拿到 portal URL", + "toast_portal_failed": "打开 Portal 失败" + }, + "checkout": { + "session_label": "Checkout session {{prefix}}…", + "loading": "正在加载订单状态…", + "fetch_failed": "拉取订单状态失败:{{error}}", + "fetch_failed_unknown": "未知错误", + "final_status": "最终状态:{{status}}", + "polling_status": "正在轮询订单状态…当前:{{status}}", + "status_unknown": "unknown", + "label_order": "订单", + "label_tier": "档位", + "label_charged": "已收款", + "charged_value": "{{money}} · {{credits}} credits", + "charged_with_bonus": "{{money}} · {{credits}} credits + {{bonus}} 赠送", + "clear_url": "从 URL 清除" + }, + "transactions": { + "title": "流水", + "empty": "暂无流水。", + "credits_value": "{{value}} credits", + "row_meta": "{{date}} · balance after: {{balance}} credits · ref: {{ref}}", + "ref_empty": "—" + }, + "batches": { + "title": "Credit 批次", + "empty": "暂无批次。", + "remaining_over_total": "{{remaining}} / {{total}} credits", + "id_suffix": "{{id}}…", + "consumed": "已消耗:{{value}} credits", + "expires_suffix": " · 过期时间 {{value}}", + "never_expires_suffix": " · 永不过期" + }, + "topups": { + "title": "充值订单", + "empty": "暂无充值订单。", + "amount_to_credits": "{{money}} → {{credits}} credits", + "amount_to_credits_with_bonus": "{{money}} → {{credits}} credits + {{bonus}} 赠送", + "row_meta": "{{date}} · stripe: {{checkout}}", + "stripe_empty": "—" + }, + "shared": { + "request_failed": "请求失败", + "refresh": "刷新", + "paging": "第 {{page}} / {{totalPages}} 页 · 共 {{total}} 条", + "date_dash": "—" + } +}