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:
NuklearRabbit
2026-08-03 13:45:55 +02:00
parent 728e380d63
commit ac427f4427
25 changed files with 919 additions and 108 deletions
+7
View File
@@ -14,6 +14,13 @@ TZ=Europe/Brussels
# otherwise browsers will silently drop the cookie and no one can log in.
SESSION_COOKIE_SECURE=false
# Demo presentation (fictional org identity, badge/manifest, reset safety valve).
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
# of role -- a safety valve for any environment where the dataset must not be rebuildable.
DEMO_ORGANIZATION_NAME=Northstar Mobility
DEMO_TIMEZONE=Europe/Brussels
DEMO_ALLOW_RESET=true
# n8n
N8N_BASE_URL=http://n8n:5678
N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return
+37
View File
@@ -548,3 +548,40 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
`http://192.168.10.150:1236/` returns 200.
- Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent
demo badge (task #30), then the Demo Guide + scenario overview (task #31).
### Batch 2 — demo manifest, Dutch demo entry, permanent demo badge, About page (complete)
- `GET /api/v1/demo/manifest` (unauthenticated): single source of truth for demo org
identity, synthetic-data flag, reset allowance/timestamp/anchor date, guide
availability, and the 5 named scenarios with **live** readiness (queries the actual
`BK-DEMO-RETURN`/`DQ-DEMO-DUPLICATE`/`DQ-DEMO-OVERLAP`/seeded-failed-event/knowledge-
provider records — not hardcoded), plus plain-language integration summaries. Backed by
new `backend/app/services/demo_manifest.py`. Refactored the n8n status derivation out of
`integration_status.py` into a shared `services/integration_status.py` so the manifest
and the existing authenticated `/integrations/status` endpoint reuse one implementation.
- New settings (`backend/app/core/config.py`, wired through `compose.yaml`/`.env.example`):
`DEMO_ORGANIZATION_NAME` (default "Northstar Mobility" — surfaces the project's already-
locked fictitious tenant, previously only used internally as the `ragcore_tenant` slug),
`DEMO_TIMEZONE`, `DEMO_ALLOW_RESET` (a safety valve — `false` makes `POST
/api/v1/demo/reset` return 403 regardless of role; the now-dead `demo_today` setting
removed in Batch 1 stays removed).
- Rewrote `Login.tsx` in Dutch: names the fictional org, one-sentence explanation sourced
from the manifest, no password shown/copyable anywhere, "Start begeleide demo" primary
CTA (logs in as Operations Manager, navigates to `/dashboard?guide=start` for task #31 to
consume) plus "Verken als Operations Manager"/"Verken als Rental Employee" secondary
actions. Added a permanent demo badge (topbar pill + popover: synthetic notice,
"workflows are real" reassurance, last-reset timestamp, link to `/about`) replacing the
old full-width static `.demo-banner` bar — subtle by design per the brief, not a warning
bar. New `/about`
page (`AboutDemo.tsx`) covering the fictional problem, what's really implemented, what's
synthetic, honest per-integration labels (via the manifest), and a reset pointer —
reachable from the badge popover, not added to primary nav (preserves the existing Control
Rail nav per the "not a redesign" constraint). Frontend nav/design otherwise untouched.
- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files);
frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **41 passed**
(37 existing + 4 new `demo-entry.spec.ts` covering entry copy/no-password, guided-demo
login redirect, badge popover content + About link, and Escape/outside-click close).
Updated stale English login-button aria-labels and login-copy assertions across the
existing specs to match the new Dutch copy.
- 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.
+16 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import time
import uuid
from fastapi import APIRouter, Depends, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -11,14 +11,23 @@ from app.api.deps import get_current_user, get_db, require_operations_manager
from app.core.config import get_settings
from app.core.security import SessionPayload, create_session_token, read_session_token
from app.models.user import User
from app.schemas import CurrentUser, DemoLoginRequest
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
from app.seed_loader import reset_and_seed
from app.services.audit import record_audit_event
from app.services.demo_manifest import build_demo_manifest
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings()
@router.get("/manifest", response_model=DemoManifestOut)
def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
# Deliberately unauthenticated: the demo-entry screen and the permanent demo badge
# both need this before any session exists. Nothing here is sensitive — it's the same
# honest "what is this demo" summary a logged-in user would see.
return build_demo_manifest(db)
@router.post("/login", response_model=CurrentUser)
def demo_login(
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
@@ -92,6 +101,11 @@ def demo_reset(
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> dict:
if not settings.demo_allow_reset:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Demo reset is disabled on this deployment.",
)
result = reset_and_seed(db)
record_audit_event(
db,
+2 -49
View File
@@ -1,75 +1,28 @@
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings
from app.models.outbox import OutboxEvent
from app.schemas import (
CurrentUser,
IntegrationStatusOut,
McpHubIntegrationStatus,
N8nIntegrationStatus,
)
from app.services.integration_status import derive_n8n_status
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
settings = get_settings()
def _n8n_status(db: Session) -> N8nIntegrationStatus:
counts: dict[str, int] = dict(
db.execute(
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
).all() # type: ignore[arg-type]
)
pending = counts.get("pending", 0)
delivering = counts.get("delivering", 0)
failed = counts.get("failed", 0)
succeeded = counts.get("succeeded", 0)
latest_success_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
)
latest_failure_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed")
)
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
if not settings.n8n_dispatch_enabled:
state = "disabled"
elif failed > 0 and succeeded == 0:
state = "unavailable"
elif failed > 0:
state = "degraded"
elif succeeded > 0 or pending > 0 or delivering > 0:
state = "operational"
else:
state = "no_evidence"
return N8nIntegrationStatus(
configured=bool(settings.n8n_webhook_url),
dispatch_enabled=settings.n8n_dispatch_enabled,
state=state,
pending=pending,
delivering=delivering,
failed=failed,
succeeded=succeeded,
latest_success_at=latest_success_at,
latest_failure_at=latest_failure_at,
)
@router.get("/status", response_model=IntegrationStatusOut)
def integration_status(
db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager),
) -> IntegrationStatusOut:
return IntegrationStatusOut(
n8n=_n8n_status(db),
n8n=derive_n8n_status(db),
mcp_hub=McpHubIntegrationStatus(
registration_enabled=settings.mcp_hub_registration_enabled,
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
+3
View File
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
mcp_hub_registration_enabled: bool = False
cors_allow_origins: str = "http://localhost:1228"
demo_organization_name: str = "Northstar Mobility"
demo_timezone: str = "Europe/Brussels"
demo_allow_reset: bool = True
@lru_cache
+34
View File
@@ -197,6 +197,40 @@ class IntegrationStatusOut(BaseModel):
mcp_hub: McpHubIntegrationStatus
class DemoScenarioOut(BaseModel):
id: str
title: str
operational_problem: str
estimated_minutes: int
required_roles: list[Role]
start_path: str
demonstrates: str
ready: bool
blocked_reason: str | None = None
class DemoIntegrationSummaryOut(BaseModel):
key: Literal["n8n", "ragcore", "mcp_hub"]
label: str
status_label: str
detail: str
class DemoManifestOut(BaseModel):
demo_mode: bool
organization_name: str
organization_description: str
timezone: str
synthetic_data: bool
allow_reset: bool
last_reset_at: datetime | None
anchor_date: str | None
guide_available: bool
required_roles: list[Role]
scenarios: list[DemoScenarioOut]
integrations: list[DemoIntegrationSummaryOut]
class VehicleDetailOut(VehicleOut):
bookings: list[BookingSummaryOut] = Field(default_factory=list)
inspections: list[InspectionOut] = Field(default_factory=list)
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
from app.services.integration_status import derive_n8n_status
from app.services.knowledge import get_knowledge_provider
settings = get_settings()
# The default name matches the project's locked fictitious tenant (see PROJECT_STATE.md
# "Locked decisions"; the same slug already backs `ragcore_tenant`) — this surfaces that
# existing decision in the UI rather than inventing a new one. Configurable via
# DEMO_ORGANIZATION_NAME so a redeployment can rebrand the fictional org without a code change.
ORGANIZATION_DESCRIPTION = (
"MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, "
"ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en "
"automatiseert gecontroleerde vervolgstappen."
)
_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020"
_N8N_STATE_LABELS = {
"disabled": "Niet gekoppeld",
"unavailable": "Verwerking mislukt",
"degraded": "Opnieuw proberen mogelijk",
"operational": "Operationeel",
"no_evidence": "Voorbereid",
}
def _last_reset(db: Session) -> tuple[datetime | None, str | None]:
marker = db.scalar(
select(AuditEvent)
.where(AuditEvent.action == "demo_data_seeded")
.order_by(AuditEvent.occurred_at.desc())
)
if marker is None:
return None, None
metadata = marker.metadata_json or {}
return marker.occurred_at, metadata.get("anchor_date")
def _scenarios(db: Session) -> list[DemoScenarioOut]:
booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN"))
duplicate_issue = db.scalar(
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE")
)
overlap_issue = db.scalar(
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
)
failed_run = db.scalar(
select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID)
)
knowledge_health = get_knowledge_provider().health()
reset_hint = "Reset de demo-data om dit scenario opnieuw beschikbaar te maken."
return [
DemoScenarioOut(
id="return-anomaly",
title="Retour met afwijkende kilometerstand",
operational_problem=(
"Een voertuig komt terug met een kilometerstand die lager ligt dan de "
"laatst geregistreerde stand — een teken van een foutieve invoer of een "
"verwisseld voertuig."
),
estimated_minutes=3,
required_roles=["rental_employee", "operations_manager"],
start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings",
demonstrates=(
"Retourverwerking, automatische detectie van datakwaliteitsproblemen en de "
"audit trail die daaruit ontstaat."
),
ready=bool(
booking and booking.status == "active" and booking.end_odometer_km is None
),
blocked_reason=(
None
if booking and booking.status == "active" and booking.end_odometer_km is None
else (
f"Demoboeking BK-DEMO-RETURN niet gevonden. {reset_hint}"
if booking is None
else f"Deze boeking is al verwerkt sinds de laatste reset. {reset_hint}"
)
),
),
DemoScenarioOut(
id="duplicate-customer",
title="Mogelijke dubbele klant samenvoegen",
operational_problem=(
"Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — "
"waarschijnlijk dezelfde persoon, twee keer geregistreerd."
),
estimated_minutes=3,
required_roles=["operations_manager"],
start_path=(
f"/data-quality/{duplicate_issue.public_ref}"
if duplicate_issue
else "/data-quality"
),
demonstrates=(
"Samenvoegen van klanten met behoud van boekingsgeschiedenis en audit trail."
),
ready=bool(duplicate_issue and duplicate_issue.status == "open"),
blocked_reason=(
None
if duplicate_issue and duplicate_issue.status == "open"
else (
f"Demo-issue DQ-DEMO-DUPLICATE niet gevonden. {reset_hint}"
if duplicate_issue is None
else f"Dit issue is al opgelost sinds de laatste reset. {reset_hint}"
)
),
),
DemoScenarioOut(
id="booking-overlap",
title="Overlappende boekingen herstellen",
operational_problem=(
"Eén voertuig staat dubbel gereserveerd voor overlappende periodes — een "
"planningsfout die vóór vertrek moet worden opgelost."
),
estimated_minutes=2,
required_roles=["operations_manager"],
start_path=(
f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality"
),
demonstrates="Detectie en gecontroleerde oplossing van planningsconflicten.",
ready=bool(overlap_issue and overlap_issue.status == "open"),
blocked_reason=(
None
if overlap_issue and overlap_issue.status == "open"
else (
f"Demo-issue DQ-DEMO-OVERLAP niet gevonden. {reset_hint}"
if overlap_issue is None
else f"Dit issue is al opgelost sinds de laatste reset. {reset_hint}"
)
),
),
DemoScenarioOut(
id="automation-retry",
title="Mislukte automatisering opnieuw proberen",
operational_problem=(
"Eén eerdere gebeurtenis kon niet worden afgeleverd aan de automatisering "
"door een gesimuleerde verbindingsfout."
),
estimated_minutes=2,
required_roles=["operations_manager"],
start_path="/automation",
demonstrates=(
"Betrouwbare aflevering met begrensde herpogingen en zichtbare foutstatus."
),
ready=bool(failed_run and failed_run.delivery_status == "failed"),
blocked_reason=(
None
if failed_run and failed_run.delivery_status == "failed"
else (
f"Gesimuleerde mislukte gebeurtenis niet gevonden. {reset_hint}"
if failed_run is None
else f"Deze gebeurtenis is al hersteld sinds de laatste reset. {reset_hint}"
)
),
),
DemoScenarioOut(
id="knowledge-question",
title="Een procedurevraag stellen",
operational_problem=(
"Een medewerker weet niet zeker welke procedure van toepassing is bij een "
"specifieke operationele situatie."
),
estimated_minutes=2,
required_roles=["rental_employee", "operations_manager"],
start_path="/knowledge",
demonstrates=(
"Antwoorden met brongebaseerde onderbouwing uit een afgebakende demokennisbank."
),
ready=knowledge_health.available,
blocked_reason=(
None
if knowledge_health.available
else "De demokennisbank is momenteel niet beschikbaar."
),
),
]
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
n8n = derive_n8n_status(db)
knowledge_health = get_knowledge_provider().health()
return [
DemoIntegrationSummaryOut(
key="n8n",
label="Automatisering (n8n)",
status_label=_N8N_STATE_LABELS.get(n8n.state, n8n.state),
detail=f"{n8n.succeeded} geslaagd, {n8n.failed} mislukt, {n8n.pending} in wachtrij.",
),
DemoIntegrationSummaryOut(
key="ragcore",
label="Kennisassistent (RAGcore)",
status_label=(
"Demomodus — lokale kennisprovider"
if knowledge_health.provider != "ragcore"
else "Operationeel"
),
detail=knowledge_health.detail,
),
DemoIntegrationSummaryOut(
key="mcp_hub",
label="ITWorx MCP Hub",
status_label=(
"Operationeel" if settings.mcp_hub_registration_enabled else "Niet gekoppeld"
),
detail="Voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.",
),
]
def build_demo_manifest(db: Session) -> DemoManifestOut:
last_reset_at, anchor_date = _last_reset(db)
return DemoManifestOut(
demo_mode=settings.mobilityops_demo_mode,
organization_name=settings.demo_organization_name,
organization_description=ORGANIZATION_DESCRIPTION,
timezone=settings.demo_timezone,
synthetic_data=True,
allow_reset=settings.demo_allow_reset,
last_reset_at=last_reset_at,
anchor_date=anchor_date,
guide_available=True,
required_roles=["operations_manager", "rental_employee"],
scenarios=_scenarios(db),
integrations=_integrations(db),
)
@@ -0,0 +1,55 @@
from __future__ import annotations
from typing import Literal
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.outbox import OutboxEvent
from app.schemas import N8nIntegrationStatus
settings = get_settings()
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
counts: dict[str, int] = dict(
db.execute(
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
).all() # type: ignore[arg-type]
)
pending = counts.get("pending", 0)
delivering = counts.get("delivering", 0)
failed = counts.get("failed", 0)
succeeded = counts.get("succeeded", 0)
latest_success_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
)
latest_failure_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed")
)
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
if not settings.n8n_dispatch_enabled:
state = "disabled"
elif failed > 0 and succeeded == 0:
state = "unavailable"
elif failed > 0:
state = "degraded"
elif succeeded > 0 or pending > 0 or delivering > 0:
state = "operational"
else:
state = "no_evidence"
return N8nIntegrationStatus(
configured=bool(settings.n8n_webhook_url),
dispatch_enabled=settings.n8n_dispatch_enabled,
state=state,
pending=pending,
delivering=delivering,
failed=failed,
succeeded=succeeded,
latest_success_at=latest_success_at,
latest_failure_at=latest_failure_at,
)
+15
View File
@@ -18,6 +18,21 @@ def test_operations_manager_can_reset_demo(ops_client):
response = ops_client.post("/api/v1/demo/reset")
assert response.status_code == 200
assert response.json()["counts"]["vehicles"] == 50
assert response.json()["anchor_date"]
assert response.json()["seeded_at"]
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
import app.api.routers.demo as demo_router
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", False)
response = ops_client.post("/api/v1/demo/reset")
assert response.status_code == 403
# Restore real demo data: this test intentionally disabled reset, so a following test
# module must not inherit a database left mid-mutation by an earlier test.
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", True)
assert ops_client.post("/api/v1/demo/reset").status_code == 200
def test_session_endpoint_requires_authentication(client):
+55
View File
@@ -0,0 +1,55 @@
from app.core.db import SessionLocal
from app.seed_loader import reset_and_seed
def test_demo_manifest_is_public(client):
# No login call at all -- the demo-entry screen and badge need this before any
# session exists.
response = client.get("/api/v1/demo/manifest")
assert response.status_code == 200
def test_demo_manifest_shape(client):
body = client.get("/api/v1/demo/manifest").json()
assert body["organization_name"] == "Northstar Mobility"
assert body["demo_mode"] is True
assert body["synthetic_data"] is True
assert body["allow_reset"] is True
assert body["timezone"] == "Europe/Brussels"
assert body["guide_available"] is True
assert set(body["required_roles"]) == {"operations_manager", "rental_employee"}
assert body["last_reset_at"] is not None
assert body["anchor_date"] is not None
scenario_ids = {s["id"] for s in body["scenarios"]}
assert scenario_ids == {
"return-anomaly",
"duplicate-customer",
"booking-overlap",
"automation-retry",
"knowledge-question",
}
integration_keys = {i["key"] for i in body["integrations"]}
assert integration_keys == {"n8n", "ragcore", "mcp_hub"}
def test_demo_manifest_scenarios_ready_after_fresh_reset(client):
db = SessionLocal()
try:
reset_and_seed(db)
finally:
db.close()
body = client.get("/api/v1/demo/manifest").json()
scenarios = {s["id"]: s for s in body["scenarios"]}
for scenario_id, scenario in scenarios.items():
assert scenario["ready"] is True, f"{scenario_id} should be ready right after a reset"
assert scenario["blocked_reason"] is None
assert scenario["start_path"]
def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client):
body = client.get("/api/v1/demo/manifest").json()
ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore")
assert "Demomodus" in ragcore["status_label"]
assert "RAGcore" not in ragcore["status_label"]
+3
View File
@@ -34,6 +34,9 @@ services:
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels}
DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true}
ports:
- "8128:8000"
depends_on:
+8 -1
View File
@@ -11,10 +11,17 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu
### System and demo
- `GET /health`
- `GET /api/v1/demo/manifest` — unauthenticated; demo org name/description, synthetic-data
flag, reset allowance and timestamp, guide availability, the 5 named scenarios (with
live readiness derived from actual records, not hardcoded), and plain-language
integration summaries. Single source of truth for the demo-entry screen, the permanent
demo badge, the scenario overview and the About page — avoids duplicating this logic
per surface.
- `POST /api/v1/demo/login`
- `GET /api/v1/demo/session` — confirms the current session; `Cache-Control: no-store`
- `POST /api/v1/demo/logout` — safe to call without a session
- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own session
- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own
session; returns 403 if `DEMO_ALLOW_RESET=false`
### Dashboard
+1 -1
View File
@@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => {
await page.goto("/login");
await page.screenshot({ path: `${OUT}/1-login.png` });
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
+56
View File
@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";
test.describe.configure({ mode: "serial" });
test("demo entry screen names the fictional org and never shows a password", async ({ page }) => {
await page.goto("/login");
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Operations Manager" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible();
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 }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page).toHaveURL(/\/dashboard\?guide=start$/);
await expect(page.getByText("Amelie De Ridder")).toBeVisible();
});
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
await expect(trigger).toBeVisible();
await trigger.click();
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
await expect(page.getByText(/Laatste reset:/)).toBeVisible();
await page.getByRole("link", { name: /Over deze demo/ }).click();
await expect(page).toHaveURL(/\/about$/);
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible();
await expect(page.getByText("Northstar Mobility").first()).toBeVisible();
await expect(page.getByText("Demomodus — lokale kennisprovider")).toBeVisible();
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
});
test("badge popover closes on Escape and outside click", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
await trigger.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toBeHidden();
await trigger.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.mouse.click(10, 10);
await expect(page.getByRole("dialog")).toBeHidden();
});
+3 -3
View File
@@ -18,8 +18,8 @@ test("five-minute demo script end to end", async ({ page, request }) => {
await test.step("1. login as Operations Manager", async () => {
await page.goto("/login");
await expect(page.getByText(/Synthetic proof of concept/)).toBeVisible();
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
@@ -89,7 +89,7 @@ test("five-minute demo script end to end", async ({ page, request }) => {
await test.step("9. verify responsive navigation at mobile width", async () => {
await page.setViewportSize({ width: 360, height: 800 });
await page.goto("/dashboard");
await expect(page.getByText(/Synthetic demo data/).first()).toBeVisible();
await expect(page.getByText(/Synthetische demo/).first()).toBeVisible();
await expect(page.getByRole("link", { name: "Overview" }).first()).toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
+4 -4
View File
@@ -9,7 +9,7 @@ test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
@@ -340,7 +340,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
page,
}) => {
await page.getByRole("button", { name: "Switch role" }).click();
await page.getByRole("button", { name: "Open as Rental Employee" }).click();
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
// Manager-only nav items are not shown at all, not merely disabled.
@@ -374,7 +374,7 @@ test("operations manager can reset demo data and is returned to login", async ({
await expect(page).toHaveURL(/\/login$/);
// The reset must not have affected the ability to log back in against fresh data.
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
@@ -392,7 +392,7 @@ test("rental employee direct API access to manager-only endpoints is rejected",
page,
}) => {
await page.getByRole("button", { name: "Switch role" }).click();
await page.getByRole("button", { name: "Open as Rental Employee" }).click();
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
// page.request shares the browser context's cookies, and (via the web container's
+1 -1
View File
@@ -8,7 +8,7 @@ async function resetDemoData(request: APIRequestContext) {
test.beforeEach(async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/login");
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
+30 -25
View File
@@ -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>
);
}
+34
View File
@@ -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;
+81
View File
@@ -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>
);
}
+5 -2
View File
@@ -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;
}
+124
View File
@@ -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>
);
}
+36 -16
View File
@@ -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
View File
@@ -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; }