1
0
Fork 0

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).
This commit is contained in:
Jason Fraley 2026-09-30 18:43:21 -04:00
parent fdb6ee120a
commit 8b79c1379d
30 changed files with 1127 additions and 34 deletions

View file

@ -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() {
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Guided Tours</CardTitle>
<CardDescription>
Choose whether pages greet you with a short walkthrough.
</CardDescription>
</CardHeader>
<CardContent>
<ToursPreference />
</CardContent>
</Card>
</div>
</div>
);

View file

@ -132,7 +132,7 @@ export default async function AwardsPage() {
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-1.5">
<div className="flex flex-col gap-1.5" data-tour="page-header">
<div className="flex items-center gap-2">
<MedalIcon className="size-4 text-muted-foreground" />
<h1 className="text-2xl font-bold tracking-tight uppercase">Awards</h1>
@ -143,7 +143,7 @@ export default async function AwardsPage() {
on the fly and are not catalogued here.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2" data-tour="awards-actions">
<Button asChild size="sm">
<Link href="/awards/design">Design a ribbon</Link>
</Button>

View file

@ -9,7 +9,7 @@ export const metadata: Metadata = {
export default function EventLogPage() {
return (
<div className="flex flex-col gap-6 p-6">
<div>
<div data-tour="page-header">
<h1 className="text-2xl font-semibold tracking-tight">Event Log</h1>
<p className="text-muted-foreground text-sm">
The full game event ledger: system actions and GM narrative events. Filter by type,

View file

@ -161,7 +161,7 @@ export default async function FridayCalendarPage() {
return (
<div className="p-5">
<div className="mx-auto w-full max-w-5xl">
<div className="mx-auto w-full max-w-5xl" data-tour="page-header">
<FridayCalendar
entries={entries}
remainingSlots={remainingSlots}

View file

@ -33,6 +33,7 @@ import { ImpersonationBanner } from "@/components/frontend/impersonation/Imperso
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
import { AnnouncementHost } from "@/components/frontend/announcements/AnnouncementHost";
import type { AnnouncementEntry } from "@/lib/announcements/evaluate";
import { TourHost } from "@/components/frontend/tours/TourHost";
import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate";
// Skip static prerendering of this layout (and all pages under (frontend)/).
@ -203,6 +204,17 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
userRoleDocIds={userRoleDocIds}
/>
)}
{user && (
<TourHost
gates={{
hasIntelligence: isIntelligence,
hasLogistics: isLogistics,
hasAdminPanel: isAdmin,
statisticsEnabled,
helpdeskEnabled,
}}
/>
)}
{children}
</SidebarInset>
<CommandPalette

View file

@ -55,7 +55,7 @@ export default async function LockerPage() {
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1" data-tour="page-header">
<div className="flex items-center gap-2">
<LockKeyholeIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Personal Locker</h1>

View file

@ -70,7 +70,7 @@ export default async function BankingPage() {
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1" data-tour="page-header">
<div className="flex items-center gap-2">
<LandmarkIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Banking</h1>

View file

@ -82,7 +82,7 @@ export default async function MarketPage() {
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1" data-tour="page-header">
<div className="flex items-center gap-2">
<StoreIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Market</h1>

View file

@ -31,7 +31,7 @@ export default async function LogisticsPage() {
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1" data-tour="page-header">
<div className="flex items-center gap-2">
<Building2Icon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Structures</h1>

View file

@ -22,7 +22,7 @@ export default async function ShipmentsPage() {
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1" data-tour="page-header">
<div className="flex items-center gap-2">
<TruckIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Shipments</h1>

View file

@ -54,7 +54,7 @@ export default async function MapPickerPage() {
return (
<div className="flex min-h-[240px] flex-1 flex-col">
<div className="flex items-center justify-between gap-3 border-b border-white/15 bg-black/50 px-5 py-2 backdrop-blur-sm">
<div className="flex items-center justify-between gap-3 border-b border-white/15 bg-black/50 px-5 py-2 backdrop-blur-sm" data-tour="map-picker-header">
<div className="flex items-center gap-2">
<MapIcon className="size-4 text-muted-foreground" />
<h1 className="text-sm font-semibold uppercase tracking-[0.2em]">World Map</h1>
@ -76,7 +76,7 @@ export default async function MapPickerPage() {
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 p-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid grid-cols-1 gap-4 p-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" data-tour="map-picker-grid">
{maps.map((map) => {
const campaign =
map.campaign && typeof map.campaign === "object" ? (map.campaign as Campaign) : null;

View file

@ -52,7 +52,11 @@ export default async function OperationsLedgerPage({
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<OperationsRealtimeRefresher />
<PageShell title="Operations ledger" meta="SOURCE EVENTS // EFFECT LEDGER">
<PageShell
title="Operations ledger"
meta="SOURCE EVENTS // EFFECT LEDGER"
dataTour="page-header"
>
<p className="max-w-2xl text-sm text-white/70">
Every validated, rejected, and dead-lettered operation event with its derived effects.
Rejected and dead-lettered entries are retained as evidence and never applied.

View file

@ -32,7 +32,7 @@ export default async function OperationsPage() {
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<OperationsRealtimeRefresher />
<PageShell title="Operations" meta="OPERATION FEED // AFTER-ACTION RECORDS">
<PageShell title="Operations" meta="OPERATION FEED // AFTER-ACTION RECORDS" dataTour="page-header">
<p className="max-w-2xl text-sm text-white/70">
Live and historical operation activity: extraction returns, settlements, readiness
aggregates, and zone pressure. Every effect here is provenance-linked to a validated

View file

@ -331,17 +331,21 @@ export default async function HomePage() {
widgetNodes.set("recent-events", <RecentEvents events={events} currentUserId={userId} />);
widgetNodes.set(
"quick-stats",
<div data-tour="dashboard-quick-stats">
<QuickStats
activeCampaigns={campaignsRes.totalDocs}
totalMissions={missionsCountRes.totalDocs}
totalStructures={structuresRes.totalDocs}
totalFactions={factionsRes.totalDocs}
totalMembers={profilesRes.totalDocs}
/>,
/>
</div>,
);
widgetNodes.set(
"wallet",
<WalletWidget account={walletAccount} currencyConfig={currencyConfig} />,
<div data-tour="dashboard-wallet">
<WalletWidget account={walletAccount} currencyConfig={currencyConfig} />
</div>,
);
if (negotiations.length > 0) {
widgetNodes.set(
@ -367,10 +371,7 @@ export default async function HomePage() {
);
}
if (hasIntelligence) {
widgetNodes.set(
"upcoming-missions",
<UpcomingMissions missions={upcomingMissions} />,
);
widgetNodes.set("upcoming-missions", <UpcomingMissions missions={upcomingMissions} />);
widgetNodes.set("active-campaigns", <ActiveCampaigns campaigns={activeCampaigns} />);
}
@ -390,7 +391,7 @@ export default async function HomePage() {
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1" data-tour="page-header">
<h1 className="text-lg font-semibold">
Welcome back, {rankData.name}. {/*{profileUser?.displayName ?? user.username}*/}
</h1>

View file

@ -134,7 +134,7 @@ export default async function RosterPage() {
return (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1" data-tour="page-header">
<div className="flex items-center gap-2">
<UsersIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Unit Roster</h1>

View file

@ -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;
}

View file

@ -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 = () => {
<Command className="h-3 w-3" />
<span>K</span>
</div>
<div className="ml-auto flex items-center">
<div className="ml-auto flex items-center gap-1">
<TourReplayButton />
<NotificationsBell />
</div>
</header>

View file

@ -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 (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label htmlFor="tours-autostart" className="text-sm">
Auto-start page tours
</Label>
<p className="text-xs text-muted-foreground">
Short guided walkthroughs the first time you visit a page. You can always replay the
current page&apos;s tour from the header help icon.
</p>
</div>
<Switch
id="tours-autostart"
checked={mounted ? !optedOut : true}
onCheckedChange={(checked) => {
setOptedOut(!checked);
setToursOptedOut(!checked);
}}
/>
</div>
);
}

View file

@ -99,7 +99,7 @@ export function BankingOverview({
label.length > 8 ? `${label.slice(0, 8)}…` : label;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6" data-tour="banking-overview">
<WalletHero
account={myAccount ?? null}
currentUser={currentUser}

View file

@ -76,7 +76,7 @@ export function ShipmentsList({ shipments }: ShipmentsListProps) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap gap-1.5" data-tour="shipments-filters">
{statusFilters.map((f) => (
<Button
key={f.value ?? "all"}

View file

@ -14,13 +14,16 @@ export function PageShell({
title,
meta,
children,
dataTour,
}: {
title: string;
meta?: string;
children: ReactNode;
/** Optional stable anchor for the guided-tour system. */
dataTour?: string;
}) {
return (
<div data-slot="page-shell" className="flex flex-col gap-4">
<div data-slot="page-shell" className="flex flex-col gap-4" data-tour={dataTour}>
<header className="flex flex-col gap-1">
<h1 className="text-xl font-bold tracking-tight text-foreground">{title}</h1>
{meta ? <ClassificationStrip left={meta} right="Polaris // Ops" /> : null}

View file

@ -0,0 +1,195 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { TourDefinition, TourGateContext } from "@/lib/tours/types";
import { visibleTourSteps } from "@/lib/tours/types";
const SPOTLIGHT_PADDING = 8;
const CARD_GAP = 12;
interface GuidedTourProps {
readonly tour: TourDefinition;
readonly gates: TourGateContext;
/** Called when the user dismisses the tour (skip, close, or finish). */
readonly onDismiss: () => void;
/** Called for "Skip all tours": opts this browser out of future auto-starts. */
readonly onSkipAll: () => void;
}
interface Rects {
readonly top: number;
readonly left: number;
readonly width: number;
readonly height: number;
}
/**
* The guided-tour overlay: a spotlight punched over the step's target element
* plus a small control card. Purely visual/positional; dismissal persistence
* lives in the parent (TourHost). Steps whose selector is missing (target
* rendered conditionally) fall back to a centered card without a spotlight.
*/
export function GuidedTour({ tour, gates, onDismiss, onSkipAll }: GuidedTourProps) {
const steps = visibleTourSteps(tour.steps, gates);
const [stepIndex, setStepIndex] = useState(0);
const [targetRect, setTargetRect] = useState<Rects | null>(null);
const step = steps[stepIndex];
const updateRect = useCallback(() => {
if (!step) return;
const el = document.querySelector(step.selector);
if (!el) {
setTargetRect(null);
return;
}
const rect = el.getBoundingClientRect();
setTargetRect({
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
});
}, [step]);
useLayoutEffect(() => {
updateRect();
}, [updateRect]);
useEffect(() => {
if (!step) return;
const el = document.querySelector(step.selector);
el?.scrollIntoView({ block: "center", behavior: "smooth" });
// The target may mount after the step starts (client-side navigation into
// a still-hydrating page): watch the DOM until it appears, then keep the
// spotlight glued to it across scrolls and resizes.
const observer = new MutationObserver(() => {
if (!document.querySelector(step.selector)) return;
updateRect();
observer.disconnect();
});
if (!el) observer.observe(document.body, { childList: true, subtree: true });
window.addEventListener("resize", updateRect);
// Capture: catch scrolls inside nested containers too.
window.addEventListener("scroll", updateRect, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", updateRect);
window.removeEventListener("scroll", updateRect, true);
};
}, [step, updateRect]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onDismiss();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onDismiss]);
if (!step) return null;
const isLast = stepIndex === steps.length - 1;
const cardWidth = 340;
// Prefer below the target, fall back to above; clamp to the viewport.
let cardTop: number;
if (targetRect) {
const below = targetRect.top + targetRect.height + CARD_GAP;
cardTop = below + 180 < window.innerHeight ? below : targetRect.top - CARD_GAP - 180;
cardTop = Math.max(8, Math.min(cardTop, window.innerHeight - 8));
} else {
cardTop = Math.max(8, (window.innerHeight - 180) / 2);
}
let cardLeft = targetRect
? targetRect.left + targetRect.width / 2 - cardWidth / 2
: (window.innerWidth - cardWidth) / 2;
cardLeft = Math.max(8, Math.min(cardLeft, window.innerWidth - cardWidth - 8));
return (
<div role="dialog" aria-label={`Guided tour: ${tour.title}`} data-tour-overlay="">
{/* Click blocker: swallows page interaction while the tour is active. */}
<div className="fixed inset-0 z-[200]" aria-hidden />
{/* Spotlight: transparent center over the target, dark elsewhere. */}
{targetRect && (
<div
aria-hidden
className="tour-spotlight pointer-events-none fixed z-[201] rounded-md transition-all duration-200"
style={{
top: targetRect.top - SPOTLIGHT_PADDING,
left: targetRect.left - SPOTLIGHT_PADDING,
width: targetRect.width + SPOTLIGHT_PADDING * 2,
height: targetRect.height + SPOTLIGHT_PADDING * 2,
boxShadow: "0 0 0 100vmax rgba(0,0,0,0.8)",
}}
/>
)}
<div
className="tour-card fixed z-[202] w-[340px] rounded-lg border bg-popover p-4 text-popover-foreground"
style={{ top: cardTop, left: cardLeft }}
data-tour-card=""
>
<div className="mb-2 flex items-start justify-between gap-2">
<div>
<div className="font-mono text-[9px] uppercase tracking-widest text-primary/90">
Guided tour // {tour.title}
</div>
<h2 className="text-sm font-semibold leading-tight">{step.title}</h2>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0"
aria-label="Close tour"
onClick={onDismiss}
>
<XIcon className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">{step.body}</p>
<div className="mt-3 flex items-center justify-between gap-2">
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{stepIndex + 1} / {steps.length}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onDismiss}>
Skip
</Button>
{stepIndex > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setStepIndex((index) => Math.max(0, index - 1))}
>
Back
</Button>
)}
<Button
size="sm"
onClick={() => {
if (isLast) onDismiss();
else setStepIndex((index) => Math.min(steps.length - 1, index + 1));
}}
>
{isLast ? "Done" : "Next"}
</Button>
</div>
</div>
<div className="mt-2 border-t border-border/60 pt-2">
<Button
variant="link"
size="sm"
className="h-auto p-0 text-xs text-muted-foreground"
onClick={onSkipAll}
>
Skip all tours on this device
</Button>
</div>
</div>
</div>
);
}

View file

@ -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<TourDefinition | null>(null);
const startedRef = useRef<Set<string>>(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 <GuidedTour tour={activeTour} gates={gates} onDismiss={dismiss} onSkipAll={skipAll} />;
}

View file

@ -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 (
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
aria-label="Replay page tour"
title={`Replay the ${tour.title} tour`}
onClick={() => {
window.dispatchEvent(new CustomEvent(TOUR_REPLAY_EVENT, { detail: { tourId: tour.id } }));
}}
>
<CircleHelpIcon className="size-4" />
</Button>
);
}

View file

@ -69,7 +69,7 @@ export function WikiIndex({ pages, isModerator }: WikiIndexProps) {
}, [category, pages, search, tagFilter]);
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-5" data-tour="wiki-controls">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">

View file

@ -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<string, number>;
function isRecord(value: unknown): value is Record<string, unknown> {
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;
}
}

239
src/lib/tours/registry.ts Normal file
View file

@ -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;
}

54
src/lib/tours/types.ts Normal file
View file

@ -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);
}

View file

@ -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<void> {
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);
});
});

192
tests/int/tours.int.spec.ts Normal file
View file

@ -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>): 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);
});
});