Files
MobilityOps/frontend/src/components/PageChrome.tsx
T
NuklearRabbit d17af1c52a feat: centralize API error localization
Replace the err instanceof ApiError ? err.message : t(fallback) anti-
pattern -- which showed raw English backend text for the common case and
only used the localized fallback for the rare network-failure case -- at
all 13 call sites across 7 files.

New frontend/src/api/errorMessages.ts (describeApiError) resolves a
caught error to a localized {title, explanation, nextStep?, technical}
by checking the 32 known AppError codes first, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. New
ApiErrorNotice (PageChrome.tsx) renders title/explanation/nextStep with
the raw text demoted to a "Technical details"/"Details techniques"
disclosure -- never shown as the primary message.

ApiError itself is split out of client.ts into a standalone
api/apiError.ts with no import.meta.env dependency, so errorMessages.ts
(and its tests) can be loaded outside a Vite/browser context.
2026-08-04 03:08:07 +02:00

111 lines
3.0 KiB
TypeScript

import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Icon, type IconName } from "./Icons";
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow: string;
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div>
<p className="page-eyebrow">{eyebrow}</p>
<h1>{title}</h1>
{description && <p className="page-description">{description}</p>}
</div>
{actions && <div className="page-actions">{actions}</div>}
</header>
);
}
export function SectionHeading({
title,
description,
action,
headingId,
}: {
title: string;
description?: string;
action?: ReactNode;
headingId?: string;
}) {
return (
<div className="section-heading">
<div>
<h2 id={headingId}>{title}</h2>
{description && <p>{description}</p>}
</div>
{action}
</div>
);
}
export function LoadingState({ label }: { label?: string }) {
const { t } = useTranslation("common");
return (
<div className="state-panel" role="status">
<span className="spinner" aria-hidden="true" />
<p>{label ?? t("states.loadingDefault")}</p>
</div>
);
}
export function ErrorState({ message }: { message: string }) {
const { t } = useTranslation("common");
return (
<div className="state-panel state-error" role="alert">
<Icon name="alert" />
<div><strong>{t("states.errorTitle")}</strong><p>{message}</p></div>
</div>
);
}
// Shared rendering for describeApiError()'s output: a localized title + explanation +
// optional next step, with the raw backend/network text demoted to a "Technical
// details" disclosure -- never shown as the primary message. See
// frontend/src/api/errorMessages.ts and docs/fleet-ops-final-localization/audit.md.
export function ApiErrorNotice({ error }: { error: import("../api/errorMessages").ApiErrorInfo | null }) {
const { t } = useTranslation("common");
if (!error) return null;
return (
<div className="error api-error-notice" role="alert">
<strong>{error.title}</strong>
<p>{error.explanation}</p>
{error.nextStep && <p className="api-error-next-step">{error.nextStep}</p>}
<details className="evidence-disclosure">
<summary>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{error.technical}</pre>
</details>
</div>
);
}
export function EmptyState({
icon = "check",
title,
detail,
}: {
icon?: IconName;
title: string;
detail: string;
}) {
return (
<div className="state-panel state-empty">
<Icon name={icon} />
<div><strong>{title}</strong><p>{detail}</p></div>
</div>
);
}
export function IntegrationMark({ kind }: { kind: "n8n" | "rag" | "mcp" }) {
const labels = { n8n: "n8n", rag: "RA", mcp: "MC" };
return <span className={`integration-mark integration-${kind}`} aria-hidden="true">{labels[kind]}</span>;
}