From 1fbb20b1abe67d3859342c78500aae8e92fc1518 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:02:50 +0200 Subject: [PATCH 1/9] docs: audit remaining Fleet Ops localization gaps Documents every remaining untranslated/incorrect NL/FR string, raw-backend- error call site, over-permissive i18n allowlist entry, the static-greeting bug, and doc staleness found by a dedicated read-only sweep before any file was touched, per the Fleet Ops final localization brief. --- docs/fleet-ops-final-localization/audit.md | 130 +++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/fleet-ops-final-localization/audit.md diff --git a/docs/fleet-ops-final-localization/audit.md b/docs/fleet-ops-final-localization/audit.md new file mode 100644 index 0000000..6a083c1 --- /dev/null +++ b/docs/fleet-ops-final-localization/audit.md @@ -0,0 +1,130 @@ +# Fleet Ops final localization — gap audit + +Branch: `fix/fleet-ops-final-i18n-ux` (created from `master` @ `f7805579f7c73bd3085d73a725fa985b4a4892ed`, +working tree clean at audit time). Deployed revision on Unraid at audit time: `de0bdea84fea01b4501deb7099107bc753c2e6d7` +(the merge commit; `f780557` is an evidence-only commit not separately deployed). Both containers healthy. + +## 1. Remaining untranslated/incorrect text + +### nl-BE +| File | Key | Current | Fix | +|---|---|---|---| +| `audit.json` | `title` | "Audit trail" | "Auditgeschiedenis" | +| `audit.json` | `columns.actor` | "Actor" | "Uitvoerder" | +| `navigation.json` | `items.audit` | "Audit trail" | "Auditgeschiedenis" | +| `auth.json` | `exploreAsOperationsManager` | "Verken als Operations Manager" | "Verken als Operationsmanager" | +| `auth.json` | `exploreAsRentalEmployee` | "Verken als Rental Employee" | "Verken als Verhuurmedewerker" | +| `auth.json` | `roleOperationsManager` | "Operations manager" | "Operationsmanager" | +| `auth.json` | `roleRentalEmployee` | "Rental employee" | "Verhuurmedewerker" | +| `demo.json` | `scenarios.roles.operations_manager` | "Operations Manager" | "Operationsmanager" | +| `demo.json` | `scenarios.roles.rental_employee` | "Rental Employee" | "Verhuurmedewerker" | +| `demo.json` | `scenarios.startScenario` | "Start scenario" | "Scenario starten" | +| `integrations.json` | `ledger.filterRecent` | "Recent" | "Recentste" | +| `quality.json` | `list.statusOpen` | "Open" | "Openstaand" | +| `audit.json`, `quality.json`, `integrations.json`, `demo.json` | 8 `managerOnly`/`whyItMatters`/`scopeBody`/etc. keys (16 nl+fr occurrences) | embedded "Operations Manager(s)" mid-sentence | "Operationsmanager(s)" | + +`columns.details` ("Details") judged fine as-is: short data-table column header, genuine NL/EN cognate, +siblings are single-word labels too. + +### fr-BE +Same key set as nl-BE (role labels + embedded mentions), fr-BE `columns.actor` is already correctly +"Acteur" (no fix needed). French role translations: "Responsable des opérations" / +"Collaborateur de location", per the correction brief. + +### Hardcoded JSX (bypasses i18n entirely) +`frontend/src/pages/DataQualityIssueDetail.tsx` line ~213: `data-label="Field"` — literal English, +never localized. Fix: reuse the already-existing, already-translated +`detail.duplicateCustomer.fieldColumn` key (same table's `` seven lines above already uses it +correctly) — zero locale-file changes needed, pure JSX fix. + +No other hardcoded `data-label`/`aria-label`/`title`/`placeholder` found across `frontend/src/**/*.tsx`. + +## 2. Raw backend errors shown directly + +13 call sites across 7 files (`Automation.tsx`, `ReturnForm.tsx` ×2, `DataQuality.tsx`, `DemoGuide.tsx`, +`Layout.tsx`, `DataQualityIssueDetail.tsx` ×7, `Knowledge.tsx`) all follow: +`err instanceof ApiError ? err.message : t("some:fallback")` — i.e. the **common** case (a real, +structured `ApiError` from the backend) shows raw, un-localized English `error.message` verbatim; the +translated fallback only fires for network-level failures where no `ApiError` could even be +constructed. One site (`DataQualityIssueDetail.tsx` apply-status handler) already special-cases +`err.code === "RECOMMENDATION_STALE"` inline — this needs migrating into the new central system rather +than staying a one-off. + +Backend `AppError`/`HTTPException` codes found (32 semantic `AppError` codes + generic HTTP-status +fallback codes "401"/"403"/"404"/"422" for plain `HTTPException`s, verified via +`app/main.py`'s `error_body()` envelope — both AppError and HTTPException responses share the same +`{"error": {"code", "message", "correlation_id"}}` shape): + +`BOOKING_NOT_ACTIVE, BOOKING_NOT_FOUND, CONFLICT_STILL_PRESENT, CORRECTED_VALUE_REQUIRED, +CORRECTION_BELOW_CANONICAL, CUSTOMER_NOT_FOUND, EMPTY_VALUE, ENTITY_NOT_FOUND, EVENT_NOT_FOUND, +IDEMPOTENCY_KEY_REUSED, INVALID_BOOKING_REFERENCE, INVALID_BOOKING_STATE, INVALID_EVENT_ID, +INVALID_FIELD, INVALID_FIELD_OVERRIDE, INVALID_IDEMPOTENCY_KEY, INVALID_SURVIVOR, ISSUE_NOT_FOUND, +ISSUE_NOT_OPEN, MANUAL_REVIEW_REQUIRED, NOT_AN_ODOMETER_ISSUE, NOT_AN_OVERLAP_ISSUE, +NOT_A_DUPLICATE_ISSUE, NOT_A_MISSING_FIELD_ISSUE, NOT_A_STATUS_CONFLICT_ISSUE, NOT_RETRYABLE, +NO_CONFLICT_DETECTED, NO_FIELDS_PROVIDED, OVERLAP_STILL_PRESENT, RECOMMENDATION_STALE, +UNAUTHORIZED_SERVICE, UNSUPPORTED_ENTITY, VEHICLE_NOT_FOUND`. + +Only one code (`INVALID_SURVIVOR`) carries structured `details` params; the rest embed specifics only +in the raw English `message` string — so localized messages will be generic per-code (title + +explanation + optional next step), not parameterized with extracted specifics, with the raw string +preserved verbatim under "Technical details". + +`errors.json` namespace already exists (all 3 locales) with 6 generic keys (`generic`, +`workspaceLoadFailed`, `unauthorized`, `forbidden`, `notFound`, `networkUnavailable`) but is not wired +to `ApiError.code` at all — only used as the non-`ApiError` fallback string. + +## 3. i18n allowlist over-permissiveness + +`frontend/e2e/i18n-coverage.spec.ts`'s `IDENTICAL_VALUE_ALLOWLIST` currently contains 4 entries that +must be removed once role labels are translated: `auth.roleOperationsManager`, +`auth.roleRentalEmployee`, `demo.scenarios.roles.operations_manager`, +`demo.scenarios.roles.rental_employee` (comment: "deliberately-untranslated role title" — no longer +true once fixed). `audit.title` and `navigation.items.audit` ("Audit trail" kept as compliance term) +also need removing once translated to "Auditgeschiedenis". + +Remaining ~19 allowlist entries are genuine cognates/proper nouns/templates (verified by the audit +agent against a broad Dutch-word grep of fr-BE — zero Dutch leakage found) and should stay. + +Also noted: the coverage test's identical-value check only catches **whole-string** identity to en-GB, +not **mid-sentence embedded English** (the 16 "Operations Manager(s)" occurrences above) — this is a +real blind spot the new tests (section 8 of the correction brief) need to close with a targeted, +explicit check for known English substrings appearing in nl-BE/fr-BE prose. + +## 4. Dashboard greeting + +No time-of-day logic exists anywhere in the codebase — `dashboard.json`'s `title` key is a **static** +string ("Good morning. Here's the fleet." / "Goedemorgen. Hier is je wagenpark." / "Bonjour. Voici +votre flotte.") shown unconditionally at all times of day, despite implying dynamism. Needs: a central, +testable, clock-injectable greeting function keyed on `Europe/Brussels` wall-clock hour, 4 periods per +the brief, updating on language change and on period rollover while the app stays open. + +## 5. Documentation staleness + +- `PROJECT_STATE.md` "Locked decisions" block: `"Product name: MobilityOps."` and `"PoC only..."` — + predates the Fleet Ops rebrand, contradicts the later (correct) sections of the same file. +- `PROJECT_STATE.md`'s final section header still reads `"...IN PROGRESS on + fix/fleet-ops-i18n-status-flow"` and states `"Not yet merged to master"` / `"Do not claim PASS..."` — + **false**: the merge (`de0bdea`) and final evidence commit (`f780557`) both already exist in git + history, neither is mentioned in the file, and the branch name has moved on to + `fix/fleet-ops-final-i18n-ux`. +- A separate, older `"## Demo productization (in progress, same branch + feat/mobilityops-functional-completion)"` section header was also never marked complete. +- README.md is accurate and current — no fix needed there beyond a version-count refresh after this + round's test additions. +- No false "RAGcore live" / "MCP Hub connected" claims found anywhere — this part is already honest. + +## Plan + +1. Fix the ~10 nl-BE + ~10 fr-BE locale-file translations above (role labels, "Audit trail", "Actor", + "Start scenario", "Recent", "Open", embedded mid-sentence mentions). +2. Fix the one hardcoded `data-label="Field"` JSX bug. +3. Build a central `describeApiError(t, err)` helper + shared rendering component, wire all 13 call + sites through it, with a code→message map covering all 32 backend codes + generic HTTP fallbacks, + raw text demoted to "Technical details". +4. Tighten the allowlist (remove the 6 now-stale entries) and add a targeted embedded-English-substring + test, a `describeApiError` coverage test, and greeting boundary tests. +5. Build the time-of-day greeting function + wire into `Dashboard.tsx`, with matching locale copy for + 4 periods × 3 languages + a localized description line replacing the current static one. +6. Fix `PROJECT_STATE.md` staleness (append a new dated entry, do not rewrite prior entries). +7. Full local validation → clean-checkout drill → deploy fix branch → live validation in 3 languages → + merge to master → redeploy → final evidence. From 37a362c4a01518febe3b301fc823f92f8c018649 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:03:46 +0200 Subject: [PATCH 2/9] fix: translate remaining NL/FR interface gaps Role names, audit/scenario labels, and status text were previously either left in English or only partially translated: - auth.json/demo.json role labels actually translated (not just labelled as translated): Operationsmanager/Verhuurmedewerker, Responsable des operations/Collaborateur de location. - "Audit trail" -> Auditgeschiedenis/Piste d'audit (title, column header, and every mid-sentence occurrence across demo.json, quality.json, returns.json -- these embedded leaks were previously invisible to the whole-string identity check). - "Open" (status) -> Openstaand, "Recent" -> Recentste, "Start scenario" -> Scenario starten / Demarrer le scenario. Matching Playwright spec text updated in the same commit so the suite never regresses through a broken intermediate state. --- .../e2e/_capture-demo-screenshots.spec.ts | 2 +- frontend/e2e/_capture-screenshots.spec.ts | 2 +- frontend/e2e/demo-accessibility.spec.ts | 6 ++--- frontend/e2e/demo-entry.spec.ts | 8 +++---- frontend/e2e/demo-guide.spec.ts | 8 +++---- frontend/e2e/demo-legibility.spec.ts | 10 ++++----- frontend/e2e/demo.spec.ts | 2 +- frontend/e2e/fleet-ops-correction.spec.ts | 14 ++++++++++-- frontend/src/i18n/locales/fr-BE/audit.json | 4 ++-- frontend/src/i18n/locales/fr-BE/auth.json | 8 +++---- frontend/src/i18n/locales/fr-BE/demo.json | 10 ++++----- .../src/i18n/locales/fr-BE/integrations.json | 2 +- frontend/src/i18n/locales/fr-BE/quality.json | 6 ++--- frontend/src/i18n/locales/nl-BE/audit.json | 12 +++++----- frontend/src/i18n/locales/nl-BE/auth.json | 8 +++---- frontend/src/i18n/locales/nl-BE/demo.json | 22 +++++++++---------- .../src/i18n/locales/nl-BE/integrations.json | 4 ++-- .../src/i18n/locales/nl-BE/navigation.json | 2 +- frontend/src/i18n/locales/nl-BE/quality.json | 14 ++++++------ frontend/src/i18n/locales/nl-BE/returns.json | 2 +- 20 files changed, 78 insertions(+), 68 deletions(-) diff --git a/frontend/e2e/_capture-demo-screenshots.spec.ts b/frontend/e2e/_capture-demo-screenshots.spec.ts index af34dd4..df65044 100644 --- a/frontend/e2e/_capture-demo-screenshots.spec.ts +++ b/frontend/e2e/_capture-demo-screenshots.spec.ts @@ -18,7 +18,7 @@ test("capture demo-release evidence screenshots", async ({ page, request }) => { await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` }); await page.setViewportSize({ width: 1280, height: 900 }); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible(); await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true }); diff --git a/frontend/e2e/_capture-screenshots.spec.ts b/frontend/e2e/_capture-screenshots.spec.ts index 10ffeb0..d3f0558 100644 --- a/frontend/e2e/_capture-screenshots.spec.ts +++ b/frontend/e2e/_capture-screenshots.spec.ts @@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => { await page.goto("/login"); await page.screenshot({ path: `${OUT}/1-login.png` }); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible(); await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true }); diff --git a/frontend/e2e/demo-accessibility.spec.ts b/frontend/e2e/demo-accessibility.spec.ts index ff7bd52..52b689b 100644 --- a/frontend/e2e/demo-accessibility.spec.ts +++ b/frontend/e2e/demo-accessibility.spec.ts @@ -38,7 +38,7 @@ test("demo guide does not cover the return form's action buttons on desktop", as test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); const guideTrigger = page.getByRole("button", { name: /Demo-gids/ }); @@ -74,7 +74,7 @@ test("key demo pages load without console errors", async ({ page }) => { page.on("pageerror", (err) => errors.push(err.message)); await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/scenarios"); await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible(); @@ -96,7 +96,7 @@ test("status-recommendation panel is fully keyboard operable, respects reduced m await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/data-quality/DQ-DEMO-STATUS"); diff --git a/frontend/e2e/demo-entry.spec.ts b/frontend/e2e/demo-entry.spec.ts index 4b4c7ae..4a1b51f 100644 --- a/frontend/e2e/demo-entry.spec.ts +++ b/frontend/e2e/demo-entry.spec.ts @@ -7,8 +7,8 @@ test("demo entry screen names the fictional org and never shows a password", asy await expect(page.getByText(/Northstar Mobility/)).toBeVisible(); await expect(page.getByText(/Synthetische demo/)).toBeVisible(); await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Verken als Operations Manager" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible(); await expect(page.locator('input[type="password"]')).toHaveCount(0); }); @@ -26,7 +26,7 @@ test("start guided demo logs in as Operations Manager and opens the guide at ste test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); const trigger = page.getByRole("button", { name: /Synthetische demo/ }); @@ -45,7 +45,7 @@ test("permanent demo badge shows a popover with last reset info and a working Ab test("badge popover closes on Escape and outside click", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); const trigger = page.getByRole("button", { name: /Synthetische demo/ }); diff --git a/frontend/e2e/demo-guide.spec.ts b/frontend/e2e/demo-guide.spec.ts index 2c1e8b2..b81656a 100644 --- a/frontend/e2e/demo-guide.spec.ts +++ b/frontend/e2e/demo-guide.spec.ts @@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" }); test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => { await resetDemoData(request); await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/scenarios"); @@ -27,12 +27,12 @@ test("scenario overview lists all 5 scenarios, ready right after a reset", async test("starting a scenario navigates to its fixed record", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/scenarios"); const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" }); - await duplicateCard.getByRole("link", { name: "Start scenario" }).click(); + await duplicateCard.getByRole("link", { name: "Scenario starten" }).click(); await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/); }); @@ -92,7 +92,7 @@ test("demo guide progress persists across navigation and the trigger shows it", test("demo guide is not shown to a rental employee", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Rental Employee" }).click(); + await page.getByRole("button", { name: "Verken als Verhuurmedewerker" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0); }); diff --git a/frontend/e2e/demo-legibility.spec.ts b/frontend/e2e/demo-legibility.spec.ts index 189c247..2613b7c 100644 --- a/frontend/e2e/demo-legibility.spec.ts +++ b/frontend/e2e/demo-legibility.spec.ts @@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" }); test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => { await resetDemoData(request); await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/bookings/BK-DEMO-RETURN"); @@ -27,12 +27,12 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a await page.getByRole("button", { name: "Retour bevestigen" }).click(); await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible(); await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Audit trail bekijken" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Auditgeschiedenis bekijken" })).toBeVisible(); }); test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); @@ -43,7 +43,7 @@ test("data quality issue detail explains what's wrong and why it matters", async test("data quality list can filter to demo scenarios only", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/data-quality"); @@ -61,7 +61,7 @@ test("data quality list can filter to demo scenarios only", async ({ page }) => test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/knowledge"); diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts index 0d3fdf6..de579a1 100644 --- a/frontend/e2e/demo.spec.ts +++ b/frontend/e2e/demo.spec.ts @@ -19,7 +19,7 @@ test("five-minute demo script end to end", async ({ page, request }) => { await test.step("1. login as Operations Manager", async () => { await page.goto("/login"); await expect(page.getByText(/Synthetische demo/)).toBeVisible(); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); diff --git a/frontend/e2e/fleet-ops-correction.spec.ts b/frontend/e2e/fleet-ops-correction.spec.ts index 1bd0ba8..cbdb37f 100644 --- a/frontend/e2e/fleet-ops-correction.spec.ts +++ b/frontend/e2e/fleet-ops-correction.spec.ts @@ -19,9 +19,9 @@ async function resetDemoData(request: APIRequestContext) { } const EXPLORE_OPS_MANAGER: Record = { - "nl-BE": "Verken als Operations Manager", + "nl-BE": "Verken als Operationsmanager", "en-GB": "Explore as Operations Manager", - "fr-BE": "Explorer en tant qu'Operations Manager", + "fr-BE": "Explorer en tant que Responsable des opérations", }; const REVIEW_RECOMMENDATION: Record = { @@ -74,6 +74,16 @@ test.describe("branding", () => { } }); +test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => { + await resetDemoData(request); + await page.goto("/login"); + const href = await page.locator('link[rel="icon"]').getAttribute("href"); + expect(href).toBe("/favicon.svg"); + const response = await page.request.get(href as string); + expect(response.ok()).toBeTruthy(); + expect(response.headers()["content-type"]).toContain("svg"); +}); + test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => { await resetDemoData(request); // Deliberately not using loginAsOpsManager here: its addInitScript would re-force diff --git a/frontend/src/i18n/locales/fr-BE/audit.json b/frontend/src/i18n/locales/fr-BE/audit.json index e7cda2e..a8a8b61 100644 --- a/frontend/src/i18n/locales/fr-BE/audit.json +++ b/frontend/src/i18n/locales/fr-BE/audit.json @@ -4,8 +4,8 @@ "description": "Suivez les changements d'état importants, les acteurs et les références corrélées.", "actionFilterLabel": "Action", "actionFilterPlaceholder": "p. ex. demo_login", - "managerOnly": "La piste d'audit est visible uniquement pour les Operations Managers.", - "managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Operations Managers.", + "managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.", + "managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.", "loading": "Chargement de la piste d'audit…", "unavailable": "La piste d'audit est actuellement indisponible.", "empty": "Aucun événement d'audit trouvé", diff --git a/frontend/src/i18n/locales/fr-BE/auth.json b/frontend/src/i18n/locales/fr-BE/auth.json index ead5bc9..e68b016 100644 --- a/frontend/src/i18n/locales/fr-BE/auth.json +++ b/frontend/src/i18n/locales/fr-BE/auth.json @@ -9,15 +9,15 @@ "accessHeading": "Choisissez comment démarrer", "accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.", "startGuidedDemo": "Démarrer la démo guidée", - "exploreAsOperationsManager": "Explorer en tant qu'Operations Manager", + "exploreAsOperationsManager": "Explorer en tant que Responsable des opérations", "exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives", - "exploreAsRentalEmployee": "Explorer en tant que Rental Employee", + "exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location", "exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures", "safeByDesignTitle": "Conçu pour la sécurité", "safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.", "loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.", - "roleOperationsManager": "Operations manager", - "roleRentalEmployee": "Rental employee", + "roleOperationsManager": "Responsable des opérations", + "roleRentalEmployee": "Collaborateur de location", "switchRole": "Changer de rôle", "logout": "Déconnexion" } diff --git a/frontend/src/i18n/locales/fr-BE/demo.json b/frontend/src/i18n/locales/fr-BE/demo.json index fc5f02e..d467c1e 100644 --- a/frontend/src/i18n/locales/fr-BE/demo.json +++ b/frontend/src/i18n/locales/fr-BE/demo.json @@ -32,7 +32,7 @@ "understand-state": { "title": "1. Comprendre l'état opérationnel", "whatYouWillSee": "Le tableau de bord affiche la disponibilité de la flotte, les points d'attention ouverts et les mouvements du jour.", - "whyItMatters": "Un Operations Manager commence chaque journée par cet aperçu pour décider où intervenir.", + "whyItMatters": "Un Responsable des opérations commence chaque journée par cet aperçu pour décider où intervenir.", "startAction": "Ouvrez le tableau de bord et consultez la file d'attention et la chronologie du jour.", "expectedOutcome": "Vous voyez quelles réservations, véhicules ou problèmes de qualité nécessitent de l'attention." }, @@ -102,8 +102,8 @@ "startScenario": "Démarrer le scénario", "roleOr": "{{a}} ou {{b}}", "roles": { - "operations_manager": "Operations Manager", - "rental_employee": "Rental Employee" + "operations_manager": "Responsable des opérations", + "rental_employee": "Collaborateur de location" }, "items": { "return-anomaly": { @@ -155,7 +155,7 @@ "problemTitle": "Le problème fictif", "problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. {{productName}} montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.", "scopeTitle": "Pour qui et avec quelle portée", - "scopeBody": "Cette démo s'adresse à quiconque veut voir comment {{productName}} traite les problèmes opérationnels d'un petit loueur : Operations Managers et Rental Employees, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.", + "scopeBody": "Cette démo s'adresse à quiconque veut voir comment {{productName}} traite les problèmes opérationnels d'un petit loueur : Responsables des opérations et Collaborateurs de location, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.", "realTitle": "Ce qui fonctionne réellement", "realBody": "Tout ce qui suit est du code fonctionnel, pas seulement une maquette : accès et sessions basés sur les rôles, gestion des véhicules et réservations, traitement des retours avec validation côté serveur, cinq règles de qualité des données avec chacune sa propre étape de résolution, une piste d'audit complète, une livraison automatisée vers n8n avec nouvelles tentatives limitées, un déploiement basé sur Docker, et une suite de tests automatisés (backend et Playwright de bout en bout).", "syntheticTitle": "Ce qui est synthétique", @@ -170,7 +170,7 @@ "integrationsDescription": "Ce qui est opérationnel, ce qui est en mode démo, et ce qui n'est pas encore connecté.", "resetTitle": "Restaurer l'environnement de démo", "resetBodyManager": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : {{when}}. Utilisez Réinitialiser les données de démo dans la barre latérale pour recommencer.", - "resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : {{when}}. Un Operations Manager peut réinitialiser l'environnement de démo via la barre latérale.", + "resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : {{when}}. Un Responsable des opérations peut réinitialiser l'environnement de démo via la barre latérale.", "limitationsTitle": "Limitations", "limitationsBody": "Ceci est une preuve de concept ciblée, pas un ERP complet. RAGcore et l'ITWorx MCP Hub ne sont pas encore connectés en direct ; l'assistant de connaissances utilise une base de connaissances de démo locale et délimitée au lieu d'un environnement RAGcore en direct.", "unknown": "inconnu" diff --git a/frontend/src/i18n/locales/fr-BE/integrations.json b/frontend/src/i18n/locales/fr-BE/integrations.json index 6ee7997..2c4a29a 100644 --- a/frontend/src/i18n/locales/fr-BE/integrations.json +++ b/frontend/src/i18n/locales/fr-BE/integrations.json @@ -41,7 +41,7 @@ "statusSucceeded": "Réussi", "statusFailed": "Échoué", "unavailable": "Les tâches d'automatisation sont actuellement indisponibles.", - "managerOnly": "L'automatisation est visible uniquement pour les Operations Managers.", + "managerOnly": "L'automatisation est visible uniquement pour les Responsables des opérations.", "loading": "Chargement des tâches d'automatisation…", "empty": "Aucune tâche d'automatisation ne correspond à ce filtre.", "count": "{{count}} événements de workflow", diff --git a/frontend/src/i18n/locales/fr-BE/quality.json b/frontend/src/i18n/locales/fr-BE/quality.json index f0571d6..9693782 100644 --- a/frontend/src/i18n/locales/fr-BE/quality.json +++ b/frontend/src/i18n/locales/fr-BE/quality.json @@ -55,8 +55,8 @@ "title": "Examinez les preuves enregistrées et consignez une résolution auditée.", "notFound": "Ce problème est introuvable.", "loading": "Chargement des preuves du problème…", - "managerOnly": "L'atelier qualité est visible uniquement pour les Operations Managers.", - "managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Operations Managers.", + "managerOnly": "L'atelier qualité est visible uniquement pour les Responsables des opérations.", + "managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Responsables des opérations.", "summary": { "rule": "Règle", "entity": "Entité", @@ -212,7 +212,7 @@ "reviewAgain": "Revoir la recommandation", "manualReview": { "heading": "Évaluation manuelle requise", - "body": "Les faits concernant ce véhicule se contredisent. Un Operations Manager doit évaluer la situation en personne plutôt que d'appliquer un changement de statut automatique.", + "body": "Les faits concernant ce véhicule se contredisent. Un Responsable des opérations doit évaluer la situation en personne plutôt que d'appliquer un changement de statut automatique.", "hint": "Utilisez « Reporter » ou « Rejeter » ci-dessous, ou examinez manuellement le véhicule et les réservations concernées." }, "noConflict": { diff --git a/frontend/src/i18n/locales/nl-BE/audit.json b/frontend/src/i18n/locales/nl-BE/audit.json index e6dd60b..dd41e31 100644 --- a/frontend/src/i18n/locales/nl-BE/audit.json +++ b/frontend/src/i18n/locales/nl-BE/audit.json @@ -1,13 +1,13 @@ { "eyebrow": "Bewaken / Onveranderlijke geschiedenis", - "title": "Audit trail", + "title": "Auditgeschiedenis", "description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.", "actionFilterLabel": "Actie", "actionFilterPlaceholder": "bv. demo-login", - "managerOnly": "De audit trail is enkel zichtbaar voor Operations Managers.", - "managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operations Managers.", - "loading": "Audit trail laden…", - "unavailable": "Audit trail is momenteel niet beschikbaar.", + "managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.", + "managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.", + "loading": "Auditgeschiedenis laden…", + "unavailable": "Auditgeschiedenis is momenteel niet beschikbaar.", "empty": "Geen auditgebeurtenissen gevonden", "emptyDetail": "Pas het actiefilter aan.", "relatedFilterActive": "Enkel gebeurtenissen gekoppeld aan deze actie ({{count}} gerelateerde gebeurtenissen).", @@ -16,7 +16,7 @@ "storedRendered": "UTC opgeslagen · Brussel weergegeven", "columns": { "when": "Wanneer", - "actor": "Actor", + "actor": "Uitvoerder", "action": "Actie", "entity": "Entiteit", "change": "Wijziging", diff --git a/frontend/src/i18n/locales/nl-BE/auth.json b/frontend/src/i18n/locales/nl-BE/auth.json index e92c159..4e181cf 100644 --- a/frontend/src/i18n/locales/nl-BE/auth.json +++ b/frontend/src/i18n/locales/nl-BE/auth.json @@ -9,15 +9,15 @@ "accessHeading": "Kies hoe je wil starten", "accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.", "startGuidedDemo": "Start begeleide demo", - "exploreAsOperationsManager": "Verken als Operations Manager", + "exploreAsOperationsManager": "Verken als Operationsmanager", "exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen", - "exploreAsRentalEmployee": "Verken als Rental Employee", + "exploreAsRentalEmployee": "Verken als Verhuurmedewerker", "exploreAsRentalEmployeeDetail": "Boekingen, retours, wagenpark en procedures", "safeByDesignTitle": "Veilig ontworpen", "safeByDesignDetail": "Elke actie wordt gelogd en is in deze demo herstelbaar.", "loginFailed": "De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.", - "roleOperationsManager": "Operations manager", - "roleRentalEmployee": "Rental employee", + "roleOperationsManager": "Operationsmanager", + "roleRentalEmployee": "Verhuurmedewerker", "switchRole": "Wissel van rol", "logout": "Uitloggen" } diff --git a/frontend/src/i18n/locales/nl-BE/demo.json b/frontend/src/i18n/locales/nl-BE/demo.json index dadb4e2..6fc3db8 100644 --- a/frontend/src/i18n/locales/nl-BE/demo.json +++ b/frontend/src/i18n/locales/nl-BE/demo.json @@ -32,7 +32,7 @@ "understand-state": { "title": "1. Begrijp de operationele status", "whatYouWillSee": "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.", - "whyItMatters": "Een Operations Manager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.", + "whyItMatters": "Een Operationsmanager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.", "startAction": "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.", "expectedOutcome": "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen." }, @@ -72,10 +72,10 @@ "expectedOutcome": "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is." }, "check-automation-audit": { - "title": "7. Controleer automatisering en audit trail", + "title": "7. Controleer automatisering en auditgeschiedenis", "whatYouWillSee": "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.", "whyItMatters": "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.", - "startAction": "Open Integraties om de afleverstatus te zien, en Audit trail voor het volledige spoor.", + "startAction": "Open Integraties om de afleverstatus te zien, en Auditgeschiedenis voor het volledige spoor.", "expectedOutcome": "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties." }, "review-real-vs-simulated": { @@ -99,22 +99,22 @@ "role": "Rol", "demonstrates": "Toont aan:", "requiresRole": "Vereist rol: {{roles}}.", - "startScenario": "Start scenario", + "startScenario": "Scenario starten", "roleOr": "{{a}} of {{b}}", "roles": { - "operations_manager": "Operations Manager", - "rental_employee": "Rental Employee" + "operations_manager": "Operationsmanager", + "rental_employee": "Verhuurmedewerker" }, "items": { "return-anomaly": { "title": "Retour met afwijkende kilometerstand", "problem": "Een voertuig komt terug met een kilometerstand die lager ligt dan de laatst geregistreerde stand — een teken van een foutieve invoer of een verwisseld voertuig.", - "demonstrates": "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de audit trail die daaruit ontstaat." + "demonstrates": "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de auditgeschiedenis die daaruit ontstaat." }, "duplicate-customer": { "title": "Mogelijke dubbele klant samenvoegen", "problem": "Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — waarschijnlijk dezelfde persoon, twee keer geregistreerd.", - "demonstrates": "Samenvoegen van klanten met behoud van boekingsgeschiedenis en audit trail." + "demonstrates": "Samenvoegen van klanten met behoud van boekingsgeschiedenis en auditgeschiedenis." }, "booking-overlap": { "title": "Overlappende boekingen herstellen", @@ -155,9 +155,9 @@ "problemTitle": "Het fictieve probleem", "problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. {{productName}} toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.", "scopeTitle": "Voor wie en met welke scope", - "scopeBody": "Deze demo is bedoeld voor wie wil zien hoe {{productName}} operationele problemen bij een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.", + "scopeBody": "Deze demo is bedoeld voor wie wil zien hoe {{productName}} operationele problemen bij een kleine verhuurder aanpakt: Operationsmanagers en Verhuurmedewerkers, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.", "realTitle": "Wat écht werkt", - "realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).", + "realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige auditgeschiedenis, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).", "syntheticTitle": "Wat synthetisch is", "syntheticBody": "De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis, procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of bedrijf; e-mailadressen gebruiken uitsluitend het testdomein {{testDomain}}.", "architectureTitle": "Architectuur in het kort", @@ -170,7 +170,7 @@ "integrationsDescription": "Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is.", "resetTitle": "Demo-omgeving herstellen", "resetBodyManager": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: {{when}}. Gebruik Demogegevens herstellen in de zijbalk om opnieuw te beginnen.", - "resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: {{when}}. Een Operations Manager kan de demo-omgeving herstellen via de zijbalk.", + "resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: {{when}}. Een Operationsmanager kan de demo-omgeving herstellen via de zijbalk.", "limitationsTitle": "Beperkingen", "limitationsBody": "Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale, afgebakende demokennisbank in plaats van een live RAGcore-omgeving.", "unknown": "onbekend" diff --git a/frontend/src/i18n/locales/nl-BE/integrations.json b/frontend/src/i18n/locales/nl-BE/integrations.json index f08c916..15517b9 100644 --- a/frontend/src/i18n/locales/nl-BE/integrations.json +++ b/frontend/src/i18n/locales/nl-BE/integrations.json @@ -31,7 +31,7 @@ "description": "Vastgelegde automatiseringspogingen met de recentste foutevidentie.", "filterLabel": "Weergave", "filterNeedsAttention": "Vraagt aandacht", - "filterRecent": "Recent", + "filterRecent": "Recentste", "filterSucceeded": "Geslaagd", "filterAll": "Alle", "statusFilterLabel": "Status", @@ -41,7 +41,7 @@ "statusSucceeded": "Geslaagd", "statusFailed": "Mislukt", "unavailable": "Automatiseringsopdrachten zijn momenteel niet beschikbaar.", - "managerOnly": "Automatisering is enkel zichtbaar voor Operations Managers.", + "managerOnly": "Automatisering is enkel zichtbaar voor Operationsmanagers.", "loading": "Automatiseringsopdrachten laden…", "empty": "Geen automatiseringsopdrachten voor dit filter.", "count": "{{count}} workflowgebeurtenissen", diff --git a/frontend/src/i18n/locales/nl-BE/navigation.json b/frontend/src/i18n/locales/nl-BE/navigation.json index 0f1f1cc..2c29003 100644 --- a/frontend/src/i18n/locales/nl-BE/navigation.json +++ b/frontend/src/i18n/locales/nl-BE/navigation.json @@ -11,7 +11,7 @@ "quality": "Datakwaliteit", "knowledge": "Kennis", "integrations": "Integraties", - "audit": "Audit trail" + "audit": "Auditgeschiedenis" }, "primaryNavLabel": "Hoofdnavigatie", "mobileNavLabel": "Mobiele navigatie", diff --git a/frontend/src/i18n/locales/nl-BE/quality.json b/frontend/src/i18n/locales/nl-BE/quality.json index 07c9a2c..c5ec0db 100644 --- a/frontend/src/i18n/locales/nl-BE/quality.json +++ b/frontend/src/i18n/locales/nl-BE/quality.json @@ -14,7 +14,7 @@ "scanFailed": "Kwaliteitscontrole kon niet uitgevoerd worden.", "statusLabel": "Status", "statusAll": "Alle statussen", - "statusOpen": "Open", + "statusOpen": "Openstaand", "statusDeferred": "Uitgesteld", "statusResolved": "Opgelost", "statusRejected": "Verworpen", @@ -55,8 +55,8 @@ "title": "Bekijk vastgelegde evidentie en registreer een geauditeerde oplossing.", "notFound": "Dit probleem kon niet gevonden worden.", "loading": "Probleemevidentie laden…", - "managerOnly": "De kwaliteitswerkbank is enkel zichtbaar voor Operations Managers.", - "managerOnlyDetail": "Datakwaliteitsevidentie en -oplossingen zijn enkel zichtbaar voor Operations Managers.", + "managerOnly": "De kwaliteitswerkbank is enkel zichtbaar voor Operationsmanagers.", + "managerOnlyDetail": "Datakwaliteitsevidentie en -oplossingen zijn enkel zichtbaar voor Operationsmanagers.", "summary": { "rule": "Regel", "entity": "Entiteit", @@ -64,8 +64,8 @@ }, "resolved": { "title": "Probleem {{ref}} opgelost", - "body": "De wijziging is doorgevoerd en vastgelegd in de audit trail.", - "viewAudit": "Audit trail bekijken", + "body": "De wijziging is doorgevoerd en vastgelegd in de auditgeschiedenis.", + "viewAudit": "Auditgeschiedenis bekijken", "viewVehicle": "Voertuig bekijken", "continueDemo": "Ga verder met de demo" }, @@ -201,7 +201,7 @@ "consequence": { "statusWillChange": "De voertuigstatus wordt gewijzigd naar {{status}}.", "issueWillBeRechecked": "Dit kwaliteitsprobleem wordt opnieuw gecontroleerd.", - "changeWillBeAudited": "De wijziging wordt vastgelegd in de audit trail.", + "changeWillBeAudited": "De wijziging wordt vastgelegd in de auditgeschiedenis.", "bookingsNotDeleted": "De boekingen zelf worden niet verwijderd." }, "changeStatusTo": "Status wijzigen naar {{status}}", @@ -212,7 +212,7 @@ "reviewAgain": "Aanbeveling opnieuw bekijken", "manualReview": { "heading": "Handmatige beoordeling vereist", - "body": "De feiten voor dit voertuig spreken elkaar tegen. Een Operations Manager moet dit persoonlijk beoordelen in plaats van een automatische statuswijziging toe te passen.", + "body": "De feiten voor dit voertuig spreken elkaar tegen. Een Operationsmanager moet dit persoonlijk beoordelen in plaats van een automatische statuswijziging toe te passen.", "hint": "Gebruik 'Uitstellen' of 'Verwerpen' hieronder, of onderzoek het voertuig en de betrokken boekingen manueel." }, "noConflict": { diff --git a/frontend/src/i18n/locales/nl-BE/returns.json b/frontend/src/i18n/locales/nl-BE/returns.json index 416db01..cc688e9 100644 --- a/frontend/src/i18n/locales/nl-BE/returns.json +++ b/frontend/src/i18n/locales/nl-BE/returns.json @@ -69,7 +69,7 @@ "odometerRegressionNotice": "De ingevoerde kilometerstand lag onder de laatst bevestigde stand van het voertuig. Ze werd zo geregistreerd; de laatst bevestigde kilometerstand is niet gewijzigd en er is een datakwaliteitsprobleem geopend ter controle.", "viewVehicle": "Voertuig {{ref}} bekijken", "viewAutomation": "Automatiseringsstatus bekijken", - "viewAudit": "Audit trail bekijken", + "viewAudit": "Auditgeschiedenis bekijken", "continueDemo": "Ga verder met de demo" }, "scenario": { From 94cfb7bcbbc9c2cbe6ea24c919453577f777cbff Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:04:14 +0200 Subject: [PATCH 3/9] test: tighten i18n allowlist, add substring and brand-leak guards Remove 7 now-stale IDENTICAL_VALUE_ALLOWLIST entries (audit.title, auth.roleOperationsManager, auth.roleRentalEmployee, demo.scenarios.startScenario, demo.scenarios.roles.operations_manager/ rental_employee, navigation.items.audit) now that they are genuinely translated -- their old comments describing them as "deliberately untranslated" were no longer true. Add two new checks: one closing the embedded-English/Dutch-substring blind spot the whole-string identity test structurally cannot catch (a mid-sentence phrase surviving inside otherwise-translated prose), one asserting no locale file contains "MobilityOps" or the word "PoC". --- frontend/e2e/i18n-coverage.spec.ts | 80 +++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/frontend/e2e/i18n-coverage.spec.ts b/frontend/e2e/i18n-coverage.spec.ts index c83e01c..bd7c3f9 100644 --- a/frontend/e2e/i18n-coverage.spec.ts +++ b/frontend/e2e/i18n-coverage.spec.ts @@ -97,6 +97,24 @@ test("no locale file defines an 'appName' key or the literal brand string", () = } }); +test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => { + for (const language of LANGUAGES) { + for (const namespace of namespaces) { + const raw = JSON.stringify(loadNamespace(language, namespace)); + expect( + raw.includes("MobilityOps"), + `${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` + + `always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`, + ).toBe(false); + expect( + /\bPoC\b/.test(raw), + `${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` + + `in user-facing copy`, + ).toBe(false); + } + } +}); + // --- Translation-quality: prove values were actually translated, not copy-pasted --- // Sleutelpariteit alone doesn't prove translation happened (a locale file could contain // the literal English string under the right key and still pass). For every "real prose" @@ -110,19 +128,13 @@ test("no locale file defines an 'appName' key or the literal brand string", () = // a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly // hide an unrelated real mistranslation under the same key in a different namespace. const IDENTICAL_VALUE_ALLOWLIST = new Set([ - "audit.title", // "Audit trail" kept as an established cross-language compliance term "audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch - "auth.roleOperationsManager", // deliberately-untranslated role title (see demo.json roles) - "auth.roleRentalEmployee", - "demo.scenarios.roles.operations_manager", // same convention, scenario-overview role labels - "demo.scenarios.roles.rental_employee", "common.language.nl-BE", // language-picker options show each language's own endonym "common.language.fr-BE", "common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text "common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3 "dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative "demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3 - "demo.scenarios.startScenario", // "start"/"scenario" are naturalised loanwords in Dutch "demo.about.limitationsTitle", // "Limitations" -- identical spelling in French "demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun "fleet.list.columns.attention", // "Attention" -- identical spelling in French @@ -131,7 +143,6 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([ "knowledge.questionLabel", // "Question" -- identical spelling in French "knowledge.retrievalFlow.question", "knowledge.questionLabelExchange", - "navigation.items.audit", // "Audit trail" kept as an established cross-language term "returns.result.inspection", // "Inspection" -- identical spelling in French ]); @@ -185,6 +196,61 @@ test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or ea } }); +// --- Embedded English/Dutch fragments inside otherwise-translated prose --- +// The whole-string identity check above only catches a value that is IDENTICAL to +// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final +// localization pass: a sentence gets 95% translated but a role/status noun phrase is +// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden +// goedgekeurd." This scan flags known English fragments appearing literally inside any +// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste +// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common +// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary). +const FORBIDDEN_ENGLISH_FRAGMENTS = [ + "Operations Manager", + "Operations Managers", + "Rental Employee", + "Rental Employees", + "Audit trail", + "Start scenario", +]; +const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [ + "Operationsmanager", + "Verhuurmedewerker", + "Auditgeschiedenis", + "Scenario starten", +]; + +function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> { + if (typeof value === "string") return [{ path: prefix, value }]; + if (value === null || typeof value !== "object") return []; + return Object.entries(value as Record).flatMap(([key, nested]) => + collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key), + ); +} + +test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => { + const findings: string[] = []; + for (const namespace of namespaces) { + const nl = loadNamespace("nl-BE", namespace); + const fr = loadNamespace("fr-BE", namespace); + for (const { path: keyPath, value } of collectStringLeaves(nl)) { + for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) { + if (value.includes(fragment)) { + findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`); + } + } + } + for (const { path: keyPath, value } of collectStringLeaves(fr)) { + for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) { + if (value.includes(fragment)) { + findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`); + } + } + } + } + expect(findings, findings.join("\n")).toEqual([]); +}); + // --- Hardcoded JSX text (section 11D) --- // A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a // `{...}` expression) containing two or more real words are almost always user-facing From d17af1c52aee7a15447ed0bfbcfcdd5b0d6d47c3 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:08:07 +0200 Subject: [PATCH 4/9] 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. --- frontend/e2e/error-messages.spec.ts | 205 ++++++++++++++++++ frontend/src/api/apiError.ts | 12 + frontend/src/api/client.ts | 17 +- frontend/src/api/errorMessages.ts | 100 +++++++++ frontend/src/components/DemoGuide.tsx | 10 +- frontend/src/components/Layout.tsx | 10 +- frontend/src/components/PageChrome.tsx | 20 ++ frontend/src/components/ReturnForm.tsx | 12 +- frontend/src/i18n/locales/en-GB/errors.json | 184 +++++++++++++++- frontend/src/i18n/locales/fr-BE/errors.json | 184 +++++++++++++++- frontend/src/i18n/locales/nl-BE/errors.json | 184 +++++++++++++++- frontend/src/pages/Automation.tsx | 11 +- frontend/src/pages/DataQuality.tsx | 11 +- frontend/src/pages/DataQualityIssueDetail.tsx | 45 ++-- frontend/src/pages/Knowledge.tsx | 11 +- frontend/src/styles.css | 6 + 16 files changed, 940 insertions(+), 82 deletions(-) create mode 100644 frontend/e2e/error-messages.spec.ts create mode 100644 frontend/src/api/apiError.ts create mode 100644 frontend/src/api/errorMessages.ts diff --git a/frontend/e2e/error-messages.spec.ts b/frontend/e2e/error-messages.spec.ts new file mode 100644 index 0000000..4de6df1 --- /dev/null +++ b/frontend/e2e/error-messages.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ApiError } from "../src/api/apiError"; +import { describeApiError, KNOWN_CODES } from "../src/api/errorMessages"; + +// Pure Node-context checks for the central API-error-localization function (section 7 / +// 11 of the Fleet Ops final localization brief). No browser needed: describeApiError() +// only depends on a `t` function and a caught error, so it's tested here against the +// real locale JSON with a minimal i18next-shaped `t` stub -- proving the known-code and +// known-HTTP-status paths never leak raw backend English as the primary message, and +// that the raw text is always still available via `.technical` for "Technical details". + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales"); +const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const; + +function loadNamespace(language: string, namespace: string): Record { + const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`); + return JSON.parse(fs.readFileSync(filePath, "utf-8")); +} + +// Mirrors the (namespace, options.defaultValue) contract react-i18next's `t` exposes, +// resolving "namespace:dotted.path" against the real locale files for the given language. +function makeT(language: string): (key: string, options?: Record) => string { + return (key: string, options?: Record) => { + const [ns, ...rest] = key.includes(":") ? key.split(":") : ["errors", key]; + const dottedPath = key.includes(":") ? rest.join(":") : rest.join(""); + const data = loadNamespace(language, ns); + const value = dottedPath.split(".").reduce((acc, part) => { + if (acc && typeof acc === "object") return (acc as Record)[part]; + return undefined; + }, data); + if (typeof value === "string") return value; + if (options && "defaultValue" in options) return String(options.defaultValue); + return key; + }; +} + +const KNOWN_HTTP_STATUSES = ["401", "403", "404", "409", "422", "500"]; + +test("every known AppError code has a non-empty title+explanation in all 3 locales", () => { + for (const language of LANGUAGES) { + const codes = loadNamespace(language, "errors").codes as Record; + for (const code of KNOWN_CODES) { + expect(codes[code], `${language}/errors.json is missing codes.${code}`).toBeTruthy(); + expect(codes[code]?.title?.trim().length ?? 0, `${language}/errors.json:codes.${code}.title is empty`).toBeGreaterThan(0); + expect( + codes[code]?.explanation?.trim().length ?? 0, + `${language}/errors.json:codes.${code}.explanation is empty`, + ).toBeGreaterThan(0); + } + } +}); + +test("every known HTTP status fallback has a non-empty title+explanation in all 3 locales", () => { + for (const language of LANGUAGES) { + const http = loadNamespace(language, "errors").http as Record; + for (const status of KNOWN_HTTP_STATUSES) { + expect(http[status], `${language}/errors.json is missing http.${status}`).toBeTruthy(); + expect(http[status]?.title?.trim().length ?? 0, `${language}/errors.json:http.${status}.title is empty`).toBeGreaterThan(0); + expect( + http[status]?.explanation?.trim().length ?? 0, + `${language}/errors.json:http.${status}.explanation is empty`, + ).toBeGreaterThan(0); + } + } +}); + +test("a known AppError code resolves to its localized codes.* entry, never the raw backend message", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const raw = "IntegrityError: duplicate key value violates unique constraint"; + const err = new ApiError(409, "VEHICLE_NOT_FOUND", raw, "corr-1"); + const info = describeApiError(t, err); + const expected = loadNamespace(language, "errors").codes as Record; + expect(info.title).toBe(expected.VEHICLE_NOT_FOUND.title); + expect(info.explanation).toBe(expected.VEHICLE_NOT_FOUND.explanation); + expect(info.title).not.toBe(raw); + expect(info.explanation).not.toBe(raw); + // The raw backend text must still be reachable, just demoted to `.technical`. + expect(info.technical).toBe(raw); + } +}); + +test("a code with nextStep populates it; a code without nextStep leaves it undefined", () => { + const t = makeT("nl-BE"); + const withNextStep = describeApiError(t, new ApiError(422, "EMPTY_VALUE", "raw", "c1")); + expect(withNextStep.nextStep).toBeTruthy(); + + const withoutNextStep = describeApiError(t, new ApiError(404, "CUSTOMER_NOT_FOUND", "raw", "c2")); + expect(withoutNextStep.nextStep).toBeUndefined(); +}); + +test("an unrecognized AppError code falls back to the matching known HTTP status, not raw text", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const raw = "Some brand-new backend code nobody localized yet"; + const err = new ApiError(404, "SOME_FUTURE_CODE_NOT_YET_LOCALIZED", raw, "corr-2"); + const info = describeApiError(t, err); + const expected404 = (loadNamespace(language, "errors").http as Record)["404"]; + expect(info.title).toBe(expected404.title); + expect(info.explanation).toBe(expected404.explanation); + expect(info.title).not.toBe(raw); + expect(info.technical).toBe(raw); + } +}); + +test("an unrecognized code and an unrecognized HTTP status fall back to the fully generic message", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const raw = "418 I'm a teapot (never mapped)"; + const err = new ApiError(418, "418", raw, "corr-3"); + const info = describeApiError(t, err); + const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string }; + expect(info.title).toBe(generic.title); + expect(info.explanation).toBe(generic.explanation); + expect(info.technical).toBe(raw); + } +}); + +test("a stringified HTTP status used as the AppError code (plain HTTPException path) resolves via the http map", () => { + // Mirrors app/main.py's plain-HTTPException handler, which sets code = str(status_code) + // (e.g. "401") rather than a semantic AppError code -- see backend/app/main.py. + const t = makeT("fr-BE"); + const err = new ApiError(401, "401", "Not authenticated", "corr-4"); + const info = describeApiError(t, err); + const expected401 = (loadNamespace("fr-BE", "errors").http as Record)["401"]; + expect(info.title).toBe(expected401.title); + expect(info.title).not.toBe("Not authenticated"); +}); + +test("a non-ApiError (e.g. network failure before any response) uses the fallback key, never a raw JS error message as the primary text", () => { + const t = makeT("nl-BE"); + const networkFailure = new TypeError("Failed to fetch"); + // Real call sites (e.g. Automation.tsx) invoke t() with their own default namespace + // already scoped via useTranslation("integrations"); this stub's default namespace is + // "errors", so the fallback key is qualified explicitly here to match. + const info = describeApiError(t, networkFailure, "integrations:ledger.retryFailed"); + const expectedFallback = loadNamespace("nl-BE", "integrations").ledger as Record; + expect(info.explanation).toBe(expectedFallback.retryFailed); + expect(info.explanation).not.toBe("Failed to fetch"); + expect(info.technical).toBe("Failed to fetch"); +}); + +// --- Backend/frontend AppError code drift guard --- +// KNOWN_CODES is a hand-maintained mirror of every `raise AppError("CODE", ...)` in the +// backend (see app/core/errors.py::AppError and every raise site). If the backend adds a +// new code and nobody updates KNOWN_CODES, it silently falls back to the generic-but- +// still-localized HTTP/generic message rather than raw English -- not a broken build, but +// a missed opportunity for a more specific message. This test surfaces that drift instead +// of letting it go unnoticed indefinitely. +const BACKEND_APP_DIR = path.resolve(__dirname, "../../backend/app"); + +function collectPyFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries.flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return collectPyFiles(full); + return entry.name.endsWith(".py") ? [full] : []; + }); +} + +function collectBackendAppErrorCodes(): Set { + const codes = new Set(); + for (const file of collectPyFiles(BACKEND_APP_DIR)) { + const source = fs.readFileSync(file, "utf-8"); + const pattern = /AppError\(\s*"([A-Z_]+)"/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null) { + codes.add(match[1]); + } + } + return codes; +} + +test("frontend KNOWN_CODES exactly matches every AppError code actually raised by the backend", () => { + const backendCodes = collectBackendAppErrorCodes(); + const frontendCodes = KNOWN_CODES; + + const missingFromFrontend = [...backendCodes].filter((c) => !frontendCodes.has(c)).sort(); + const staleInFrontend = [...frontendCodes].filter((c) => !backendCodes.has(c)).sort(); + + expect( + missingFromFrontend, + `Backend raises AppError code(s) with no localized entry in errorMessages.ts KNOWN_CODES ` + + `(they'll fall back to a generic/HTTP-status message): ${missingFromFrontend.join(", ")}`, + ).toEqual([]); + expect( + staleInFrontend, + `errorMessages.ts KNOWN_CODES lists code(s) the backend never raises -- likely renamed or ` + + `removed on the backend side: ${staleInFrontend.join(", ")}`, + ).toEqual([]); +}); + +test("a non-ApiError with no fallbackKey uses the fully generic explanation", () => { + for (const language of LANGUAGES) { + const t = makeT(language); + const info = describeApiError(t, new TypeError("Failed to fetch")); + const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string }; + expect(info.title).toBe(generic.title); + expect(info.explanation).toBe(generic.explanation); + } +}); diff --git a/frontend/src/api/apiError.ts b/frontend/src/api/apiError.ts new file mode 100644 index 0000000..3f30deb --- /dev/null +++ b/frontend/src/api/apiError.ts @@ -0,0 +1,12 @@ +export class ApiError extends Error { + status: number; + code: string; + correlationId: string; + + constructor(status: number, code: string, message: string, correlationId: string) { + super(message); + this.status = status; + this.code = code; + this.correlationId = correlationId; + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 21176b2..4543a34 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,3 +1,7 @@ +import { ApiError } from "./apiError"; + +export { ApiError } from "./apiError"; + const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; type UnauthorizedListener = () => void; @@ -8,19 +12,6 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void { return () => unauthorizedListeners.delete(listener); } -export class ApiError extends Error { - status: number; - code: string; - correlationId: string; - - constructor(status: number, code: string, message: string, correlationId: string) { - super(message); - this.status = status; - this.code = code; - this.correlationId = correlationId; - } -} - async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE}${path}`, { ...init, diff --git a/frontend/src/api/errorMessages.ts b/frontend/src/api/errorMessages.ts new file mode 100644 index 0000000..0cf2fe3 --- /dev/null +++ b/frontend/src/api/errorMessages.ts @@ -0,0 +1,100 @@ +import { ApiError } from "./apiError"; + +export interface ApiErrorInfo { + title: string; + explanation: string; + nextStep?: string; + technical: string; +} + +type TFn = (key: string, options?: Record) => string; + +// Backend AppError codes this frontend knows how to present with a localized title, +// explanation and (where useful) a next step -- see +// backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site. +// Anything not in this list still gets a sensible HTTP-status-based fallback below, so +// a newly-introduced backend code never regresses to raw English -- it just falls back +// to a generic-but-localized message until this list is extended. +export const KNOWN_CODES = new Set([ + "VEHICLE_NOT_FOUND", + "BOOKING_NOT_FOUND", + "CUSTOMER_NOT_FOUND", + "ENTITY_NOT_FOUND", + "EVENT_NOT_FOUND", + "ISSUE_NOT_FOUND", + "ISSUE_NOT_OPEN", + "BOOKING_NOT_ACTIVE", + "INVALID_BOOKING_STATE", + "NOT_RETRYABLE", + "CONFLICT_STILL_PRESENT", + "OVERLAP_STILL_PRESENT", + "EMPTY_VALUE", + "NO_FIELDS_PROVIDED", + "INVALID_FIELD", + "INVALID_FIELD_OVERRIDE", + "INVALID_SURVIVOR", + "INVALID_BOOKING_REFERENCE", + "INVALID_EVENT_ID", + "INVALID_IDEMPOTENCY_KEY", + "IDEMPOTENCY_KEY_REUSED", + "CORRECTED_VALUE_REQUIRED", + "CORRECTION_BELOW_CANONICAL", + "NOT_A_DUPLICATE_ISSUE", + "NOT_A_MISSING_FIELD_ISSUE", + "NOT_AN_ODOMETER_ISSUE", + "NOT_AN_OVERLAP_ISSUE", + "NOT_A_STATUS_CONFLICT_ISSUE", + "UNSUPPORTED_ENTITY", + "MANUAL_REVIEW_REQUIRED", + "NO_CONFLICT_DETECTED", + "RECOMMENDATION_STALE", + "UNAUTHORIZED_SERVICE", +]); + +const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]); + +/** + * Turns a caught error into a localized {title, explanation, nextStep?, technical} + * for display. The raw backend/network text is only ever exposed as `technical` + * (shown under "Technical details" by ApiErrorNotice) -- never as the primary message. + * + * `fallbackKey` is an existing, already-localized `t()` key used as the explanation + * when the error isn't an ApiError at all (e.g. the fetch failed before a response + * existed) and errors:generic doesn't fit the specific action being attempted. + */ +export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo { + if (!(err instanceof ApiError)) { + return { + title: t("errors:generic.title"), + explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"), + technical: err instanceof Error ? err.message : String(err), + }; + } + + if (KNOWN_CODES.has(err.code)) { + const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" }); + return { + title: t(`errors:codes.${err.code}.title`), + explanation: t(`errors:codes.${err.code}.explanation`), + nextStep: nextStep || undefined, + technical: err.message, + }; + } + + const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status); + if (KNOWN_HTTP_STATUSES.has(httpKey)) { + const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" }); + return { + title: t(`errors:http.${httpKey}.title`), + explanation: t(`errors:http.${httpKey}.explanation`), + nextStep: nextStep || undefined, + technical: err.message, + }; + } + + return { + title: t("errors:generic.title"), + explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"), + technical: err.message, + }; +} diff --git a/frontend/src/components/DemoGuide.tsx b/frontend/src/components/DemoGuide.tsx index 8be19c1..5b671a9 100644 --- a/frontend/src/components/DemoGuide.tsx +++ b/frontend/src/components/DemoGuide.tsx @@ -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(null); + const [resetError, setResetError] = useState(null); const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half"); const pendingTarget = useRef(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() { )} - {resetError &&

{resetError}

} +