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:
parent
fdb6ee120a
commit
8b79c1379d
30 changed files with 1127 additions and 34 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", <RecentEvents events={events} currentUserId={userId} />);
|
||||
widgetNodes.set(
|
||||
"quick-stats",
|
||||
<QuickStats
|
||||
activeCampaigns={campaignsRes.totalDocs}
|
||||
totalMissions={missionsCountRes.totalDocs}
|
||||
totalStructures={structuresRes.totalDocs}
|
||||
totalFactions={factionsRes.totalDocs}
|
||||
totalMembers={profilesRes.totalDocs}
|
||||
/>,
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
43
src/components/frontend/account/ToursPreference.tsx
Normal file
43
src/components/frontend/account/ToursPreference.tsx
Normal 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's tour from the header help icon.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="tours-autostart"
|
||||
checked={mounted ? !optedOut : true}
|
||||
onCheckedChange={(checked) => {
|
||||
setOptedOut(!checked);
|
||||
setToursOptedOut(!checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
195
src/components/frontend/tours/GuidedTour.tsx
Normal file
195
src/components/frontend/tours/GuidedTour.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/components/frontend/tours/TourHost.tsx
Normal file
88
src/components/frontend/tours/TourHost.tsx
Normal 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} />;
|
||||
}
|
||||
33
src/components/frontend/tours/TourReplayButton.tsx
Normal file
33
src/components/frontend/tours/TourReplayButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
89
src/lib/tours/dismissals.ts
Normal file
89
src/lib/tours/dismissals.ts
Normal 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
239
src/lib/tours/registry.ts
Normal 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
54
src/lib/tours/types.ts
Normal 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);
|
||||
}
|
||||
95
tests/e2e/tours.e2e.spec.ts
Normal file
95
tests/e2e/tours.e2e.spec.ts
Normal 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
192
tests/int/tours.int.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue