mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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:
@@ -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'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'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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
75
packages/views/locales/en/billing.json
Normal file
75
packages/views/locales/en/billing.json
Normal 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": "—"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
75
packages/views/locales/zh-Hans/billing.json
Normal file
75
packages/views/locales/zh-Hans/billing.json
Normal 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": "对于尚未付款的用户预期返回 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": "—"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user