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:
@@ -593,3 +593,41 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
|
|||||||
server demo-ready.
|
server demo-ready.
|
||||||
- Exact next action: Demo Guide (collapsible panel, 8 steps) + scenario overview (5 cards
|
- Exact next action: Demo Guide (collapsible panel, 8 steps) + scenario overview (5 cards
|
||||||
on the dashboard, consuming `/api/v1/demo/manifest`'s `scenarios` array) — task #31.
|
on the dashboard, consuming `/api/v1/demo/manifest`'s `scenarios` array) — task #31.
|
||||||
|
|
||||||
|
### Batch 3 — Demo Guide + scenario overview (complete)
|
||||||
|
|
||||||
|
- New `/scenarios` page (`Scenarios.tsx`): all 5 named scenarios as cards (title,
|
||||||
|
operational problem, duration, required role(s), "toont aan", ready/blocked status
|
||||||
|
from the manifest, "Start scenario" linking to the live `start_path`). Dashboard gets
|
||||||
|
one compact "Probeer een demonstratiescenario" panel (not 5 more cards — keeps the
|
||||||
|
existing dashboard uncluttered per the brief) showing readiness count and, for
|
||||||
|
Operations Managers, a guide resume/start control.
|
||||||
|
- New Demo Guide: `DemoGuideContext` (sessionStorage-persisted `currentIndex`/`completed`
|
||||||
|
set — browser-only, never touches auth or business logic), 8 static steps
|
||||||
|
(`data/demoGuideSteps.ts`) each with what-you'll-see/why/start-action/expected-outcome,
|
||||||
|
resolving live routes from the manifest for the two scenario-backed steps (return,
|
||||||
|
duplicate-merge) so they can't drift from actual records. `DemoGuide.tsx` renders a
|
||||||
|
fixed side panel (desktop) that becomes a bottom sheet at ≤700px via CSS only (no
|
||||||
|
layout duplication); `DemoGuideTrigger` (topbar, Operations-Manager-only — the 8 steps
|
||||||
|
require OM throughout) shows a live `completed/8` pill. "Demo opnieuw voorbereiden"
|
||||||
|
calls the real reset endpoint, resets guide progress, and returns to `/login` (mirrors
|
||||||
|
the existing sidebar reset flow). Login's "Start begeleide demo" logs in as OM and
|
||||||
|
passes a one-shot `?guide=start` marker the dashboard consumes once then strips.
|
||||||
|
- Fixed a real regression caught by the responsive-overflow tests: the new topbar guide
|
||||||
|
trigger pushed `.topbar-meta` past the viewport at ≤420px; fixed by hiding the guide
|
||||||
|
trigger (icon+pill) at that breakpoint — the Dashboard's own "Start demo-gids" control
|
||||||
|
remains reachable there. Also fixed a genuine mobile overflow in the new
|
||||||
|
`.demo-start-panel` (flex items without `min-width:0`/wrap on narrow screens).
|
||||||
|
- Fixed one fragile new test (asserted on the transient `?guide=start` URL param, which
|
||||||
|
the app intentionally strips immediately — changed to assert the guide's actual open
|
||||||
|
state instead) and two Playwright strict-mode ambiguous-match errors; confirmed the
|
||||||
|
full 41+6=47-test suite passes twice in a row after these fixes (ruling out flakiness).
|
||||||
|
- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite
|
||||||
|
**47 passed** (41 existing + 6 new `demo-guide.spec.ts`: scenario overview shows 5
|
||||||
|
ready cards after reset, starting a scenario navigates to its fixed record, guide
|
||||||
|
step navigation/jump/close, progress persists across page navigation, guide hidden
|
||||||
|
from Rental Employee, restart-from-guide resets data and returns to login). Backend
|
||||||
|
untouched this batch (no re-run needed; last backend gate was 127 passed/ruff/mypy
|
||||||
|
clean in Batch 2).
|
||||||
|
- Exact next action: layer plain-language Dutch explanation onto the return flow, the 5
|
||||||
|
data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33.
|
||||||
|
|||||||
@@ -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);
|
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.goto("/login");
|
||||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
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.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 }) => {
|
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
import { DemoManifestProvider } from "./context/DemoManifestContext";
|
import { DemoManifestProvider } from "./context/DemoManifestContext";
|
||||||
|
import { DemoGuideProvider } from "./context/DemoGuideContext";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
import { RequireAuth } from "./components/RequireAuth";
|
import { RequireAuth } from "./components/RequireAuth";
|
||||||
import { Login } from "./pages/Login";
|
import { Login } from "./pages/Login";
|
||||||
@@ -15,35 +16,39 @@ import { Automation } from "./pages/Automation";
|
|||||||
import { Knowledge } from "./pages/Knowledge";
|
import { Knowledge } from "./pages/Knowledge";
|
||||||
import { Audit } from "./pages/Audit";
|
import { Audit } from "./pages/Audit";
|
||||||
import { AboutDemo } from "./pages/AboutDemo";
|
import { AboutDemo } from "./pages/AboutDemo";
|
||||||
|
import { Scenarios } from "./pages/Scenarios";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<DemoManifestProvider>
|
<DemoManifestProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<DemoGuideProvider>
|
||||||
<Route path="/login" element={<Login />} />
|
<Routes>
|
||||||
<Route
|
<Route path="/login" element={<Login />} />
|
||||||
element={
|
<Route
|
||||||
<RequireAuth>
|
element={
|
||||||
<Layout />
|
<RequireAuth>
|
||||||
</RequireAuth>
|
<Layout />
|
||||||
}
|
</RequireAuth>
|
||||||
>
|
}
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
>
|
||||||
<Route path="/vehicles" element={<Vehicles />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
|
<Route path="/vehicles" element={<Vehicles />} />
|
||||||
<Route path="/bookings" element={<Bookings />} />
|
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
|
||||||
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
|
<Route path="/bookings" element={<Bookings />} />
|
||||||
<Route path="/data-quality" element={<DataQuality />} />
|
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
|
||||||
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
|
<Route path="/data-quality" element={<DataQuality />} />
|
||||||
<Route path="/automation" element={<Automation />} />
|
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
|
||||||
<Route path="/knowledge" element={<Knowledge />} />
|
<Route path="/automation" element={<Automation />} />
|
||||||
<Route path="/audit" element={<Audit />} />
|
<Route path="/knowledge" element={<Knowledge />} />
|
||||||
<Route path="/about" element={<AboutDemo />} />
|
<Route path="/audit" element={<Audit />} />
|
||||||
</Route>
|
<Route path="/about" element={<AboutDemo />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/scenarios" element={<Scenarios />} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
</Route>
|
||||||
</Routes>
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</DemoGuideProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</DemoManifestProvider>
|
</DemoManifestProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import type { Role, SearchResultItem } from "../api/types";
|
import type { Role, SearchResultItem } from "../api/types";
|
||||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||||
import { DemoBadge } from "./DemoBadge";
|
import { DemoBadge } from "./DemoBadge";
|
||||||
|
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
|
|
||||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||||
@@ -277,6 +278,7 @@ export function Layout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-meta">
|
<div className="topbar-meta">
|
||||||
|
<DemoGuideTrigger />
|
||||||
<DemoBadge />
|
<DemoBadge />
|
||||||
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
||||||
{user && (
|
{user && (
|
||||||
@@ -307,6 +309,8 @@ export function Layout() {
|
|||||||
<span>More</span>
|
<span>More</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<DemoGuide />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
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 { api } from "../api/client";
|
||||||
import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types";
|
import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||||
import { Icon } from "../components/Icons";
|
import { Icon } from "../components/Icons";
|
||||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||||
@@ -26,8 +28,21 @@ function localTime(value: string, withDate = false) {
|
|||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { manifest } = useDemoManifest();
|
||||||
|
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const canSeeQuality = user?.role === "operations_manager";
|
const canSeeQuality = user?.role === "operations_manager";
|
||||||
const canSeeAutomation = 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 [data, setData] = useState<DashboardData | null>(null);
|
||||||
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
||||||
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
|
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
|
||||||
@@ -64,6 +79,26 @@ export function Dashboard() {
|
|||||||
<div className="page dashboard-page">
|
<div className="page dashboard-page">
|
||||||
<PageHeader eyebrow="Operations / Live overview" title="Good morning. Here’s the fleet." description="Readiness, exceptions and hand-offs across today’s operation." actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> View fleet</Link>} />
|
<PageHeader eyebrow="Operations / Live overview" title="Good morning. Here’s the fleet." description="Readiness, exceptions and hand-offs across today’s 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">
|
<section className="readiness-band" aria-labelledby="readiness-heading">
|
||||||
<div className="readiness-label">
|
<div className="readiness-label">
|
||||||
<span className="live-indicator" />
|
<span className="live-indicator" />
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
.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-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-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; }
|
.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); }
|
.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) {
|
@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; }
|
.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) {
|
@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; }
|
.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; }
|
.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; }
|
.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) {
|
@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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user