Files
MobilityOps/frontend/src/components/DemoGuide.tsx
T
NuklearRabbit ae39a8947f
MobilityOps acceptance / backend (push) Failing after 45s
MobilityOps acceptance / frontend (push) Successful in 32s
MobilityOps acceptance / e2e (push) Skipped
M39: harden application and acceptance gates
2026-08-17 03:17:44 +02:00

252 lines
8.7 KiB
TypeScript

import { useNavigate, useLocation } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
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() {
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<ApiErrorInfo | 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, setCollapsedToChip]);
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(describeApiError(t, err, "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`, { productName: PRODUCT_NAME })}</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>
)}
<ApiErrorNotice error={resetError} />
<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>
);
}