From 8b79c1379d143cd17696e2408bc87ca8bdca1e1a Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 30 Sep 2026 18:43:21 -0400 Subject: [PATCH] feat(tours): add permission-aware guided tours with replay and device opt-out Pages declare brief tours in code, keyed to stable data-tour anchors. The layout resolves each visitor's access signals (intelligence and logistics qualifications, admin-panel access, feature flags) and step filtering hides what a user cannot reach, mirroring the sidebar. A tour auto-starts the first time a browser visits its page; dismissals are remembered per browser in localStorage. The header gains a replay control for on-demand runs on toured routes, the tour card gains Skip all tours on this device to opt a browser out of auto-starts, and the Account page holds the re-enable toggle. The card and spotlight get a distinct overlay treatment (glow, accent strip, outlined target). --- src/app/(frontend)/account/page.tsx | 13 + src/app/(frontend)/awards/page.tsx | 4 +- src/app/(frontend)/events/page.tsx | 2 +- .../intelligence/missions/calendar/page.tsx | 2 +- src/app/(frontend)/layout.tsx | 12 + src/app/(frontend)/locker/page.tsx | 2 +- src/app/(frontend)/logistics/banking/page.tsx | 2 +- src/app/(frontend)/logistics/market/page.tsx | 2 +- src/app/(frontend)/logistics/page.tsx | 2 +- .../(frontend)/logistics/shipments/page.tsx | 2 +- src/app/(frontend)/map/page.tsx | 4 +- src/app/(frontend)/operations/ledger/page.tsx | 6 +- src/app/(frontend)/operations/page.tsx | 2 +- src/app/(frontend)/page.tsx | 29 ++- src/app/(frontend)/roster/page.tsx | 2 +- src/app/(frontend)/styles.css | 32 ++- src/components/frontend/SiteHeader.tsx | 4 +- .../frontend/account/ToursPreference.tsx | 43 ++++ .../frontend/banking/BankingOverview.tsx | 2 +- .../frontend/logistics/ShipmentsList.tsx | 2 +- .../frontend/operations/PageShell.tsx | 5 +- src/components/frontend/tours/GuidedTour.tsx | 195 ++++++++++++++ src/components/frontend/tours/TourHost.tsx | 88 +++++++ .../frontend/tours/TourReplayButton.tsx | 33 +++ src/components/frontend/wiki/WikiIndex.tsx | 2 +- src/lib/tours/dismissals.ts | 89 +++++++ src/lib/tours/registry.ts | 239 ++++++++++++++++++ src/lib/tours/types.ts | 54 ++++ tests/e2e/tours.e2e.spec.ts | 95 +++++++ tests/int/tours.int.spec.ts | 192 ++++++++++++++ 30 files changed, 1127 insertions(+), 34 deletions(-) create mode 100644 src/components/frontend/account/ToursPreference.tsx create mode 100644 src/components/frontend/tours/GuidedTour.tsx create mode 100644 src/components/frontend/tours/TourHost.tsx create mode 100644 src/components/frontend/tours/TourReplayButton.tsx create mode 100644 src/lib/tours/dismissals.ts create mode 100644 src/lib/tours/registry.ts create mode 100644 src/lib/tours/types.ts create mode 100644 tests/e2e/tours.e2e.spec.ts create mode 100644 tests/int/tours.int.spec.ts diff --git a/src/app/(frontend)/account/page.tsx b/src/app/(frontend)/account/page.tsx index c53f9e3..017e101 100644 --- a/src/app/(frontend)/account/page.tsx +++ b/src/app/(frontend)/account/page.tsx @@ -12,6 +12,7 @@ import type { AccountAssignment } from "@/components/frontend/transfers/transfer import { ChangePasswordForm } from "@/components/frontend/account/ChangePasswordForm"; import { DiscordUsernameRequestForm } from "@/components/frontend/account/DiscordUsernameRequestForm"; import { PreferencesForm } from "@/components/frontend/account/PreferencesForm"; +import { ToursPreference } from "@/components/frontend/account/ToursPreference"; import { RequestTransferDialog } from "@/components/frontend/account/RequestTransferDialog"; export const metadata = { @@ -188,6 +189,18 @@ export default async function AccountPage() { /> + + + + Guided Tours + + Choose whether pages greet you with a short walkthrough. + + + + + + ); diff --git a/src/app/(frontend)/awards/page.tsx b/src/app/(frontend)/awards/page.tsx index 3c76f85..48c6ec3 100644 --- a/src/app/(frontend)/awards/page.tsx +++ b/src/app/(frontend)/awards/page.tsx @@ -132,7 +132,7 @@ export default async function AwardsPage() { return (
-
+

Awards

@@ -143,7 +143,7 @@ export default async function AwardsPage() { on the fly and are not catalogued here.

-
+
diff --git a/src/app/(frontend)/events/page.tsx b/src/app/(frontend)/events/page.tsx index d918533..3345fa4 100644 --- a/src/app/(frontend)/events/page.tsx +++ b/src/app/(frontend)/events/page.tsx @@ -9,7 +9,7 @@ export const metadata: Metadata = { export default function EventLogPage() { return (
-
+

Event Log

The full game event ledger: system actions and GM narrative events. Filter by type, diff --git a/src/app/(frontend)/intelligence/missions/calendar/page.tsx b/src/app/(frontend)/intelligence/missions/calendar/page.tsx index 15008ad..d977abc 100644 --- a/src/app/(frontend)/intelligence/missions/calendar/page.tsx +++ b/src/app/(frontend)/intelligence/missions/calendar/page.tsx @@ -161,7 +161,7 @@ export default async function FridayCalendarPage() { return (

-
+
)} + {user && ( + + )} {children} -
+

Personal Locker

diff --git a/src/app/(frontend)/logistics/banking/page.tsx b/src/app/(frontend)/logistics/banking/page.tsx index bfd2eb1..ae2d978 100644 --- a/src/app/(frontend)/logistics/banking/page.tsx +++ b/src/app/(frontend)/logistics/banking/page.tsx @@ -70,7 +70,7 @@ export default async function BankingPage() { return (
-
+

Banking

diff --git a/src/app/(frontend)/logistics/market/page.tsx b/src/app/(frontend)/logistics/market/page.tsx index ac2bd3f..0610ef8 100644 --- a/src/app/(frontend)/logistics/market/page.tsx +++ b/src/app/(frontend)/logistics/market/page.tsx @@ -82,7 +82,7 @@ export default async function MarketPage() { return (
-
+

Market

diff --git a/src/app/(frontend)/logistics/page.tsx b/src/app/(frontend)/logistics/page.tsx index eeab73f..5d0a9dd 100644 --- a/src/app/(frontend)/logistics/page.tsx +++ b/src/app/(frontend)/logistics/page.tsx @@ -31,7 +31,7 @@ export default async function LogisticsPage() { return (
-
+

Structures

diff --git a/src/app/(frontend)/logistics/shipments/page.tsx b/src/app/(frontend)/logistics/shipments/page.tsx index 063a1f6..eda4966 100644 --- a/src/app/(frontend)/logistics/shipments/page.tsx +++ b/src/app/(frontend)/logistics/shipments/page.tsx @@ -22,7 +22,7 @@ export default async function ShipmentsPage() { return (
-
+

Shipments

diff --git a/src/app/(frontend)/map/page.tsx b/src/app/(frontend)/map/page.tsx index 18387a0..e95cfc5 100644 --- a/src/app/(frontend)/map/page.tsx +++ b/src/app/(frontend)/map/page.tsx @@ -54,7 +54,7 @@ export default async function MapPickerPage() { return (
-
+

World Map

@@ -76,7 +76,7 @@ export default async function MapPickerPage() {

) : ( -
+
{maps.map((map) => { const campaign = map.campaign && typeof map.campaign === "object" ? (map.campaign as Campaign) : null; diff --git a/src/app/(frontend)/operations/ledger/page.tsx b/src/app/(frontend)/operations/ledger/page.tsx index a5dc184..81ee653 100644 --- a/src/app/(frontend)/operations/ledger/page.tsx +++ b/src/app/(frontend)/operations/ledger/page.tsx @@ -52,7 +52,11 @@ export default async function OperationsLedgerPage({ return (
- +

Every validated, rejected, and dead-lettered operation event with its derived effects. Rejected and dead-lettered entries are retained as evidence and never applied. diff --git a/src/app/(frontend)/operations/page.tsx b/src/app/(frontend)/operations/page.tsx index beb39cd..2a54ac9 100644 --- a/src/app/(frontend)/operations/page.tsx +++ b/src/app/(frontend)/operations/page.tsx @@ -32,7 +32,7 @@ export default async function OperationsPage() { return (

- +

Live and historical operation activity: extraction returns, settlements, readiness aggregates, and zone pressure. Every effect here is provenance-linked to a validated diff --git a/src/app/(frontend)/page.tsx b/src/app/(frontend)/page.tsx index fa0c582..7715d61 100644 --- a/src/app/(frontend)/page.tsx +++ b/src/app/(frontend)/page.tsx @@ -138,7 +138,7 @@ export default async function HomePage() { : null, coverImage: briefingMission.coverImage, } - : null; + : null; const upcomingMissions = upcomingRes.docs.map((m) => ({ id: m.id, @@ -331,17 +331,21 @@ export default async function HomePage() { widgetNodes.set("recent-events", ); widgetNodes.set( "quick-stats", - , +

+ +
, ); widgetNodes.set( "wallet", - , +
+ +
, ); if (negotiations.length > 0) { widgetNodes.set( @@ -367,10 +371,7 @@ export default async function HomePage() { ); } if (hasIntelligence) { - widgetNodes.set( - "upcoming-missions", - , - ); + widgetNodes.set("upcoming-missions", ); widgetNodes.set("active-campaigns", ); } @@ -390,7 +391,7 @@ export default async function HomePage() { return (
-
+

Welcome back, {rankData.name}. {/*{profileUser?.displayName ?? user.username}*/}

diff --git a/src/app/(frontend)/roster/page.tsx b/src/app/(frontend)/roster/page.tsx index 90ee706..832ac6a 100644 --- a/src/app/(frontend)/roster/page.tsx +++ b/src/app/(frontend)/roster/page.tsx @@ -134,7 +134,7 @@ export default async function RosterPage() { return (
-
+

Unit Roster

diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 5862b92..718ee72 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -564,6 +564,37 @@ background: color-mix(in oklch, var(--color-destructive) 8%, transparent); } +/* Guided-tour surfaces: the card must read as an overlay, not page content. */ +.tour-card { + border-color: color-mix(in oklch, var(--color-primary) 55%, var(--color-border)); + box-shadow: + 0 12px 40px rgba(0, 0, 0, 0.85), + 0 0 0 1px color-mix(in oklch, var(--color-primary) 30%, transparent), + 0 0 36px -10px color-mix(in oklch, var(--color-primary) 60%, transparent); + background: color-mix(in oklch, var(--color-popover) 94%, transparent); + backdrop-filter: blur(10px); +} + +.tour-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + border-radius: 0.5rem 0.5rem 0 0; + background: linear-gradient( + 90deg, + transparent, + color-mix(in oklch, var(--color-primary) 80%, transparent), + transparent + ); +} + +.tour-spotlight { + outline: 1px solid color-mix(in oklch, var(--color-primary) 45%, transparent); +} + /* Lexical rich-text typography (scoped to .lexical-content); Tailwind preflight resets headings */ .lexical-content { font-size: 0.875rem; @@ -3590,4 +3621,3 @@ .book-view-body [style*="text-align"] { text-align: left !important; } - diff --git a/src/components/frontend/SiteHeader.tsx b/src/components/frontend/SiteHeader.tsx index 469552a..3ea7322 100644 --- a/src/components/frontend/SiteHeader.tsx +++ b/src/components/frontend/SiteHeader.tsx @@ -1,6 +1,7 @@ import { SidebarTrigger } from "@/components/ui/sidebar"; import { Separator } from "@/components/ui/separator"; import { NotificationsBell } from "@/components/frontend/notifications/NotificationsBell"; +import { TourReplayButton } from "@/components/frontend/tours/TourReplayButton"; import { Command } from "lucide-react"; export const SiteHeader = () => { @@ -12,7 +13,8 @@ export const SiteHeader = () => { K
-
+
+
diff --git a/src/components/frontend/account/ToursPreference.tsx b/src/components/frontend/account/ToursPreference.tsx new file mode 100644 index 0000000..d3efdb1 --- /dev/null +++ b/src/components/frontend/account/ToursPreference.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { isToursOptedOut, setToursOptedOut } from "@/lib/tours/dismissals"; + +/** + * Per-browser guided-tours preference. Lives in localStorage (same store the + * tour dismissals use), not the user record: tours are a device concern, and + * the header replay control stays available either way. + */ +export function ToursPreference() { + const [optedOut, setOptedOut] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setOptedOut(isToursOptedOut()); + setMounted(true); + }, []); + + return ( +
+
+ +

+ Short guided walkthroughs the first time you visit a page. You can always replay the + current page's tour from the header help icon. +

+
+ { + setOptedOut(!checked); + setToursOptedOut(!checked); + }} + /> +
+ ); +} diff --git a/src/components/frontend/banking/BankingOverview.tsx b/src/components/frontend/banking/BankingOverview.tsx index 0b6f689..5e5ad34 100644 --- a/src/components/frontend/banking/BankingOverview.tsx +++ b/src/components/frontend/banking/BankingOverview.tsx @@ -99,7 +99,7 @@ export function BankingOverview({ label.length > 8 ? `${label.slice(0, 8)}…` : label; return ( -
+
-
+
{statusFilters.map((f) => ( +
+

{step.body}

+
+ + {stepIndex + 1} / {steps.length} + +
+ + {stepIndex > 0 && ( + + )} + +
+
+
+ +
+
+
+ ); +} diff --git a/src/components/frontend/tours/TourHost.tsx b/src/components/frontend/tours/TourHost.tsx new file mode 100644 index 0000000..22a6cea --- /dev/null +++ b/src/components/frontend/tours/TourHost.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePathname } from "next/navigation"; +import { GuidedTour } from "./GuidedTour"; +import type { TourDefinition, TourGateContext } from "@/lib/tours/types"; +import { visibleTourSteps } from "@/lib/tours/types"; +import { getTourForPath, TOURS } from "@/lib/tours/registry"; +import { + isTourDismissed, + isToursOptedOut, + setToursOptedOut, + storeTourDismissal, +} from "@/lib/tours/dismissals"; + +/** Window event dispatched by the header replay button. */ +export const TOUR_REPLAY_EVENT = "ptf:tour:replay"; + +interface TourHostProps { + readonly gates: TourGateContext; +} + +/** + * Owns tour lifecycle for the current route: auto-starts the route's tour the + * first time this browser visits it (dismissal remembered per-browser via + * localStorage) and handles replay requests from the header control. Mounted + * once in the (frontend) layout for logged-in users only. + */ +export function TourHost({ gates }: TourHostProps) { + const pathname = usePathname(); + const [activeTour, setActiveTour] = useState(null); + const startedRef = useRef>(new Set()); + + const startTour = useCallback( + (tour: TourDefinition) => { + // A tour with no visible steps for this user never opens. + if (visibleTourSteps(tour.steps, gates).length === 0) return; + startedRef.current.add(tour.id); + setActiveTour(tour); + }, + [gates], + ); + + const dismiss = useCallback(() => { + if (activeTour) storeTourDismissal(activeTour.id, Date.now()); + setActiveTour(null); + }, [activeTour]); + + const skipAll = useCallback(() => { + setToursOptedOut(true); + if (activeTour) storeTourDismissal(activeTour.id, Date.now()); + setActiveTour(null); + }, [activeTour]); + + // Navigating away kills the running tour (it belongs to one page). Declared + // BEFORE the auto-start effect so a same-render pathname change clears the + // old tour first and then may start the new page's tour. + useEffect(() => { + setActiveTour(null); + }, [pathname]); + + // Auto-start on route entry (first visit per browser only). Skipped entirely + // when the browser opted out of tours; the header replay stays available. + useEffect(() => { + const tour = getTourForPath(pathname); + if (!tour) return; + if (isToursOptedOut()) return; + if (startedRef.current.has(tour.id)) return; + if (isTourDismissed(tour.id, Date.now())) return; + startTour(tour); + }, [pathname, startTour]); + + // Replay: the header control asks for the tour regardless of dismissal. + useEffect(() => { + const onReplay = (event: Event) => { + const detail = (event as CustomEvent<{ tourId?: string }>).detail; + const tour = detail?.tourId + ? (TOURS.find((entry) => entry.id === detail.tourId) ?? null) + : getTourForPath(window.location.pathname); + if (tour) startTour(tour); + }; + window.addEventListener(TOUR_REPLAY_EVENT, onReplay); + return () => window.removeEventListener(TOUR_REPLAY_EVENT, onReplay); + }, [startTour]); + + if (!activeTour) return null; + return ; +} diff --git a/src/components/frontend/tours/TourReplayButton.tsx b/src/components/frontend/tours/TourReplayButton.tsx new file mode 100644 index 0000000..e1f1c7a --- /dev/null +++ b/src/components/frontend/tours/TourReplayButton.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { CircleHelpIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { getTourForPath } from "@/lib/tours/registry"; +import { TOUR_REPLAY_EVENT } from "./TourHost"; + +/** + * Persistent replay control in the site header: re-runs the current page's + * tour on demand, for any user, regardless of prior dismissal. Hidden on + * pages that have no tour defined. + */ +export function TourReplayButton() { + const pathname = usePathname(); + const tour = getTourForPath(pathname); + if (!tour) return null; + + return ( + + ); +} diff --git a/src/components/frontend/wiki/WikiIndex.tsx b/src/components/frontend/wiki/WikiIndex.tsx index 684fa19..6dd047c 100644 --- a/src/components/frontend/wiki/WikiIndex.tsx +++ b/src/components/frontend/wiki/WikiIndex.tsx @@ -69,7 +69,7 @@ export function WikiIndex({ pages, isModerator }: WikiIndexProps) { }, [category, pages, search, tagFilter]); return ( -
+
diff --git a/src/lib/tours/dismissals.ts b/src/lib/tours/dismissals.ts new file mode 100644 index 0000000..78f17b2 --- /dev/null +++ b/src/lib/tours/dismissals.ts @@ -0,0 +1,89 @@ +/** + * Per-browser tour dismissals in localStorage, keyed by tour id. Mirrors the + * announcement dismissal store (browser storage is read during mount and + * written during mount or the explicit dismissal handler; it is never + * accessed during render). Dismissals are permanent per browser: a tour that + * was dismissed never auto-starts again, but the header replay control can + * always run it on demand. + */ + +const DISMISSALS_STORAGE_KEY = "ptf:tour:dismissals"; +const OPTED_OUT_STORAGE_KEY = "ptf:tours:opted-out"; + +export type TourDismissals = Record; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function readTourDismissals(now: number): TourDismissals { + if (typeof window === "undefined") return {}; + + try { + const raw = window.localStorage.getItem(DISMISSALS_STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed)) return {}; + + const dismissals: TourDismissals = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + dismissals[key] = value; + } + } + return dismissals; + } catch (error) { + if (error instanceof Error) return {}; + throw error; + } +} + +export function writeTourDismissals(dismissals: TourDismissals, now: number): void { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify(dismissals)); + } catch (error) { + if (error instanceof Error) return; + throw error; + } +} + +/** Merges a single dismissal (timestamp = dismissal time) into the stored map. */ +export function storeTourDismissal(tourId: string, now: number): void { + const existing = readTourDismissals(now); + writeTourDismissals({ ...existing, [tourId]: now }, now); +} + +/** True when the tour has been dismissed by this browser before `now`. */ +export function isTourDismissed(tourId: string, now: number): boolean { + return readTourDismissals(now)[tourId] !== undefined; +} + +/** + * Site-wide tours opt-out (per browser). When set, tours never auto-start; + * the header replay control remains as the explicit opt-in. + */ +export function isToursOptedOut(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(OPTED_OUT_STORAGE_KEY) === "1"; + } catch (error) { + if (error instanceof Error) return false; + throw error; + } +} + +export function setToursOptedOut(optedOut: boolean): void { + if (typeof window === "undefined") return; + try { + if (optedOut) { + window.localStorage.setItem(OPTED_OUT_STORAGE_KEY, "1"); + } else { + window.localStorage.removeItem(OPTED_OUT_STORAGE_KEY); + } + } catch (error) { + if (error instanceof Error) return; + throw error; + } +} diff --git a/src/lib/tours/registry.ts b/src/lib/tours/registry.ts new file mode 100644 index 0000000..9ef0895 --- /dev/null +++ b/src/lib/tours/registry.ts @@ -0,0 +1,239 @@ +/** + * Tour registry: one TourDefinition per page path, defined in code. Brief + * steps keyed to stable `data-tour` anchors placed in the page markup. New + * pages join by adding a definition here and placing the anchors; the header + * replay control and auto-start pick the tour up automatically. + * + * Pure module (client-safe): no Payload or server imports. + */ + +import type { TourDefinition } from "./types"; + +export const TOURS: TourDefinition[] = [ + { + id: "dashboard", + path: "/", + title: "Dashboard", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "Your operations dashboard", + body: "Everything that matters at a glance: your service summary, the mission briefing, live events, and quick stats.", + }, + { + id: "quick-stats", + selector: '[data-tour="dashboard-quick-stats"]', + title: "Quick stats", + body: "Unit-wide counters: active campaigns, missions, structures, factions, and members. Updated as the campaign progresses.", + }, + { + id: "wallet", + selector: '[data-tour="dashboard-wallet"]', + title: "Wallet", + body: "Your personal balance in the unit's main currency. Move money in the Banking section under Logistics.", + }, + ], + }, + { + id: "roster", + path: "/roster", + title: "Unit Roster", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "Unit Roster", + body: "Every operator in the task force, grouped by assignment. Open a member to see their rank, awards, and service record.", + }, + ], + }, + { + id: "calendar", + path: "/intelligence/missions/calendar", + title: "Friday Ops", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "Friday operations calendar", + body: "The upcoming Friday op nights at a glance. Each Friday shows the missions scheduled for it; open one to read the full OPORD.", + }, + ], + }, + { + id: "events", + path: "/events", + title: "Event Log", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "The event log", + body: "A live ledger of everything the simulation does: shipments, salaries, market moves, and GM-written narrative entries (marked with a book icon).", + }, + ], + }, + { + id: "map", + path: "/map", + title: "World Map", + steps: [ + { + id: "welcome", + selector: '[data-tour="map-picker-header"]', + title: "World map", + body: "Every active theater. Pick a grid to open the full tactical map with structures, resource nodes, roads, and live shipments.", + }, + { + id: "grid", + selector: '[data-tour="map-picker-grid"]', + title: "Pick your theater", + body: "Cards show weather, ease of access, and who controls the area. Logistics-qualified members can place new structures from inside a map.", + }, + ], + }, + { + id: "operations", + path: "/operations", + title: "Ops Overview", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "Operations overview", + body: "Live and historical operation activity fed from the Arma bridge: extraction returns, settlements, and readiness aggregates.", + }, + ], + }, + { + id: "operations-ledger", + path: "/operations/ledger", + title: "Ops Ledger", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "Operations ledger", + body: "Every operation event with its derived effects. Rejected and dead-lettered entries are kept as evidence and never applied.", + }, + ], + }, + { + id: "structures", + path: "/logistics", + title: "Structures", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "Structures", + body: "Every building the unit holds and what is stored inside. Open a structure to manage its storage grid, staff, and upgrades.", + showWhen: (gates) => gates.hasLogistics, + }, + ], + }, + { + id: "shipments", + path: "/logistics/shipments", + title: "Shipments", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "Shipments", + body: "Cargo moving between structures by road, air, or sea. Progress updates live; the map shows vehicles moving along their routes.", + }, + { + id: "filters", + selector: '[data-tour="shipments-filters"]', + title: "Filter by status", + body: "Slice the list by where a shipment is in its journey: pending, in transit, arrived, or failed.", + }, + ], + }, + { + id: "banking", + path: "/logistics/banking", + title: "Banking", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "Banking", + body: "Personal wallets plus unit and faction treasuries. Deposits, withdrawals, and transfers all land here.", + }, + { + id: "overview", + selector: '[data-tour="banking-overview"]', + title: "Your wallet", + body: "Your personal balance sits at the top. The tabs below show your activity and, for managers, every unit account.", + }, + ], + }, + { + id: "market", + path: "/logistics/market", + title: "Market", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "The market", + body: "Buy gear from other members and NPC vendors, or list your own locker items for sale. Haggling is welcome: vendors counter-offer.", + }, + ], + }, + { + id: "wiki", + path: "/wiki", + title: "Wiki", + steps: [ + { + id: "controls", + selector: '[data-tour="wiki-controls"]', + title: "The unit wiki", + body: "A shared field guide for campaigns, lore, and how the unit's systems work. Search, filter by tag, or browse by category tab.", + }, + ], + }, + { + id: "awards", + path: "/awards", + title: "Awards", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "Awards", + body: "Every medal and ribbon a member can earn, with requirements and artwork. Design new ribbons or, as staff, review submissions.", + }, + { + id: "actions", + selector: '[data-tour="awards-actions"]', + title: "Design and review", + body: "Anyone can design a ribbon. Staff reviewers approve or send back submissions from this same toolbar.", + showWhen: (gates) => gates.hasAdminPanel, + }, + ], + }, + { + id: "locker", + path: "/locker", + title: "Locker", + steps: [ + { + id: "welcome", + selector: '[data-tour="page-header"]', + title: "Personal locker", + body: "Your gear storage: items on the grid, loadouts you build from them, and what you can list on the market.", + }, + ], + }, +]; + +/** Exact-path lookup. Query strings are ignored (pathname only). */ +export function getTourForPath(pathname: string | null | undefined): TourDefinition | null { + if (!pathname) return null; + return TOURS.find((tour) => tour.path === pathname) ?? null; +} diff --git a/src/lib/tours/types.ts b/src/lib/tours/types.ts new file mode 100644 index 0000000..e309933 --- /dev/null +++ b/src/lib/tours/types.ts @@ -0,0 +1,54 @@ +/** + * Guided tour domain types. A tour is defined in code per page: brief steps + * keyed to stable selectors (usually `data-tour="..."` attributes). Steps can + * be gated so users only see what their permissions and qualifications + * actually unlock, mirroring the sidebar's conditional groups. + */ + +/** + * Per-user access signals the layout resolves server-side and hands to the + * tour host. Mirrors the sidebar's visibility inputs (AppSidebar props). + */ +export interface TourGateContext { + readonly hasIntelligence: boolean; + readonly hasLogistics: boolean; + readonly hasAdminPanel: boolean; + readonly statisticsEnabled: boolean; + readonly helpdeskEnabled: boolean; +} + +export interface TourStep { + /** Stable id within the tour (used for keys and debugging). */ + readonly id: string; + /** CSS selector the spotlight anchors to. Must exist on the page. */ + readonly selector: string; + readonly title: string; + readonly body: string; + /** + * Show this step only when the gate predicate passes. Omitted means the + * step is visible to every user who can see the page at all. + */ + readonly showWhen?: (gates: TourGateContext) => boolean; +} + +export interface TourDefinition { + /** Stable id used in per-browser dismissal storage. */ + readonly id: string; + /** Exact pathname this tour belongs to. */ + readonly path: string; + readonly title: string; + readonly steps: readonly TourStep[]; +} + +export const DEFAULT_GATES: TourGateContext = { + hasIntelligence: true, + hasLogistics: true, + hasAdminPanel: true, + statisticsEnabled: true, + helpdeskEnabled: true, +}; + +/** Steps filtered down to what the visitor can actually access. */ +export function visibleTourSteps(steps: readonly TourStep[], gates: TourGateContext): TourStep[] { + return steps.filter((step) => step.showWhen?.(gates) ?? true); +} diff --git a/tests/e2e/tours.e2e.spec.ts b/tests/e2e/tours.e2e.spec.ts new file mode 100644 index 0000000..ca0e3a2 --- /dev/null +++ b/tests/e2e/tours.e2e.spec.ts @@ -0,0 +1,95 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * Guided tour smoke: auto-start on first visit, per-browser dismissal via + * localStorage, and the header replay control. Each test gets a fresh browser + * context, so localStorage starts empty: exactly the "first visit" state. + */ + +/** Login and wait for the redirect to land (cold dev-server compiles are slow). */ +async function login(page: Page): Promise { + await page.goto("/login"); + await page.getByLabel("Username").fill("dev"); + await page.getByLabel("Password").fill("Test123"); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 90_000 }); +} + +test.describe("Guided tours", () => { + test("auto-starts on first visit, dismisses, and never auto-starts again", async ({ page }) => { + await login(page); + + // Landing on the dashboard starts its tour (first visit for this browser). + await expect(page.locator("[data-tour-card]")).toBeVisible(); + await expect(page.locator("[data-tour-card]")).toContainText("1 / 3"); + + // Walk the tour to the end: dismissal is remembered. + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page.locator("[data-tour-card]")).toContainText("2 / 3"); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page.locator("[data-tour-card]")).toContainText("3 / 3"); + await page.getByRole("button", { name: "Done" }).click(); + await expect(page.locator("[data-tour-card]")).toHaveCount(0); + + const dismissals = await page.evaluate(() => + window.localStorage.getItem("ptf:tour:dismissals"), + ); + expect(dismissals).not.toBeNull(); + expect(JSON.parse(dismissals ?? "{}")).toHaveProperty("dashboard"); + + // Reload: the tour does NOT auto-start again. + await page.reload(); + await expect(page.locator("[data-tour-card]")).toHaveCount(0); + }); + + test("replay control re-runs the tour after dismissal", async ({ page }) => { + await login(page); + + // Dismiss the auto-started tour immediately. + await expect(page.locator("[data-tour-card]")).toBeVisible(); + await page.getByRole("button", { name: "Skip", exact: true }).click(); + await expect(page.locator("[data-tour-card]")).toHaveCount(0); + + // The header replay control brings it back on demand. + await page.getByRole("button", { name: "Replay page tour" }).click(); + await expect(page.locator("[data-tour-card]")).toBeVisible(); + await expect(page.locator("[data-tour-card]")).toContainText("Dashboard"); + }); + + test("skip all opts the browser out; the account toggle re-enables", async ({ page }) => { + await login(page); + + // Opt out from the tour card itself. + await expect(page.locator("[data-tour-card]")).toBeVisible(); + await page.getByRole("button", { name: "Skip all tours on this device" }).click(); + await expect(page.locator("[data-tour-card]")).toHaveCount(0); + expect(await page.evaluate(() => window.localStorage.getItem("ptf:tours:opted-out"))).toBe("1"); + + // Another toured page no longer auto-starts... + await page.goto("/wiki"); + await expect(page.locator("[data-tour-card]")).toHaveCount(0); + + // ...but replay stays available as the explicit opt-in. + await page.getByRole("button", { name: "Replay page tour" }).click(); + await expect(page.locator("[data-tour-card]")).toBeVisible(); + await page.getByRole("button", { name: "Skip", exact: true }).click(); + await expect(page.locator("[data-tour-card]")).toHaveCount(0); + + // Re-enable from the account page (the switch reads off after the opt-out), + // then a never-visited toured page auto-starts again. + await page.goto("/account"); + const toursSwitch = page.getByRole("switch", { name: "Auto-start page tours" }); + await expect(toursSwitch).toBeVisible(); + await toursSwitch.click(); + await page.goto("/awards"); + await expect(page.locator("[data-tour-card]")).toBeVisible(); + }); + + test("pages without tours show no replay control and no overlay", async ({ page }) => { + await login(page); + + await page.goto("/account"); + await expect(page.locator("[data-tour-card]")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Replay page tour" })).toHaveCount(0); + }); +}); diff --git a/tests/int/tours.int.spec.ts b/tests/int/tours.int.spec.ts new file mode 100644 index 0000000..e89f09e --- /dev/null +++ b/tests/int/tours.int.spec.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + DEFAULT_GATES, + visibleTourSteps, + type TourGateContext, + type TourStep, +} from "@/lib/tours/types"; +import { TOURS, getTourForPath } from "@/lib/tours/registry"; +import { + isTourDismissed, + isToursOptedOut, + readTourDismissals, + setToursOptedOut, + storeTourDismissal, + writeTourDismissals, +} from "@/lib/tours/dismissals"; + +const gates = (overrides: Partial): TourGateContext => ({ + ...DEFAULT_GATES, + ...overrides, +}); + +describe("tour step filtering", () => { + const steps: TourStep[] = [ + { id: "open", selector: '[data-tour="page-header"]', title: "Open", body: "b" }, + { + id: "logistics-only", + selector: '[data-tour="logistics-only"]', + title: "Logistics", + body: "b", + showWhen: (g) => g.hasLogistics, + }, + { + id: "intel-only", + selector: '[data-tour="intel-only"]', + title: "Intel", + body: "b", + showWhen: (g) => g.hasIntelligence, + }, + { + id: "stats-flag", + selector: '[data-tour="stats"]', + title: "Stats", + body: "b", + showWhen: (g) => g.statisticsEnabled, + }, + ]; + + it("a fully privileged visitor sees every step", () => { + expect(visibleTourSteps(steps, DEFAULT_GATES)).toHaveLength(4); + }); + + it("a visitor without logistics qualification never sees logistics steps", () => { + const visible = visibleTourSteps(steps, gates({ hasLogistics: false })); + expect(visible.map((s) => s.id)).toEqual(["open", "intel-only", "stats-flag"]); + }); + + it("a visitor without intelligence qualification never sees intel steps", () => { + const visible = visibleTourSteps(steps, gates({ hasIntelligence: false })); + expect(visible.map((s) => s.id)).toEqual(["open", "logistics-only", "stats-flag"]); + }); + + it("feature-flag gates hide steps when the feature is disabled", () => { + const visible = visibleTourSteps(steps, gates({ statisticsEnabled: false })); + expect(visible.map((s) => s.id)).toEqual(["open", "logistics-only", "intel-only"]); + }); + + it("a visitor with nothing unlocked sees only ungated steps", () => { + const visible = visibleTourSteps( + steps, + gates({ + hasLogistics: false, + hasIntelligence: false, + hasAdminPanel: false, + statisticsEnabled: false, + helpdeskEnabled: false, + }), + ); + expect(visible.map((s) => s.id)).toEqual(["open"]); + }); +}); + +describe("tour registry", () => { + it("covers the major sidebar routes", () => { + const paths = TOURS.map((tour) => tour.path); + for (const path of [ + "/", + "/roster", + "/map", + "/operations", + "/logistics", + "/logistics/shipments", + "/logistics/banking", + "/logistics/market", + "/wiki", + "/awards", + "/locker", + ]) { + expect(paths, `missing tour for ${path}`).toContain(path); + } + }); + + it("has unique tour ids and unique paths", () => { + expect(new Set(TOURS.map((t) => t.id)).size).toBe(TOURS.length); + expect(new Set(TOURS.map((t) => t.path)).size).toBe(TOURS.length); + }); + + it("gives every step a unique id and a data-tour anchor", () => { + for (const tour of TOURS) { + expect(tour.steps.length, `${tour.id} has steps`).toBeGreaterThan(0); + expect(new Set(tour.steps.map((s) => s.id)).size, tour.id).toBe(tour.steps.length); + for (const step of tour.steps) { + expect(step.selector, `${tour.id}/${step.id}`).toMatch(/^(\[data-tour=|html|body|#)/); + expect(step.body.length, `${tour.id}/${step.id}`).toBeGreaterThan(0); + expect(step.body, `${tour.id}/${step.id} copy is dash-clean`).not.toMatch(/[–—]/); + } + } + }); + + it("looks up tours by exact path and ignores query strings", () => { + expect(getTourForPath("/")?.id).toBe("dashboard"); + expect(getTourForPath("/logistics/shipments")?.id).toBe("shipments"); + expect(getTourForPath("/logistics/shipments?foo=bar")).toBeNull(); + expect(getTourForPath("/no/such/page")).toBeNull(); + expect(getTourForPath(null)).toBeNull(); + expect(getTourForPath(undefined)).toBeNull(); + }); +}); + +describe("tour dismissals (per-browser localStorage)", () => { + const originalSetItem = window.localStorage.setItem.bind(window.localStorage); + const originalGetItem = window.localStorage.getItem.bind(window.localStorage); + + beforeEach(() => { + window.localStorage.clear(); + }); + + it("stores, reads, and reports dismissals", () => { + const now = 1_000_000; + expect(isTourDismissed("dashboard", now)).toBe(false); + storeTourDismissal("dashboard", now); + expect(isTourDismissed("dashboard", now)).toBe(true); + expect(isTourDismissed("other-tour", now)).toBe(false); + expect(readTourDismissals(now)).toEqual({ dashboard: now }); + }); + + it("merges dismissals without losing other tours", () => { + const now = 5_000; + window.localStorage.clear(); + writeTourDismissals({ a: now }, now); + storeTourDismissal("b", now + 1); + expect(readTourDismissals(now)).toEqual({ a: now, b: now + 1 }); + }); + + it("degrades to an empty map when storage reads fail", () => { + window.localStorage.setItem = () => { + throw new Error("disabled"); + }; + window.localStorage.getItem = () => { + throw new Error("disabled"); + }; + expect(readTourDismissals(1)).toEqual({}); + expect(isTourDismissed("dashboard", 1)).toBe(false); + // Writers swallow the failure instead of crashing the page. + expect(() => storeTourDismissal("dashboard", 1)).not.toThrow(); + }); + + window.localStorage.setItem = originalSetItem; + window.localStorage.getItem = originalGetItem; +}); + +describe("tours opt-out (skip all)", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("defaults to opted-in and toggles per browser", () => { + expect(isToursOptedOut()).toBe(false); + setToursOptedOut(true); + expect(isToursOptedOut()).toBe(true); + setToursOptedOut(false); + expect(isToursOptedOut()).toBe(false); + }); + + it("keeps the opt-out independent of per-tour dismissals", () => { + setToursOptedOut(true); + storeTourDismissal("dashboard", 1); + setToursOptedOut(false); + expect(isTourDismissed("dashboard", 1)).toBe(true); + expect(isToursOptedOut()).toBe(false); + }); +});