252 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|