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.
This commit is contained in:
NuklearRabbit
2026-08-04 03:08:07 +02:00
parent 94cfb7bcbb
commit d17af1c52a
16 changed files with 940 additions and 82 deletions
+6 -4
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons";
@@ -11,6 +12,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { PRODUCT_NAME } from "../product";
import { ApiErrorNotice } from "./PageChrome";
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
vehicle: "fleet",
@@ -86,7 +88,7 @@ export function Layout() {
const [activeIndex, setActiveIndex] = useState(-1);
const [resetConfirming, setResetConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const searchInput = useRef<HTMLInputElement>(null);
const searchBox = useRef<HTMLDivElement>(null);
@@ -163,7 +165,7 @@ export function Layout() {
await logout();
navigate("/login");
} catch (err) {
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
setResetError(describeApiError(t, err, "resetFailed"));
setResetConfirming(false);
} finally {
setResetting(false);
@@ -231,7 +233,7 @@ export function Layout() {
</div>
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset">
{resetError && <p className="error" role="alert">{resetError}</p>}
<ApiErrorNotice error={resetError} />
{!resetConfirming ? (
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
{t("resetDemoData")}