fix(portal/i18n): add inline default values to account-link + billing t() calls (#6842)

## 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.
This commit is contained in:
ConnorYoh
2026-06-30 09:23:13 +00:00
committed by GitHub
parent c8af6e3b7e
commit 425b76e9a7
19 changed files with 508 additions and 215 deletions
@@ -56,7 +56,12 @@ export function AccountLinkPanel() {
<div className="portal-link portal-link--in-settings">
<header className="portal-link__header">
<div>
<p className="portal-link__page-sub">{t("accountLink.panel.sub")}</p>
<p className="portal-link__page-sub">
{t(
"accountLink.panel.sub",
"Link this self-hosted org to its Stirling account so unattended processing bills against your org wallet.",
)}
</p>
</div>
<StatusBadge
tone={
@@ -68,7 +73,7 @@ export function AccountLinkPanel() {
}
size="md"
>
{t(LINK_INFO[linkState].labelKey)}
{t(LINK_INFO[linkState].labelKey, LINK_INFO[linkState].labelDefault)}
</StatusBadge>
</header>
@@ -78,10 +83,13 @@ export function AccountLinkPanel() {
<section className="portal-link__instances">
<div className="portal-link__section-head">
<h2 className="portal-link__section-title">
{t("accountLink.panel.instancesTitle")}
{t("accountLink.panel.instancesTitle", "Linked instances")}
</h2>
<p className="portal-link__section-sub">
{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.",
)}
</p>
</div>
{instancesState.loading ? (
@@ -93,12 +101,21 @@ export function AccountLinkPanel() {
) : instancesState.error ? (
<Banner
tone="danger"
title={t("accountLink.panel.loadError.title")}
title={t(
"accountLink.panel.loadError.title",
"Couldn't load linked instances",
)}
>
{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.",
)}
</Banner>
) : (
<LinkedInstancesTable
@@ -109,7 +126,13 @@ export function AccountLinkPanel() {
)}
{revokeError && (
<Banner tone="danger" title={t("accountLink.panel.revokeError")}>
<Banner
tone="danger"
title={t(
"accountLink.panel.revokeError",
"Couldn't revoke instance",
)}
>
{revokeError}
</Banner>
)}
@@ -24,30 +24,44 @@ export function LinkAccountCard({ link }: Props) {
<div className="portal-link__card-head">
<div>
<span className="portal-link__eyebrow">
{t("accountLink.card.eyebrow")}
{t("accountLink.card.eyebrow", "Account link")}
</span>
<h2 className="portal-link__title">{t("accountLink.card.title")}</h2>
<h2 className="portal-link__title">
{t(
"accountLink.card.title",
"Link this org to its Stirling account",
)}
</h2>
</div>
<StatusBadge tone={linked ? "success" : "neutral"} size="sm">
{linked
? t("accountLink.card.linked")
: t("accountLink.card.notLinked")}
? t("accountLink.card.linked", "Linked")
: t("accountLink.card.notLinked", "Not linked")}
</StatusBadge>
</div>
{!link.loginConfigured && (
<Banner
tone="neutral"
title={t("accountLink.card.loginNotConfigured.title")}
title={t(
"accountLink.card.loginNotConfigured.title",
"SaaS login not configured",
)}
>
{t("accountLink.card.loginNotConfigured.before")}{" "}
{t("accountLink.card.loginNotConfigured.before", "Set")}{" "}
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
{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.",
)}
</Banner>
)}
{link.error && (
<Banner tone="danger" title={t("accountLink.card.error.title")}>
<Banner
tone="danger"
title={t("accountLink.card.error.title", "Couldn't link")}
>
{link.error}
</Banner>
)}
@@ -56,9 +70,17 @@ export function LinkAccountCard({ link }: Props) {
<div className="portal-link__actions">
<span className="portal-link__muted">
{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.",
)}
</span>
<Button
variant="outline"
@@ -66,13 +88,13 @@ export function LinkAccountCard({ link }: Props) {
loading={linking}
onClick={link.unlink}
>
{t("accountLink.card.unlink")}
{t("accountLink.card.unlink", "Unlink")}
</Button>
</div>
) : (
<div className="portal-link__actions">
<Button loading={linking} onClick={() => openLinkModal()}>
{t("accountLink.card.linkButton")}
{t("accountLink.card.linkButton", "Link your Stirling account")}
</Button>
</div>
)}
@@ -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({
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<Banner
tone="neutral"
title={t("accountLink.modal.loginNotConfigured.title")}
title={t(
"accountLink.modal.loginNotConfigured.title",
"SaaS login not configured",
)}
>
{t("accountLink.modal.loginNotConfigured.before")}{" "}
{t("accountLink.modal.loginNotConfigured.before", "Set")}{" "}
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
{t("accountLink.modal.loginNotConfigured.and")}{" "}
{t("accountLink.modal.loginNotConfigured.and", "and")}{" "}
<code>VITE_SAAS_SUPABASE_ANON_KEY</code>{" "}
{t("accountLink.modal.loginNotConfigured.after")}
{t(
"accountLink.modal.loginNotConfigured.after",
"to enable in-app linking against the hosted Stirling account.",
)}
</Banner>
{import.meta.env.DEV && (
<Button
@@ -97,7 +109,7 @@ export function LinkAccountModal({
onClose();
}}
>
{t("accountLink.modal.simulateSignIn")}
{t("accountLink.modal.simulateSignIn", "Simulate sign-in (dev)")}
</Button>
)}
</div>
@@ -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={
<Button size="sm" onClick={() => openLinkModal()}>
{t("accountLink.gate.action")}
{t("accountLink.gate.action", "Link account")}
</Button>
}
/>
@@ -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<LinkedInstanceRow>[] = [
{
key: "name",
header: t("accountLink.instances.columns.instance"),
header: t("accountLink.instances.columns.instance", "Instance"),
render: (i) => (
<div className="portal-link__cell-stack">
<span className="portal-link__cell-strong">
{i.name ?? t("accountLink.instances.unnamed")}
{i.name ?? t("accountLink.instances.unnamed", "Unnamed instance")}
</span>
<code className="portal-link__device-id">{i.deviceId}</code>
</div>
@@ -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 ? (
<StatusBadge tone="danger" size="sm">
{t("accountLink.instances.revoked")}
{t("accountLink.instances.revoked", "Revoked")}
</StatusBadge>
) : (
<StatusBadge tone="success" size="sm" pulse>
{t("accountLink.instances.active")}
{t("accountLink.instances.active", "Active")}
</StatusBadge>
),
},
{
key: "lastSeen",
header: t("accountLink.instances.columns.lastSeen"),
header: t("accountLink.instances.columns.lastSeen", "Last seen"),
render: (i) => (
<span className="portal-link__muted">
{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) => (
<span className="portal-link__muted">
{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")}
</Button>
),
},
@@ -108,8 +113,11 @@ export function LinkedInstancesTable({
{instances.length === 0 ? (
<EmptyState
size="compact"
title={t("accountLink.instances.empty.title")}
description={t("accountLink.instances.empty.description")}
title={t("accountLink.instances.empty.title", "No linked instances")}
description={t(
"accountLink.instances.empty.description",
"Link this org's account, then register your self-hosted instances to see them here.",
)}
/>
) : (
<Table
@@ -15,20 +15,23 @@ export function EnterpriseUpsell({ bare = false }: Props) {
const body = (
<>
<span className="portal-billing__eyebrow">
{t("billing.enterpriseUpsell.eyebrow")}
{t("billing.enterpriseUpsell.eyebrow", "Volume discount · 1M+ PDFs")}
</span>
<div className="portal-billing__enterprise-head">
<div>
<h3 className="portal-billing__section-title">
{t("billing.enterpriseUpsell.title")}
{t("billing.enterpriseUpsell.title", "Stirling Enterprise")}
</h3>
<p className="portal-billing__section-sub">
{t("billing.enterpriseUpsell.description")}
{t(
"billing.enterpriseUpsell.description",
"Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships.",
)}
</p>
</div>
{/* Destination wired when the enterprise/sales URL is confirmed. */}
<Button variant="gradient" size="sm" disabled>
{t("billing.enterpriseUpsell.cta")}
{t("billing.enterpriseUpsell.cta", "Build your Enterprise quote")}
</Button>
</div>
</>
@@ -36,30 +36,39 @@ export function FreePdfEditorsCard() {
</span>
<div>
<h3 className="portal-billing__section-title">
{t("billing.freeEditors.title")}{" "}
{t("billing.freeEditors.title", "Free PDF Editors")}{" "}
<StatusBadge tone="warning" size="sm" showDot={false}>
{t("billing.freeEditors.previewBadge")}
{t("billing.freeEditors.previewBadge", "Preview · sample data")}
</StatusBadge>
</h3>
<p className="portal-billing__section-sub">
{t("billing.freeEditors.subtitle")}
{t(
"billing.freeEditors.subtitle",
"Deploy anywhere, for your whole team.",
)}
</p>
</div>
</div>
<MetricStrip className="portal-billing__fleet-metrics">
<MetricCard
label={t("billing.freeEditors.editorsDeployed")}
label={t("billing.freeEditors.editorsDeployed", "Editors deployed")}
value={SAMPLE.editorsDeployed}
/>
<MetricCard
label={t("billing.freeEditors.activeThisMonth")}
label={t(
"billing.freeEditors.activeThisMonth",
"Active this month",
)}
value={SAMPLE.activeThisMonth}
/>
<MetricCard
label={t("billing.freeEditors.pdfsEdited")}
label={t("billing.freeEditors.pdfsEdited", "PDFs edited")}
value={SAMPLE.pdfsEdited}
/>
<MetricCard label={t("billing.freeEditors.cost")} value="$0" />
<MetricCard
label={t("billing.freeEditors.cost", "Cost")}
value="$0"
/>
</MetricStrip>
{/* Opens the Users tab with its invite-member modal (via the ?invite param). */}
<Button
@@ -68,7 +77,7 @@ export function FreePdfEditorsCard() {
leadingIcon={<PersonAddIcon sx={{ fontSize: 16 }} />}
onClick={() => navigate("/users?invite=1")}
>
{t("billing.freeEditors.inviteTeammates")}
{t("billing.freeEditors.inviteTeammates", "Invite teammates")}
</Button>
</div>
</Card>
@@ -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 →")}
</Button>
) : null;
@@ -57,20 +62,20 @@ export function FreePlanView({ wallet, onSubscribed }: Props) {
{/* Current plan */}
<div className="portal-billing__current-plan">
<span className="portal-billing__eyebrow">
{t("billing.freePlan.currentPlan")}
{t("billing.freePlan.currentPlan", "Current plan")}
</span>
<div className="portal-billing__current-plan-row">
<h2 className="portal-billing__current-plan-name">
{t("billing.freePlan.planName")}
{t("billing.freePlan.planName", "Editor")}
</h2>
<StatusBadge tone="success" size="sm" showDot={false}>
{t("billing.freePlan.freeForever")}
{t("billing.freePlan.freeForever", "Free forever")}
</StatusBadge>
<StatusBadge tone="info" size="sm" showDot={false}>
{t("billing.freePlan.ssoIncluded")}
{t("billing.freePlan.ssoIncluded", "SSO included")}
</StatusBadge>
<StatusBadge tone="purple" size="sm" showDot={false}>
{t("billing.freePlan.unlimitedUsers")}
{t("billing.freePlan.unlimitedUsers", "Unlimited users")}
</StatusBadge>
</div>
</div>
@@ -81,13 +86,22 @@ export function FreePlanView({ wallet, onSubscribed }: Props) {
<WalletMeter wallet={wallet} action={switchOnAction} />
{missingTeam && (
<Banner tone="warning" title={t("billing.freePlan.checkoutErrorTitle")}>
<Banner
tone="warning"
title={t(
"billing.freePlan.checkoutErrorTitle",
"Couldn't start checkout",
)}
>
{missingTeam}
</Banner>
)}
{!isLeader && (
<p className="portal-billing__plan-readonly">
{t("billing.freePlan.ownerOnly")}
{t(
"billing.freePlan.ownerOnly",
"Only the team owner can switch on the Processor plan.",
)}
</p>
)}
@@ -83,13 +83,13 @@ export function InvoicesList() {
const columns: TableColumn<Invoice>[] = [
{
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) => (
<StatusBadge tone={statusTone(inv.status)} size="sm">
{inv.status}
@@ -116,10 +116,11 @@ export function InvoicesList() {
},
{
key: "description",
header: t("billing.invoices.columnDescription"),
header: t("billing.invoices.columnDescription", "Description"),
render: (inv) => (
<span className="portal-billing__invoice-desc">
{inv.description ?? t("billing.invoices.descriptionFallback")}
{inv.description ??
t("billing.invoices.descriptionFallback", "Invoice")}
</span>
),
},
@@ -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 ↗")}
</a>
)}
{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 ↓")}
</a>
)}
</div>
@@ -163,7 +172,7 @@ export function InvoicesList() {
return (
<Card padding="loose">
<h3 className="portal-billing__section-title">
{t("billing.invoices.title")}
{t("billing.invoices.title", "Invoice history")}
</h3>
{invoices === null && !error && (
@@ -176,15 +185,22 @@ export function InvoicesList() {
{error && (
<p className="portal-billing__error" role="alert">
{t("billing.invoices.loadError", { error })}
{t(
"billing.invoices.loadError",
"Couldn't load invoices: {{error}}",
{ error },
)}
</p>
)}
{invoices !== null && invoices.length === 0 && !error && (
<EmptyState
size="compact"
title={t("billing.invoices.emptyTitle")}
description={t("billing.invoices.emptyDescription")}
title={t("billing.invoices.emptyTitle", "No invoices yet")}
description={t(
"billing.invoices.emptyDescription",
"Once your team subscribes and the first cycle closes, your invoices appear here.",
)}
/>
)}
@@ -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,
})}
</Button>
{showAll && atFetchLimit && (
<span className="portal-billing__invoice-note">
{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 },
)}
</span>
)}
</div>
@@ -14,11 +14,14 @@ export function LinkAccountPrompt() {
<Card padding="loose">
<EmptyState
size="default"
title={t("billing.linkPrompt.title")}
description={t("billing.linkPrompt.description")}
title={t("billing.linkPrompt.title", "Link your Stirling account")}
description={t(
"billing.linkPrompt.description",
"Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use.",
)}
actions={
<Button variant="gradient" onClick={() => openLinkModal()}>
{t("billing.linkPrompt.cta")}
{t("billing.linkPrompt.cta", "Link Stirling account")}
</Button>
}
/>
@@ -46,33 +46,45 @@ export function PaymentMethodCard({ onManage, managing }: Props) {
<div className="portal-billing__subscription-head">
<div>
<span className="portal-billing__eyebrow">
{t("billing.paymentMethod.eyebrow")}
{t("billing.paymentMethod.eyebrow", "Payment method")}
</span>
{hasCard ? (
<>
<h3 className="portal-billing__section-title">
{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,
},
)}
</h3>
<p className="portal-billing__section-sub">
{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")}
</p>
</>
) : (
<>
<h3 className="portal-billing__section-title">
{t("billing.paymentMethod.managedTitle")}
{t("billing.paymentMethod.managedTitle", "Managed in Stripe")}
</h3>
<p className="portal-billing__section-sub">
{t("billing.paymentMethod.managedSub")}
{t(
"billing.paymentMethod.managedSub",
"Your card and billing details are kept securely in Stripe's customer portal.",
)}
</p>
</>
)}
@@ -83,7 +95,7 @@ export function PaymentMethodCard({ onManage, managing }: Props) {
loading={managing}
onClick={onManage}
>
{t("billing.paymentMethod.update")}
{t("billing.paymentMethod.update", "Update")}
</Button>
</div>
</Card>
@@ -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 (
<Card padding="loose">
<span className="portal-billing__eyebrow">
{t("billing.pdfsProcessed.eyebrow")}
{t("billing.pdfsProcessed.eyebrow", "PDFs processed this period")}
</span>
<div className="portal-billing__bignum-row">
<span className="portal-billing__bignum">
{wallet.billableUsed.toLocaleString()}
</span>
<span className="portal-billing__bignum-unit">
{t("billing.pdfsProcessed.unit")}
{t("billing.pdfsProcessed.unit", "metered PDFs")}
</span>
</div>
@@ -59,7 +67,10 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
<div
className="portal-billing__segbar"
role="img"
aria-label={t("billing.pdfsProcessed.segbarAriaLabel")}
aria-label={t(
"billing.pdfsProcessed.segbarAriaLabel",
"Metered PDFs split by category",
)}
>
{SEGMENTS.map((s) =>
b[s.key] > 0 ? (
@@ -79,16 +90,20 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
aria-hidden
/>
<span className="portal-billing__seglegend-label">
{t(s.labelKey)}
{t(s.labelKey, s.labelDefault)}
</span>
<span className="portal-billing__seglegend-val">
{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(),
},
)}
</span>
<span className="portal-billing__seglegend-desc">
{t(s.descKey)}
{t(s.descKey, s.descDefault)}
</span>
</div>
))}
@@ -96,7 +111,10 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) {
</>
) : (
<p className="portal-billing__section-sub">
{t("billing.pdfsProcessed.emptyPeriod")}
{t(
"billing.pdfsProcessed.emptyPeriod",
"No metered processing yet this period.",
)}
</p>
)}
</Card>
@@ -127,10 +127,10 @@ export function SpendLimitCard({
return (
<Card padding="loose">
<span className="portal-billing__eyebrow">
{t("billing.spendLimit.eyebrow")}
{t("billing.spendLimit.eyebrow", "Spend limit")}
</span>
<h3 className="portal-billing__section-title">
{t("billing.spendLimit.editTitle")}
{t("billing.spendLimit.editTitle", "Set your monthly ceiling")}
</h3>
<SharedSpendCapControl
@@ -138,7 +138,10 @@ export function SpendLimitCard({
onChange={setDraftCap}
pricePerDocMinor={wallet.pricePerDocMinor}
currency={wallet.currency}
note={t("billing.spendLimit.capControlNote")}
note={t(
"billing.spendLimit.capControlNote",
"Changes apply immediately — raise or lower the ceiling any time.",
)}
/>
{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),
},
)}
</button>
)}
<div className="portal-billing__guardrail">
<strong>{t("billing.spendLimit.guardrailLabel")}</strong>{" "}
{t("billing.spendLimit.guardrailBody")}
<strong>
{t("billing.spendLimit.guardrailLabel", "Your guardrail:")}
</strong>{" "}
{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.",
)}
</div>
{error && (
<Banner tone="danger" title={t("billing.spendLimit.saveError")}>
<Banner
tone="danger"
title={t("billing.spendLimit.saveError", "Couldn't save limit")}
>
{error}
</Banner>
)}
@@ -170,10 +185,10 @@ export function SpendLimitCard({
size="sm"
onClick={() => onAdjustingChange(false)}
>
{t("billing.spendLimit.cancel")}
{t("billing.spendLimit.cancel", "Cancel")}
</Button>
<Button variant="gradient" size="sm" loading={saving} onClick={save}>
{t("billing.spendLimit.save")}
{t("billing.spendLimit.save", "Save limit")}
</Button>
</div>
</Card>
@@ -194,10 +209,13 @@ export function SpendLimitCard({
<div className="portal-billing__subscription-head">
<div>
<span className="portal-billing__eyebrow">
{t("billing.spendLimit.eyebrow")}
{t("billing.spendLimit.eyebrow", "Spend limit")}
</span>
<p className="portal-billing__section-sub">
{t("billing.spendLimit.displaySub")}
{t(
"billing.spendLimit.displaySub",
"You're only billed for what you process automatically — never past the ceiling.",
)}
</p>
</div>
{isLeader && (
@@ -206,7 +224,7 @@ export function SpendLimitCard({
size="sm"
onClick={() => onAdjustingChange(true)}
>
{t("billing.spendLimit.adjustLimit")}
{t("billing.spendLimit.adjustLimit", "Adjust limit")}
</Button>
)}
</div>
@@ -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 ? (
<>
<span>
{t("billing.spendLimit.usedThisMonth", {
amount: spentLabel,
})}
{t(
"billing.spendLimit.usedThisMonth",
"{{amount}} used this month",
{
amount: spentLabel,
},
)}
</span>
<span>
{t("billing.spendLimit.remaining", {
{t("billing.spendLimit.remaining", "{{amount}} remaining", {
amount: formatMinor(remainingMinor, wallet.currency),
})}
</span>
</>
) : (
<span>
{t("billing.spendLimit.thisPeriodUncapped", {
amount: spentLabel,
})}
{t(
"billing.spendLimit.thisPeriodUncapped",
"{{amount}} this period · uncapped",
{
amount: spentLabel,
},
)}
</span>
)
}
@@ -260,18 +292,24 @@ export function SpendLimitCard({
{proj && (
<p className="portal-billing__projection">
<strong>{t("billing.spendLimit.projection.label")}</strong>{" "}
{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),
})}
<strong>
{t("billing.spendLimit.projection.label", "Projected to exceed.")}
</strong>{" "}
{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),
},
)}
</p>
)}
</Card>
@@ -19,7 +19,7 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) {
return (
<Card padding="loose" className="portal-billing__spend-this-month">
<span className="portal-billing__eyebrow">
{t("billing.spendThisMonth.eyebrow")}
{t("billing.spendThisMonth.eyebrow", "Spend this month")}
</span>
<div className="portal-billing__bignum-row">
<span className="portal-billing__bignum">
@@ -28,15 +28,23 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) {
</div>
<p className="portal-billing__section-sub">
{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(),
},
)}
</p>
<div className="portal-billing__spend-foot">
@@ -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 && (
<Banner
tone="neutral"
title={t("billing.checkout.notConfigured.title")}
title={t(
"billing.checkout.notConfigured.title",
"Stripe not configured",
)}
>
{t("billing.checkout.notConfigured.bodyBefore")}{" "}
{t("billing.checkout.notConfigured.bodyBefore", "Set")}{" "}
<code>VITE_STRIPE_PUBLISHABLE_KEY</code>{" "}
{t("billing.checkout.notConfigured.bodyAfter")}
{t(
"billing.checkout.notConfigured.bodyAfter",
"in the portal env to enable in-app checkout.",
)}
</Banner>
)}
{publishableKey && error && (
<Banner tone="danger" title={t("billing.checkout.error.title")}>
<Banner
tone="danger"
title={t("billing.checkout.error.title", "Couldn't start checkout")}
>
{error}
</Banner>
)}
@@ -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 ? (
<Button size="sm" onClick={raiseLimit}>
{t("billing.subscribedPlan.capWarn.raiseLimit")}
{t("billing.subscribedPlan.capWarn.raiseLimit", "Raise limit")}
</Button>
) : 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.",
)}
</Banner>
)}
@@ -96,7 +109,10 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) {
{portal.error && (
<Banner
tone="danger"
title={t("billing.subscribedPlan.portalError.title")}
title={t(
"billing.subscribedPlan.portalError.title",
"Couldn't open Stripe portal",
)}
>
{portal.error}
</Banner>
@@ -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) {
<div className="portal-billing__subscription-head">
<div>
<span className="portal-billing__eyebrow">
{t("billing.walletMeter.eyebrow")}
{t("billing.walletMeter.eyebrow", "Processor trial")}
</span>
<h2 className="portal-billing__meter-title">{title}</h2>
<p className="portal-billing__section-sub">
{t("billing.walletMeter.sub")}
{t(
"billing.walletMeter.sub",
"Use the PDF Editor for free. Pay to process PDFs automatically.",
)}
</p>
</div>
{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(),
},
)}
/>
</div>
</Card>
+13 -2
View File
@@ -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<LinkState, LinkInfo> = {
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,
},
};
+43 -14
View File
@@ -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() {
<header className="portal-usage__header">
<div className="portal-usage__header-inner">
<div>
<h1 className="portal-usage__title">{t("usage.title")}</h1>
<p className="portal-usage__subtitle">{t("usage.subtitle")}</p>
<h1 className="portal-usage__title">
{t("usage.title", "Usage & billing")}
</h1>
<p className="portal-usage__subtitle">
{t(
"usage.subtitle",
"Consumption, invoices, and plan management for every PDF Stirling has billed, in one console.",
)}
</p>
</div>
{wallet?.status === "subscribed" && (
<Button
@@ -150,7 +161,7 @@ export function Usage() {
loading={portal.opening}
onClick={portal.open}
>
{t("usage.managePayment")}
{t("usage.managePayment", "Manage Payment")}
</Button>
)}
</div>
@@ -167,33 +178,51 @@ export function Usage() {
)}
{isLinked && finalizing && (
<Banner tone="info" title={t("usage.finalizing.title")}>
{t("usage.finalizing.body")}
<Banner
tone="info"
title={t("usage.finalizing.title", "Finalizing your subscription…")}
>
{t(
"usage.finalizing.body",
"It can take a few seconds for your subscription to activate. This page updates automatically.",
)}
</Banner>
)}
{isLinked && needsReauth && (
<Banner
tone="warning"
title={t("usage.sessionExpired.title")}
title={t("usage.sessionExpired.title", "Session expired")}
action={
<Button size="sm" onClick={() => openLinkModal("reauth")}>
{t("usage.sessionExpired.action")}
{t("usage.sessionExpired.action", "Sign in again")}
</Button>
}
>
{t("usage.sessionExpired.body")}
{t(
"usage.sessionExpired.body",
"Your Stirling account session has expired. Sign in again to view billing — your instance stays linked.",
)}
</Banner>
)}
{isLinked && error && (
<Banner tone="danger" title={t("usage.error.loadWallet")}>
<Banner
tone="danger"
title={t("usage.error.loadWallet", "Couldn't load wallet")}
>
{error}
</Banner>
)}
{isLinked && portal.error && (
<Banner tone="danger" title={t("usage.error.openStripePortal")}>
<Banner
tone="danger"
title={t(
"usage.error.openStripePortal",
"Couldn't open Stripe portal",
)}
>
{portal.error}
</Banner>
)}