From 425b76e9a79e27237fcc9c3abcf4d2c2fc830bdc Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:23:13 +0100 Subject: [PATCH] fix(portal/i18n): add inline default values to account-link + billing t() calls (#6842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The account-link / billing / Usage strings migrated to i18next in #6738 call `t("key")` with **no inline default**. When no i18next instance is initialized — which is the case in **Storybook** (the preview doesn't load the portal i18n config) — or whenever a key is missing, react-i18next renders the **raw key** (e.g. `billing.walletMeter.title`) instead of English. That's why the billing stories regressed to showing keys. ## Fix Add the English string as the `t()` default value, matching the **existing portal convention** (`AuthGate`, `Header`, `Sidebar`) and the editor: - plain → `t("key", "English")` - interpolation → `t("key", "English {{var}}", { var })` - plural → `t("key", "{{count}} …", { count })` Dynamic keys resolved via data fields carry a sibling `*Default` string passed as the default: - `LINK_INFO` badge labels → `labelDefault` (`t(info.labelKey, info.labelDefault)`) - `PdfsProcessedCard` segment legend → `labelDefault` / `descDefault` Defaults were sourced **verbatim from the merged `en-US/translation.toml`**, so the TOML stays the source of truth — the inline default only fills in when the catalogue isn't loaded or lacks the key. ## Scope All strings added in #6738: 5 account-link + 12 billing components + the Usage view (157 static call sites + the `LINK_INFO` / segment dynamic ones). No new keys; no copy changes. ## Verification - `tsc -p portal/tsconfig.json` → 0 - `eslint --max-warnings=0` (changed files) → 0 - `prettier --check` → clean - portal `vitest` → **62/62 pass** No behaviour change when i18n is initialized; Storybook and any missing-key fallback now render English. --- .../account-link/AccountLinkPanel.tsx | 39 ++++-- .../account-link/LinkAccountCard.tsx | 48 ++++++-- .../account-link/LinkAccountModal.tsx | 30 +++-- .../src/components/account-link/LinkGate.tsx | 13 +- .../account-link/LinkedInstancesTable.tsx | 38 +++--- .../components/billing/EnterpriseUpsell.tsx | 11 +- .../components/billing/FreePdfEditorsCard.tsx | 25 ++-- .../src/components/billing/FreePlanView.tsx | 32 +++-- .../src/components/billing/InvoicesList.tsx | 74 +++++++---- .../components/billing/LinkAccountPrompt.tsx | 9 +- .../components/billing/PaymentMethodCard.tsx | 40 +++--- .../components/billing/PdfsProcessedCard.tsx | 38 ++++-- .../src/components/billing/SpendLimitCard.tsx | 116 ++++++++++++------ .../components/billing/SpendThisMonthCard.tsx | 28 +++-- .../billing/StripeCheckoutModal.tsx | 31 +++-- .../components/billing/SubscribedPlanView.tsx | 32 +++-- .../src/components/billing/WalletMeter.tsx | 47 ++++--- frontend/portal/src/contexts/LinkContext.tsx | 15 ++- frontend/portal/src/views/Usage.tsx | 57 ++++++--- 19 files changed, 508 insertions(+), 215 deletions(-) diff --git a/frontend/portal/src/components/account-link/AccountLinkPanel.tsx b/frontend/portal/src/components/account-link/AccountLinkPanel.tsx index bccf2cfb74..eaf3c86be6 100644 --- a/frontend/portal/src/components/account-link/AccountLinkPanel.tsx +++ b/frontend/portal/src/components/account-link/AccountLinkPanel.tsx @@ -56,7 +56,12 @@ export function AccountLinkPanel() {
-

{t("accountLink.panel.sub")}

+

+ {t( + "accountLink.panel.sub", + "Link this self-hosted org to its Stirling account so unattended processing bills against your org wallet.", + )} +

- {t(LINK_INFO[linkState].labelKey)} + {t(LINK_INFO[linkState].labelKey, LINK_INFO[linkState].labelDefault)}
@@ -78,10 +83,13 @@ export function AccountLinkPanel() {

- {t("accountLink.panel.instancesTitle")} + {t("accountLink.panel.instancesTitle", "Linked instances")}

- {t("accountLink.panel.instancesSub")} + {t( + "accountLink.panel.instancesSub", + "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access.", + )}

{instancesState.loading ? ( @@ -93,12 +101,21 @@ export function AccountLinkPanel() { ) : instancesState.error ? ( {instancesState.error instanceof HttpError && instancesState.error.status === 403 - ? t("accountLink.panel.loadError.forbidden") - : t("accountLink.panel.loadError.generic")} + ? t( + "accountLink.panel.loadError.forbidden", + "Only the team owner can view the org's linked instances.", + ) + : t( + "accountLink.panel.loadError.generic", + "Couldn't load the team's linked instances. Try again in a moment.", + )} ) : ( + {revokeError} )} diff --git a/frontend/portal/src/components/account-link/LinkAccountCard.tsx b/frontend/portal/src/components/account-link/LinkAccountCard.tsx index 7231fbbe28..f47accf391 100644 --- a/frontend/portal/src/components/account-link/LinkAccountCard.tsx +++ b/frontend/portal/src/components/account-link/LinkAccountCard.tsx @@ -24,30 +24,44 @@ export function LinkAccountCard({ link }: Props) {
- {t("accountLink.card.eyebrow")} + {t("accountLink.card.eyebrow", "Account link")} -

{t("accountLink.card.title")}

+

+ {t( + "accountLink.card.title", + "Link this org to its Stirling account", + )} +

{linked - ? t("accountLink.card.linked") - : t("accountLink.card.notLinked")} + ? t("accountLink.card.linked", "Linked") + : t("accountLink.card.notLinked", "Not linked")}
{!link.loginConfigured && ( - {t("accountLink.card.loginNotConfigured.before")}{" "} + {t("accountLink.card.loginNotConfigured.before", "Set")}{" "} VITE_SAAS_SUPABASE_URL{" "} - {t("accountLink.card.loginNotConfigured.after")} + {t( + "accountLink.card.loginNotConfigured.after", + "to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog.", + )} )} {link.error && ( - + {link.error} )} @@ -56,9 +70,17 @@ export function LinkAccountCard({ link }: Props) {
{link.status?.name - ? t("accountLink.card.linkedAs", { name: link.status.name }) - : t("accountLink.card.linkedGeneric")}{" "} - {t("accountLink.card.billingNote")} + ? t("accountLink.card.linkedAs", "Linked as {{name}}.", { + name: link.status.name, + }) + : t( + "accountLink.card.linkedGeneric", + "This instance is linked.", + )}{" "} + {t( + "accountLink.card.billingNote", + "Unattended processing bills against your org wallet.", + )}
) : (
)} diff --git a/frontend/portal/src/components/account-link/LinkAccountModal.tsx b/frontend/portal/src/components/account-link/LinkAccountModal.tsx index b0dafac4ec..8c23bbcd0f 100644 --- a/frontend/portal/src/components/account-link/LinkAccountModal.tsx +++ b/frontend/portal/src/components/account-link/LinkAccountModal.tsx @@ -66,13 +66,19 @@ export function LinkAccountModal({ width="md" title={ reauth - ? t("accountLink.modal.reauthTitle") - : t("accountLink.modal.linkTitle") + ? t("accountLink.modal.reauthTitle", "Sign in again") + : t("accountLink.modal.linkTitle", "Link your Stirling account") } subtitle={ reauth - ? t("accountLink.modal.reauthSubtitle") - : t("accountLink.modal.linkSubtitle") + ? t( + "accountLink.modal.reauthSubtitle", + "Your session expired — sign back in to your Stirling account. Your instance stays linked.", + ) + : t( + "accountLink.modal.linkSubtitle", + "Sign in to the account this server should bill against.", + ) } > {isSaasSupabaseConfigured ? ( @@ -81,13 +87,19 @@ export function LinkAccountModal({
- {t("accountLink.modal.loginNotConfigured.before")}{" "} + {t("accountLink.modal.loginNotConfigured.before", "Set")}{" "} VITE_SAAS_SUPABASE_URL{" "} - {t("accountLink.modal.loginNotConfigured.and")}{" "} + {t("accountLink.modal.loginNotConfigured.and", "and")}{" "} VITE_SAAS_SUPABASE_ANON_KEY{" "} - {t("accountLink.modal.loginNotConfigured.after")} + {t( + "accountLink.modal.loginNotConfigured.after", + "to enable in-app linking against the hosted Stirling account.", + )} {import.meta.env.DEV && ( )}
diff --git a/frontend/portal/src/components/account-link/LinkGate.tsx b/frontend/portal/src/components/account-link/LinkGate.tsx index 254f4739bc..de56c844e9 100644 --- a/frontend/portal/src/components/account-link/LinkGate.tsx +++ b/frontend/portal/src/components/account-link/LinkGate.tsx @@ -29,13 +29,18 @@ export function LinkGate({ children, feature }: Props) { tone="info" title={ feature - ? t("accountLink.gate.titleFeature", { feature }) - : t("accountLink.gate.title") + ? t("accountLink.gate.titleFeature", "Link to unlock {{feature}}", { + feature, + }) + : t("accountLink.gate.title", "Link to unlock") } - description={t("accountLink.gate.description")} + description={t( + "accountLink.gate.description", + "Link this org's Stirling account to use billable features.", + )} action={ } /> diff --git a/frontend/portal/src/components/account-link/LinkedInstancesTable.tsx b/frontend/portal/src/components/account-link/LinkedInstancesTable.tsx index bfe7fd0258..a6460016d5 100644 --- a/frontend/portal/src/components/account-link/LinkedInstancesTable.tsx +++ b/frontend/portal/src/components/account-link/LinkedInstancesTable.tsx @@ -19,15 +19,20 @@ interface Props { } function relativeTime(iso: string | null, t: TFunction): string { - if (!iso) return t("accountLink.instances.time.never"); + if (!iso) return t("accountLink.instances.time.never", "never"); const diffMs = Date.now() - new Date(iso).getTime(); const mins = Math.round(diffMs / 60_000); - if (mins < 1) return t("accountLink.instances.time.justNow"); + if (mins < 1) return t("accountLink.instances.time.justNow", "just now"); if (mins < 60) - return t("accountLink.instances.time.minutesAgo", { count: mins }); + return t("accountLink.instances.time.minutesAgo", "{{count}}m ago", { + count: mins, + }); const hrs = Math.round(mins / 60); - if (hrs < 24) return t("accountLink.instances.time.hoursAgo", { count: hrs }); - return t("accountLink.instances.time.daysAgo", { + if (hrs < 24) + return t("accountLink.instances.time.hoursAgo", "{{count}}h ago", { + count: hrs, + }); + return t("accountLink.instances.time.daysAgo", "{{count}}d ago", { count: Math.round(hrs / 24), }); } @@ -42,11 +47,11 @@ export function LinkedInstancesTable({ const cols: TableColumn[] = [ { key: "name", - header: t("accountLink.instances.columns.instance"), + header: t("accountLink.instances.columns.instance", "Instance"), render: (i) => (
- {i.name ?? t("accountLink.instances.unnamed")} + {i.name ?? t("accountLink.instances.unnamed", "Unnamed instance")} {i.deviceId}
@@ -54,21 +59,21 @@ export function LinkedInstancesTable({ }, { key: "status", - header: t("accountLink.instances.columns.status"), + header: t("accountLink.instances.columns.status", "Status"), render: (i) => i.revoked ? ( - {t("accountLink.instances.revoked")} + {t("accountLink.instances.revoked", "Revoked")} ) : ( - {t("accountLink.instances.active")} + {t("accountLink.instances.active", "Active")} ), }, { key: "lastSeen", - header: t("accountLink.instances.columns.lastSeen"), + header: t("accountLink.instances.columns.lastSeen", "Last seen"), render: (i) => ( {relativeTime(i.lastSeenAt, t)} @@ -77,7 +82,7 @@ export function LinkedInstancesTable({ }, { key: "created", - header: t("accountLink.instances.columns.linked"), + header: t("accountLink.instances.columns.linked", "Linked"), render: (i) => ( {relativeTime(i.createdAt, t)} @@ -97,7 +102,7 @@ export function LinkedInstancesTable({ loading={revokingId === i.instanceId} onClick={() => onRevoke(i)} > - {t("accountLink.instances.revoke")} + {t("accountLink.instances.revoke", "Revoke")} ), }, @@ -108,8 +113,11 @@ export function LinkedInstancesTable({ {instances.length === 0 ? ( ) : ( - {t("billing.enterpriseUpsell.eyebrow")} + {t("billing.enterpriseUpsell.eyebrow", "Volume discount · 1M+ PDFs")}

- {t("billing.enterpriseUpsell.title")} + {t("billing.enterpriseUpsell.title", "Stirling Enterprise")}

- {t("billing.enterpriseUpsell.description")} + {t( + "billing.enterpriseUpsell.description", + "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships.", + )}

{/* Destination wired when the enterprise/sales URL is confirmed. */}
diff --git a/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx b/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx index e6eaa65d51..2744e26d08 100644 --- a/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx +++ b/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx @@ -36,30 +36,39 @@ export function FreePdfEditorsCard() {

- {t("billing.freeEditors.title")}{" "} + {t("billing.freeEditors.title", "Free PDF Editors")}{" "} - {t("billing.freeEditors.previewBadge")} + {t("billing.freeEditors.previewBadge", "Preview · sample data")}

- {t("billing.freeEditors.subtitle")} + {t( + "billing.freeEditors.subtitle", + "Deploy anywhere, for your whole team.", + )}

- + {/* Opens the Users tab with its invite-member modal (via the ?invite param). */} diff --git a/frontend/portal/src/components/billing/FreePlanView.tsx b/frontend/portal/src/components/billing/FreePlanView.tsx index 2d8526d805..02a5352a43 100644 --- a/frontend/portal/src/components/billing/FreePlanView.tsx +++ b/frontend/portal/src/components/billing/FreePlanView.tsx @@ -35,7 +35,12 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { function openCheckout() { if (wallet.teamId == null) { - setMissingTeam(t("billing.freePlan.noTeamResolved")); + setMissingTeam( + t( + "billing.freePlan.noTeamResolved", + "No team is resolved on your wallet yet — refresh and try again.", + ), + ); return; } setMissingTeam(null); @@ -48,7 +53,7 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { onClick={openCheckout} disabled={wallet.teamId == null} > - {t("billing.freePlan.switchOnProcessor")} + {t("billing.freePlan.switchOnProcessor", "Switch on the Processor →")} ) : null; @@ -57,20 +62,20 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { {/* Current plan */}
- {t("billing.freePlan.currentPlan")} + {t("billing.freePlan.currentPlan", "Current plan")}

- {t("billing.freePlan.planName")} + {t("billing.freePlan.planName", "Editor")}

- {t("billing.freePlan.freeForever")} + {t("billing.freePlan.freeForever", "Free forever")} - {t("billing.freePlan.ssoIncluded")} + {t("billing.freePlan.ssoIncluded", "SSO included")} - {t("billing.freePlan.unlimitedUsers")} + {t("billing.freePlan.unlimitedUsers", "Unlimited users")}
@@ -81,13 +86,22 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { {missingTeam && ( - + {missingTeam} )} {!isLeader && (

- {t("billing.freePlan.ownerOnly")} + {t( + "billing.freePlan.ownerOnly", + "Only the team owner can switch on the Processor plan.", + )}

)} diff --git a/frontend/portal/src/components/billing/InvoicesList.tsx b/frontend/portal/src/components/billing/InvoicesList.tsx index 425bd10582..8b13670148 100644 --- a/frontend/portal/src/components/billing/InvoicesList.tsx +++ b/frontend/portal/src/components/billing/InvoicesList.tsx @@ -83,13 +83,13 @@ export function InvoicesList() { const columns: TableColumn[] = [ { key: "date", - header: t("billing.invoices.columnDate"), + header: t("billing.invoices.columnDate", "Date"), render: (inv) => inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "—", }, { key: "pdfs", - header: t("billing.invoices.columnPdfsProcessed"), + header: t("billing.invoices.columnPdfsProcessed", "PDFs processed"), align: "right", // Billed units on the invoice's metered line item; "—" when the // line-item table isn't synced into the Stripe mirror. @@ -98,7 +98,7 @@ export function InvoicesList() { }, { key: "amount", - header: t("billing.invoices.columnAmount"), + header: t("billing.invoices.columnAmount", "Amount"), align: "right", render: (inv) => inv.totalMinor == null @@ -107,7 +107,7 @@ export function InvoicesList() { }, { key: "status", - header: t("billing.invoices.columnStatus"), + header: t("billing.invoices.columnStatus", "Status"), render: (inv) => ( {inv.status} @@ -116,10 +116,11 @@ export function InvoicesList() { }, { key: "description", - header: t("billing.invoices.columnDescription"), + header: t("billing.invoices.columnDescription", "Description"), render: (inv) => ( - {inv.description ?? t("billing.invoices.descriptionFallback")} + {inv.description ?? + t("billing.invoices.descriptionFallback", "Invoice")} ), }, @@ -135,11 +136,15 @@ export function InvoicesList() { href={inv.hostedInvoiceUrl} target="_blank" rel="noopener noreferrer" - aria-label={t("billing.invoices.viewAriaLabel", { - number: inv.number ?? inv.id, - })} + aria-label={t( + "billing.invoices.viewAriaLabel", + "View invoice {{number}} in Stripe", + { + number: inv.number ?? inv.id, + }, + )} > - {t("billing.invoices.viewLink")} + {t("billing.invoices.viewLink", "View ↗")} )} {inv.invoicePdf && ( @@ -148,11 +153,15 @@ export function InvoicesList() { href={inv.invoicePdf} target="_blank" rel="noopener noreferrer" - aria-label={t("billing.invoices.downloadAriaLabel", { - number: inv.number ?? inv.id, - })} + aria-label={t( + "billing.invoices.downloadAriaLabel", + "Download invoice {{number}} as PDF", + { + number: inv.number ?? inv.id, + }, + )} > - {t("billing.invoices.pdfLink")} + {t("billing.invoices.pdfLink", "PDF ↓")} )} @@ -163,7 +172,7 @@ export function InvoicesList() { return (

- {t("billing.invoices.title")} + {t("billing.invoices.title", "Invoice history")}

{invoices === null && !error && ( @@ -176,15 +185,22 @@ export function InvoicesList() { {error && (

- {t("billing.invoices.loadError", { error })} + {t( + "billing.invoices.loadError", + "Couldn't load invoices: {{error}}", + { error }, + )}

)} {invoices !== null && invoices.length === 0 && !error && ( )} @@ -204,14 +220,28 @@ export function InvoicesList() { onClick={() => setShowAll((v) => !v)} > {showAll - ? t("billing.invoices.showFewer", { count: DEFAULT_VISIBLE }) + ? t( + "billing.invoices.showFewer", + "Show fewer (top {{count}})", + { count: DEFAULT_VISIBLE }, + ) : atFetchLimit - ? t("billing.invoices.showMostRecent", { count: total }) - : t("billing.invoices.showAll", { count: total })} + ? t( + "billing.invoices.showMostRecent", + "Show {{count}} most recent", + { count: total }, + ) + : t("billing.invoices.showAll", "Show all {{count}}", { + count: total, + })} {showAll && atFetchLimit && ( - {t("billing.invoices.fetchLimitNote", { count: FETCH_LIMIT })} + {t( + "billing.invoices.fetchLimitNote", + "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal.", + { count: FETCH_LIMIT }, + )} )} diff --git a/frontend/portal/src/components/billing/LinkAccountPrompt.tsx b/frontend/portal/src/components/billing/LinkAccountPrompt.tsx index 72fb366142..df7429784e 100644 --- a/frontend/portal/src/components/billing/LinkAccountPrompt.tsx +++ b/frontend/portal/src/components/billing/LinkAccountPrompt.tsx @@ -14,11 +14,14 @@ export function LinkAccountPrompt() { openLinkModal()}> - {t("billing.linkPrompt.cta")} + {t("billing.linkPrompt.cta", "Link Stirling account")} } /> diff --git a/frontend/portal/src/components/billing/PaymentMethodCard.tsx b/frontend/portal/src/components/billing/PaymentMethodCard.tsx index 9876a8a4f3..ee2a1a4f01 100644 --- a/frontend/portal/src/components/billing/PaymentMethodCard.tsx +++ b/frontend/portal/src/components/billing/PaymentMethodCard.tsx @@ -46,33 +46,45 @@ export function PaymentMethodCard({ onManage, managing }: Props) {
- {t("billing.paymentMethod.eyebrow")} + {t("billing.paymentMethod.eyebrow", "Payment method")} {hasCard ? ( <>

- {t("billing.paymentMethod.cardEnding", { - brand: titleCase( - pm.brand ?? t("billing.paymentMethod.cardFallback"), - ), - last4: pm.last4, - })} + {t( + "billing.paymentMethod.cardEnding", + "{{brand}} ending {{last4}}", + { + brand: titleCase( + pm.brand ?? + t("billing.paymentMethod.cardFallback", "Card"), + ), + last4: pm.last4, + }, + )}

{pm.expMonth != null && pm.expYear != null - ? t("billing.paymentMethod.expiresBilledMonthly", { - expiry: `${String(pm.expMonth).padStart(2, "0")}/${pm.expYear}`, - }) - : t("billing.paymentMethod.billedMonthly")} + ? t( + "billing.paymentMethod.expiresBilledMonthly", + "Expires {{expiry}} · billed monthly", + { + expiry: `${String(pm.expMonth).padStart(2, "0")}/${pm.expYear}`, + }, + ) + : t("billing.paymentMethod.billedMonthly", "Billed monthly")}

) : ( <>

- {t("billing.paymentMethod.managedTitle")} + {t("billing.paymentMethod.managedTitle", "Managed in Stripe")}

- {t("billing.paymentMethod.managedSub")} + {t( + "billing.paymentMethod.managedSub", + "Your card and billing details are kept securely in Stripe's customer portal.", + )}

)} @@ -83,7 +95,7 @@ export function PaymentMethodCard({ onManage, managing }: Props) { loading={managing} onClick={onManage} > - {t("billing.paymentMethod.update")} + {t("billing.paymentMethod.update", "Update")}
diff --git a/frontend/portal/src/components/billing/PdfsProcessedCard.tsx b/frontend/portal/src/components/billing/PdfsProcessedCard.tsx index 1718ea2a19..ba5ac17ea9 100644 --- a/frontend/portal/src/components/billing/PdfsProcessedCard.tsx +++ b/frontend/portal/src/components/billing/PdfsProcessedCard.tsx @@ -12,25 +12,33 @@ import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing"; const SEGMENTS: ReadonlyArray<{ key: keyof WalletCategoryBreakdown; labelKey: string; + labelDefault: string; descKey: string; + descDefault: string; cls: string; }> = [ { key: "api", labelKey: "billing.pdfsProcessed.segmentApiLabel", + labelDefault: "API", descKey: "billing.pdfsProcessed.segmentApiDesc", + descDefault: "Direct API requests", cls: "blue", }, { key: "ai", labelKey: "billing.pdfsProcessed.segmentAgentsLabel", + labelDefault: "Agents", descKey: "billing.pdfsProcessed.segmentAgentsDesc", + descDefault: "AI agent actions", cls: "purple", }, { key: "automation", labelKey: "billing.pdfsProcessed.segmentAutomationLabel", + labelDefault: "Automation", descKey: "billing.pdfsProcessed.segmentAutomationDesc", + descDefault: "Automations & pipelines", cls: "teal", }, ]; @@ -43,14 +51,14 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) { return ( - {t("billing.pdfsProcessed.eyebrow")} + {t("billing.pdfsProcessed.eyebrow", "PDFs processed this period")}
{wallet.billableUsed.toLocaleString()} - {t("billing.pdfsProcessed.unit")} + {t("billing.pdfsProcessed.unit", "metered PDFs")}
@@ -59,7 +67,10 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
{SEGMENTS.map((s) => b[s.key] > 0 ? ( @@ -79,16 +90,20 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) { aria-hidden /> - {t(s.labelKey)} + {t(s.labelKey, s.labelDefault)} - {t("billing.pdfsProcessed.legendValue", { - count: b[s.key], - formatted: b[s.key].toLocaleString(), - })} + {t( + "billing.pdfsProcessed.legendValue", + "{{formatted}} PDFs", + { + count: b[s.key], + formatted: b[s.key].toLocaleString(), + }, + )} - {t(s.descKey)} + {t(s.descKey, s.descDefault)}
))} @@ -96,7 +111,10 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) { ) : (

- {t("billing.pdfsProcessed.emptyPeriod")} + {t( + "billing.pdfsProcessed.emptyPeriod", + "No metered processing yet this period.", + )}

)}
diff --git a/frontend/portal/src/components/billing/SpendLimitCard.tsx b/frontend/portal/src/components/billing/SpendLimitCard.tsx index d4861dc2d1..2593810017 100644 --- a/frontend/portal/src/components/billing/SpendLimitCard.tsx +++ b/frontend/portal/src/components/billing/SpendLimitCard.tsx @@ -127,10 +127,10 @@ export function SpendLimitCard({ return ( - {t("billing.spendLimit.eyebrow")} + {t("billing.spendLimit.eyebrow", "Spend limit")}

- {t("billing.spendLimit.editTitle")} + {t("billing.spendLimit.editTitle", "Set your monthly ceiling")}

{proj && draftCap !== proj.suggestedMajor && ( @@ -147,19 +150,31 @@ export function SpendLimitCard({ className="portal-billing__suggested" onClick={() => setDraftCap(proj.suggestedMajor)} > - {t("billing.spendLimit.useSuggested", { - amount: formatMoneyMajor(proj.suggestedMajor, wallet.currency), - })} + {t( + "billing.spendLimit.useSuggested", + "Use suggested · {{amount}} / month", + { + amount: formatMoneyMajor(proj.suggestedMajor, wallet.currency), + }, + )} )}
- {t("billing.spendLimit.guardrailLabel")}{" "} - {t("billing.spendLimit.guardrailBody")} + + {t("billing.spendLimit.guardrailLabel", "Your guardrail:")} + {" "} + {t( + "billing.spendLimit.guardrailBody", + "a hard ceiling — you're never billed past it. At the cap, metered processing pauses (unlimited PDF editing keeps working) until you raise it or the cycle resets. Nothing is lost.", + )}
{error && ( - + {error} )} @@ -170,10 +185,10 @@ export function SpendLimitCard({ size="sm" onClick={() => onAdjustingChange(false)} > - {t("billing.spendLimit.cancel")} + {t("billing.spendLimit.cancel", "Cancel")}
@@ -194,10 +209,13 @@ export function SpendLimitCard({
- {t("billing.spendLimit.eyebrow")} + {t("billing.spendLimit.eyebrow", "Spend limit")}

- {t("billing.spendLimit.displaySub")} + {t( + "billing.spendLimit.displaySub", + "You're only billed for what you process automatically — never past the ceiling.", + )}

{isLeader && ( @@ -206,7 +224,7 @@ export function SpendLimitCard({ size="sm" onClick={() => onAdjustingChange(true)} > - {t("billing.spendLimit.adjustLimit")} + {t("billing.spendLimit.adjustLimit", "Adjust limit")} )}
@@ -221,15 +239,21 @@ export function SpendLimitCard({ capSuffix={ capActive ? docEstimate != null - ? t("billing.spendLimit.capSuffixWithDocs", { - documents: docEstimate.toLocaleString(), - }) - : t("billing.spendLimit.capSuffix") - : t("billing.spendLimit.noCap") + ? t( + "billing.spendLimit.capSuffixWithDocs", + "/ month · ≈ {{documents}} documents", + { + documents: docEstimate.toLocaleString(), + }, + ) + : t("billing.spendLimit.capSuffix", "/ month") + : t("billing.spendLimit.noCap", "no cap") } statusLabel={ capActive - ? t("billing.spendLimit.pctUsed", { pct: Math.round(pct) }) + ? t("billing.spendLimit.pctUsed", "{{pct}}% used", { + pct: Math.round(pct), + }) : null } showBar={capActive} @@ -237,21 +261,29 @@ export function SpendLimitCard({ capActive ? ( <> - {t("billing.spendLimit.usedThisMonth", { - amount: spentLabel, - })} + {t( + "billing.spendLimit.usedThisMonth", + "{{amount}} used this month", + { + amount: spentLabel, + }, + )} - {t("billing.spendLimit.remaining", { + {t("billing.spendLimit.remaining", "{{amount}} remaining", { amount: formatMinor(remainingMinor, wallet.currency), })} ) : ( - {t("billing.spendLimit.thisPeriodUncapped", { - amount: spentLabel, - })} + {t( + "billing.spendLimit.thisPeriodUncapped", + "{{amount}} this period · uncapped", + { + amount: spentLabel, + }, + )} ) } @@ -260,18 +292,24 @@ export function SpendLimitCard({ {proj && (

- {t("billing.spendLimit.projection.label")}{" "} - {t("billing.spendLimit.projection.body", { - count: proj.daysToCap, - rate: `${symbol}${proj.dailyRateMajor.toLocaleString(undefined, { - maximumFractionDigits: 2, - })}`, - monthEnd: formatMoneyMajor( - Math.round(proj.projectedEndMajor), - wallet.currency, - ), - suggested: formatMoneyMajor(proj.suggestedMajor, wallet.currency), - })} + + {t("billing.spendLimit.projection.label", "Projected to exceed.")} + {" "} + {t( + "billing.spendLimit.projection.body", + "At {{rate}}/day you reach the cap in ~{{count}} days (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}.", + { + count: proj.daysToCap, + rate: `${symbol}${proj.dailyRateMajor.toLocaleString(undefined, { + maximumFractionDigits: 2, + })}`, + monthEnd: formatMoneyMajor( + Math.round(proj.projectedEndMajor), + wallet.currency, + ), + suggested: formatMoneyMajor(proj.suggestedMajor, wallet.currency), + }, + )}

)}
diff --git a/frontend/portal/src/components/billing/SpendThisMonthCard.tsx b/frontend/portal/src/components/billing/SpendThisMonthCard.tsx index 1989224df9..2a8baf2986 100644 --- a/frontend/portal/src/components/billing/SpendThisMonthCard.tsx +++ b/frontend/portal/src/components/billing/SpendThisMonthCard.tsx @@ -19,7 +19,7 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) { return ( - {t("billing.spendThisMonth.eyebrow")} + {t("billing.spendThisMonth.eyebrow", "Spend this month")}
@@ -28,15 +28,23 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) {

{rateLabel - ? t("billing.spendThisMonth.processedWithRate", { - count: wallet.billableUsed, - formattedCount: wallet.billableUsed.toLocaleString(), - rate: rateLabel, - }) - : t("billing.spendThisMonth.processed", { - count: wallet.billableUsed, - formattedCount: wallet.billableUsed.toLocaleString(), - })} + ? t( + "billing.spendThisMonth.processedWithRate", + "{{formattedCount}} PDFs processed, at {{rate}} each.", + { + count: wallet.billableUsed, + formattedCount: wallet.billableUsed.toLocaleString(), + rate: rateLabel, + }, + ) + : t( + "billing.spendThisMonth.processed", + "{{formattedCount}} PDFs processed.", + { + count: wallet.billableUsed, + formattedCount: wallet.billableUsed.toLocaleString(), + }, + )}

diff --git a/frontend/portal/src/components/billing/StripeCheckoutModal.tsx b/frontend/portal/src/components/billing/StripeCheckoutModal.tsx index ac590adc71..24d5cbb251 100644 --- a/frontend/portal/src/components/billing/StripeCheckoutModal.tsx +++ b/frontend/portal/src/components/billing/StripeCheckoutModal.tsx @@ -89,7 +89,12 @@ export function StripeCheckoutModal({ return; } if (!session.clientSecret) { - setError(t("billing.checkout.noClientSecret")); + setError( + t( + "billing.checkout.noClientSecret", + "Edge function returned no client_secret.", + ), + ); return; } setClientSecret(session.clientSecret); @@ -115,21 +120,33 @@ export function StripeCheckoutModal({ open={open} onClose={onClose} width="lg" - title={t("billing.checkout.title")} - subtitle={t("billing.checkout.subtitle")} + title={t("billing.checkout.title", "Turn on the Processor plan")} + subtitle={t( + "billing.checkout.subtitle", + "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest.", + )} > {!publishableKey && ( - {t("billing.checkout.notConfigured.bodyBefore")}{" "} + {t("billing.checkout.notConfigured.bodyBefore", "Set")}{" "} VITE_STRIPE_PUBLISHABLE_KEY{" "} - {t("billing.checkout.notConfigured.bodyAfter")} + {t( + "billing.checkout.notConfigured.bodyAfter", + "in the portal env to enable in-app checkout.", + )} )} {publishableKey && error && ( - + {error} )} diff --git a/frontend/portal/src/components/billing/SubscribedPlanView.tsx b/frontend/portal/src/components/billing/SubscribedPlanView.tsx index 6d9d6bd6f9..6ccdbe7e8d 100644 --- a/frontend/portal/src/components/billing/SubscribedPlanView.tsx +++ b/frontend/portal/src/components/billing/SubscribedPlanView.tsx @@ -56,22 +56,35 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) { tone={state === "DEGRADED" ? "danger" : "warning"} title={ state === "DEGRADED" - ? t("billing.subscribedPlan.capWarn.reachedTitle") - : t("billing.subscribedPlan.capWarn.approachingTitle", { - pct: Math.round(pct), - }) + ? t( + "billing.subscribedPlan.capWarn.reachedTitle", + "Monthly spend limit reached", + ) + : t( + "billing.subscribedPlan.capWarn.approachingTitle", + "You're at {{pct}}% of your monthly spend limit", + { + pct: Math.round(pct), + }, + ) } action={ isLeader ? ( ) : undefined } > {state === "DEGRADED" - ? t("billing.subscribedPlan.capWarn.reachedBody") - : t("billing.subscribedPlan.capWarn.approachingBody")} + ? t( + "billing.subscribedPlan.capWarn.reachedBody", + "Metered processing is paused until you raise the limit or the cycle resets. Unlimited PDF editing keeps working.", + ) + : t( + "billing.subscribedPlan.capWarn.approachingBody", + "Raise it now so automated processing never pauses.", + )} )} @@ -96,7 +109,10 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) { {portal.error && ( {portal.error} diff --git a/frontend/portal/src/components/billing/WalletMeter.tsx b/frontend/portal/src/components/billing/WalletMeter.tsx index 506faffb8b..c8a2ed81de 100644 --- a/frontend/portal/src/components/billing/WalletMeter.tsx +++ b/frontend/portal/src/components/billing/WalletMeter.tsx @@ -26,12 +26,16 @@ export function WalletMeter({ wallet, action }: Props) { : null; const title = rate != null - ? t("billing.walletMeter.titleWithRate", { - count: wallet.freeAllowance, - allowance: wallet.freeAllowance.toLocaleString(), - rate: formatMinor(rate, wallet.currency), - }) - : t("billing.walletMeter.title", { + ? t( + "billing.walletMeter.titleWithRate", + "Process {{allowance}} PDFs free, then {{rate}}/PDF", + { + count: wallet.freeAllowance, + allowance: wallet.freeAllowance.toLocaleString(), + rate: formatMinor(rate, wallet.currency), + }, + ) + : t("billing.walletMeter.title", "Process {{allowance}} PDFs free", { count: wallet.freeAllowance, allowance: wallet.freeAllowance.toLocaleString(), }); @@ -41,11 +45,14 @@ export function WalletMeter({ wallet, action }: Props) {
- {t("billing.walletMeter.eyebrow")} + {t("billing.walletMeter.eyebrow", "Processor trial")}

{title}

- {t("billing.walletMeter.sub")} + {t( + "billing.walletMeter.sub", + "Use the PDF Editor for free. Pay to process PDFs automatically.", + )}

{action} @@ -55,14 +62,22 @@ export function WalletMeter({ wallet, action }: Props) { state={state} pct={pct} figure={wallet.billableUsed.toLocaleString()} - capSuffix={t("billing.walletMeter.capSuffix", { - count: wallet.freeAllowance, - allowance: wallet.freeAllowance.toLocaleString(), - })} - statusLabel={t("billing.walletMeter.statusLabel", { - count: wallet.freeRemaining, - remaining: wallet.freeRemaining.toLocaleString(), - })} + capSuffix={t( + "billing.walletMeter.capSuffix", + "of {{allowance}} free PDFs used", + { + count: wallet.freeAllowance, + allowance: wallet.freeAllowance.toLocaleString(), + }, + )} + statusLabel={t( + "billing.walletMeter.statusLabel", + "{{remaining}} left", + { + count: wallet.freeRemaining, + remaining: wallet.freeRemaining.toLocaleString(), + }, + )} />
diff --git a/frontend/portal/src/contexts/LinkContext.tsx b/frontend/portal/src/contexts/LinkContext.tsx index daeb1cd71a..f110937533 100644 --- a/frontend/portal/src/contexts/LinkContext.tsx +++ b/frontend/portal/src/contexts/LinkContext.tsx @@ -28,15 +28,26 @@ export type LinkState = "unlinked" | "linked-free" | "linked-subscribed"; export interface LinkInfo { /** i18n key for the badge label; resolve with `t()` at the call site. */ labelKey: string; + /** English fallback for {@link labelKey}, passed as the t() default value. */ + labelDefault: string; /** Whether billable features are unlocked (any linked state). */ unlocked: boolean; } export const LINK_INFO: Record = { - unlinked: { labelKey: "accountLink.state.unlinked", unlocked: false }, - "linked-free": { labelKey: "accountLink.state.free", unlocked: true }, + unlinked: { + labelKey: "accountLink.state.unlinked", + labelDefault: "Not linked", + unlocked: false, + }, + "linked-free": { + labelKey: "accountLink.state.free", + labelDefault: "Editor plan", + unlocked: true, + }, "linked-subscribed": { labelKey: "accountLink.state.subscribed", + labelDefault: "Processor plan", unlocked: true, }, }; diff --git a/frontend/portal/src/views/Usage.tsx b/frontend/portal/src/views/Usage.tsx index 172b88c3cf..817e2039e8 100644 --- a/frontend/portal/src/views/Usage.tsx +++ b/frontend/portal/src/views/Usage.tsx @@ -89,10 +89,14 @@ export function Usage() { setError(e.message); } else if (e instanceof HttpError) { setError( - t("usage.error.walletUnavailable", { - status: e.status, - statusText: e.statusText, - }), + t( + "usage.error.walletUnavailable", + "Wallet unavailable: {{status}} {{statusText}}", + { + status: e.status, + statusText: e.statusText, + }, + ), ); } else { setError(e instanceof Error ? e.message : String(e)); @@ -140,8 +144,15 @@ export function Usage() {
-

{t("usage.title")}

-

{t("usage.subtitle")}

+

+ {t("usage.title", "Usage & billing")} +

+

+ {t( + "usage.subtitle", + "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console.", + )} +

{wallet?.status === "subscribed" && ( )}
@@ -167,33 +178,51 @@ export function Usage() { )} {isLinked && finalizing && ( - - {t("usage.finalizing.body")} + + {t( + "usage.finalizing.body", + "It can take a few seconds for your subscription to activate. This page updates automatically.", + )} )} {isLinked && needsReauth && ( openLinkModal("reauth")}> - {t("usage.sessionExpired.action")} + {t("usage.sessionExpired.action", "Sign in again")} } > - {t("usage.sessionExpired.body")} + {t( + "usage.sessionExpired.body", + "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked.", + )} )} {isLinked && error && ( - + {error} )} {isLinked && portal.error && ( - + {portal.error} )}