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
+22 -23
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type {
ApplyRecommendedStatusResult,
DataQualityIssueDetail as IssueDetail,
@@ -15,7 +16,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
@@ -124,7 +125,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const { user } = useAuth();
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({});
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [confirming, setConfirming] = useState(false);
@@ -155,7 +156,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.duplicateCustomer.mergeFailed"));
setError(describeApiError(t, err, "detail.duplicateCustomer.mergeFailed"));
setConfirming(false);
} finally {
setSubmitting(false);
@@ -171,7 +172,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
return (
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
<SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<fieldset className="choice-fieldset">
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
@@ -210,7 +211,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const differ = valueA !== valueB;
return (
<tr key={field}>
<th scope="row" data-label="Field">{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
<th scope="row" data-label={t("detail.duplicateCustomer.fieldColumn")}>{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
<td data-label={a.public_ref}>
{differ ? (
<label className="checkbox-label">
@@ -285,7 +286,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
}
return initial;
});
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
@@ -299,7 +300,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.missingField.saveFailed"));
setError(describeApiError(t, err, "detail.missingField.saveFailed"));
} finally {
setSubmitting(false);
}
@@ -312,7 +313,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
title={t("detail.missingField.heading")}
description={t("detail.missingField.description", { ref: snapshot?.public_ref ?? issue.entity_ref })}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<div className="form-grid">
{fieldKeys.map((field) => (
<label key={field}>
@@ -345,7 +346,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
const [correctedValue, setCorrectedValue] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
@@ -362,7 +363,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.odometerRegression.resolveFailed"));
setError(describeApiError(t, err, "detail.odometerRegression.resolveFailed"));
} finally {
setSubmitting(false);
}
@@ -375,7 +376,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
title={t("detail.odometerRegression.heading")}
description={t("detail.odometerRegression.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<dl className="detail-grid">
<div><dt>{t("detail.odometerRegression.canonicalOdometer")}</dt><dd>{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km</dd></div>
</dl>
@@ -453,7 +454,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? "");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
@@ -467,7 +468,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.bookingOverlap.resolveFailed"));
setError(describeApiError(t, err, "detail.bookingOverlap.resolveFailed"));
} finally {
setSubmitting(false);
}
@@ -480,7 +481,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
title={t("detail.bookingOverlap.heading")}
description={t("detail.bookingOverlap.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<fieldset className="choice-fieldset choice-fieldset-grid">
<legend className="visually-hidden">{t("detail.bookingOverlap.columns.blockThis")}</legend>
{bookings.map((b) => (
@@ -570,7 +571,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
const [recommendation, setRecommendation] = useState<StatusRecommendation | null>(null);
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
const [stale, setStale] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -596,7 +597,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
);
setRecommendation(preview);
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.recommendationFailed"));
setError(describeApiError(t, err, "detail.vehicleStatusConflict.recommendationFailed"));
} finally {
setLoadingRecommendation(false);
}
@@ -614,12 +615,10 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
setResult(applied);
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.vehicleStatusConflict.applyFailed"));
if (err instanceof ApiError && err.code === "RECOMMENDATION_STALE") {
setError(t("detail.vehicleStatusConflict.staleRecommendation"));
setRecommendation(null);
setStale(true);
} else {
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
}
} finally {
setSubmitting(false);
@@ -645,7 +644,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
title={t("detail.vehicleStatusConflict.heading")}
description={t("detail.vehicleStatusConflict.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<dl className="detail-grid">
<div><dt>{t("detail.vehicleStatusConflict.currentStatus")}</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} label={t(`fleet:statuses.${issue.entity_snapshot?.operational_status}`, { defaultValue: String(issue.entity_snapshot?.operational_status ?? "") })} /></dd></div>
</dl>
@@ -739,7 +738,7 @@ export function DataQualityIssueDetail() {
const { publicRef } = useParams<{ publicRef: string }>();
const [issue, setIssue] = useState<IssueDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [justResolved, setJustResolved] = useState(false);
const load = useCallback(() => {
@@ -776,7 +775,7 @@ export function DataQualityIssueDetail() {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
load();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : t(`detail.deferOrReject.${action}Failed`));
setActionError(describeApiError(t, err, `detail.deferOrReject.${action}Failed`));
}
}
@@ -808,7 +807,7 @@ export function DataQualityIssueDetail() {
<RuleExplainer ruleType={issue.rule_type} />
{actionError && <p className="error" role="alert">{actionError}</p>}
<ApiErrorNotice error={actionError} />
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
<section className="panel success-panel" aria-live="polite">