Rebrands the product from MobilityOps to Fleet Ops across the UI, backend defaults and knowledge base, and makes nl-BE/en-GB/fr-BE full first-class languages: i18next with eager-bundled per-namespace resources, a persisted accessible language switcher (topbar and mobile drawer), locale-aware date/number formatting, and a coverage test that fails the build on any missing or empty translation key. Backend dynamic content (demo scenarios, blocked-reason text, integration status) moves from fixed English/Dutch prose to stable message codes + params so the frontend can localize it; the demo knowledge base gains a fully translated NL/EN/FR procedure corpus (11 documents each) with per-language retrieval and localized evidence-state messages. The Demo Guide becomes breakpoint-adaptive: a docked rail on extra-wide desktop, a floating panel that auto-collapses to a persistent, closable progress chip on standard desktop/tablet, and a collapsed/half/full bottom sheet on mobile -- with scroll+focus+ highlight on "go to this step", Escape handling, and reduced-motion support. The Data Quality Workbench gets accessible choice-card decisions with a clear primary/ secondary/tertiary action hierarchy; the Automation ledger groups repeated successes and uses meaningful short refs; the Audit trail groups events by correlation id with human action labels and readable before/after diffs. Attention Queue, Today's movements, Vehicles, Bookings and Data Quality rows are fully clickable (stretched-link pattern) with independent secondary links, keyboard support and mobile touch targets. Fixes a topbar overflow on mobile caused by the new language switcher (moved into the mobile drawer at <=960px) and two dangling aria-labelledby references introduced this session. Updates all affected Playwright specs for the new nl-BE default and the new Audit/DemoGuide DOM structure, and adds new i18n-coverage, demo-guide-adaptive and clickable-rows specs. 131 backend tests, Ruff and mypy, and 71 Playwright tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
249 lines
8.5 KiB
TypeScript
249 lines
8.5 KiB
TypeScript
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 { 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";
|
|
|
|
export function DemoGuideTrigger() {
|
|
const { t } = useTranslation("demo");
|
|
const { user } = useAuth();
|
|
const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide();
|
|
|
|
if (user?.role !== "operations_manager") return null;
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="demo-guide-trigger"
|
|
aria-expanded={open}
|
|
aria-haspopup="dialog"
|
|
onClick={toggleGuide}
|
|
>
|
|
<Icon name="spark" />
|
|
<span>{t("guide.trigger")}</span>
|
|
<span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span>
|
|
<span className="visually-hidden">, {t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function highlightTarget(selector: string | undefined) {
|
|
if (!selector) return;
|
|
const el = document.querySelector<HTMLElement>(selector);
|
|
if (!el) return;
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
const previousTabIndex = el.getAttribute("tabindex");
|
|
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
|
|
el.focus({ preventScroll: true });
|
|
el.classList.add("demo-guide-highlight");
|
|
window.setTimeout(() => {
|
|
el.classList.remove("demo-guide-highlight");
|
|
if (previousTabIndex === null) el.removeAttribute("tabindex");
|
|
}, 2200);
|
|
}
|
|
|
|
export function DemoGuide() {
|
|
const { t } = useTranslation("demo");
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { logout } = useAuth();
|
|
const { manifest, refresh } = useDemoManifest();
|
|
const tier = useViewportTier();
|
|
const {
|
|
open,
|
|
closeGuide,
|
|
currentIndex,
|
|
completed,
|
|
totalSteps,
|
|
goToStep,
|
|
completeAndAdvance,
|
|
restart,
|
|
collapsedToChip,
|
|
setCollapsedToChip,
|
|
} = useDemoGuide();
|
|
const [resetting, setResetting] = useState(false);
|
|
const [resetError, setResetError] = useState<string | null>(null);
|
|
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
|
|
const pendingTarget = useRef<string | null>(null);
|
|
|
|
const step = DEMO_GUIDE_STEPS[currentIndex];
|
|
const isLastStep = currentIndex === totalSteps - 1;
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key !== "Escape") return;
|
|
if (tier === "standard" && !collapsedToChip) {
|
|
setCollapsedToChip(true);
|
|
} else if (tier === "mobile" && mobileSheetState !== "collapsed") {
|
|
setMobileSheetState("collapsed");
|
|
} else {
|
|
closeGuide();
|
|
}
|
|
}
|
|
document.addEventListener("keydown", handleKeydown);
|
|
return () => document.removeEventListener("keydown", handleKeydown);
|
|
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide]);
|
|
|
|
useEffect(() => {
|
|
if (!pendingTarget.current) return;
|
|
const target = pendingTarget.current;
|
|
pendingTarget.current = null;
|
|
const raf = requestAnimationFrame(() => highlightTarget(target));
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [location.pathname]);
|
|
|
|
if (!open) return null;
|
|
|
|
function goToStepRoute() {
|
|
pendingTarget.current = step.target ?? null;
|
|
navigate(step.route(manifest));
|
|
if (tier === "standard") setCollapsedToChip(true);
|
|
if (tier === "mobile") setMobileSheetState("collapsed");
|
|
}
|
|
|
|
async function handleRestartDemo() {
|
|
setResetError(null);
|
|
setResetting(true);
|
|
try {
|
|
await api.post("/api/v1/demo/reset");
|
|
restart();
|
|
refresh();
|
|
closeGuide();
|
|
await logout();
|
|
navigate("/login");
|
|
} catch (err) {
|
|
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
|
|
} finally {
|
|
setResetting(false);
|
|
}
|
|
}
|
|
|
|
if (tier === "standard" && collapsedToChip) {
|
|
return (
|
|
<div className="demo-guide-chip">
|
|
<button
|
|
type="button"
|
|
className="demo-guide-chip-expand"
|
|
onClick={() => setCollapsedToChip(false)}
|
|
aria-label={`${t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}, ${t("guide.expand")}`}
|
|
>
|
|
<Icon name="spark" />
|
|
{t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}
|
|
<Icon name="chevron" />
|
|
</button>
|
|
<button type="button" className="demo-guide-chip-close" onClick={closeGuide} aria-label={t("guide.close")}>
|
|
<Icon name="x" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const panelClassName = [
|
|
"demo-guide-panel",
|
|
tier === "wide" ? "is-wide" : "",
|
|
tier === "mobile" ? `is-mobile sheet-${mobileSheetState}` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
|
|
return (
|
|
<aside className={panelClassName} role="dialog" aria-label={t("guide.dialogLabel")}>
|
|
{tier === "mobile" && (
|
|
<button
|
|
type="button"
|
|
className="demo-guide-sheet-handle"
|
|
onClick={() =>
|
|
setMobileSheetState((s) => (s === "collapsed" ? "half" : s === "half" ? "full" : "collapsed"))
|
|
}
|
|
aria-label={
|
|
mobileSheetState === "full"
|
|
? t("guide.collapse")
|
|
: t("guide.expand")
|
|
}
|
|
>
|
|
<span aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
|
|
<header className="demo-guide-header">
|
|
<div>
|
|
<p className="demo-guide-kicker">{t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</p>
|
|
<h2>{t(`guide.steps.${step.id}.title`)}</h2>
|
|
</div>
|
|
{tier !== "mobile" && (
|
|
<button
|
|
type="button"
|
|
className="icon-button"
|
|
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeGuide}
|
|
aria-label={tier === "standard" ? t("guide.collapse") : t("guide.close")}
|
|
>
|
|
<Icon name="x" />
|
|
</button>
|
|
)}
|
|
</header>
|
|
|
|
{mobileSheetState !== "collapsed" && (
|
|
<>
|
|
<div className="demo-guide-progress-bar" aria-hidden="true">
|
|
{DEMO_GUIDE_STEPS.map((s, index) => (
|
|
<span
|
|
key={s.id}
|
|
className={
|
|
index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : ""
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{(mobileSheetState !== "half" || tier !== "mobile") && (
|
|
<div className="demo-guide-body">
|
|
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
|
|
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
|
|
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
|
|
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`)}</p>
|
|
</div>
|
|
)}
|
|
|
|
{tier !== "mobile" && (
|
|
<nav className="demo-guide-steps" aria-label={t("guide.allStepsLabel")}>
|
|
{DEMO_GUIDE_STEPS.map((s, index) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
className={index === currentIndex ? "is-current" : ""}
|
|
onClick={() => goToStep(index)}
|
|
>
|
|
{completed.has(s.id) && <Icon name="check" />}
|
|
{t(`guide.steps.${s.id}.title`)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
)}
|
|
|
|
{resetError && <p className="error" role="alert">{resetError}</p>}
|
|
|
|
<footer className="demo-guide-footer">
|
|
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
|
{t("guide.goToStep")}
|
|
</button>
|
|
<button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}>
|
|
{t("guide.next")}
|
|
</button>
|
|
{tier !== "mobile" && (
|
|
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
|
|
{resetting ? t("guide.restarting") : t("guide.restart")}
|
|
</button>
|
|
)}
|
|
</footer>
|
|
</>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|