feat(demo): add demo manifest, Dutch demo entry, permanent badge and About page
Adds GET /api/v1/demo/manifest as a single source of truth for the demo's fictional org identity (Northstar Mobility -- surfacing the project's already-locked tenant name), synthetic-data/reset state, and live scenario readiness. Rewrites the login screen in Dutch with an honest, no-password demo entry and a guided-demo entry point, replaces the loud full-width demo banner with a subtle badge + popover, and adds a compact About page explaining what's real vs. synthetic vs. not yet connected.
This commit is contained in:
+30
-25
@@ -1,5 +1,6 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { DemoManifestProvider } from "./context/DemoManifestContext";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import { Login } from "./pages/Login";
|
||||
@@ -13,33 +14,37 @@ import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
|
||||
import { Automation } from "./pages/Automation";
|
||||
import { Knowledge } from "./pages/Knowledge";
|
||||
import { Audit } from "./pages/Audit";
|
||||
import { AboutDemo } from "./pages/AboutDemo";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<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>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
<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>
|
||||
</AuthProvider>
|
||||
</DemoManifestProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,6 +252,40 @@ export interface IntegrationStatus {
|
||||
mcp_hub: McpHubIntegrationStatus;
|
||||
}
|
||||
|
||||
export interface DemoScenario {
|
||||
id: string;
|
||||
title: string;
|
||||
operational_problem: string;
|
||||
estimated_minutes: number;
|
||||
required_roles: Role[];
|
||||
start_path: string;
|
||||
demonstrates: string;
|
||||
ready: boolean;
|
||||
blocked_reason: string | null;
|
||||
}
|
||||
|
||||
export interface DemoIntegrationSummary {
|
||||
key: "n8n" | "ragcore" | "mcp_hub";
|
||||
label: string;
|
||||
status_label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface DemoManifest {
|
||||
demo_mode: boolean;
|
||||
organization_name: string;
|
||||
organization_description: string;
|
||||
timezone: string;
|
||||
synthetic_data: boolean;
|
||||
allow_reset: boolean;
|
||||
last_reset_at: string | null;
|
||||
anchor_date: string | null;
|
||||
guide_available: boolean;
|
||||
required_roles: Role[];
|
||||
scenarios: DemoScenario[];
|
||||
integrations: DemoIntegrationSummary[];
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
actor_type: string;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { Icon } from "./Icons";
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return "onbekend";
|
||||
return new Date(value).toLocaleString("nl-BE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: "Europe/Brussels",
|
||||
});
|
||||
}
|
||||
|
||||
export function DemoBadge() {
|
||||
const { manifest } = useDemoManifest();
|
||||
const [open, setOpen] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (boxRef.current && !boxRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="demo-badge" ref={boxRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="demo-badge-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Icon name="shield" />
|
||||
<span>Synthetische demo</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="demo-badge-popover" role="dialog" aria-label="Over deze demo-omgeving">
|
||||
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label="Sluiten">
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
<p>
|
||||
{manifest ? (
|
||||
<>
|
||||
<strong>{manifest.organization_name}</strong> is een fictieve organisatie. Alle
|
||||
namen, voertuigen en boekingen zijn synthetisch.
|
||||
</>
|
||||
) : (
|
||||
"Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch."
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
De workflows, controles en automatisering zijn echt geïmplementeerd — enkel de
|
||||
gegevens zijn verzonnen.
|
||||
</p>
|
||||
{manifest && (
|
||||
<p className="demo-badge-reset">
|
||||
Laatste reset: <strong>{formatDateTime(manifest.last_reset_at)}</strong> · deze
|
||||
omgeving is op elk moment herstelbaar.
|
||||
</p>
|
||||
)}
|
||||
<Link to="/about" onClick={() => setOpen(false)}>
|
||||
Over deze demo <Icon name="chevron" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { api, ApiError } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import type { Role, SearchResultItem } from "../api/types";
|
||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||
import { DemoBadge } from "./DemoBadge";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
|
||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||
vehicle: "fleet",
|
||||
@@ -42,6 +44,7 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
|
||||
|
||||
export function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -192,7 +195,7 @@ export function Layout() {
|
||||
<span className="environment-dot" />
|
||||
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
|
||||
</div>
|
||||
{user?.role === "operations_manager" && (
|
||||
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||
<div className="sidebar-reset">
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
{!resetConfirming ? (
|
||||
@@ -274,6 +277,7 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
<DemoBadge />
|
||||
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
||||
{user && (
|
||||
<div className="operator">
|
||||
@@ -287,7 +291,6 @@ export function Layout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="demo-banner"><Icon name="shield" /> Synthetic demo data · no real customer or vehicle information</p>
|
||||
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
||||
<footer className="app-footer"><span>MobilityOps PoC</span><span>Europe/Brussels · Synthetic demo data</span></footer>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { DemoManifest } from "../api/types";
|
||||
|
||||
interface DemoManifestState {
|
||||
manifest: DemoManifest | null;
|
||||
loading: boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const DemoManifestContext = createContext<DemoManifestState | undefined>(undefined);
|
||||
|
||||
export function DemoManifestProvider({ children }: { children: ReactNode }) {
|
||||
const [manifest, setManifest] = useState<DemoManifest | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
// Public endpoint by design: the demo-entry screen needs this before any session
|
||||
// exists, so it is never gated behind auth.
|
||||
api
|
||||
.get<DemoManifest>("/api/v1/demo/manifest")
|
||||
.then((result) => {
|
||||
if (!cancelled) setManifest(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setManifest(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [version]);
|
||||
|
||||
return (
|
||||
<DemoManifestContext.Provider
|
||||
value={{ manifest, loading, refresh: () => setVersion((v) => v + 1) }}
|
||||
>
|
||||
{children}
|
||||
</DemoManifestContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDemoManifest(): DemoManifestState {
|
||||
const ctx = useContext(DemoManifestContext);
|
||||
if (!ctx) throw new Error("useDemoManifest must be used within DemoManifestProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return "onbekend";
|
||||
return new Date(value).toLocaleString("nl-BE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: "Europe/Brussels",
|
||||
});
|
||||
}
|
||||
|
||||
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = {
|
||||
n8n: "n8n",
|
||||
ragcore: "rag",
|
||||
mcp_hub: "mcp",
|
||||
};
|
||||
|
||||
export function AboutDemo() {
|
||||
const { manifest, loading } = useDemoManifest();
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
eyebrow="Over deze demo"
|
||||
title="Wat MobilityOps wel en niet is"
|
||||
description={
|
||||
manifest
|
||||
? `${manifest.organization_name} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{loading && <LoadingState label="Demo-informatie laden…" />}
|
||||
|
||||
{manifest && (
|
||||
<>
|
||||
<section className="record-surface about-card">
|
||||
<h2>Het fictieve probleem</h2>
|
||||
<p>
|
||||
{manifest.organization_name} verhuurt zo'n 50 campers en bestelwagens vanuit één
|
||||
hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit
|
||||
losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten,
|
||||
foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen.
|
||||
MobilityOps toont hoe één samenhangend systeem die problemen vroeg signaleert en
|
||||
gecontroleerd laat oplossen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>Wat écht werkt</h2>
|
||||
<p>
|
||||
Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde
|
||||
toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met
|
||||
serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen
|
||||
oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n
|
||||
met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde
|
||||
testsuite (backend en Playwright end-to-end).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>Wat synthetisch is</h2>
|
||||
<p>
|
||||
De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis,
|
||||
procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig
|
||||
verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of
|
||||
bedrijf; e-mailadressen gebruiken uitsluitend het testdomein <code>.test</code>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-label="Koppelingsstatus" className="record-surface">
|
||||
<SectionHeading
|
||||
title="Koppelingen — eerlijk gelabeld"
|
||||
description="Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is."
|
||||
/>
|
||||
<div className="integration-cards">
|
||||
{manifest.integrations.map((integration) => (
|
||||
<article key={integration.key}>
|
||||
<IntegrationMark kind={INTEGRATION_ICON[integration.key]} />
|
||||
<div>
|
||||
<span className="integration-kicker">{integration.status_label}</span>
|
||||
<h2>{integration.label}</h2>
|
||||
<p>{integration.detail}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>Demo-omgeving herstellen</h2>
|
||||
<p>
|
||||
De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset:{" "}
|
||||
<strong>{formatDateTime(manifest.last_reset_at)}</strong>.{" "}
|
||||
{user?.role === "operations_manager" ? (
|
||||
<>
|
||||
Gebruik <strong>Reset demo data</strong> in de zijbalk om opnieuw te beginnen.
|
||||
</>
|
||||
) : (
|
||||
<>Een Operations Manager kan de demo-omgeving herstellen via de zijbalk.</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="access-note record-surface" aria-label="Beperkingen">
|
||||
<Icon name="shield" />
|
||||
<p>
|
||||
<strong>Beperkingen</strong>
|
||||
<span>
|
||||
Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx
|
||||
MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale,
|
||||
afgebakende demokennisbank in plaats van een live RAGcore-omgeving.
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import type { Role } from "../api/types";
|
||||
import { BrandMark, Icon } from "../components/Icons";
|
||||
|
||||
export function Login() {
|
||||
const { loginAs, loading } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleLogin(role: Role) {
|
||||
async function handleLogin(role: Role, guided = false) {
|
||||
setError(null);
|
||||
try {
|
||||
await loginAs(role);
|
||||
navigate("/dashboard");
|
||||
navigate(guided ? "/dashboard?guide=start" : "/dashboard");
|
||||
} catch {
|
||||
setError("Could not start a demo session. The API may be unavailable.");
|
||||
setError("De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.");
|
||||
}
|
||||
}
|
||||
|
||||
const orgName = manifest?.organization_name ?? "Northstar Mobility";
|
||||
const description =
|
||||
manifest?.organization_description ??
|
||||
"MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt " +
|
||||
"verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde " +
|
||||
"vervolgstappen.";
|
||||
|
||||
return (
|
||||
<main className="login-shell">
|
||||
<section className="login-story" aria-labelledby="product-name">
|
||||
<div className="brand-lockup login-brand"><BrandMark className="brand-mark" /><div><strong>MobilityOps</strong><span>Control centre</span></div></div>
|
||||
<div className="brand-lockup login-brand"><BrandMark className="brand-mark" /><div><strong>MobilityOps</strong><span>Bedieningscentrum</span></div></div>
|
||||
<div className="login-message">
|
||||
<p className="eyebrow">Connected mobility operations</p>
|
||||
<h1 id="product-name">Every hand-off.<br />One clear view.</h1>
|
||||
<p>Turn fleet state, rental returns, data quality and automation into one calm operational rhythm.</p>
|
||||
<p className="eyebrow">Demo-organisatie: {orgName} (fictief)</p>
|
||||
<h1 id="product-name">Elke overdracht.<br />Eén helder overzicht.</h1>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="control-illustration" aria-hidden="true">
|
||||
<div className="illustration-orbit orbit-one"><span /></div>
|
||||
@@ -36,28 +45,39 @@ export function Login() {
|
||||
<span className="illustration-node node-two"><Icon name="bookings" /></span>
|
||||
<span className="illustration-node node-three"><Icon name="quality" /></span>
|
||||
</div>
|
||||
<p className="login-footnote"><Icon name="shield" /> Synthetic proof of concept · no real customer data</p>
|
||||
<p className="login-footnote"><Icon name="shield" /> Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar</p>
|
||||
</section>
|
||||
|
||||
<section className="login-access" aria-labelledby="login-heading">
|
||||
<div className="login-panel">
|
||||
<p className="page-eyebrow">Demo access</p>
|
||||
<h2 id="login-heading">Choose your workspace</h2>
|
||||
<p className="login-intro">No password is required. Each role opens a scoped synthetic environment.</p>
|
||||
<p className="page-eyebrow">Demo-toegang</p>
|
||||
<h2 id="login-heading">Kies hoe je wil starten</h2>
|
||||
<p className="login-intro">Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.</p>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="button button-primary login-guide-cta"
|
||||
disabled={loading}
|
||||
onClick={() => handleLogin("operations_manager", true)}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
Start begeleide demo
|
||||
</button>
|
||||
|
||||
<div className="login-options">
|
||||
<button type="button" aria-label="Open as Operations Manager" disabled={loading} onClick={() => handleLogin("operations_manager")}>
|
||||
<button type="button" aria-label="Verken als Operations Manager" disabled={loading} onClick={() => handleLogin("operations_manager")}>
|
||||
<span className="role-icon"><Icon name="activity" /></span>
|
||||
<span><strong>Operations Manager</strong><small>Full overview, quality resolution and retries</small></span>
|
||||
<span><strong>Verken als Operations Manager</strong><small>Volledig overzicht, kwaliteitsoplossing en herpogingen</small></span>
|
||||
<Icon name="chevron" />
|
||||
</button>
|
||||
<button type="button" aria-label="Open as Rental Employee" disabled={loading} onClick={() => handleLogin("rental_employee")}>
|
||||
<button type="button" aria-label="Verken als Rental Employee" disabled={loading} onClick={() => handleLogin("rental_employee")}>
|
||||
<span className="role-icon"><Icon name="user" /></span>
|
||||
<span><strong>Rental Employee</strong><small>Bookings, returns, fleet and procedures</small></span>
|
||||
<span><strong>Verken als Rental Employee</strong><small>Boekingen, retours, wagenpark en procedures</small></span>
|
||||
<Icon name="chevron" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="access-note"><Icon name="shield" /><p><strong>Safe by design</strong><span>All actions are audited and resettable in this demo.</span></p></div>
|
||||
<div className="access-note"><Icon name="shield" /><p><strong>Veilig ontworpen</strong><span>Elke actie wordt gelogd en is in deze demo herstelbaar.</span></p></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
+16
-4
@@ -103,8 +103,17 @@ a:hover { color: var(--teal); }
|
||||
.icon-button:hover { background: var(--surface-subtle); border-color: var(--line); }
|
||||
.icon-button svg { width: 18px; height: 18px; }
|
||||
.mobile-menu { display: none; }
|
||||
.demo-banner { min-height: 32px; margin: 0; display: flex; align-items: center; justify-content: center; gap: 7px; padding: 6px 20px; color: #48566a; background: #eaf0f5; border-bottom: 1px solid #d7e0e8; font-size: .68rem; font-weight: 600; letter-spacing: .02em; }
|
||||
.demo-banner svg { width: 14px; height: 14px; }
|
||||
.demo-badge { position: relative; }
|
||||
.demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; }
|
||||
.demo-badge-trigger:hover { background: #dfe8ef; }
|
||||
.demo-badge-trigger svg { width: 13px; height: 13px; }
|
||||
.demo-badge-popover { position: absolute; z-index: 30; top: calc(100% + 8px); right: 0; width: min(320px, 84vw); padding: 16px; display: grid; gap: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }
|
||||
.demo-badge-popover p { margin: 0; color: var(--ink-soft); font-size: .74rem; line-height: 1.55; }
|
||||
.demo-badge-reset { color: var(--muted) !important; font-size: .68rem !important; }
|
||||
.demo-badge-popover a { display: inline-flex; align-items: center; gap: 4px; color: var(--teal-dark); text-decoration: none; font-size: .74rem; font-weight: 700; }
|
||||
.demo-badge-popover a svg { width: 13px; }
|
||||
.demo-badge-close { position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; }
|
||||
.demo-badge-close svg { width: 14px; height: 14px; }
|
||||
#main-content { width: min(1320px, calc(100% - 56px)); margin: 0 auto; padding: 38px 0 64px; flex: 1; }
|
||||
.app-footer { min-height: 52px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 0 28px; color: var(--muted); border-top: 1px solid var(--line); font-size: .68rem; }
|
||||
.mobile-nav, .nav-scrim { display: none; }
|
||||
@@ -219,6 +228,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.tabs button { position: relative; min-height: 44px; padding: 8px 13px; border: 0; background: transparent; color: var(--muted); font-size: .72rem; font-weight: 700; text-transform: capitalize; cursor: pointer; }
|
||||
.tabs button.active { color: var(--teal-dark); }.tabs button.active::after { content: ""; position: absolute; inset: auto 7px -1px; height: 2px; background: var(--teal); }
|
||||
.record-surface { padding: 18px; margin-bottom: 18px; }
|
||||
.about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; }
|
||||
.about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; }
|
||||
.about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); }
|
||||
.detail-grid div { min-height: 76px; padding: 13px 14px; background: white; }
|
||||
.detail-grid dt { margin: 0; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: .82rem; font-weight: 700; }
|
||||
@@ -260,7 +272,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.login-brand { height: auto; padding: 0; border: 0; }.login-brand .brand-mark { width: 36px; height: 36px; }.login-brand strong { font-size: 1.05rem; }.login-message { position: relative; z-index: 2; max-width: 620px; margin: auto 0 22px; }.login-message .eyebrow { color: #5eead4; }.login-message h1 { margin: 0; color: white; font-size: clamp(2.8rem, 5.5vw, 5.4rem); line-height: .98; letter-spacing: -.06em; }.login-message > p:last-child { max-width: 520px; margin: 23px 0 0; color: #a9b7c8; font-size: .98rem; line-height: 1.65; }
|
||||
.control-illustration { position: absolute; width: min(35vw, 500px); aspect-ratio: 1; right: -80px; top: 7vh; opacity: .88; }.illustration-orbit { position: absolute; inset: 10%; border: 1px solid #29435a; border-radius: 50%; }.orbit-two { inset: 28%; border-style: dashed; transform: rotate(35deg); }.illustration-orbit::before, .illustration-orbit::after { content: ""; position: absolute; background: #2dd4bf; border-radius: 50%; }.illustration-orbit::before { width: 7px; height: 7px; top: 13%; left: 16%; }.illustration-orbit::after { width: 5px; height: 5px; right: 2%; bottom: 33%; }.illustration-core { position: absolute; inset: 40%; display: grid; place-items: center; color: white; background: #172a3e; border: 1px solid #2c465e; border-radius: 50%; }.illustration-core svg { width: 45px; }.illustration-node { position: absolute; width: 42px; height: 42px; display: grid; place-items: center; color: #5eead4; background: #152b3f; border: 1px solid #2c465e; border-radius: 50%; }.illustration-node svg { width: 18px; }.node-one { top: 13%; left: 20%; }.node-two { right: 5%; bottom: 27%; }.node-three { left: 15%; bottom: 12%; }
|
||||
.login-footnote { position: relative; z-index: 2; display: flex; align-items: center; gap: 7px; margin: auto 0 0; color: #7f90a4; font-size: .68rem; }.login-footnote svg { width: 14px; }
|
||||
.login-access { display: grid; place-items: center; padding: 46px max(38px, 7vw); background: #f8fafb; }.login-panel { width: min(470px, 100%); }.login-panel h2 { margin: 0; color: var(--ink); font-size: 1.7rem; letter-spacing: -.04em; }.login-intro { margin: 9px 0 26px; color: var(--muted); font-size: .8rem; line-height: 1.55; }.login-options { display: grid; gap: 10px; }.login-options button { min-height: 76px; display: grid; grid-template-columns: 40px 1fr 18px; align-items: center; gap: 12px; padding: 13px; text-align: left; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 7px 20px rgba(15,23,42,.04); cursor: pointer; transition: border .16s ease, transform .16s ease, box-shadow .16s ease; }.login-options button:hover { transform: translateY(-2px); border-color: var(--teal); box-shadow: var(--shadow-float); }.login-options button > svg { width: 17px; color: var(--teal-dark); }.login-options button > span:nth-child(2) { display: grid; gap: 5px; }.login-options strong { font-size: .8rem; }.login-options small { color: var(--muted); font-size: .66rem; }.role-icon { width: 40px; height: 40px; display: grid !important; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.role-icon svg { width: 18px; }.access-note { display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; color: var(--muted); border-top: 1px solid var(--line); }.access-note > svg { width: 18px; color: var(--teal-dark); }.access-note p { display: grid; gap: 3px; margin: 0; }.access-note strong { color: var(--ink-soft); font-size: .69rem; }.access-note span { font-size: .64rem; }
|
||||
.login-access { display: grid; place-items: center; padding: 46px max(38px, 7vw); background: #f8fafb; }.login-panel { width: min(470px, 100%); }.login-panel h2 { margin: 0; color: var(--ink); font-size: 1.7rem; letter-spacing: -.04em; }.login-intro { margin: 9px 0 26px; color: var(--muted); font-size: .8rem; line-height: 1.55; }.login-guide-cta { width: 100%; min-height: 48px; margin-bottom: 18px; font-size: .85rem; }.login-options { display: grid; gap: 10px; }.login-options button { min-height: 76px; display: grid; grid-template-columns: 40px 1fr 18px; align-items: center; gap: 12px; padding: 13px; text-align: left; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 7px 20px rgba(15,23,42,.04); cursor: pointer; transition: border .16s ease, transform .16s ease, box-shadow .16s ease; }.login-options button:hover { transform: translateY(-2px); border-color: var(--teal); box-shadow: var(--shadow-float); }.login-options button > svg { width: 17px; color: var(--teal-dark); }.login-options button > span:nth-child(2) { display: grid; gap: 5px; }.login-options strong { font-size: .8rem; }.login-options small { color: var(--muted); font-size: .66rem; }.role-icon { width: 40px; height: 40px; display: grid !important; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.role-icon svg { width: 18px; }.access-note { display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; color: var(--muted); border-top: 1px solid var(--line); }.access-note > svg { width: 18px; color: var(--teal-dark); }.access-note p { display: grid; gap: 3px; margin: 0; }.access-note strong { color: var(--ink-soft); font-size: .69rem; }.access-note span { font-size: .64rem; }
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.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); }
|
||||
@@ -271,7 +283,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
}
|
||||
|
||||
@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-banner { min-height: 28px; padding-inline: 10px; font-size: .59rem; }.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; }.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; }
|
||||
|
||||
Reference in New Issue
Block a user