Skip to content

Commit 425b76e

Browse files
authored
fix(portal/i18n): add inline default values to account-link + billing t() calls (Stirling-Tools#6842)
## Problem The account-link / billing / Usage strings migrated to i18next in Stirling-Tools#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 Stirling-Tools#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.
1 parent c8af6e3 commit 425b76e

19 files changed

Lines changed: 508 additions & 215 deletions

frontend/portal/src/components/account-link/AccountLinkPanel.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ export function AccountLinkPanel() {
5656
<div className="portal-link portal-link--in-settings">
5757
<header className="portal-link__header">
5858
<div>
59-
<p className="portal-link__page-sub">{t("accountLink.panel.sub")}</p>
59+
<p className="portal-link__page-sub">
60+
{t(
61+
"accountLink.panel.sub",
62+
"Link this self-hosted org to its Stirling account so unattended processing bills against your org wallet.",
63+
)}
64+
</p>
6065
</div>
6166
<StatusBadge
6267
tone={
@@ -68,7 +73,7 @@ export function AccountLinkPanel() {
6873
}
6974
size="md"
7075
>
71-
{t(LINK_INFO[linkState].labelKey)}
76+
{t(LINK_INFO[linkState].labelKey, LINK_INFO[linkState].labelDefault)}
7277
</StatusBadge>
7378
</header>
7479

@@ -78,10 +83,13 @@ export function AccountLinkPanel() {
7883
<section className="portal-link__instances">
7984
<div className="portal-link__section-head">
8085
<h2 className="portal-link__section-title">
81-
{t("accountLink.panel.instancesTitle")}
86+
{t("accountLink.panel.instancesTitle", "Linked instances")}
8287
</h2>
8388
<p className="portal-link__section-sub">
84-
{t("accountLink.panel.instancesSub")}
89+
{t(
90+
"accountLink.panel.instancesSub",
91+
"Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access.",
92+
)}
8593
</p>
8694
</div>
8795
{instancesState.loading ? (
@@ -93,12 +101,21 @@ export function AccountLinkPanel() {
93101
) : instancesState.error ? (
94102
<Banner
95103
tone="danger"
96-
title={t("accountLink.panel.loadError.title")}
104+
title={t(
105+
"accountLink.panel.loadError.title",
106+
"Couldn't load linked instances",
107+
)}
97108
>
98109
{instancesState.error instanceof HttpError &&
99110
instancesState.error.status === 403
100-
? t("accountLink.panel.loadError.forbidden")
101-
: t("accountLink.panel.loadError.generic")}
111+
? t(
112+
"accountLink.panel.loadError.forbidden",
113+
"Only the team owner can view the org's linked instances.",
114+
)
115+
: t(
116+
"accountLink.panel.loadError.generic",
117+
"Couldn't load the team's linked instances. Try again in a moment.",
118+
)}
102119
</Banner>
103120
) : (
104121
<LinkedInstancesTable
@@ -109,7 +126,13 @@ export function AccountLinkPanel() {
109126
)}
110127

111128
{revokeError && (
112-
<Banner tone="danger" title={t("accountLink.panel.revokeError")}>
129+
<Banner
130+
tone="danger"
131+
title={t(
132+
"accountLink.panel.revokeError",
133+
"Couldn't revoke instance",
134+
)}
135+
>
113136
{revokeError}
114137
</Banner>
115138
)}

frontend/portal/src/components/account-link/LinkAccountCard.tsx

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,30 +24,44 @@ export function LinkAccountCard({ link }: Props) {
2424
<div className="portal-link__card-head">
2525
<div>
2626
<span className="portal-link__eyebrow">
27-
{t("accountLink.card.eyebrow")}
27+
{t("accountLink.card.eyebrow", "Account link")}
2828
</span>
29-
<h2 className="portal-link__title">{t("accountLink.card.title")}</h2>
29+
<h2 className="portal-link__title">
30+
{t(
31+
"accountLink.card.title",
32+
"Link this org to its Stirling account",
33+
)}
34+
</h2>
3035
</div>
3136
<StatusBadge tone={linked ? "success" : "neutral"} size="sm">
3237
{linked
33-
? t("accountLink.card.linked")
34-
: t("accountLink.card.notLinked")}
38+
? t("accountLink.card.linked", "Linked")
39+
: t("accountLink.card.notLinked", "Not linked")}
3540
</StatusBadge>
3641
</div>
3742

3843
{!link.loginConfigured && (
3944
<Banner
4045
tone="neutral"
41-
title={t("accountLink.card.loginNotConfigured.title")}
46+
title={t(
47+
"accountLink.card.loginNotConfigured.title",
48+
"SaaS login not configured",
49+
)}
4250
>
43-
{t("accountLink.card.loginNotConfigured.before")}{" "}
51+
{t("accountLink.card.loginNotConfigured.before", "Set")}{" "}
4452
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
45-
{t("accountLink.card.loginNotConfigured.after")}
53+
{t(
54+
"accountLink.card.loginNotConfigured.after",
55+
"to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog.",
56+
)}
4657
</Banner>
4758
)}
4859

4960
{link.error && (
50-
<Banner tone="danger" title={t("accountLink.card.error.title")}>
61+
<Banner
62+
tone="danger"
63+
title={t("accountLink.card.error.title", "Couldn't link")}
64+
>
5165
{link.error}
5266
</Banner>
5367
)}
@@ -56,23 +70,31 @@ export function LinkAccountCard({ link }: Props) {
5670
<div className="portal-link__actions">
5771
<span className="portal-link__muted">
5872
{link.status?.name
59-
? t("accountLink.card.linkedAs", { name: link.status.name })
60-
: t("accountLink.card.linkedGeneric")}{" "}
61-
{t("accountLink.card.billingNote")}
73+
? t("accountLink.card.linkedAs", "Linked as {{name}}.", {
74+
name: link.status.name,
75+
})
76+
: t(
77+
"accountLink.card.linkedGeneric",
78+
"This instance is linked.",
79+
)}{" "}
80+
{t(
81+
"accountLink.card.billingNote",
82+
"Unattended processing bills against your org wallet.",
83+
)}
6284
</span>
6385
<Button
6486
variant="outline"
6587
accent="red"
6688
loading={linking}
6789
onClick={link.unlink}
6890
>
69-
{t("accountLink.card.unlink")}
91+
{t("accountLink.card.unlink", "Unlink")}
7092
</Button>
7193
</div>
7294
) : (
7395
<div className="portal-link__actions">
7496
<Button loading={linking} onClick={() => openLinkModal()}>
75-
{t("accountLink.card.linkButton")}
97+
{t("accountLink.card.linkButton", "Link your Stirling account")}
7698
</Button>
7799
</div>
78100
)}

frontend/portal/src/components/account-link/LinkAccountModal.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,19 @@ export function LinkAccountModal({
6666
width="md"
6767
title={
6868
reauth
69-
? t("accountLink.modal.reauthTitle")
70-
: t("accountLink.modal.linkTitle")
69+
? t("accountLink.modal.reauthTitle", "Sign in again")
70+
: t("accountLink.modal.linkTitle", "Link your Stirling account")
7171
}
7272
subtitle={
7373
reauth
74-
? t("accountLink.modal.reauthSubtitle")
75-
: t("accountLink.modal.linkSubtitle")
74+
? t(
75+
"accountLink.modal.reauthSubtitle",
76+
"Your session expired — sign back in to your Stirling account. Your instance stays linked.",
77+
)
78+
: t(
79+
"accountLink.modal.linkSubtitle",
80+
"Sign in to the account this server should bill against.",
81+
)
7682
}
7783
>
7884
{isSaasSupabaseConfigured ? (
@@ -81,13 +87,19 @@ export function LinkAccountModal({
8187
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
8288
<Banner
8389
tone="neutral"
84-
title={t("accountLink.modal.loginNotConfigured.title")}
90+
title={t(
91+
"accountLink.modal.loginNotConfigured.title",
92+
"SaaS login not configured",
93+
)}
8594
>
86-
{t("accountLink.modal.loginNotConfigured.before")}{" "}
95+
{t("accountLink.modal.loginNotConfigured.before", "Set")}{" "}
8796
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
88-
{t("accountLink.modal.loginNotConfigured.and")}{" "}
97+
{t("accountLink.modal.loginNotConfigured.and", "and")}{" "}
8998
<code>VITE_SAAS_SUPABASE_ANON_KEY</code>{" "}
90-
{t("accountLink.modal.loginNotConfigured.after")}
99+
{t(
100+
"accountLink.modal.loginNotConfigured.after",
101+
"to enable in-app linking against the hosted Stirling account.",
102+
)}
91103
</Banner>
92104
{import.meta.env.DEV && (
93105
<Button
@@ -97,7 +109,7 @@ export function LinkAccountModal({
97109
onClose();
98110
}}
99111
>
100-
{t("accountLink.modal.simulateSignIn")}
112+
{t("accountLink.modal.simulateSignIn", "Simulate sign-in (dev)")}
101113
</Button>
102114
)}
103115
</div>

frontend/portal/src/components/account-link/LinkGate.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,18 @@ export function LinkGate({ children, feature }: Props) {
2929
tone="info"
3030
title={
3131
feature
32-
? t("accountLink.gate.titleFeature", { feature })
33-
: t("accountLink.gate.title")
32+
? t("accountLink.gate.titleFeature", "Link to unlock {{feature}}", {
33+
feature,
34+
})
35+
: t("accountLink.gate.title", "Link to unlock")
3436
}
35-
description={t("accountLink.gate.description")}
37+
description={t(
38+
"accountLink.gate.description",
39+
"Link this org's Stirling account to use billable features.",
40+
)}
3641
action={
3742
<Button size="sm" onClick={() => openLinkModal()}>
38-
{t("accountLink.gate.action")}
43+
{t("accountLink.gate.action", "Link account")}
3944
</Button>
4045
}
4146
/>

frontend/portal/src/components/account-link/LinkedInstancesTable.tsx

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,20 @@ interface Props {
1919
}
2020

2121
function relativeTime(iso: string | null, t: TFunction): string {
22-
if (!iso) return t("accountLink.instances.time.never");
22+
if (!iso) return t("accountLink.instances.time.never", "never");
2323
const diffMs = Date.now() - new Date(iso).getTime();
2424
const mins = Math.round(diffMs / 60_000);
25-
if (mins < 1) return t("accountLink.instances.time.justNow");
25+
if (mins < 1) return t("accountLink.instances.time.justNow", "just now");
2626
if (mins < 60)
27-
return t("accountLink.instances.time.minutesAgo", { count: mins });
27+
return t("accountLink.instances.time.minutesAgo", "{{count}}m ago", {
28+
count: mins,
29+
});
2830
const hrs = Math.round(mins / 60);
29-
if (hrs < 24) return t("accountLink.instances.time.hoursAgo", { count: hrs });
30-
return t("accountLink.instances.time.daysAgo", {
31+
if (hrs < 24)
32+
return t("accountLink.instances.time.hoursAgo", "{{count}}h ago", {
33+
count: hrs,
34+
});
35+
return t("accountLink.instances.time.daysAgo", "{{count}}d ago", {
3136
count: Math.round(hrs / 24),
3237
});
3338
}
@@ -42,33 +47,33 @@ export function LinkedInstancesTable({
4247
const cols: TableColumn<LinkedInstanceRow>[] = [
4348
{
4449
key: "name",
45-
header: t("accountLink.instances.columns.instance"),
50+
header: t("accountLink.instances.columns.instance", "Instance"),
4651
render: (i) => (
4752
<div className="portal-link__cell-stack">
4853
<span className="portal-link__cell-strong">
49-
{i.name ?? t("accountLink.instances.unnamed")}
54+
{i.name ?? t("accountLink.instances.unnamed", "Unnamed instance")}
5055
</span>
5156
<code className="portal-link__device-id">{i.deviceId}</code>
5257
</div>
5358
),
5459
},
5560
{
5661
key: "status",
57-
header: t("accountLink.instances.columns.status"),
62+
header: t("accountLink.instances.columns.status", "Status"),
5863
render: (i) =>
5964
i.revoked ? (
6065
<StatusBadge tone="danger" size="sm">
61-
{t("accountLink.instances.revoked")}
66+
{t("accountLink.instances.revoked", "Revoked")}
6267
</StatusBadge>
6368
) : (
6469
<StatusBadge tone="success" size="sm" pulse>
65-
{t("accountLink.instances.active")}
70+
{t("accountLink.instances.active", "Active")}
6671
</StatusBadge>
6772
),
6873
},
6974
{
7075
key: "lastSeen",
71-
header: t("accountLink.instances.columns.lastSeen"),
76+
header: t("accountLink.instances.columns.lastSeen", "Last seen"),
7277
render: (i) => (
7378
<span className="portal-link__muted">
7479
{relativeTime(i.lastSeenAt, t)}
@@ -77,7 +82,7 @@ export function LinkedInstancesTable({
7782
},
7883
{
7984
key: "created",
80-
header: t("accountLink.instances.columns.linked"),
85+
header: t("accountLink.instances.columns.linked", "Linked"),
8186
render: (i) => (
8287
<span className="portal-link__muted">
8388
{relativeTime(i.createdAt, t)}
@@ -97,7 +102,7 @@ export function LinkedInstancesTable({
97102
loading={revokingId === i.instanceId}
98103
onClick={() => onRevoke(i)}
99104
>
100-
{t("accountLink.instances.revoke")}
105+
{t("accountLink.instances.revoke", "Revoke")}
101106
</Button>
102107
),
103108
},
@@ -108,8 +113,11 @@ export function LinkedInstancesTable({
108113
{instances.length === 0 ? (
109114
<EmptyState
110115
size="compact"
111-
title={t("accountLink.instances.empty.title")}
112-
description={t("accountLink.instances.empty.description")}
116+
title={t("accountLink.instances.empty.title", "No linked instances")}
117+
description={t(
118+
"accountLink.instances.empty.description",
119+
"Link this org's account, then register your self-hosted instances to see them here.",
120+
)}
113121
/>
114122
) : (
115123
<Table

frontend/portal/src/components/billing/EnterpriseUpsell.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,23 @@ export function EnterpriseUpsell({ bare = false }: Props) {
1515
const body = (
1616
<>
1717
<span className="portal-billing__eyebrow">
18-
{t("billing.enterpriseUpsell.eyebrow")}
18+
{t("billing.enterpriseUpsell.eyebrow", "Volume discount · 1M+ PDFs")}
1919
</span>
2020
<div className="portal-billing__enterprise-head">
2121
<div>
2222
<h3 className="portal-billing__section-title">
23-
{t("billing.enterpriseUpsell.title")}
23+
{t("billing.enterpriseUpsell.title", "Stirling Enterprise")}
2424
</h3>
2525
<p className="portal-billing__section-sub">
26-
{t("billing.enterpriseUpsell.description")}
26+
{t(
27+
"billing.enterpriseUpsell.description",
28+
"Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships.",
29+
)}
2730
</p>
2831
</div>
2932
{/* Destination wired when the enterprise/sales URL is confirmed. */}
3033
<Button variant="gradient" size="sm" disabled>
31-
{t("billing.enterpriseUpsell.cta")}
34+
{t("billing.enterpriseUpsell.cta", "Build your Enterprise quote")}
3235
</Button>
3336
</div>
3437
</>

0 commit comments

Comments
 (0)