mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
## 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.
122 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|