fix(billing): wire test page through useT instead of silencing i18n rule (#3451)

The previous fix (#3446) for the i18next/no-literal-string CI failure
took the lazy route — added a file-level eslint-disable comment with
the rationale that the test page is slated for deletion when the real
billing UI ships, so investing in a translation namespace was wasted
churn.

That argument is weak in practice:

  * The test page has been around since #3442 and there is no
    concrete date for when the real billing UI lands. "Throwaway"
    surfaces tend to outlive their stated half-life.
  * File-level disables hide future violations of the same rule from
    review — anyone touching this file later inherits an opt-out
    they didn't ask for.
  * The translation namespace is genuinely cheap. The page has ~50
    distinct strings, all of which now have a single en.json/zh.json
    home that the locale-parity test guards.

This commit replaces the silencer with a real `billing` namespace:

  * packages/views/locales/en/billing.json + zh-Hans/billing.json —
    every literal English label from the page, plus interpolated
    sentences with i18next `{{var}}` placeholders. The zh-Hans
    bundle is a basic translation; not perfect prose, but the parity
    test passes and a Chinese reviewer testing the page won't see
    raw English mixed with native UI chrome.
  * packages/views/i18n/resources-types.ts — adds the namespace to
    `I18nResources` so `t($ => $.billing.x.y)` is type-checked.
  * packages/views/locales/index.ts — registers both bundles in
    `RESOURCES` so the parity test sees them.
  * packages/views/billing/billing-test-page.tsx — every JSX literal
    rewritten as `t(($) => $.path)`, including aria-label,
    interpolated sentences (balance meta line, transactions row meta,
    paging footer), and conditional branches (with-bonus vs
    without-bonus rendering goes through two distinct keys rather
    than two literals). The file-level eslint-disable is removed
    along with its multi-paragraph rationale comment.
  * formatDate now takes `t` as an argument so the en/zh "—" dash
    placeholder is itself translated; calling useT inside the util
    would have violated the rule of hooks (the function is called
    from conditional render branches).

Verification:
  * pnpm --filter @multica/views lint   — 0 errors (15 unrelated
    warnings, all pre-existing on main)
  * pnpm --filter @multica/views typecheck — clean
  * pnpm --filter @multica/views test    — 883/883 passing,
    including the locale-parity test (which fails the build if
    en and zh-Hans diverge)

When the real billing UI ships and this file is deleted, the
`billing` namespace JSONs and the `resources-types.ts` /
`locales/index.ts` entries get deleted with it — same blast radius
as the eslint-disable would have had, but with proper i18n along
the way.
This commit is contained in:
LinYushen
2026-05-28 17:38:46 +08:00
committed by GitHub
parent ee4ec3b76d
commit 372a330707
5 changed files with 310 additions and 85 deletions

View File

@@ -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 (
<div className="space-y-6 p-6">
<header>
<h1 className="text-xl font-semibold">Billing (test page)</h1>
<h1 className="text-xl font-semibold">{t(($) => $.title)}</h1>
<p className="mt-1 text-sm text-muted-foreground">
Direct passthrough to multica-cloud&apos;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)}
</p>
</header>
@@ -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({
<Card className="border-primary/40 bg-primary/5">
<CardHeader>
<CardTitle className="text-sm">
Checkout session {sessionId.slice(0, 16)}
{t(($) => $.checkout.session_label, { prefix: sessionId.slice(0, 16) })}
</CardTitle>
<CardDescription className="text-xs">
{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),
})}
</CardDescription>
</CardHeader>
{data && (
<CardContent className="text-xs">
<dl className="grid grid-cols-[120px_1fr] gap-y-1">
<dt className="text-muted-foreground">Order</dt>
<dt className="text-muted-foreground">{t(($) => $.checkout.label_order)}</dt>
<dd className="font-mono">{data.order_id}</dd>
<dt className="text-muted-foreground">Tier</dt>
<dt className="text-muted-foreground">{t(($) => $.checkout.label_tier)}</dt>
<dd>{data.tier_id}</dd>
<dt className="text-muted-foreground">Charged</dt>
<dt className="text-muted-foreground">{t(($) => $.checkout.label_charged)}</dt>
<dd>
{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(),
})}
</dd>
</dl>
{terminal && (
@@ -181,7 +195,7 @@ function CheckoutSessionStatusBanner({
className="mt-3"
onClick={onDismiss}
>
Clear from URL
{t(($) => $.checkout.clear_url)}
</Button>
)}
</CardContent>
@@ -193,15 +207,16 @@ function CheckoutSessionStatusBanner({
// ─── Balance ─────────────────────────────────────────────────────────
function BalanceCard() {
const { t } = useT("billing");
const balance = useQuery(billingBalanceOptions());
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm">Balance</CardTitle>
<CardTitle className="text-sm">{t(($) => $.balance.title)}</CardTitle>
<CardDescription className="text-xs">
GET /api/cloud-billing/balance
{t(($) => $.endpoints.balance)}
</CardDescription>
</div>
<RefreshButton
@@ -219,13 +234,15 @@ function BalanceCard() {
<div className="text-2xl font-semibold tabular-nums">
{balance.data?.balance_credit.toLocaleString() ?? 0}
<span className="ml-1 text-sm font-normal text-muted-foreground">
credits
{t(($) => $.balance.credits_suffix)}
</span>
</div>
<div className="text-xs text-muted-foreground">
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),
})}
</div>
</div>
)}
@@ -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 (
<Card>
<CardHeader>
<CardTitle className="text-sm">Buy credits / Manage billing</CardTitle>
<CardTitle className="text-sm">{t(($) => $.buy.title)}</CardTitle>
<CardDescription className="text-xs">
GET /price-tiers · POST /checkout-sessions · POST /portal-sessions
{t(($) => $.endpoints.buy)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -307,10 +329,7 @@ function BuyAndPortalSection() {
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No price tiers configured. Cloud probably has no Stripe key set
checkout will return 503.
</p>
<p className="text-xs text-muted-foreground">{t(($) => $.buy.no_tiers)}</p>
)}
<div className="border-t pt-4">
@@ -326,11 +345,10 @@ function BuyAndPortalSection() {
) : (
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
)}
Open Stripe Billing Portal
{t(($) => $.buy.open_portal)}
</Button>
<p className="mt-1 text-xs text-muted-foreground">
400 is expected for users who haven&apos;t paid yet (no Stripe
customer record exists).
{t(($) => $.buy.portal_hint)}
</p>
</div>
</CardContent>
@@ -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 (
<button
type="button"
@@ -362,18 +395,11 @@ function TierButton({
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatMoney(tier.amount_cents, "usd")} {" "}
{tier.credits.toLocaleString()} credits
{tier.bonus_credits ? (
<>
{" "}
+ {tier.bonus_credits.toLocaleString()} bonus
{tier.bonus_expires_in ? ` (${tier.bonus_expires_in})` : ""}
</>
) : null}
{baseLine}
{bonusLine}
</div>
<div className="mt-1 font-mono text-[10px] text-muted-foreground/70">
id: {tier.id}
{t(($) => $.buy.tier_id, { id: tier.id })}
</div>
</button>
);
@@ -382,14 +408,15 @@ function TierButton({
// ─── Lists ───────────────────────────────────────────────────────────
function TransactionsCard() {
const { t } = useT("billing");
const txs = useQuery(billingTransactionsOptions({ page: 1, page_size: 20 }));
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm">Transactions</CardTitle>
<CardTitle className="text-sm">{t(($) => $.transactions.title)}</CardTitle>
<CardDescription className="text-xs">
GET /api/cloud-billing/transactions
{t(($) => $.endpoints.transactions)}
</CardDescription>
</div>
<RefreshButton
@@ -409,7 +436,7 @@ function TransactionsCard() {
))}
</ul>
) : (
<EmptyText>No transactions yet.</EmptyText>
<EmptyText>{t(($) => $.transactions.empty)}</EmptyText>
)}
<PagingFooter
page={txs.data?.page ?? 1}
@@ -422,6 +449,7 @@ function TransactionsCard() {
}
function TransactionRow({ row }: { row: BillingTransaction }) {
const { t } = useT("billing");
const credit = row.amount_micro / MICRO_PER_CREDIT;
return (
<li className="rounded-md border bg-background p-2.5">
@@ -434,34 +462,40 @@ function TransactionRow({ row }: { row: BillingTransaction }) {
</span>
<span
className={`text-sm tabular-nums ${
credit >= 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()}`,
})}
</span>
</div>
{row.description && (
<div className="mt-1 text-xs text-muted-foreground">{row.description}</div>
)}
<div className="mt-1 font-mono text-[10px] text-muted-foreground/70">
{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),
})}
</div>
</li>
);
}
function BatchesCard() {
const { t } = useT("billing");
const batches = useQuery(billingBatchesOptions({ page: 1, page_size: 20 }));
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm">Credit batches</CardTitle>
<CardTitle className="text-sm">{t(($) => $.batches.title)}</CardTitle>
<CardDescription className="text-xs">
GET /api/cloud-billing/batches
{t(($) => $.endpoints.batches)}
</CardDescription>
</div>
<RefreshButton
@@ -481,7 +515,7 @@ function BatchesCard() {
))}
</ul>
) : (
<EmptyText>No batches yet.</EmptyText>
<EmptyText>{t(($) => $.batches.empty)}</EmptyText>
)}
<PagingFooter
page={batches.data?.page ?? 1}
@@ -494,6 +528,7 @@ function BatchesCard() {
}
function BatchRow({ row }: { row: BillingBatch }) {
const { t } = useT("billing");
const total = row.total_micro / MICRO_PER_CREDIT;
const remaining = row.remaining_micro / MICRO_PER_CREDIT;
const consumed = total - remaining;
@@ -503,30 +538,36 @@ function BatchRow({ row }: { row: BillingBatch }) {
<span className="text-xs font-medium">
{row.source_type}
<span className="ml-1.5 font-mono text-[10px] text-muted-foreground">
{row.id.slice(0, 8)}
{t(($) => $.batches.id_suffix, { id: row.id.slice(0, 8) })}
</span>
</span>
<span className="text-sm tabular-nums">
{remaining.toLocaleString()} / {total.toLocaleString()} credits
{t(($) => $.batches.remaining_over_total, {
remaining: remaining.toLocaleString(),
total: total.toLocaleString(),
})}
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
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)}
</div>
</li>
);
}
function TopupsCard() {
const { t } = useT("billing");
const topups = useQuery(billingTopupsOptions({ page: 1, page_size: 20 }));
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm">Topup orders</CardTitle>
<CardTitle className="text-sm">{t(($) => $.topups.title)}</CardTitle>
<CardDescription className="text-xs">
GET /api/cloud-billing/topups
{t(($) => $.endpoints.topups)}
</CardDescription>
</div>
<RefreshButton
@@ -546,7 +587,7 @@ function TopupsCard() {
))}
</ul>
) : (
<EmptyText>No topup orders yet.</EmptyText>
<EmptyText>{t(($) => $.topups.empty)}</EmptyText>
)}
<PagingFooter
page={topups.data?.page ?? 1}
@@ -559,6 +600,7 @@ function TopupsCard() {
}
function TopupRow({ row }: { row: BillingTopup }) {
const { t } = useT("billing");
return (
<li className="rounded-md border bg-background p-2.5">
<div className="flex items-center justify-between gap-2">
@@ -577,13 +619,23 @@ function TopupRow({ row }: { row: BillingTopup }) {
</span>
</span>
<span className="text-sm tabular-nums">
{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(),
})}
</span>
</div>
<div className="mt-1 font-mono text-[10px] text-muted-foreground/70">
{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),
})}
</div>
</li>
);
@@ -600,10 +652,15 @@ function PagingFooter({
pageSize: number;
total: number;
}) {
const { t } = useT("billing");
if (total === 0) return null;
return (
<div className="mt-3 text-[10px] text-muted-foreground">
page {page} / {Math.max(1, Math.ceil(total / pageSize))} · {total} total
{t(($) => $.shared.paging, {
page,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
total,
})}
</div>
);
}
@@ -615,6 +672,7 @@ function RefreshButton({
isLoading: boolean;
onClick: () => void;
}) {
const { t } = useT("billing");
return (
<Button
type="button"
@@ -623,7 +681,7 @@ function RefreshButton({
className="h-7 w-7 p-0"
onClick={onClick}
disabled={isLoading}
aria-label="Refresh"
aria-label={t(($) => $.shared.refresh)}
>
<RefreshCw className={`h-3.5 w-3.5 ${isLoading ? "animate-spin" : ""}`} />
</Button>
@@ -631,9 +689,10 @@ function RefreshButton({
}
function ErrorText({ error }: { error: unknown }) {
const { t } = useT("billing");
return (
<p className="text-xs text-destructive">
{error instanceof Error ? error.message : "Request failed"}
{error instanceof Error ? error.message : t(($) => $.shared.request_failed)}
</p>
);
}
@@ -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<typeof useT<"billing">>["t"],
): string {
if (!value) return t(($) => $.shared.date_dash);
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString();

View File

@@ -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;
}
}

View File

@@ -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": "—"
}
}

View File

@@ -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<SupportedLocale, LocaleResources> = {
usage: enUsage,
ui: enUi,
squads: enSquads,
billing: enBilling,
},
"zh-Hans": {
common: zhHansCommon,
@@ -103,5 +106,6 @@ export const RESOURCES: Record<SupportedLocale, LocaleResources> = {
usage: zhHansUsage,
ui: zhHansUi,
squads: zhHansSquads,
billing: zhHansBilling,
},
};

View File

@@ -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": "对于尚未付款的用户预期返回 400Stripe 还没建 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": "—"
}
}