Files
Stirling-PDF/frontend/portal/src/components/usage/SpendCapControl.tsx
T
Reece Browne dffc292888 I18n on portal (#6761)
## Overview

Internationalizes the **developer portal**, which previously had **zero
i18n** — every string was hardcoded across ~118 components. Rather than
stand up a parallel system, this shares the **editor's** existing i18n
setup (same TOML locale format, same Crowdin pipeline), then converts
every portal surface to `react-i18next` and adds a CI guard so coverage
can't regress.

## What's included

### 🔗 Shared i18n core (`@shared/i18n`)
- Extracts the editor's `TomlBackend` (HTTP loader for
`public/locales/{lng}/translation.toml`) and language metadata/helpers
(the 42-language list, RTL set, `LanguageSource` priority, code
normalizers) into `frontend/shared/i18n/`.
- The **editor** now imports and re-exports these from `@shared/i18n` —
its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted.
- The **portal** builds its own i18next instance from the shared core,
with **en-US as the source of truth** and the same
`/locales/{lng}/translation.toml` layout.

### 🌍 Full portal coverage
- Every view and component converted to `t()` — all feature areas (home,
pipelines, sources, infrastructure, usage, documents, agent-builder,
editor-admin, policies, users, docs, catalogue, components view) plus
app shell, nav, modals, and the home/domain widgets.
- **1108 keys across ~30 namespaces** in
`portal/public/locales/en-US/translation.toml`, grouped by feature;
shared strings under `[common]`. Plurals use i18next count forms;
dynamic labels (nav, settings sections, status badges) use template keys
against populated tables.
- Data-driven strings (values from `@portal/api/*` mocks, enum/id
values, code samples) are intentionally left untranslated — they're
data, not UI chrome.

###  CI coverage guard
- `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in
portal source is missing from the en-US locale. Wired into
`frontend:check` and `frontend:check:all`, so missed keys break CI. This
mirrors the editor's `missingTranslations` test for the portal, which
has no vitest harness of its own.

## Testing
- `task frontend:check:all` passes locally (typecheck all variants,
lint, format, **portal i18n guard**, builds, tests, storybook).
- Every static `t()` key verified to resolve in the locale (1108 keys /
186 source files); all dynamic key prefixes map to populated tables.
- Runtime sweep of all 12 portal routes shows **no unresolved keys** on
screen; nav labels, plurals, and array-backed copy all render real text.

## Follow-ups (not in this PR)
- **Crowdin** — register `frontend/portal/public/locales/` as a source
so portal strings flow through the same translation pipeline as the
editor (an ops step on the Crowdin side; there's no Crowdin config in
the repo).
- Only `en-US` is populated; other languages will arrive via the
pipeline.
2026-06-22 12:57:12 +00:00

122 lines
3.6 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
Card,
ProgressBar,
Slider,
StatusBadge,
} from "@shared/components";
import { useTier } from "@portal/contexts/TierContext";
import type { BillingSummary } from "@portal/api/usage";
import { USD } from "@portal/components/usage/format";
import "@portal/views/Usage.css";
/**
* Monthly spend-cap control. Only pay-as-you-go can accrue spend, so free and
* enterprise render explanatory cards instead of the interactive slider.
*/
export function SpendCapControl({ summary }: { summary: BillingSummary }) {
const { t } = useTranslation();
const { tier } = useTier();
const [enabled, setEnabled] = useState(summary.spendCap !== null);
const [cap, setCap] = useState(summary.spendCap ?? 1_000);
if (tier === "free") {
return (
<Card padding="loose" className="portal-usage__cap-card">
<h2 className="portal-usage__section-title">
{t("usage.spendCap.free.title")}
</h2>
<p className="portal-usage__section-sub">
{t("usage.spendCap.free.description")}
</p>
</Card>
);
}
if (tier === "enterprise") {
return (
<Card padding="loose" className="portal-usage__cap-card">
<h2 className="portal-usage__section-title">
{t("usage.spendCap.enterprise.title")}
</h2>
<p className="portal-usage__section-sub">
{t("usage.spendCap.enterprise.description")}
</p>
<div className="portal-usage__cap-meta">
<StatusBadge tone="purple" size="sm">
{t("usage.spendCap.enterprise.badge")}
</StatusBadge>
<span>
{t("usage.spendCap.enterprise.overage", {
rate: summary.overageRate.toFixed(3),
})}
</span>
</div>
</Card>
);
}
const projected = summary.costThisMonth;
const capRatio = enabled ? Math.min(projected / cap, 1) : 0;
// TODO(backend): PUT /v1/billing/spend-cap { enabled, cap } — persist the cap
// so processing pauses server-side when projected spend reaches the limit.
return (
<Card padding="loose" className="portal-usage__cap-card">
<div className="portal-usage__cap-card-head">
<div>
<h2 className="portal-usage__section-title">
{t("usage.spendCap.pro.title")}
</h2>
<p className="portal-usage__section-sub">
{t("usage.spendCap.pro.subtitle")}
</p>
</div>
<Button
variant={enabled ? "outline" : "gradient"}
size="sm"
onClick={() => setEnabled((v) => !v)}
>
{enabled
? t("usage.spendCap.pro.disable")
: t("usage.spendCap.pro.enable")}
</Button>
</div>
{enabled && (
<>
<div className="portal-usage__cap-slider">
<Slider
value={cap}
min={500}
max={10_000}
step={250}
onChange={setCap}
formatValue={(v) => USD.format(v)}
/>
</div>
<div className="portal-usage__cap-row">
<span>
{t("usage.spendCap.pro.projected", {
projected: USD.format(projected),
cap: USD.format(cap),
})}
</span>
<span className="portal-usage__cap-pct">
{Math.round(capRatio * 100)}%
</span>
</div>
<ProgressBar
value={capRatio}
thresholded
height={8}
label={t("usage.spendCap.pro.progressLabel")}
/>
</>
)}
</Card>
);
}