feat(demo): add Demo Guide (8-step guided tour) and scenario overview

Adds a compact "Probeer een demonstratiescenario" page listing the 5 named
scenarios with live readiness from the manifest, plus a Demo Guide side
panel (bottom sheet on mobile) that walks an Operations Manager through
all 8 steps with per-step context, live-resolved routes, sessionStorage
progress, and a "Demo opnieuw voorbereiden" restart. Login's guided-demo
CTA now actually opens the guide. Fixes a real mobile topbar overflow the
new guide trigger introduced.
This commit is contained in:
NuklearRabbit
2026-08-03 14:15:15 +02:00
parent c63903cc94
commit 9fff84dc68
11 changed files with 647 additions and 29 deletions
+7 -2
View File
@@ -12,11 +12,16 @@ test("demo entry screen names the fictional org and never shows a password", asy
await expect(page.locator('input[type="password"]')).toHaveCount(0);
});
test("start guided demo logs in as Operations Manager and marks the guide to start", async ({ page }) => {
test("start guided demo logs in as Operations Manager and opens the guide at step 1", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page).toHaveURL(/\/dashboard\?guide=start$/);
// The ?guide=start marker is a one-shot signal the dashboard strips immediately after
// consuming it, so assert on its effect (the guide panel opens at step 1) rather than
// the transient URL, which can already be gone by the time this assertion runs.
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText("Amelie De Ridder")).toBeVisible();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
});
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
+85
View File
@@ -0,0 +1,85 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
async function resetDemoData(request: APIRequestContext) {
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
expect(login.ok()).toBeTruthy();
const reset = await request.post("/api/v1/demo/reset");
expect(reset.ok()).toBeTruthy();
}
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.goto("/scenarios");
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
const cards = page.locator(".scenario-card");
await expect(cards).toHaveCount(5);
for (const label of ["Klaar voor demo"]) {
await expect(page.getByText(label).first()).toBeVisible();
}
await expect(page.getByText("Niet beschikbaar")).toHaveCount(0);
});
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.goto("/scenarios");
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
await duplicateCard.getByRole("link", { name: "Start scenario" }).click();
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
});
test("demo guide: navigating steps, jumping to a step, and closing works", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
await page.getByRole("button", { name: "Volgende" }).click();
await expect(page.getByRole("heading", { name: "2. Open een boeking die aandacht nodig heeft" })).toBeVisible();
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/knowledge$/);
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await page.getByRole("button", { name: "Sluiten" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
});
test("demo guide progress persists across navigation and the trigger shows it", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: "Volgende" }).click();
await page.getByRole("button", { name: "Volgende" }).click();
await page.getByRole("button", { name: "Sluiten" }).click();
await expect(page.getByRole("button", { name: /Demo-gids/ })).toContainText("2/8");
await page.goto("/vehicles");
await page.getByRole("button", { name: /Demo-gids/ }).click();
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
});
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 expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
});
test("restarting the demo from the guide resets data and returns to login", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click();
await expect(page).toHaveURL(/\/login$/, { timeout: 10000 });
});
+29 -24
View File
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import { DemoManifestProvider } from "./context/DemoManifestContext";
import { DemoGuideProvider } from "./context/DemoGuideContext";
import { Layout } from "./components/Layout";
import { RequireAuth } from "./components/RequireAuth";
import { Login } from "./pages/Login";
@@ -15,35 +16,39 @@ import { Automation } from "./pages/Automation";
import { Knowledge } from "./pages/Knowledge";
import { Audit } from "./pages/Audit";
import { AboutDemo } from "./pages/AboutDemo";
import { Scenarios } from "./pages/Scenarios";
export function App() {
return (
<DemoManifestProvider>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/vehicles" element={<Vehicles />} />
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
<Route path="/bookings" element={<Bookings />} />
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
<Route path="/data-quality" element={<DataQuality />} />
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
<Route path="/automation" element={<Automation />} />
<Route path="/knowledge" element={<Knowledge />} />
<Route path="/audit" element={<Audit />} />
<Route path="/about" element={<AboutDemo />} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
<DemoGuideProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/vehicles" element={<Vehicles />} />
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
<Route path="/bookings" element={<Bookings />} />
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
<Route path="/data-quality" element={<DataQuality />} />
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
<Route path="/automation" element={<Automation />} />
<Route path="/knowledge" element={<Knowledge />} />
<Route path="/audit" element={<Audit />} />
<Route path="/about" element={<AboutDemo />} />
<Route path="/scenarios" element={<Scenarios />} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</DemoGuideProvider>
</AuthProvider>
</DemoManifestProvider>
);
+134
View File
@@ -0,0 +1,134 @@
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { api, ApiError } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons";
export function DemoGuideTrigger() {
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>Demo-gids</span>
<span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span>
<span className="visually-hidden">, huidige stap {currentIndex + 1}</span>
</button>
);
}
export function DemoGuide() {
const navigate = useNavigate();
const { logout } = useAuth();
const { manifest, refresh } = useDemoManifest();
const {
open,
closeGuide,
currentIndex,
completed,
totalSteps,
goToStep,
completeAndAdvance,
restart,
} = useDemoGuide();
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
if (!open) return null;
const step = DEMO_GUIDE_STEPS[currentIndex];
const isLastStep = currentIndex === totalSteps - 1;
function goToStepRoute() {
navigate(step.route(manifest));
}
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 : "De demo kon niet hersteld worden.");
} finally {
setResetting(false);
}
}
return (
<aside className="demo-guide-panel" role="dialog" aria-label="Gegidste demo">
<header className="demo-guide-header">
<div>
<p className="demo-guide-kicker">Gegidste demo · stap {currentIndex + 1} van {totalSteps}</p>
<h2>{step.title}</h2>
</div>
<button type="button" className="icon-button" onClick={closeGuide} aria-label="Sluiten">
<Icon name="x" />
</button>
</header>
<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>
<div className="demo-guide-body">
<p><strong>Wat je zal zien</strong><br />{step.whatYouWillSee}</p>
<p><strong>Waarom dit relevant is</strong><br />{step.whyItMatters}</p>
<p><strong>Aan de slag</strong><br />{step.startAction}</p>
<p><strong>Verwacht resultaat</strong><br />{step.expectedOutcome}</p>
</div>
<nav className="demo-guide-steps" aria-label="Alle stappen">
{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" />}
{s.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}>
Ga naar deze stap
</button>
<button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}>
Volgende
</button>
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
{resetting ? "Bezig met herstellen…" : "Demo opnieuw voorbereiden"}
</button>
</footer>
</aside>
);
}
+4
View File
@@ -5,6 +5,7 @@ import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons";
import { DemoBadge } from "./DemoBadge";
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
import { useDemoManifest } from "../context/DemoManifestContext";
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
@@ -277,6 +278,7 @@ export function Layout() {
)}
</div>
<div className="topbar-meta">
<DemoGuideTrigger />
<DemoBadge />
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
{user && (
@@ -307,6 +309,8 @@ export function Layout() {
<span>More</span>
</button>
</nav>
<DemoGuide />
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
const STORAGE_KEY = "mobilityops.demo-guide-progress";
interface StoredProgress {
currentIndex: number;
completed: string[];
}
function readProgress(): StoredProgress {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return { currentIndex: 0, completed: [] };
const parsed = JSON.parse(raw) as StoredProgress;
return { currentIndex: parsed.currentIndex ?? 0, completed: parsed.completed ?? [] };
} catch {
return { currentIndex: 0, completed: [] };
}
}
function writeProgress(progress: StoredProgress) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
}
interface DemoGuideState {
open: boolean;
currentIndex: number;
completed: Set<string>;
totalSteps: number;
openGuide: () => void;
closeGuide: () => void;
toggleGuide: () => void;
goToStep: (index: number) => void;
completeAndAdvance: () => void;
restart: () => void;
}
const DemoGuideContext = createContext<DemoGuideState | undefined>(undefined);
export function DemoGuideProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [completed, setCompleted] = useState<Set<string>>(new Set());
useEffect(() => {
const stored = readProgress();
setCurrentIndex(stored.currentIndex);
setCompleted(new Set(stored.completed));
}, []);
const persist = useCallback((index: number, done: Set<string>) => {
writeProgress({ currentIndex: index, completed: Array.from(done) });
}, []);
const goToStep = useCallback(
(index: number) => {
const clamped = Math.max(0, Math.min(index, DEMO_GUIDE_STEPS.length - 1));
setCurrentIndex(clamped);
persist(clamped, completed);
},
[completed, persist],
);
const completeAndAdvance = useCallback(() => {
setCompleted((prev) => {
const next = new Set(prev);
next.add(DEMO_GUIDE_STEPS[currentIndex].id);
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
persist(nextIndex, next);
setCurrentIndex(nextIndex);
return next;
});
}, [currentIndex, persist]);
const restart = useCallback(() => {
setCurrentIndex(0);
setCompleted(new Set());
writeProgress({ currentIndex: 0, completed: [] });
}, []);
return (
<DemoGuideContext.Provider
value={{
open,
currentIndex,
completed,
totalSteps: DEMO_GUIDE_STEPS.length,
openGuide: () => setOpen(true),
closeGuide: () => setOpen(false),
toggleGuide: () => setOpen((v) => !v),
goToStep,
completeAndAdvance,
restart,
}}
>
{children}
</DemoGuideContext.Provider>
);
}
export function useDemoGuide(): DemoGuideState {
const ctx = useContext(DemoGuideContext);
if (!ctx) throw new Error("useDemoGuide must be used within DemoGuideProvider");
return ctx;
}
+91
View File
@@ -0,0 +1,91 @@
import type { DemoManifest } from "../api/types";
export interface DemoGuideStep {
id: string;
title: string;
whatYouWillSee: string;
whyItMatters: string;
startAction: string;
expectedOutcome: string;
/** Resolves the route to link to, using live manifest data where a scenario supplies
* a concrete record (booking/issue ref) instead of hardcoding one that could drift. */
route: (manifest: DemoManifest | null) => string;
}
export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
{
id: "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.",
startAction: "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.",
expectedOutcome: "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen.",
route: () => "/dashboard",
},
{
id: "open-booking",
title: "2. Open een boeking die aandacht nodig heeft",
whatYouWillSee: "De boeking BK-DEMO-RETURN, actief en klaar voor retour vandaag.",
whyItMatters: "Retours zijn het moment waarop foute kilometerstanden of schade voor het eerst zichtbaar worden.",
startAction: "Open de boeking vanuit het dashboard of de boekingenlijst.",
expectedOutcome: "Je ziet de boekingsdetails en de knop om de retour te verwerken.",
route: (manifest) =>
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings",
},
{
id: "process-return",
title: "3. Verwerk een retour met een afwijkende kilometerstand",
whatYouWillSee: "Een retourformulier met een vooringevulde, verdachte kilometerstand lager dan de laatst gekende stand.",
whyItMatters: "Een dalende kilometerstand wijst op een foutieve invoer of een verwisseld voertuig — dit moet vóór vrijgave worden opgemerkt.",
startAction: "Vul de retour in met de voorgestelde waarde en bekijk de serverpreview vóór je bevestigt.",
expectedOutcome: "De retour wordt verwerkt, het voertuig krijgt een passende status en er wordt automatisch een datakwaliteitsprobleem aangemaakt.",
route: (manifest) =>
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings",
},
{
id: "handle-quality-issue",
title: "4. Bekijk en behandel het gecreëerde datakwaliteitsprobleem",
whatYouWillSee: "Een nieuw issue van het type 'kilometerstand-afwijking' bovenaan de werklijst.",
whyItMatters: "Elk gedetecteerd probleem heeft één afgebakende oplossingsstap — niets wordt automatisch stilzwijgend gecorrigeerd.",
startAction: "Open het datakwaliteitsoverzicht en kies het nieuwste issue.",
expectedOutcome: "Je ziet de aanbevolen actie, kiest een oplossing en het issue wordt opgelost met een audit-spoor.",
route: () => "/data-quality",
},
{
id: "merge-duplicate",
title: "5. Beoordeel en behandel een mogelijke dubbele klant",
whatYouWillSee: "Twee klantprofielen met hetzelfde e-mailadres en telefoonnummer, naast elkaar vergeleken.",
whyItMatters: "Dubbele klanten leiden tot verspreide boekingsgeschiedenis en verwarrende communicatie.",
startAction: "Vergelijk beide profielen en kies welk profiel behouden blijft.",
expectedOutcome: "De profielen worden samengevoegd, boekingen worden herverbonden en het verliezende profiel wordt een tombstone.",
route: (manifest) =>
manifest?.scenarios.find((s) => s.id === "duplicate-customer")?.start_path ?? "/data-quality",
},
{
id: "ask-knowledge",
title: "6. Stel een vraag aan de procedureassistent",
whatYouWillSee: "Een antwoord met bronvermelding uit de afgebakende demokennisbank.",
whyItMatters: "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.",
startAction: 'Stel de voorbeeldvraag "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?".',
expectedOutcome: "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is.",
route: () => "/knowledge",
},
{
id: "check-automation-audit",
title: "7. Controleer automatisering en audit trail",
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 Systemen om de afleverstatus te zien, en Audit trail voor het volledige spoor.",
expectedOutcome: "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties.",
route: () => "/automation",
},
{
id: "review-real-vs-simulated",
title: "8. Bekijk wat echt is, gesimuleerd is, of nog niet gekoppeld",
whatYouWillSee: "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.",
whyItMatters: "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.",
startAction: "Lees de pagina 'Over deze demo'.",
expectedOutcome: "Je kan zelf uitleggen wat MobilityOps wel en niet is, zonder mondelinge toelichting.",
route: () => "/about",
},
];
+36 -1
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { api } from "../api/client";
import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
@@ -26,8 +28,21 @@ function localTime(value: string, withDate = false) {
export function Dashboard() {
const { user } = useAuth();
const { manifest } = useDemoManifest();
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
const [searchParams, setSearchParams] = useSearchParams();
const canSeeQuality = user?.role === "operations_manager";
const canSeeAutomation = user?.role === "operations_manager";
useEffect(() => {
if (searchParams.get("guide") === "start") {
restart();
openGuide();
setSearchParams({}, { replace: true });
}
// Only ever react to the initial `?guide=start` marker set by the login screen's
// "Start begeleide demo" CTA, so this intentionally runs once on mount.
}, []);
const [data, setData] = useState<DashboardData | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
@@ -64,6 +79,26 @@ export function Dashboard() {
<div className="page dashboard-page">
<PageHeader eyebrow="Operations / Live overview" title="Good morning. Heres the fleet." description="Readiness, exceptions and hand-offs across todays operation." actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> View fleet</Link>} />
<section className="demo-start-panel" aria-label="Demo starten">
<div>
<Icon name="spark" />
<div>
<strong>Probeer een demonstratiescenario</strong>
<span>
{manifest ? `${manifest.scenarios.filter((s) => s.ready).length} van ${manifest.scenarios.length} scenario's klaar voor demo.` : "Vijf afgebakende scenario's."}
</span>
</div>
</div>
<div className="demo-start-actions">
{user?.role === "operations_manager" && (
<button type="button" className="button button-secondary" onClick={openGuide}>
<Icon name="spark" /> {completed.size > 0 ? `Verder met demo-gids (${currentIndex + 1}/${totalSteps})` : "Start demo-gids"}
</button>
)}
<Link className="button button-primary" to="/scenarios">Bekijk scenario's <Icon name="chevron" /></Link>
</div>
</section>
<section className="readiness-band" aria-labelledby="readiness-heading">
<div className="readiness-label">
<span className="live-indicator" />
+67
View File
@@ -0,0 +1,67 @@
import { Link } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { Icon } from "../components/Icons";
import { LoadingState, PageHeader } from "../components/PageChrome";
const ROLE_LABEL: Record<string, string> = {
operations_manager: "Operations Manager",
rental_employee: "Rental Employee",
};
export function Scenarios() {
const { manifest, loading } = useDemoManifest();
const { user } = useAuth();
return (
<div className="page">
<PageHeader
eyebrow="Demonstratiescenario's"
title="Probeer een demonstratiescenario"
description="Vijf afgebakende scenario's die telkens dezelfde vaste boekingen, klanten en voertuigen gebruiken — na een reset zijn ze altijd opnieuw te vinden."
/>
{loading && <LoadingState label="Scenario's laden…" />}
{manifest && (
<div className="scenario-grid">
{manifest.scenarios.map((scenario) => {
const canRun = !user || scenario.required_roles.includes(user.role);
return (
<article key={scenario.id} className="scenario-card">
<header>
<h2>{scenario.title}</h2>
<span className={`badge ${scenario.ready ? "status-available" : "status-unavailable"}`}>
{scenario.ready ? "Klaar voor demo" : "Niet beschikbaar"}
</span>
</header>
<p className="scenario-problem">{scenario.operational_problem}</p>
<dl className="scenario-meta">
<div><dt>Duur</dt><dd>± {scenario.estimated_minutes} min</dd></div>
<div><dt>Rol</dt><dd>{scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}</dd></div>
</dl>
<p className="scenario-demonstrates"><strong>Toont aan:</strong> {scenario.demonstrates}</p>
{!scenario.ready && scenario.blocked_reason && (
<p className="scenario-blocked"><Icon name="alert" /> {scenario.blocked_reason}</p>
)}
{!canRun && (
<p className="scenario-blocked"><Icon name="alert" /> Vereist rol: {scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}.</p>
)}
<Link
className={`button ${scenario.ready && canRun ? "button-primary" : "button-secondary"}`}
to={scenario.start_path}
aria-disabled={!scenario.ready || !canRun}
onClick={(event) => {
if (!scenario.ready || !canRun) event.preventDefault();
}}
>
Start scenario <Icon name="chevron" />
</Link>
</article>
);
})}
</div>
)}
</div>
);
}
+50 -2
View File
@@ -257,6 +257,21 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; }
.scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
.scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.scenario-card header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.scenario-card h2 { margin: 0; font-size: 1rem; letter-spacing: -.015em; }
.scenario-problem, .scenario-demonstrates { margin: 0; color: var(--muted); font-size: .78rem; line-height: 1.55; }
.scenario-demonstrates strong { color: var(--ink-soft); }
.scenario-meta { display: flex; gap: 20px; margin: 0; }
.scenario-meta div { display: grid; gap: 2px; }
.scenario-meta dt { color: var(--muted); font-size: .58rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; }
.scenario-meta dd { margin: 0; color: var(--ink-soft); font-size: .76rem; font-weight: 600; }
.scenario-blocked { display: flex; align-items: flex-start; gap: 6px; margin: 0; padding: 8px 10px; color: #8a5a12; background: #fdf3e2; border: 1px solid #f2ddb0; border-radius: 8px; font-size: .68rem; line-height: 1.45; }
.scenario-blocked svg { width: 14px; flex-shrink: 0; margin-top: 1px; }
.scenario-card > .button { align-self: flex-start; margin-top: auto; }
.scenario-card > .button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
.knowledge-status { min-height: 58px; display: flex; align-items: center; gap: 11px; margin-bottom: 14px; padding: 10px 16px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.knowledge-status div { flex: 1; display: grid; gap: 3px; }.knowledge-status strong { font-size: .74rem; }.knowledge-status span:not(.health-orb), .knowledge-status small { color: var(--muted); font-size: .65rem; }.health-orb { width: 9px; height: 9px; border-radius: 50%; }.health-orb.is-healthy { background: var(--success); box-shadow: 0 0 0 4px var(--success-pale); }.health-orb.is-down { background: var(--critical); box-shadow: 0 0 0 4px var(--critical-pale); }
.knowledge-form { margin-bottom: 18px; }.ask-heading { display: flex; gap: 11px; align-items: center; margin-bottom: 15px; }.ask-heading > span { width: 36px; height: 36px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.ask-heading svg { width: 18px; }.ask-heading h2 { margin: 0; font-size: .95rem; }.ask-heading p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; }
.knowledge-input-row { display: flex; gap: 8px; }.knowledge-input-row input { min-width: 0; flex: 1; }.knowledge-empty { min-height: 290px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 30px; color: var(--muted); background: white; border: 1px dashed var(--line-strong); border-radius: var(--radius); }.knowledge-empty > span { width: 50px; height: 50px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.knowledge-empty svg { width: 22px; }.knowledge-empty h2 { margin: 15px 0 7px; color: var(--ink); font-size: 1rem; }.knowledge-empty p { max-width: 540px; margin: 0; font-size: .75rem; line-height: 1.5; }
@@ -278,17 +293,50 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); }
}
.demo-start-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 22px; padding: 16px 20px; background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: var(--radius); }
.demo-start-panel > div:first-child { display: flex; align-items: center; gap: 12px; }
.demo-start-panel > div:first-child > svg { width: 22px; height: 22px; color: var(--teal-dark); flex-shrink: 0; }
.demo-start-panel strong { display: block; color: var(--ink); font-size: .85rem; }
.demo-start-panel span { display: block; margin-top: 2px; color: var(--muted); font-size: .72rem; }
.demo-start-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.demo-guide-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 700; cursor: pointer; }
.demo-guide-trigger:hover { background: #d9f0ea; }
.demo-guide-trigger svg { width: 13px; height: 13px; }
.demo-guide-progress-pill { padding: 1px 6px; color: var(--teal-dark); background: white; border-radius: 999px; font-size: .6rem; }
.demo-guide-panel { position: fixed; z-index: 40; top: 0; right: 0; width: min(400px, 92vw); height: 100vh; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: var(--surface); border-left: 1px solid var(--line); box-shadow: var(--shadow-float); overflow-y: auto; }
.demo-guide-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.demo-guide-kicker { margin: 0 0 4px; color: var(--teal-dark); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.demo-guide-header h2 { margin: 0; font-size: 1.05rem; letter-spacing: -.015em; }
.demo-guide-progress-bar { display: flex; gap: 4px; }
.demo-guide-progress-bar span { flex: 1; height: 4px; background: var(--line); border-radius: 2px; }
.demo-guide-progress-bar span.is-done { background: var(--teal); }
.demo-guide-progress-bar span.is-current { background: var(--teal-dark); }
.demo-guide-body { display: grid; gap: 12px; }
.demo-guide-body p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
.demo-guide-body p strong { color: var(--muted); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.demo-guide-steps { display: grid; gap: 3px; padding-top: 12px; border-top: 1px solid var(--line); }
.demo-guide-steps button { display: flex; align-items: center; gap: 7px; padding: 7px 8px; text-align: left; color: var(--muted); background: transparent; border: 0; border-radius: var(--radius); font-size: .68rem; cursor: pointer; }
.demo-guide-steps button svg { width: 12px; color: var(--teal); flex-shrink: 0; }
.demo-guide-steps button:hover { background: var(--surface-subtle); }
.demo-guide-steps button.is-current { color: var(--ink); background: var(--teal-pale); font-weight: 700; }
.demo-guide-footer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--line); }
.demo-guide-restart { width: 100%; padding: 8px; color: var(--muted); background: transparent; border: 1px dashed var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 600; cursor: pointer; }
.demo-guide-restart:hover { color: var(--ink-soft); border-color: var(--muted-light); }
.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 960px) {
.app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 65px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .55rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 65px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; }
}
@media (max-width: 700px) {
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
.filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; }
.table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 34px; height: auto; display: grid; grid-template-columns: minmax(90px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 7px 12px; border: 0; text-align: right; font-size: .71rem; }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: .57rem; font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; }
.tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; }
}
@media (max-width: 420px) {
.global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; }
.global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; }
}