From 337f8716bbdb1753edf087d185d95c98edaf8c91 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:33:22 +0200 Subject: [PATCH] polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul Rebrands the product from MobilityOps to Fleet Ops across the UI, backend defaults and knowledge base, and makes nl-BE/en-GB/fr-BE full first-class languages: i18next with eager-bundled per-namespace resources, a persisted accessible language switcher (topbar and mobile drawer), locale-aware date/number formatting, and a coverage test that fails the build on any missing or empty translation key. Backend dynamic content (demo scenarios, blocked-reason text, integration status) moves from fixed English/Dutch prose to stable message codes + params so the frontend can localize it; the demo knowledge base gains a fully translated NL/EN/FR procedure corpus (11 documents each) with per-language retrieval and localized evidence-state messages. The Demo Guide becomes breakpoint-adaptive: a docked rail on extra-wide desktop, a floating panel that auto-collapses to a persistent, closable progress chip on standard desktop/tablet, and a collapsed/half/full bottom sheet on mobile -- with scroll+focus+ highlight on "go to this step", Escape handling, and reduced-motion support. The Data Quality Workbench gets accessible choice-card decisions with a clear primary/ secondary/tertiary action hierarchy; the Automation ledger groups repeated successes and uses meaningful short refs; the Audit trail groups events by correlation id with human action labels and readable before/after diffs. Attention Queue, Today's movements, Vehicles, Bookings and Data Quality rows are fully clickable (stretched-link pattern) with independent secondary links, keyboard support and mobile touch targets. Fixes a topbar overflow on mobile caused by the new language switcher (moved into the mobile drawer at <=960px) and two dangling aria-labelledby references introduced this session. Updates all affected Playwright specs for the new nl-BE default and the new Audit/DemoGuide DOM structure, and adds new i18n-coverage, demo-guide-adaptive and clickable-rows specs. 131 backend tests, Ruff and mypy, and 71 Playwright tests pass. Co-Authored-By: Claude Sonnet 5 --- backend/app/api/routers/dashboard.py | 3 +- backend/app/api/routers/knowledge.py | 14 +- backend/app/schemas.py | 15 +- backend/app/services/demo_manifest.py | 162 +++------ backend/app/services/knowledge/__init__.py | 6 +- backend/app/services/knowledge/demo.py | 143 ++++++-- backend/app/services/knowledge/ragcore.py | 5 +- backend/tests/test_demo_manifest.py | 5 +- backend/tests/test_knowledge.py | 47 ++- frontend/e2e/clickable-rows.spec.ts | 105 ++++++ frontend/e2e/demo-accessibility.spec.ts | 6 +- frontend/e2e/demo-entry.spec.ts | 4 +- frontend/e2e/demo-guide.spec.ts | 84 ++++- frontend/e2e/demo-legibility.spec.ts | 30 +- frontend/e2e/demo.spec.ts | 49 ++- frontend/e2e/guided-demo-full.spec.ts | 37 +- frontend/e2e/i18n-coverage.spec.ts | 73 ++++ frontend/e2e/interactive-elements.spec.ts | 50 +-- frontend/e2e/ui-redesign.spec.ts | 16 +- frontend/index.html | 6 +- frontend/package-lock.json | 86 ++++- frontend/package.json | 2 + frontend/src/api/types.ts | 15 +- frontend/src/components/Badge.tsx | 6 +- frontend/src/components/DemoBadge.tsx | 41 +-- frontend/src/components/DemoGuide.tsx | 226 +++++++++--- frontend/src/components/LanguageSwitcher.tsx | 33 ++ frontend/src/components/Layout.tsx | 90 ++--- frontend/src/components/PageChrome.tsx | 13 +- frontend/src/components/RequireAuth.tsx | 2 +- frontend/src/components/ReturnForm.tsx | 103 +++--- frontend/src/context/DemoGuideContext.tsx | 15 +- frontend/src/data/demoGuideSteps.ts | 81 +---- frontend/src/data/integrationLabels.ts | 21 +- frontend/src/hooks/useViewportTier.ts | 36 ++ frontend/src/i18n/config.ts | 161 +++++++++ frontend/src/i18n/format.ts | 37 ++ .../src/i18n/locales/en-GB/accessibility.json | 7 + frontend/src/i18n/locales/en-GB/audit.json | 64 ++++ frontend/src/i18n/locales/en-GB/auth.json | 23 ++ frontend/src/i18n/locales/en-GB/bookings.json | 52 +++ frontend/src/i18n/locales/en-GB/common.json | 44 +++ .../src/i18n/locales/en-GB/dashboard.json | 67 ++++ frontend/src/i18n/locales/en-GB/demo.json | 189 ++++++++++ frontend/src/i18n/locales/en-GB/errors.json | 8 + frontend/src/i18n/locales/en-GB/fleet.json | 68 ++++ .../src/i18n/locales/en-GB/integrations.json | 70 ++++ .../src/i18n/locales/en-GB/knowledge.json | 41 +++ .../src/i18n/locales/en-GB/navigation.json | 39 +++ frontend/src/i18n/locales/en-GB/quality.json | 192 +++++++++++ frontend/src/i18n/locales/en-GB/returns.json | 73 ++++ .../src/i18n/locales/fr-BE/accessibility.json | 7 + frontend/src/i18n/locales/fr-BE/audit.json | 64 ++++ frontend/src/i18n/locales/fr-BE/auth.json | 23 ++ frontend/src/i18n/locales/fr-BE/bookings.json | 52 +++ frontend/src/i18n/locales/fr-BE/common.json | 44 +++ .../src/i18n/locales/fr-BE/dashboard.json | 67 ++++ frontend/src/i18n/locales/fr-BE/demo.json | 189 ++++++++++ frontend/src/i18n/locales/fr-BE/errors.json | 8 + frontend/src/i18n/locales/fr-BE/fleet.json | 68 ++++ .../src/i18n/locales/fr-BE/integrations.json | 70 ++++ .../src/i18n/locales/fr-BE/knowledge.json | 41 +++ .../src/i18n/locales/fr-BE/navigation.json | 39 +++ frontend/src/i18n/locales/fr-BE/quality.json | 192 +++++++++++ frontend/src/i18n/locales/fr-BE/returns.json | 73 ++++ .../src/i18n/locales/nl-BE/accessibility.json | 7 + frontend/src/i18n/locales/nl-BE/audit.json | 64 ++++ frontend/src/i18n/locales/nl-BE/auth.json | 23 ++ frontend/src/i18n/locales/nl-BE/bookings.json | 52 +++ frontend/src/i18n/locales/nl-BE/common.json | 44 +++ .../src/i18n/locales/nl-BE/dashboard.json | 67 ++++ frontend/src/i18n/locales/nl-BE/demo.json | 189 ++++++++++ frontend/src/i18n/locales/nl-BE/errors.json | 8 + frontend/src/i18n/locales/nl-BE/fleet.json | 68 ++++ .../src/i18n/locales/nl-BE/integrations.json | 70 ++++ .../src/i18n/locales/nl-BE/knowledge.json | 41 +++ .../src/i18n/locales/nl-BE/navigation.json | 39 +++ frontend/src/i18n/locales/nl-BE/quality.json | 192 +++++++++++ frontend/src/i18n/locales/nl-BE/returns.json | 73 ++++ frontend/src/main.tsx | 1 + frontend/src/pages/AboutDemo.tsx | 156 +++------ frontend/src/pages/Audit.tsx | 219 ++++++++---- frontend/src/pages/Automation.tsx | 241 ++++++++----- frontend/src/pages/BookingDetail.tsx | 39 +-- frontend/src/pages/Bookings.tsx | 61 ++-- frontend/src/pages/Dashboard.tsx | 161 +++++---- frontend/src/pages/DataQuality.tsx | 97 +++--- frontend/src/pages/DataQualityIssueDetail.tsx | 325 ++++++++---------- frontend/src/pages/Knowledge.tsx | 94 +++-- frontend/src/pages/Login.tsx | 45 +-- frontend/src/pages/Scenarios.tsx | 46 ++- frontend/src/pages/VehicleDetail.tsx | 68 ++-- frontend/src/pages/Vehicles.tsx | 61 ++-- frontend/src/styles.css | 72 +++- .../{ => en-GB}/01-vehicle-checkout.md | 0 .../{ => en-GB}/02-vehicle-return.md | 0 .../{ => en-GB}/03-damage-handling.md | 0 .../{ => en-GB}/04-odometer-anomalies.md | 0 .../{ => en-GB}/05-cleaning-checklist.md | 0 .../{ => en-GB}/06-maintenance-escalation.md | 0 .../{ => en-GB}/07-customer-documents.md | 0 .../procedures/{ => en-GB}/08-privacy.md | 0 .../{ => en-GB}/09-booking-conflicts.md | 0 .../{ => en-GB}/10-roles-and-escalation.md | 0 .../en-GB/11-vehicle-availability.md | 21 ++ .../procedures/fr-BE/01-vehicle-checkout.md | 21 ++ .../procedures/fr-BE/02-vehicle-return.md | 25 ++ .../procedures/fr-BE/03-damage-handling.md | 21 ++ .../procedures/fr-BE/04-odometer-anomalies.md | 21 ++ .../procedures/fr-BE/05-cleaning-checklist.md | 21 ++ .../fr-BE/06-maintenance-escalation.md | 21 ++ .../procedures/fr-BE/07-customer-documents.md | 21 ++ knowledge/procedures/fr-BE/08-privacy.md | 21 ++ .../procedures/fr-BE/09-booking-conflicts.md | 21 ++ .../fr-BE/10-roles-and-escalation.md | 21 ++ .../fr-BE/11-vehicle-availability.md | 21 ++ .../procedures/nl-BE/01-vehicle-checkout.md | 21 ++ .../procedures/nl-BE/02-vehicle-return.md | 25 ++ .../procedures/nl-BE/03-damage-handling.md | 21 ++ .../procedures/nl-BE/04-odometer-anomalies.md | 21 ++ .../procedures/nl-BE/05-cleaning-checklist.md | 21 ++ .../nl-BE/06-maintenance-escalation.md | 21 ++ .../procedures/nl-BE/07-customer-documents.md | 21 ++ knowledge/procedures/nl-BE/08-privacy.md | 21 ++ .../procedures/nl-BE/09-booking-conflicts.md | 21 ++ .../nl-BE/10-roles-and-escalation.md | 21 ++ .../nl-BE/11-vehicle-availability.md | 21 ++ 127 files changed, 5529 insertions(+), 1287 deletions(-) create mode 100644 frontend/e2e/clickable-rows.spec.ts create mode 100644 frontend/e2e/i18n-coverage.spec.ts create mode 100644 frontend/src/components/LanguageSwitcher.tsx create mode 100644 frontend/src/hooks/useViewportTier.ts create mode 100644 frontend/src/i18n/config.ts create mode 100644 frontend/src/i18n/format.ts create mode 100644 frontend/src/i18n/locales/en-GB/accessibility.json create mode 100644 frontend/src/i18n/locales/en-GB/audit.json create mode 100644 frontend/src/i18n/locales/en-GB/auth.json create mode 100644 frontend/src/i18n/locales/en-GB/bookings.json create mode 100644 frontend/src/i18n/locales/en-GB/common.json create mode 100644 frontend/src/i18n/locales/en-GB/dashboard.json create mode 100644 frontend/src/i18n/locales/en-GB/demo.json create mode 100644 frontend/src/i18n/locales/en-GB/errors.json create mode 100644 frontend/src/i18n/locales/en-GB/fleet.json create mode 100644 frontend/src/i18n/locales/en-GB/integrations.json create mode 100644 frontend/src/i18n/locales/en-GB/knowledge.json create mode 100644 frontend/src/i18n/locales/en-GB/navigation.json create mode 100644 frontend/src/i18n/locales/en-GB/quality.json create mode 100644 frontend/src/i18n/locales/en-GB/returns.json create mode 100644 frontend/src/i18n/locales/fr-BE/accessibility.json create mode 100644 frontend/src/i18n/locales/fr-BE/audit.json create mode 100644 frontend/src/i18n/locales/fr-BE/auth.json create mode 100644 frontend/src/i18n/locales/fr-BE/bookings.json create mode 100644 frontend/src/i18n/locales/fr-BE/common.json create mode 100644 frontend/src/i18n/locales/fr-BE/dashboard.json create mode 100644 frontend/src/i18n/locales/fr-BE/demo.json create mode 100644 frontend/src/i18n/locales/fr-BE/errors.json create mode 100644 frontend/src/i18n/locales/fr-BE/fleet.json create mode 100644 frontend/src/i18n/locales/fr-BE/integrations.json create mode 100644 frontend/src/i18n/locales/fr-BE/knowledge.json create mode 100644 frontend/src/i18n/locales/fr-BE/navigation.json create mode 100644 frontend/src/i18n/locales/fr-BE/quality.json create mode 100644 frontend/src/i18n/locales/fr-BE/returns.json create mode 100644 frontend/src/i18n/locales/nl-BE/accessibility.json create mode 100644 frontend/src/i18n/locales/nl-BE/audit.json create mode 100644 frontend/src/i18n/locales/nl-BE/auth.json create mode 100644 frontend/src/i18n/locales/nl-BE/bookings.json create mode 100644 frontend/src/i18n/locales/nl-BE/common.json create mode 100644 frontend/src/i18n/locales/nl-BE/dashboard.json create mode 100644 frontend/src/i18n/locales/nl-BE/demo.json create mode 100644 frontend/src/i18n/locales/nl-BE/errors.json create mode 100644 frontend/src/i18n/locales/nl-BE/fleet.json create mode 100644 frontend/src/i18n/locales/nl-BE/integrations.json create mode 100644 frontend/src/i18n/locales/nl-BE/knowledge.json create mode 100644 frontend/src/i18n/locales/nl-BE/navigation.json create mode 100644 frontend/src/i18n/locales/nl-BE/quality.json create mode 100644 frontend/src/i18n/locales/nl-BE/returns.json rename knowledge/procedures/{ => en-GB}/01-vehicle-checkout.md (100%) rename knowledge/procedures/{ => en-GB}/02-vehicle-return.md (100%) rename knowledge/procedures/{ => en-GB}/03-damage-handling.md (100%) rename knowledge/procedures/{ => en-GB}/04-odometer-anomalies.md (100%) rename knowledge/procedures/{ => en-GB}/05-cleaning-checklist.md (100%) rename knowledge/procedures/{ => en-GB}/06-maintenance-escalation.md (100%) rename knowledge/procedures/{ => en-GB}/07-customer-documents.md (100%) rename knowledge/procedures/{ => en-GB}/08-privacy.md (100%) rename knowledge/procedures/{ => en-GB}/09-booking-conflicts.md (100%) rename knowledge/procedures/{ => en-GB}/10-roles-and-escalation.md (100%) create mode 100644 knowledge/procedures/en-GB/11-vehicle-availability.md create mode 100644 knowledge/procedures/fr-BE/01-vehicle-checkout.md create mode 100644 knowledge/procedures/fr-BE/02-vehicle-return.md create mode 100644 knowledge/procedures/fr-BE/03-damage-handling.md create mode 100644 knowledge/procedures/fr-BE/04-odometer-anomalies.md create mode 100644 knowledge/procedures/fr-BE/05-cleaning-checklist.md create mode 100644 knowledge/procedures/fr-BE/06-maintenance-escalation.md create mode 100644 knowledge/procedures/fr-BE/07-customer-documents.md create mode 100644 knowledge/procedures/fr-BE/08-privacy.md create mode 100644 knowledge/procedures/fr-BE/09-booking-conflicts.md create mode 100644 knowledge/procedures/fr-BE/10-roles-and-escalation.md create mode 100644 knowledge/procedures/fr-BE/11-vehicle-availability.md create mode 100644 knowledge/procedures/nl-BE/01-vehicle-checkout.md create mode 100644 knowledge/procedures/nl-BE/02-vehicle-return.md create mode 100644 knowledge/procedures/nl-BE/03-damage-handling.md create mode 100644 knowledge/procedures/nl-BE/04-odometer-anomalies.md create mode 100644 knowledge/procedures/nl-BE/05-cleaning-checklist.md create mode 100644 knowledge/procedures/nl-BE/06-maintenance-escalation.md create mode 100644 knowledge/procedures/nl-BE/07-customer-documents.md create mode 100644 knowledge/procedures/nl-BE/08-privacy.md create mode 100644 knowledge/procedures/nl-BE/09-booking-conflicts.md create mode 100644 knowledge/procedures/nl-BE/10-roles-and-escalation.md create mode 100644 knowledge/procedures/nl-BE/11-vehicle-availability.md diff --git a/backend/app/api/routers/dashboard.py b/backend/app/api/routers/dashboard.py index 6253019..3385bec 100644 --- a/backend/app/api/routers/dashboard.py +++ b/backend/app/api/routers/dashboard.py @@ -61,12 +61,11 @@ def get_dashboard( entity = customers_by_id.get(issue.entity_id) link_type = "customer" link_ref = entity.public_ref if entity else "" - title = f"{issue.rule_type.replace('_', ' ').title()} — {link_ref}" attention_items.append( AttentionItem( kind="quality_issue", severity=issue.severity, - title=title, + rule_type=issue.rule_type, detail=issue.evidence_json.get("summary", ""), link_type=link_type, link_ref=link_ref, diff --git a/backend/app/api/routers/knowledge.py b/backend/app/api/routers/knowledge.py index ae15346..340d4f2 100644 --- a/backend/app/api/routers/knowledge.py +++ b/backend/app/api/routers/knowledge.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +from typing import Literal from fastapi import APIRouter, Depends from pydantic import BaseModel, Field @@ -13,9 +14,12 @@ from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledg router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"]) +SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"] + class AskQuestionRequest(BaseModel): question: str = Field(min_length=3, max_length=1000) + language: SupportedLanguage = "en-GB" @router.post("/questions", response_model=GroundedAnswer) @@ -26,7 +30,7 @@ def ask_question( ) -> GroundedAnswer: correlation_id = str(uuid.uuid4()) provider = get_knowledge_provider() - answer = provider.ask(body.question, correlation_id) + answer = provider.ask(body.question, correlation_id, body.language) record_audit_event( db, @@ -40,6 +44,7 @@ def ask_question( "provider": answer.provider, "source_ids": [s.document_id for s in answer.sources], "question_length": len(body.question), + "language": body.language, }, ) db.commit() @@ -47,5 +52,8 @@ def ask_question( @router.get("/status", response_model=KnowledgeHealth) -def knowledge_status(_user: CurrentUser = Depends(get_current_user)) -> KnowledgeHealth: - return get_knowledge_provider().health() +def knowledge_status( + language: SupportedLanguage = "en-GB", + _user: CurrentUser = Depends(get_current_user), +) -> KnowledgeHealth: + return get_knowledge_provider().health(language) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0bbe2a9..00c8bfc 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -199,27 +199,24 @@ class IntegrationStatusOut(BaseModel): 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 + blocked_reason_code: str | None = None + blocked_reason_params: dict[str, str] = {} class DemoIntegrationSummaryOut(BaseModel): key: Literal["n8n", "ragcore", "mcp_hub"] - label: str - status_label: str - detail: str + status_code: str + detail_code: str + detail_params: dict[str, str | int] = {} class DemoManifestOut(BaseModel): demo_mode: bool organization_name: str - organization_description: str timezone: str synthetic_data: bool allow_reset: bool @@ -251,7 +248,7 @@ class DashboardMetrics(BaseModel): class AttentionItem(BaseModel): kind: Literal["quality_issue", "vehicle"] severity: str - title: str + rule_type: str detail: str link_type: Literal["vehicle", "booking", "customer"] link_ref: str diff --git a/backend/app/services/demo_manifest.py b/backend/app/services/demo_manifest.py index d3e2106..52ac618 100644 --- a/backend/app/services/demo_manifest.py +++ b/backend/app/services/demo_manifest.py @@ -16,26 +16,8 @@ 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( @@ -61,44 +43,33 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]: 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." + + # Human copy (title, problem statement, "demonstrates" summary) lives entirely in the + # frontend's demo.json (scenarios.items..*) so it's available in all three UI + # languages. This service only emits stable identifiers and message codes -- never + # display prose -- per the message_code + params architecture used across the app. + return_ready = bool( + booking and booking.status == "active" and booking.end_odometer_km is None + ) + duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open") + overlap_ready = bool(overlap_issue and overlap_issue.status == "open") + automation_ready = bool(failed_run and failed_run.delivery_status == "failed") 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=( + ready=return_ready, + blocked_reason_code=( 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}" - ) + if return_ready + else "bookingNotFound" if booking is None else "bookingAlreadyProcessed" ), ), 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=( @@ -106,87 +77,46 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]: 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=( + ready=duplicate_ready, + blocked_reason_code=( 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}" - ) + if duplicate_ready + else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved" ), ), 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=( + ready=overlap_ready, + blocked_reason_code=( 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}" - ) + if overlap_ready + else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved" ), ), 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=( + ready=automation_ready, + blocked_reason_code=( 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}" - ) + if automation_ready + else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered" ), ), 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." - ), + blocked_reason_code=None if knowledge_health.available else "knowledgeUnavailable", ), ] @@ -198,27 +128,32 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: 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.", + status_code=n8n.state, + detail_code="n8nDetail", + detail_params={ + "succeeded": n8n.succeeded, + "failed": n8n.failed, + "pending": n8n.pending, + }, ), DemoIntegrationSummaryOut( key="ragcore", - label="Kennisassistent (RAGcore)", - status_label=( - "Demomodus — lokale kennisprovider" - if knowledge_health.provider != "ragcore" - else "Operationeel" - ), - detail=knowledge_health.detail, + status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode", + detail_code="ragcoreDetail", + detail_params={ + "count": knowledge_health.document_count, + "collection": knowledge_health.collection, + }, ), DemoIntegrationSummaryOut( key="mcp_hub", - label="ITWorx MCP Hub", - status_label=( - "Operationeel" if settings.mcp_hub_registration_enabled else "Niet gekoppeld" + status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected", + detail_code=( + "mcpDetailEnabled" + if settings.mcp_hub_registration_enabled + else "mcpDetailNotConnected" ), - detail="Voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.", + detail_params={}, ), ] @@ -230,7 +165,7 @@ def scenario_integrity_report(db: Session) -> dict: overview already use, so this can never drift from what a visitor actually sees.""" scenarios = _scenarios(db) not_ready = [ - {"id": s.id, "title": s.title, "reason": s.blocked_reason} + {"id": s.id, "reason_code": s.blocked_reason_code} for s in scenarios if not s.ready ] @@ -242,7 +177,6 @@ def build_demo_manifest(db: Session) -> DemoManifestOut: 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, diff --git a/backend/app/services/knowledge/__init__.py b/backend/app/services/knowledge/__init__.py index 48e5bcb..32c4896 100644 --- a/backend/app/services/knowledge/__init__.py +++ b/backend/app/services/knowledge/__init__.py @@ -39,9 +39,11 @@ class KnowledgeHealth(BaseModel): class KnowledgeProvider(Protocol): name: str - def health(self) -> KnowledgeHealth: ... + def health(self, language: str = "en-GB") -> KnowledgeHealth: ... - def ask(self, question: str, correlation_id: str) -> GroundedAnswer: ... + def ask( + self, question: str, correlation_id: str, language: str = "en-GB" + ) -> GroundedAnswer: ... @lru_cache diff --git a/backend/app/services/knowledge/demo.py b/backend/app/services/knowledge/demo.py index 7ab7a88..58e9433 100644 --- a/backend/app/services/knowledge/demo.py +++ b/backend/app/services/knowledge/demo.py @@ -8,12 +8,31 @@ from pathlib import Path from app.core.config import get_settings from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard -STOPWORDS = { - "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", - "to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then", - "do", "does", "did", "must", "may", "can", "could", "should", "would", - "i", "you", "it", "we", "they", "my", "your", "what", "when", "how", - "with", "without", "this", "that", "these", "those", "not", "no", +SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE") +DEFAULT_LANGUAGE = "en-GB" + +STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = { + "en-GB": { + "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", + "to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then", + "do", "does", "did", "must", "may", "can", "could", "should", "would", + "i", "you", "it", "we", "they", "my", "your", "what", "when", "how", + "with", "without", "this", "that", "these", "those", "not", "no", + }, + "nl-BE": { + "een", "de", "het", "is", "zijn", "was", "waren", "worden", "wordt", + "van", "in", "op", "voor", "en", "of", "maar", "als", "dan", + "moet", "mag", "kan", "kunnen", "zou", "zouden", + "ik", "jij", "u", "we", "wij", "zij", "mijn", "jouw", "wat", "wanneer", "hoe", + "met", "zonder", "dit", "dat", "deze", "die", "niet", "geen", + }, + "fr-BE": { + "un", "une", "le", "la", "les", "des", "est", "sont", "était", "être", + "de", "du", "en", "sur", "pour", "et", "ou", "mais", "si", "alors", + "doit", "peut", "peuvent", "pourrait", "devrait", + "je", "tu", "vous", "il", "elle", "nous", "ils", "mon", "votre", "quoi", "quand", "comment", + "avec", "sans", "ce", "cette", "ces", "cela", "pas", "non", + }, } _WORD_RE = re.compile(r"[a-z0-9]+") @@ -28,9 +47,10 @@ def _stem(word: str) -> str: return word -def _tokenize(text: str) -> set[str]: +def _tokenize(text: str, language: str) -> set[str]: + stopwords = STOPWORDS_BY_LANGUAGE.get(language, STOPWORDS_BY_LANGUAGE[DEFAULT_LANGUAGE]) words = _WORD_RE.findall(text.lower()) - return {_stem(w) for w in words if w not in STOPWORDS and len(w) > 2} + return {_stem(w) for w in words if w not in stopwords and len(w) > 2} @dataclass @@ -86,7 +106,7 @@ def _split_sections(body: str) -> list[tuple[str, str]]: return sections -def _load_sections(procedures_dir: Path) -> list[ScoredSection]: +def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]: sections: list[ScoredSection] = [] for path in sorted(procedures_dir.glob("*.md")): raw = path.read_text(encoding="utf-8") @@ -96,7 +116,7 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]: document_id=meta.get("document_id", path.stem), title=title, version=meta.get("version", "1.0"), - title_tokens=_tokenize(title), + title_tokens=_tokenize(title, language), ) for heading, text in _split_sections(body): sections.append( @@ -104,19 +124,49 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]: document=doc, heading=heading, text=text, - heading_tokens=_tokenize(heading), - body_tokens=_tokenize(text), + heading_tokens=_tokenize(heading, language), + body_tokens=_tokenize(text, language), ) ) return sections +_NO_MATCH_TEXT = { + "en-GB": "No matching procedure was found for this question.", + "nl-BE": "Er werd geen passende procedure gevonden voor deze vraag.", + "fr-BE": "Aucune procédure correspondante n'a été trouvée pour cette question.", +} +_LOW_CONFIDENCE_TEXT = { + "en-GB": ( + "The available procedures do not clearly answer this question. " + "The closest matches are included below for review." + ), + "nl-BE": ( + "De beschikbare procedures beantwoorden deze vraag niet duidelijk. " + "De dichtstbijzijnde overeenkomsten staan hieronder ter beoordeling." + ), + "fr-BE": ( + "Les procédures disponibles ne répondent pas clairement à cette question. " + "Les correspondances les plus proches sont indiquées ci-dessous pour examen." + ), +} +_LEAD_ANSWER_TEMPLATE = { + "en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}', + "nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}', + "fr-BE": 'Selon « {title} » (v{version}), section « {heading} » : {excerpt}', +} + + class DemoKnowledgeProvider: """Deterministic extractive retrieval over the local procedure Markdown files. Not a generative model: it scores sections with TF-IDF-weighted keyword overlap (downweighting terms common across the whole corpus, like "vehicle", in favor of distinctive ones, like "damage") and returns real excerpts, never invented text. + + Each supported UI language has its own translated procedure corpus under + knowledge/procedures// -- retrieval searches only within the requested + language's corpus so citations always link to a same-language document. """ name = "demo" @@ -124,10 +174,18 @@ class DemoKnowledgeProvider: def __init__(self) -> None: settings = get_settings() self._settings = settings - self._procedures_dir = Path(settings.knowledge_dir) - self._sections = _load_sections(self._procedures_dir) - self._document_count = len({s.document.document_id for s in self._sections}) - self._idf = self._build_idf(self._sections) + base_dir = Path(settings.knowledge_dir) + self._sections_by_language: dict[str, list[ScoredSection]] = {} + self._idf_by_language: dict[str, dict[str, float]] = {} + self._document_count_by_language: dict[str, int] = {} + for language in SUPPORTED_LANGUAGES: + lang_dir = base_dir / language + sections = _load_sections(lang_dir, language) if lang_dir.is_dir() else [] + self._sections_by_language[language] = sections + self._idf_by_language[language] = self._build_idf(sections) + self._document_count_by_language[language] = len( + {s.document.document_id for s in sections} + ) @staticmethod def _build_idf(sections: list[ScoredSection]) -> dict[str, float]: @@ -140,7 +198,13 @@ class DemoKnowledgeProvider: doc_freq[token] = doc_freq.get(token, 0) + 1 return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()} - def health(self) -> KnowledgeHealth: + def _normalize_language(self, language: str | None) -> str: + if language in SUPPORTED_LANGUAGES: + return language + return DEFAULT_LANGUAGE + + def health(self, language: str = DEFAULT_LANGUAGE) -> KnowledgeHealth: + language = self._normalize_language(language) return KnowledgeHealth( provider=self.name, available=True, @@ -148,36 +212,40 @@ class DemoKnowledgeProvider: tenant=self._settings.ragcore_tenant, workspace=self._settings.ragcore_workspace, collection=self._settings.ragcore_collection, - document_count=self._document_count, + document_count=self._document_count_by_language[language], ) - def _score(self, query_tokens: set[str], section: ScoredSection) -> float: + def _score( + self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float] + ) -> float: score = 0.0 for token in query_tokens: - idf = self._idf.get(token, 0.0) - if idf == 0.0: + token_idf = idf.get(token, 0.0) + if token_idf == 0.0: continue if token in section.heading_tokens: - score += 3 * idf + score += 3 * token_idf elif token in section.document.title_tokens: - score += 2 * idf + score += 2 * token_idf elif token in section.body_tokens: - score += idf + score += token_idf return score - def ask(self, question: str, correlation_id: str) -> GroundedAnswer: - query_tokens = _tokenize(question) - scored = [ - (self._score(query_tokens, section), section) - for section in self._sections - ] + def ask( + self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE + ) -> GroundedAnswer: + language = self._normalize_language(language) + sections = self._sections_by_language[language] + idf = self._idf_by_language[language] + query_tokens = _tokenize(question, language) + scored = [(self._score(query_tokens, section, idf), section) for section in sections] scored = [(score, section) for score, section in scored if score > 0] scored.sort(key=lambda item: item[0], reverse=True) top = scored[:3] if not top: return GroundedAnswer( - answer="No matching procedure was found for this question.", + answer=_NO_MATCH_TEXT[language], evidence_state="insufficient", sources=[], provider=self.name, @@ -197,10 +265,7 @@ class DemoKnowledgeProvider: if top[0][0] < 3: return GroundedAnswer( - answer=( - "The available procedures do not clearly answer this question. " - "The closest matches are included below for review." - ), + answer=_LOW_CONFIDENCE_TEXT[language], evidence_state="insufficient", sources=sources, provider=self.name, @@ -208,9 +273,11 @@ class DemoKnowledgeProvider: ) lead_section = top[0][1] - answer = ( - f'Per "{lead_section.document.title}" (v{lead_section.document.version}), ' - f'section "{lead_section.heading}": {lead_section.text.splitlines()[0][:300]}' + answer = _LEAD_ANSWER_TEMPLATE[language].format( + title=lead_section.document.title, + version=lead_section.document.version, + heading=lead_section.heading, + excerpt=lead_section.text.splitlines()[0][:300], ) return GroundedAnswer( answer=answer, diff --git a/backend/app/services/knowledge/ragcore.py b/backend/app/services/knowledge/ragcore.py index 88abd0e..b2c55b7 100644 --- a/backend/app/services/knowledge/ragcore.py +++ b/backend/app/services/knowledge/ragcore.py @@ -32,7 +32,7 @@ class RAGcoreKnowledgeProvider: timeout=self._settings.ragcore_http_timeout_seconds, ) - def health(self) -> KnowledgeHealth: + def health(self, language: str = "en-GB") -> KnowledgeHealth: try: with self._client() as client: response = client.get("/health") @@ -52,7 +52,7 @@ class RAGcoreKnowledgeProvider: document_count=0, ) - def ask(self, question: str, correlation_id: str) -> GroundedAnswer: + def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer: try: with self._client() as client: response = client.post( @@ -63,6 +63,7 @@ class RAGcoreKnowledgeProvider: "collection": self._settings.ragcore_collection, "question": question, "correlation_id": correlation_id, + "language": language, }, ) response.raise_for_status() diff --git a/backend/tests/test_demo_manifest.py b/backend/tests/test_demo_manifest.py index b5877ac..5f822d8 100644 --- a/backend/tests/test_demo_manifest.py +++ b/backend/tests/test_demo_manifest.py @@ -44,12 +44,11 @@ def test_demo_manifest_scenarios_ready_after_fresh_reset(client): 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["blocked_reason_code"] 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"] + assert ragcore["status_code"] == "demoMode" diff --git a/backend/tests/test_knowledge.py b/backend/tests/test_knowledge.py index 19691ff..ef8ebe3 100644 --- a/backend/tests/test_knowledge.py +++ b/backend/tests/test_knowledge.py @@ -32,7 +32,52 @@ def test_demo_provider_health_reports_document_count(): health = provider.health() assert health.provider == "demo" assert health.available is True - assert health.document_count == 10 + assert health.document_count == 11 + + +def test_demo_provider_health_reports_document_count_per_language(): + provider = DemoKnowledgeProvider() + for language in ("nl-BE", "en-GB", "fr-BE"): + assert provider.health(language).document_count == 11 + + +def test_demo_provider_grounds_damage_question_in_dutch(): + provider = DemoKnowledgeProvider() + answer = provider.ask( + "Wat moet ik doen als een voertuig terugkomt met schade?", + "test-correlation-nl", + "nl-BE", + ) + assert answer.evidence_state == "grounded" + document_ids = {s.document_id for s in answer.sources} + assert "damage-procedure" in document_ids + + +def test_demo_provider_grounds_damage_question_in_french(): + provider = DemoKnowledgeProvider() + answer = provider.ask( + "Que dois-je faire quand un véhicule revient avec des dommages ?", + "test-correlation-fr", + "fr-BE", + ) + assert answer.evidence_state == "grounded" + document_ids = {s.document_id for s in answer.sources} + assert "damage-procedure" in document_ids + + +def test_demo_provider_insufficient_evidence_message_is_localized(): + provider = DemoKnowledgeProvider() + nl_answer = provider.ask( + "Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE" + ) + fr_answer = provider.ask( + "Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE" + ) + assert nl_answer.evidence_state == "insufficient" + assert fr_answer.evidence_state == "insufficient" + assert nl_answer.answer != fr_answer.answer + assert "France" not in nl_answer.answer + assert "France" not in fr_answer.answer def test_ask_question_endpoint_grounded(ops_client): diff --git a/frontend/e2e/clickable-rows.spec.ts b/frontend/e2e/clickable-rows.spec.ts new file mode 100644 index 0000000..735f36d --- /dev/null +++ b/frontend/e2e/clickable-rows.spec.ts @@ -0,0 +1,105 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +async function resetDemoData(request: APIRequestContext) { + await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + await request.post("/api/v1/demo/reset"); +} + +// Verifies the "stretched link" pattern used across the Attention Queue, Today's movements, +// Vehicles, Bookings and Data Quality tables: the whole row is one activation target, not +// just its title/reference text, while any secondary in-row link stays independently usable. +test.beforeEach(async ({ page, request }) => { + await resetDemoData(request); + await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB")); + await page.goto("/login"); + await page.getByRole("button", { name: "Explore as Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +}); + +test("attention queue row opens its record when clicking empty row space, not just the title", async ({ page }) => { + const row = page.locator(".attention-list li.row-clickable").first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + expect(box).not.toBeNull(); + // Click near the far right edge of the row -- empty space, not the title text or badge. + await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2); + await expect(page).toHaveURL(/\/(data-quality|vehicles)\//); +}); + +test("attention queue row is keyboard reachable and opens on Enter", async ({ page }) => { + const row = page.locator(".attention-list li.row-clickable").first(); + const link = row.locator(".row-link"); + await link.focus(); + await expect(link).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/(data-quality|vehicles)\//); +}); + +test("today's movements row opens the correct booking on click", async ({ page }) => { + const row = page.locator(".movement-timeline li.row-clickable").first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2); + await expect(page).toHaveURL(/\/bookings\/BK-/); +}); + +test("vehicles table row opens the vehicle detail from empty row space", async ({ page }) => { + await page.goto("/vehicles"); + const row = page.locator(".data-table tbody tr.row-clickable").first(); + await expect(row).toBeVisible(); + const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim(); + const box = await row.boundingBox(); + await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2); + await expect(page).toHaveURL(new RegExp(`/vehicles/${ref}$`)); +}); + +test("bookings table row opens the booking, and the secondary vehicle link stays independently clickable", async ({ page }) => { + await page.goto("/bookings"); + const row = page.locator(".data-table tbody tr.row-clickable").first(); + await expect(row).toBeVisible(); + const vehicleLink = row.locator(".cell-link"); + const vehicleRef = (await vehicleLink.innerText()).trim(); + + // Clicking the secondary vehicle-ref link navigates to the vehicle, not the booking -- + // it must not be swallowed by the row-spanning overlay link sitting behind it. + await vehicleLink.click(); + await expect(page).toHaveURL(new RegExp(`/vehicles/${vehicleRef}$`)); + + await page.goto("/bookings"); + const rowAgain = page.locator(".data-table tbody tr.row-clickable").first(); + const bookingRef = (await rowAgain.locator("th").first().innerText()).split("\n")[0].trim(); + const box = await rowAgain.boundingBox(); + await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2); + await expect(page).toHaveURL(new RegExp(`/bookings/${bookingRef}$`)); +}); + +test("data quality table row opens the issue detail from empty row space", async ({ page }) => { + await page.goto("/data-quality"); + const row = page.locator(".data-table tbody tr.row-clickable").first(); + await expect(row).toBeVisible(); + const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim(); + const box = await row.boundingBox(); + await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2); + await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`)); +}); + +test("attention queue row opens the correct record on a mobile viewport tap", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/dashboard"); + const row = page.locator(".attention-list li.row-clickable").first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + // A real touch context needs `hasTouch`, which this shared spec file doesn't opt into; + // a mouse click at the same mobile viewport size still exercises the same CSS layout + // and click-target logic, since the app has no touch-specific event handling. + await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2); + await expect(page).toHaveURL(/\/(data-quality|vehicles)\//); +}); + +test("data quality table row has a pointer cursor and a visible focus ring covering the whole row", async ({ page }) => { + await page.goto("/data-quality"); + const row = page.locator(".data-table tbody tr.row-clickable").first(); + await expect(row).toHaveCSS("cursor", "pointer"); + await row.locator(".row-link").focus(); + await expect(row.locator(".row-link")).toBeFocused(); +}); diff --git a/frontend/e2e/demo-accessibility.spec.ts b/frontend/e2e/demo-accessibility.spec.ts index 78237f8..af2d696 100644 --- a/frontend/e2e/demo-accessibility.spec.ts +++ b/frontend/e2e/demo-accessibility.spec.ts @@ -30,10 +30,10 @@ test("demo guide does not cover the return form's action buttons on desktop", as await expect(page).toHaveURL(/\/dashboard/); await page.goto("/bookings/BK-DEMO-RETURN"); - const reviewButton = page.getByRole("button", { name: "Review return" }); + const reviewButton = page.getByRole("button", { name: "Retour nakijken" }); await expect(reviewButton).toBeVisible(); await reviewButton.click({ timeout: 5000 }); - await expect(page.getByText(/Expected fleet state/)).toBeVisible(); + await expect(page.getByText(/Verwachte wagenparkstatus/)).toBeVisible(); }); test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => { @@ -79,7 +79,7 @@ test("key demo pages load without console errors", async ({ page }) => { await page.goto("/scenarios"); await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible(); await page.goto("/about"); - await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible(); await page.getByRole("button", { name: /Demo-gids/ }).click(); await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); diff --git a/frontend/e2e/demo-entry.spec.ts b/frontend/e2e/demo-entry.spec.ts index aedc9d1..4b4c7ae 100644 --- a/frontend/e2e/demo-entry.spec.ts +++ b/frontend/e2e/demo-entry.spec.ts @@ -37,9 +37,9 @@ test("permanent demo badge shows a popover with last reset info and a working Ab 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.getByRole("heading", { name: "Wat Fleet Ops 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("Demomodus").first()).toBeVisible(); await expect(page.getByText("Niet gekoppeld").first()).toBeVisible(); }); diff --git a/frontend/e2e/demo-guide.spec.ts b/frontend/e2e/demo-guide.spec.ts index e1074c3..2c1e8b2 100644 --- a/frontend/e2e/demo-guide.spec.ts +++ b/frontend/e2e/demo-guide.spec.ts @@ -36,7 +36,10 @@ test("starting a scenario navigates to its fixed record", async ({ page }) => { await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/); }); -test("demo guide: navigating steps, jumping to a step, and closing works", async ({ page }) => { +// The default Playwright viewport (1280x720) falls in the "standard desktop/tablet" tier +// (see useViewportTier.ts): the guide is a floating, non-modal panel that auto-collapses +// to a persistent progress chip the moment the visitor acts on "Ga naar deze stap". +test("demo guide: navigating steps, jumping to a step collapses to a chip, and the chip reopens it", async ({ page }) => { await page.goto("/login"); await page.getByRole("button", { name: "Start begeleide demo" }).click(); await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); @@ -50,9 +53,26 @@ test("demo guide: navigating steps, jumping to a step, and closing works", async await page.getByRole("button", { name: "Ga naar deze stap" }).click(); await expect(page).toHaveURL(/\/knowledge$/); + // Panel auto-collapses to a persistent chip -- it must never sit over the knowledge + // page's primary "Ask" action after navigation. + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden(); + const chip = page.getByRole("button", { name: /Demo-gids · stap 6 van 8/ }); + await expect(chip).toBeVisible(); + + await chip.click(); await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible(); +}); + +test("the collapsed chip has its own close control, independent of reopening it", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await page.getByRole("button", { name: "Ga naar deze stap" }).click(); + const chip = page.getByRole("button", { name: /Demo-gids · stap/ }); + await expect(chip).toBeVisible(); await page.getByRole("button", { name: "Sluiten" }).click(); + await expect(chip).toBeHidden(); await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden(); }); @@ -61,12 +81,12 @@ test("demo guide progress persists across navigation and the trigger shows it", await page.getByRole("button", { name: "Start begeleide demo" }).click(); await page.getByRole("button", { name: "Volgende" }).click(); await page.getByRole("button", { name: "Volgende" }).click(); - await page.getByRole("button", { name: "Sluiten" }).click(); + await page.getByRole("button", { name: "Demo-gids inklappen" }).click(); - await expect(page.getByRole("button", { name: /Demo-gids/ })).toContainText("2/8"); + await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("2/8"); await page.goto("/vehicles"); - await page.getByRole("button", { name: /Demo-gids/ }).click(); + await page.getByRole("button", { name: /^Demo-gids/ }).first().click(); await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible(); }); @@ -85,3 +105,59 @@ test("restarting the demo from the guide resets data and returns to login", asyn await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click(); await expect(page).toHaveURL(/\/login$/, { timeout: 10000 }); }); + +test("wide desktop viewport docks the guide as a rail that never collapses to a chip", async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 1000 }); + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + const panel = page.locator(".demo-guide-panel.is-wide"); + await expect(panel).toBeVisible(); + + await page.getByRole("button", { name: "Ga naar deze stap" }).click(); + await expect(panel).toBeVisible(); + await expect(page.locator(".demo-guide-chip")).toHaveCount(0); +}); + +test("mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + const panel = page.locator(".demo-guide-panel.is-mobile"); + await expect(panel).toBeVisible(); + await expect(panel).toHaveClass(/sheet-half/); + + const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); + const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); + expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); + + await page.locator(".demo-guide-sheet-handle").click(); + await expect(panel).toHaveClass(/sheet-full/); + await page.locator(".demo-guide-sheet-handle").click(); + await expect(panel).toHaveClass(/sheet-collapsed/); +}); + +test("Escape collapses the standard-tier panel, then closes it", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden(); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeHidden(); +}); + +test("going to a step scrolls, focuses and highlights the on-page target", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await page.getByRole("button", { name: /6\. Stel een vraag/ }).click(); + + await page.getByRole("button", { name: "Ga naar deze stap" }).click(); + await expect(page).toHaveURL(/\/knowledge$/); + + const target = page.locator("#ask-heading"); + await expect(target).toBeFocused(); + await expect(target).toHaveClass(/demo-guide-highlight/); +}); diff --git a/frontend/e2e/demo-legibility.spec.ts b/frontend/e2e/demo-legibility.spec.ts index fb9f16d..189c247 100644 --- a/frontend/e2e/demo-legibility.spec.ts +++ b/frontend/e2e/demo-legibility.spec.ts @@ -17,17 +17,17 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a await page.goto("/bookings/BK-DEMO-RETURN"); await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible(); - const odometerInput = page.getByLabel("End odometer (km)"); + const odometerInput = page.getByLabel("Eindkilometerstand (km)"); await expect(odometerInput).not.toHaveValue(""); const prefilled = Number(await odometerInput.inputValue()); expect(prefilled).toBeGreaterThan(0); - await page.getByRole("button", { name: "Review return" }).click(); - await expect(page.getByText(/below.*canonical reading/)).toBeVisible(); - await page.getByRole("button", { name: "Confirm return" }).click(); - await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); - await expect(page.getByRole("link", { name: "View automation status" })).toBeVisible(); - await expect(page.getByRole("link", { name: "View audit trail" })).toBeVisible(); + await page.getByRole("button", { name: "Retour nakijken" }).click(); + await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible(); + await page.getByRole("button", { name: "Retour bevestigen" }).click(); + await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Audit trail bekijken" })).toBeVisible(); }); test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => { @@ -36,9 +36,9 @@ test("data quality issue detail explains what's wrong and why it matters", async await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); - await expect(page.getByText("What's wrong")).toBeVisible(); - await expect(page.getByText("Why it matters")).toBeVisible(); - await expect(page.getByText(/likely the same person/)).toBeVisible(); + await expect(page.getByText("Wat is er mis")).toBeVisible(); + await expect(page.getByText("Waarom dit belangrijk is")).toBeVisible(); + await expect(page.getByText(/waarschijnlijk dezelfde persoon/)).toBeVisible(); }); test("data quality list can filter to demo scenarios only", async ({ page }) => { @@ -49,7 +49,7 @@ test("data quality list can filter to demo scenarios only", async ({ page }) => await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); const allRows = await page.locator(".data-table tbody tr").count(); - await page.getByRole("checkbox", { name: "Demo scenario's only" }).check(); + await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).check(); const filteredRows = await page.locator(".data-table tbody tr").count(); expect(filteredRows).toBeGreaterThan(0); expect(filteredRows).toBeLessThanOrEqual(allRows); @@ -68,10 +68,10 @@ test("knowledge page suggested question returns a grounded, honestly-labelled an // The status badge and retrieval-flow diagram must name the actual active provider // honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is // allowed to mention RAGcore by name when explaining it isn't live yet. - await expect(page.locator(".knowledge-status strong")).toHaveText("Demo knowledge base"); - await expect(page.locator(".retrieval-flow")).toContainText("Demo knowledge base"); + await expect(page.locator(".knowledge-status strong")).toHaveText("de demokennisbank"); + await expect(page.locator(".retrieval-flow")).toContainText("de demokennisbank"); await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore"); - await page.getByRole("button", { name: "Who reviews an unusual odometer reading?" }).click(); - await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); + await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click(); + await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible(); }); diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts index 5afb8f8..0d3fdf6 100644 --- a/frontend/e2e/demo.spec.ts +++ b/frontend/e2e/demo.spec.ts @@ -24,7 +24,7 @@ test("five-minute demo script end to end", async ({ page, request }) => { }); await test.step("2. verify dashboard metrics are loaded", async () => { - await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible(); const metricValues = page.locator(".metric-cell dd"); await expect(metricValues.first()).toBeVisible(); const values = await metricValues.allTextContents(); @@ -35,62 +35,61 @@ test("five-minute demo script end to end", async ({ page, request }) => { await test.step("3. open active demo booking", async () => { await page.goto("/bookings/BK-DEMO-RETURN"); await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible(); - await expect(page.getByText("active", { exact: true })).toBeVisible(); + await expect(page.getByText("actief", { exact: true })).toBeVisible(); }); await test.step("4. register an odometer-regression return (S1)", async () => { const vehicleOdometerText = await page - .locator(".detail-grid div", { hasText: "Start odometer" }) + .locator(".detail-grid div", { hasText: "Startkilometerstand" }) .locator("dd") .textContent(); const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10); const lowReading = Math.max(0, startOdometer - 500); - await page.getByLabel("End odometer (km)").fill(String(lowReading)); - await page.getByLabel("Fuel level (%)").fill("55"); - await page.getByRole("button", { name: "Review return" }).click(); - await expect(page.getByRole("heading", { name: "Review return impact" })).toBeVisible(); - await page.getByRole("button", { name: "Confirm return" }).click(); + await page.getByLabel("Eindkilometerstand (km)").fill(String(lowReading)); + await page.getByLabel("Brandstofniveau (%)").fill("55"); + await page.getByRole("button", { name: "Retour nakijken" }).click(); + await expect(page.getByRole("heading", { name: "Retourimpact nakijken" })).toBeVisible(); + await page.getByRole("button", { name: "Retour bevestigen" }).click(); - await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible(); }); await test.step("5. verify quality issue and queued automation event", async () => { - await expect(page.getByText(/DQ-RET-|None created/)).toBeVisible(); - await expect(page.getByText(/Queued for delivery \(/)).toBeVisible(); + await expect(page.getByText(/DQ-RET-|Geen aangemaakt/)).toBeVisible(); + await expect(page.getByText(/Klaargezet voor verwerking \(/)).toBeVisible(); }); await test.step("6. resolve the duplicate customer scenario (S2)", async () => { await page.goto("/data-quality/DQ-DEMO-DUPLICATE"); - await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible(); - await page.getByRole("button", { name: /Merge into CUS-0012/ }).click(); - await page.getByRole("button", { name: "Yes, merge" }).click(); - await expect(page.getByText("resolved", { exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Vergelijken en samenvoegen" })).toBeVisible(); + await page.getByRole("button", { name: /Samenvoegen met CUS-0012/ }).click(); + await page.getByRole("button", { name: "Ja, samenvoegen" }).click(); + await expect(page.locator(".badge.status-resolved")).toBeVisible(); }); await test.step("7. ask the damage question and inspect citations (S6)", async () => { await page.goto("/knowledge"); await page - .getByPlaceholder(/What must I do when a vehicle returns with damage/) - .fill("What must I do when a vehicle returns with damage?"); - await page.getByRole("button", { name: "Ask" }).click(); - await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); - await expect(page.getByText("Damage handling procedure").first()).toBeVisible(); - await expect(page.getByText("Vehicle return procedure").first()).toBeVisible(); + .getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/) + .fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?"); + await page.getByRole("button", { name: "Vraag stellen" }).click(); + await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible(); + await expect(page.getByText("Procedure schadeafhandeling").first()).toBeVisible(); }); await test.step("8. inspect audit entries", async () => { await page.goto("/audit"); - await page.getByLabel("Action").fill("return_registered"); - await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); - await expect(page.getByText("return registered").first()).toBeVisible(); + await page.getByLabel("Actie").fill("return_registered"); + await expect(page.locator(".audit-group-list li").first()).toBeVisible(); + await expect(page.getByText("Voertuigretour geregistreerd").first()).toBeVisible(); }); 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(/Synthetische demo/).first()).toBeVisible(); - await expect(page.getByRole("link", { name: "Overview" }).first()).toBeVisible(); + await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible(); const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); diff --git a/frontend/e2e/guided-demo-full.spec.ts b/frontend/e2e/guided-demo-full.spec.ts index 72e296d..546ccd8 100644 --- a/frontend/e2e/guided-demo-full.spec.ts +++ b/frontend/e2e/guided-demo-full.spec.ts @@ -14,6 +14,11 @@ test("full guided demo walkthrough, start to finish, restoring the environment a request, }) => { await resetDemoData(request); + // Wide desktop viewport: the guide docks as a rail and never auto-collapses to a chip + // (see useViewportTier.ts), so this walkthrough can keep interacting with the panel + // directly across every step -- the standard-tier auto-collapse behaviour itself is + // covered separately in demo-guide.spec.ts. + await page.setViewportSize({ width: 1600, height: 1000 }); await test.step("start the guided demo from the login screen", async () => { await page.goto("/login"); @@ -24,7 +29,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a await test.step("step 1: understand the operational state", async () => { await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible(); await page.getByRole("button", { name: "Volgende" }).click(); }); @@ -38,10 +43,10 @@ test("full guided demo walkthrough, start to finish, restoring the environment a await test.step("step 3: process the return with the pre-filled odometer anomaly", async () => { await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible(); await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible(); - await page.getByRole("button", { name: "Review return" }).click(); - await expect(page.getByText(/below.*canonical reading/)).toBeVisible(); - await page.getByRole("button", { name: "Confirm return" }).click(); - await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); + await page.getByRole("button", { name: "Retour nakijken" }).click(); + await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible(); + await page.getByRole("button", { name: "Retour bevestigen" }).click(); + await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible(); await page.getByRole("button", { name: "Ga verder met de demo" }).click(); }); @@ -50,28 +55,28 @@ test("full guided demo walkthrough, start to finish, restoring the environment a await expect(page.getByRole("heading", { name: "4. Bekijk en behandel" })).toBeVisible(); const firstIssueLink = page.locator(".data-table tbody tr").first().locator("a"); await firstIssueLink.click(); - await expect(page.getByText("What's wrong")).toBeVisible(); + await expect(page.getByText("Wat is er mis")).toBeVisible(); // The newest issue is the odometer regression this return just created. - await page.getByRole("radio", { name: /Retain canonical/ }).check(); - await page.getByRole("button", { name: "Resolve issue" }).click(); - await expect(page.getByText(/Issue .* resolved/)).toBeVisible(); + await page.getByRole("radio", { name: /Laatst bevestigde stand behouden/ }).check(); + await page.getByRole("button", { name: "Probleem oplossen" }).click(); + await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible(); await page.getByRole("button", { name: "Ga verder met de demo" }).click(); }); await test.step("step 5: review and merge the possible duplicate customer", async () => { await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/); await expect(page.getByRole("heading", { name: "5. Beoordeel en behandel" })).toBeVisible(); - await page.getByRole("button", { name: /^Merge into/ }).click(); - await page.getByRole("button", { name: "Yes, merge" }).click(); - await expect(page.getByText(/Issue .* resolved/)).toBeVisible(); + await page.getByRole("button", { name: /^Samenvoegen met/ }).click(); + await page.getByRole("button", { name: "Ja, samenvoegen" }).click(); + await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible(); await page.getByRole("button", { name: "Ga verder met de demo" }).click(); }); await test.step("step 6: ask the procedure assistant a question", async () => { await expect(page).toHaveURL(/\/knowledge$/); await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible(); - await page.getByRole("button", { name: "What must I do when a vehicle returns with damage?" }).click(); - await expect(page.getByText("Grounded in cited procedures")).toBeVisible(); + await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click(); + await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible(); await page.getByRole("button", { name: "Volgende" }).click(); }); @@ -83,7 +88,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a await expect(page.locator(".integration-cards")).not.toContainText("degraded"); await expect(page.locator(".integration-cards")).not.toContainText("no_evidence"); await page.goto("/audit"); - await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); + await expect(page.locator(".audit-group-list li").first()).toBeVisible(); await page.getByRole("button", { name: /Demo-gids/ }).click(); await page.getByRole("button", { name: "Volgende" }).click(); }); @@ -92,7 +97,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a await expect(page.getByRole("heading", { name: "8. Bekijk wat echt is" })).toBeVisible(); await page.getByRole("button", { name: "Ga naar deze stap" }).click(); await expect(page).toHaveURL(/\/about$/); - await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible(); await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible(); await expect(page.getByText("Niet gekoppeld").first()).toBeVisible(); }); diff --git a/frontend/e2e/i18n-coverage.spec.ts b/frontend/e2e/i18n-coverage.spec.ts new file mode 100644 index 0000000..f5d3079 --- /dev/null +++ b/frontend/e2e/i18n-coverage.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Pure Node-context checks (no browser needed): every locale must define exactly the +// same set of translation keys. A missing key would otherwise silently fall back to +// showing the raw key string in production -- this test makes that impossible to ship. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales"); +const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"]; + +function collectKeyPaths(value: unknown, prefix = ""): string[] { + if (value === null || typeof value !== "object") { + return [prefix]; + } + return Object.entries(value as Record).flatMap(([key, nested]) => + collectKeyPaths(nested, prefix ? `${prefix}.${key}` : key), + ); +} + +function loadNamespace(language: string, namespace: string): Record { + const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`); + return JSON.parse(fs.readFileSync(filePath, "utf-8")); +} + +const namespaces = fs + .readdirSync(path.join(LOCALES_DIR, "nl-BE")) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")); + +test("every locale defines the same translation keys as nl-BE, for every namespace", () => { + expect(namespaces.length).toBeGreaterThan(0); + + for (const namespace of namespaces) { + const referenceKeys = collectKeyPaths(loadNamespace("nl-BE", namespace)).sort(); + + for (const language of LANGUAGES) { + if (language === "nl-BE") continue; + const keys = collectKeyPaths(loadNamespace(language, namespace)).sort(); + const missing = referenceKeys.filter((k) => !keys.includes(k)); + const extra = keys.filter((k) => !referenceKeys.includes(k)); + + expect( + missing, + `${language}/${namespace}.json is missing keys present in nl-BE: ${missing.join(", ")}`, + ).toEqual([]); + expect( + extra, + `${language}/${namespace}.json has extra keys not present in nl-BE: ${extra.join(", ")}`, + ).toEqual([]); + } + } +}); + +test("no locale file contains an empty string value", () => { + for (const language of LANGUAGES) { + for (const namespace of namespaces) { + const data = loadNamespace(language, namespace); + const keys = collectKeyPaths(data); + for (const keyPath of keys) { + const value = keyPath.split(".").reduce((acc, part) => { + if (acc && typeof acc === "object") return (acc as Record)[part]; + return undefined; + }, data); + if (typeof value === "string") { + expect(value.trim().length, `${language}/${namespace}.json:${keyPath} is empty`).toBeGreaterThan(0); + } + } + } + } +}); diff --git a/frontend/e2e/interactive-elements.spec.ts b/frontend/e2e/interactive-elements.spec.ts index 05b0f8b..a00160c 100644 --- a/frontend/e2e/interactive-elements.spec.ts +++ b/frontend/e2e/interactive-elements.spec.ts @@ -7,9 +7,13 @@ async function resetDemoData(request: APIRequestContext) { test.describe.configure({ mode: "serial" }); +// This file's assertions were authored against the English UI copy; nl-BE is now the +// app's default for a fresh session, so force English explicitly rather than rewriting +// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs). test.beforeEach(async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB")); await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Explore as Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); @@ -52,7 +56,7 @@ test("vehicles page: free-text search actually filters the rendered rows", async const totalRows = await page.locator(".data-table tbody tr").count(); expect(totalRows).toBeGreaterThan(1); - const searchBox = page.getByRole("form", { name: "Filter vehicles" }).getByLabel("Search"); + const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search"); await searchBox.fill("MO-001"); await expect(async () => { const rows = await page.locator(".data-table tbody tr").count(); @@ -140,7 +144,7 @@ test("data quality page: status and rule-type filters work", async ({ page }) => ); await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents(); - expect(rules.every((r) => r.includes("possible duplicate customer"))).toBeTruthy(); + expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy(); await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption(""); await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved"); @@ -159,7 +163,7 @@ test("data quality issue detail: defer and reject buttons work", async ({ page, await firstLink.click(); await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible(); await page.getByRole("button", { name: "Defer" }).click(); - await expect(page.getByText("deferred", { exact: true })).toBeVisible(); + await expect(page.locator(".badge.status-deferred")).toBeVisible(); }); test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => { @@ -173,7 +177,7 @@ test("data quality: providing missing fields resolves a vehicle issue", async ({ await page.getByLabel("Location").fill("Depot"); await page.getByRole("button", { name: "Save and re-check" }).click(); - await expect(page.getByText("resolved", { exact: true })).toBeVisible(); + await expect(page.getByText("Resolved").first()).toBeVisible(); }); test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => { @@ -184,7 +188,7 @@ test("data quality: resolving a booking overlap blocks one booking", async ({ pa await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check(); await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click(); - await expect(page.getByText("resolved", { exact: true })).toBeVisible(); + await expect(page.getByText("Resolved").first()).toBeVisible(); const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A"); expect((await booking.json()).status).toBe("blocked"); }); @@ -220,7 +224,7 @@ test("data quality: retaining canonical resolves an odometer regression issue", await page.getByRole("radio", { name: /Retain canonical/ }).check(); await page.getByRole("button", { name: "Resolve issue" }).click(); - await expect(page.getByText("resolved", { exact: true })).toBeVisible(); + await expect(page.getByText("Resolved").first()).toBeVisible(); }); test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => { @@ -255,11 +259,11 @@ test("automation page: status filter and retry button work", async ({ page, requ test("audit page: action filter works", async ({ page }) => { await page.goto("/audit"); - await expect(page.locator(".data-table")).toBeVisible(); + await expect(page.locator(".audit-group-list")).toBeVisible(); await page.getByLabel("Action").fill("demo_login"); - await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); - const actions = await page.locator(".data-table tbody tr td:nth-child(3)").allTextContents(); - expect(actions.every((a) => a.includes("demo login"))).toBeTruthy(); + await expect(page.locator(".audit-group").first()).toBeVisible(); + const headings = await page.locator(".audit-group-heading strong").allTextContents(); + expect(headings.every((a) => a === "Logged in")).toBeTruthy(); }); test("audit page: shows human-readable before/after and a safe entity link", async ({ @@ -284,14 +288,14 @@ test("audit page: shows human-readable before/after and a safe entity link", asy await page.goto("/audit"); await page.getByLabel("Action").fill("return_registered"); - await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); + const firstGroup = page.locator(".audit-group").first(); + await expect(firstGroup).toBeVisible(); - const changeCell = page.locator(".data-table tbody tr").first().locator("td").nth(4); - await expect(changeCell).toContainText("status"); - await expect(changeCell).toContainText("returned"); + const changeDiff = firstGroup.locator(".change-diff"); + await expect(changeDiff).toContainText(/status/i); + await expect(changeDiff).toContainText("returned"); - const entityCell = page.locator(".data-table tbody tr").first().locator("td").nth(3); - await expect(entityCell.locator("a")).toHaveAttribute("href", /\/bookings\/BK-/); + await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/); }); test("knowledge page: form submits and clears input", async ({ page }) => { @@ -340,7 +344,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: "Verken als Rental Employee" }).click(); + await page.getByRole("button", { name: "Explore as Rental Employee" }).click(); await expect(page).toHaveURL(/\/dashboard$/); // Manager-only nav items are not shown at all, not merely disabled. @@ -352,16 +356,16 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa // message as a defense-in-depth measure, not just a hidden button. await page.goto("/automation"); await expect( - page.getByText("Automation delivery status is visible to Operations Managers only."), + page.getByText("Automation is visible to Operations Managers only.").first(), ).toBeVisible(); await page.goto("/data-quality"); await expect( - page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only."), + page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(), ).toBeVisible(); await page.goto("/audit"); - await expect(page.getByText("Audit history is visible to Operations Managers only.")).toBeVisible(); + await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible(); await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0); }); @@ -374,7 +378,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: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Explore as Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); @@ -392,7 +396,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: "Verken als Rental Employee" }).click(); + await page.getByRole("button", { name: "Explore as Rental Employee" }).click(); await expect(page).toHaveURL(/\/dashboard$/); // page.request shares the browser context's cookies, and (via the web container's diff --git a/frontend/e2e/ui-redesign.spec.ts b/frontend/e2e/ui-redesign.spec.ts index 6d3fe9c..3f4934d 100644 --- a/frontend/e2e/ui-redesign.spec.ts +++ b/frontend/e2e/ui-redesign.spec.ts @@ -5,10 +5,14 @@ async function resetDemoData(request: APIRequestContext) { await request.post("/api/v1/demo/reset"); } +// This file's assertions were authored against the English UI copy; nl-BE is now the +// app's default for a fresh session, so force English explicitly rather than rewriting +// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs). test.beforeEach(async ({ page, request }) => { await resetDemoData(request); + await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB")); await page.goto("/login"); - await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.getByRole("button", { name: "Explore as Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); @@ -22,7 +26,7 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => { await page.keyboard.press("Control+k"); - const search = page.getByRole("combobox", { name: "Search MobilityOps" }); + const search = page.getByRole("combobox", { name: "Search Fleet Ops" }); await expect(search).toBeFocused(); await search.fill("MO-024"); const result = page.getByRole("option", { name: /MO-024/ }); @@ -33,7 +37,7 @@ test("global search supports its keyboard shortcut and finds a vehicle by refere }); test("global search supports arrow-key navigation and Enter to select", async ({ page }) => { - const search = page.getByRole("combobox", { name: "Search MobilityOps" }); + const search = page.getByRole("combobox", { name: "Search Fleet Ops" }); await search.fill("fleet"); await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible(); await search.press("ArrowDown"); @@ -42,7 +46,7 @@ test("global search supports arrow-key navigation and Enter to select", async ({ }); test("global search shows a no-results state and closes on Escape", async ({ page }) => { - const search = page.getByRole("combobox", { name: "Search MobilityOps" }); + const search = page.getByRole("combobox", { name: "Search Fleet Ops" }); await search.fill("zzz-nothing-matches-zzz"); await expect(page.getByText(/No matches for/)).toBeVisible(); await search.press("Escape"); @@ -50,7 +54,7 @@ test("global search shows a no-results state and closes on Escape", async ({ pag }); test("global search finds a booking and a data-quality issue by reference", async ({ page }) => { - const search = page.getByRole("combobox", { name: "Search MobilityOps" }); + const search = page.getByRole("combobox", { name: "Search Fleet Ops" }); await search.fill("BK-DEMO-RETURN"); const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ }); await expect(bookingResult).toBeVisible(); @@ -83,7 +87,7 @@ test("return review separates capture from irreversible commit", async ({ page } // The review step is server-evaluated (not client-guessed), so exactly one non-mutating // preview call is expected before any commit. expect(previewRequests).toBe(1); - await expect(page.getByText("Queue n8n delivery after the local commit")).toBeVisible(); + await expect(page.getByText("Queue automation after the local commit")).toBeVisible(); await page.getByRole("button", { name: "Edit details" }).click(); await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000"); diff --git a/frontend/index.html b/frontend/index.html index 0456058..307c14e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,10 @@ - + - - MobilityOps + + Fleet Ops
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 34ac772..f24777a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "mobilityops-web", "version": "0.0.1", "dependencies": { + "i18next": "^26.3.6", "react": "18.3.1", "react-dom": "18.3.1", + "react-i18next": "^17.0.11", "react-router-dom": "7.18.2" }, "devDependencies": { @@ -255,6 +257,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1463,6 +1474,43 @@ "node": ">=6.9.0" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1661,6 +1709,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -1794,7 +1869,7 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1835,6 +1910,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index e3b346b..d388ef0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,10 @@ "test:e2e": "playwright test" }, "dependencies": { + "i18next": "^26.3.6", "react": "18.3.1", "react-dom": "18.3.1", + "react-i18next": "^17.0.11", "react-router-dom": "7.18.2" }, "devDependencies": { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 495150c..df0bc30 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -88,7 +88,7 @@ export interface DashboardMetrics { export interface AttentionItem { kind: string; severity: "low" | "medium" | "high"; - title: string; + rule_type: string; detail: string; link_type: "vehicle" | "booking" | "customer"; link_ref: string; @@ -254,27 +254,24 @@ export interface IntegrationStatus { 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; + blocked_reason_code: string | null; + blocked_reason_params: Record; } export interface DemoIntegrationSummary { key: "n8n" | "ragcore" | "mcp_hub"; - label: string; - status_label: string; - detail: string; + status_code: string; + detail_code: string; + detail_params: Record; } export interface DemoManifest { demo_mode: boolean; organization_name: string; - organization_description: string; timezone: string; synthetic_data: boolean; allow_reset: boolean; diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx index b0e1eea..d169b99 100644 --- a/frontend/src/components/Badge.tsx +++ b/frontend/src/components/Badge.tsx @@ -1,6 +1,8 @@ +import { useTranslation } from "react-i18next"; + export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) { - const label = severity === "high" ? "High" : severity === "medium" ? "Medium" : "Low"; - return {label} severity; + const { t } = useTranslation("quality"); + return {t(`severities.${severity}`)}; } export function StatusBadge({ status, label }: { status: string; label?: string }) { diff --git a/frontend/src/components/DemoBadge.tsx b/frontend/src/components/DemoBadge.tsx index 5813234..ef47092 100644 --- a/frontend/src/components/DemoBadge.tsx +++ b/frontend/src/components/DemoBadge.tsx @@ -1,18 +1,13 @@ import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; +import { Trans, useTranslation } from "react-i18next"; import { useDemoManifest } from "../context/DemoManifestContext"; +import { useLocaleFormat } from "../i18n/format"; 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 { t } = useTranslation("demo"); + const { formatDateTime } = useLocaleFormat(); const { manifest } = useDemoManifest(); const [open, setOpen] = useState(false); const boxRef = useRef(null); @@ -44,35 +39,33 @@ export function DemoBadge() { onClick={() => setOpen((v) => !v)} > - Synthetische demo + {t("badge.trigger")} {open && ( -
-

{manifest ? ( - <> - {manifest.organization_name} is een fictieve organisatie. Alle - namen, voertuigen en boekingen zijn synthetisch. - + }} /> ) : ( - "Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch." + t("badge.orgIntroFallback") )}

-

- De workflows, controles en automatisering zijn echt geïmplementeerd — enkel de - gegevens zijn verzonnen. -

+

{t("badge.realWorkflows")}

{manifest && (

- Laatste reset: {formatDateTime(manifest.last_reset_at)} · deze - omgeving is op elk moment herstelbaar. + }} + />

)} setOpen(false)}> - Over deze demo + {t("badge.aboutLink")}
)} diff --git a/frontend/src/components/DemoGuide.tsx b/frontend/src/components/DemoGuide.tsx index 650d87d..55a7caf 100644 --- a/frontend/src/components/DemoGuide.tsx +++ b/frontend/src/components/DemoGuide.tsx @@ -1,13 +1,16 @@ -import { useNavigate } from "react-router-dom"; -import { useState } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { api, ApiError } from "../api/client"; import { useAuth } from "../context/AuthContext"; import { useDemoGuide } from "../context/DemoGuideContext"; import { useDemoManifest } from "../context/DemoManifestContext"; +import { useViewportTier } from "../hooks/useViewportTier"; import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; import { Icon } from "./Icons"; export function DemoGuideTrigger() { + const { t } = useTranslation("demo"); const { user } = useAuth(); const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide(); @@ -22,17 +25,35 @@ export function DemoGuideTrigger() { onClick={toggleGuide} > - Demo-gids + {t("guide.trigger")} {completed.size}/{totalSteps} - , huidige stap {currentIndex + 1} + , {t("guide.kicker", { current: currentIndex + 1, total: totalSteps })} ); } +function highlightTarget(selector: string | undefined) { + if (!selector) return; + const el = document.querySelector(selector); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + const previousTabIndex = el.getAttribute("tabindex"); + if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1"); + el.focus({ preventScroll: true }); + el.classList.add("demo-guide-highlight"); + window.setTimeout(() => { + el.classList.remove("demo-guide-highlight"); + if (previousTabIndex === null) el.removeAttribute("tabindex"); + }, 2200); +} + export function DemoGuide() { + const { t } = useTranslation("demo"); const navigate = useNavigate(); + const location = useLocation(); const { logout } = useAuth(); const { manifest, refresh } = useDemoManifest(); + const tier = useViewportTier(); const { open, closeGuide, @@ -42,17 +63,48 @@ export function DemoGuide() { goToStep, completeAndAdvance, restart, + collapsedToChip, + setCollapsedToChip, } = useDemoGuide(); const [resetting, setResetting] = useState(false); const [resetError, setResetError] = useState(null); - - if (!open) return null; + const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half"); + const pendingTarget = useRef(null); const step = DEMO_GUIDE_STEPS[currentIndex]; const isLastStep = currentIndex === totalSteps - 1; + useEffect(() => { + if (!open) return; + function handleKeydown(event: KeyboardEvent) { + if (event.key !== "Escape") return; + if (tier === "standard" && !collapsedToChip) { + setCollapsedToChip(true); + } else if (tier === "mobile" && mobileSheetState !== "collapsed") { + setMobileSheetState("collapsed"); + } else { + closeGuide(); + } + } + document.addEventListener("keydown", handleKeydown); + return () => document.removeEventListener("keydown", handleKeydown); + }, [open, tier, collapsedToChip, mobileSheetState, closeGuide]); + + useEffect(() => { + if (!pendingTarget.current) return; + const target = pendingTarget.current; + pendingTarget.current = null; + const raf = requestAnimationFrame(() => highlightTarget(target)); + return () => cancelAnimationFrame(raf); + }, [location.pathname]); + + if (!open) return null; + function goToStepRoute() { + pendingTarget.current = step.target ?? null; navigate(step.route(manifest)); + if (tier === "standard") setCollapsedToChip(true); + if (tier === "mobile") setMobileSheetState("collapsed"); } async function handleRestartDemo() { @@ -66,69 +118,131 @@ export function DemoGuide() { await logout(); navigate("/login"); } catch (err) { - setResetError(err instanceof ApiError ? err.message : "De demo kon niet hersteld worden."); + setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed")); } finally { setResetting(false); } } - return ( -