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:
@@ -1,13 +1,15 @@
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useViewportTier } from "../hooks/useViewportTier";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "./Icons";
|
||||
import { ApiErrorNotice } from "./PageChrome";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
|
||||
export function DemoGuideTrigger() {
|
||||
@@ -68,7 +70,7 @@ export function DemoGuide() {
|
||||
setCollapsedToChip,
|
||||
} = useDemoGuide();
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
|
||||
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
|
||||
const pendingTarget = useRef<string | null>(null);
|
||||
|
||||
@@ -119,7 +121,7 @@ export function DemoGuide() {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
|
||||
setResetError(describeApiError(t, err, "guide.restartFailed"));
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
@@ -227,7 +229,7 @@ export function DemoGuide() {
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
<ApiErrorNotice error={resetError} />
|
||||
|
||||
<footer className="demo-guide-footer">
|
||||
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -67,6 +67,26 @@ export function ErrorState({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link, 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 type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
@@ -10,6 +11,7 @@ import { useLocaleFormat } from "../i18n/format";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "./Icons";
|
||||
import { StatusBadge } from "./Badge";
|
||||
import { ApiErrorNotice } from "./PageChrome";
|
||||
|
||||
function newIdempotencyKey(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
@@ -103,7 +105,7 @@ export function ReturnForm({
|
||||
const [notes, setNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
const [idempotencyKey] = useState(newIdempotencyKey);
|
||||
const [step, setStep] = useState<"capture" | "review">("capture");
|
||||
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
|
||||
@@ -132,7 +134,7 @@ export function ReturnForm({
|
||||
setPreview(evaluated);
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
||||
setError(describeApiError(t, err));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
@@ -148,7 +150,7 @@ export function ReturnForm({
|
||||
);
|
||||
onRegistered(registered);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
||||
setError(describeApiError(t, err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -158,7 +160,7 @@ export function ReturnForm({
|
||||
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
|
||||
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
|
||||
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<ApiErrorNotice error={error} />
|
||||
|
||||
{step === "capture" ? <div className="return-capture">
|
||||
<div className="form-grid"><label>
|
||||
|
||||
Reference in New Issue
Block a user