Compare commits
9 commits
4b9184ce23
...
954e6e2c44
| Author | SHA1 | Date | |
|---|---|---|---|
| 954e6e2c44 | |||
| 7c53cc020c | |||
| 471e4eaacf | |||
| d1ae4c3bb7 | |||
| d16e721ccc | |||
| 8b79c1379d | |||
| fdb6ee120a | |||
| 780d2bd8f0 | |||
| c9a3d23f96 |
63 changed files with 37403 additions and 93 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -46,7 +46,7 @@ Known pre-existing errors (not yours, don't widen scope to fix them): `src/colle
|
||||||
|
|
||||||
- **Dedicated test database**: all tests run against `<db>_test` (derived from `DATABASE_URI`, or set `TEST_DATABASE_URI`), never the dev database. `tests/test-db.ts` bootstraps it idempotently: ensure database exists, push the schema (drizzle `push`, because the migration chain assumes a schema that push created), truncate every table (migration journals and PostGIS internals survive), seed baselines (`seedRoles` + a Game Rules global with a main currency resource, `tests/seed-baseline.ts`), and repair id sequences (migrations backfill explicit-id rows, which leaves sequences behind and causes `ValidationError: field is invalid: id` on creates). Truncate runs at both global setup (clears crashed-run leftovers) and teardown, so every run starts and ends empty. `TEST_DB_HARD_RESET=1` drops and recreates the test database from scratch instead of truncating. Serialized test files (`fileParallelism: false`) because parallel workers share the one database.
|
- **Dedicated test database**: all tests run against `<db>_test` (derived from `DATABASE_URI`, or set `TEST_DATABASE_URI`), never the dev database. `tests/test-db.ts` bootstraps it idempotently: ensure database exists, push the schema (drizzle `push`, because the migration chain assumes a schema that push created), truncate every table (migration journals and PostGIS internals survive), seed baselines (`seedRoles` + a Game Rules global with a main currency resource, `tests/seed-baseline.ts`), and repair id sequences (migrations backfill explicit-id rows, which leaves sequences behind and causes `ValidationError: field is invalid: id` on creates). Truncate runs at both global setup (clears crashed-run leftovers) and teardown, so every run starts and ends empty. `TEST_DB_HARD_RESET=1` drops and recreates the test database from scratch instead of truncating. Serialized test files (`fileParallelism: false`) because parallel workers share the one database.
|
||||||
- **Integration tests**: `tests/int/**/*.int.spec.ts` — Vitest with jsdom. Requires the live PostgreSQL test database (bootstrapped automatically via `vitest-global.ts`; `vitest.setup.ts` points the process at it). Uses `dotenv/config` via `vitest.setup.ts`.
|
- **Integration tests**: `tests/int/**/*.int.spec.ts` — Vitest with jsdom. Requires the live PostgreSQL test database (bootstrapped automatically via `vitest-global.ts`; `vitest.setup.ts` points the process at it). Uses `dotenv/config` via `vitest.setup.ts`.
|
||||||
- **E2E tests**: `tests/e2e/*.e2e.spec.ts` — Playwright with a project named "vivaldi" that launches the **Vivaldi binary** (`/usr/bin/vivaldi`, headless args in `playwright.config.ts`) — not plain Chromium. The `webServer` config auto-starts `pnpm dev` with `DATABASE_URI` pointed at the test DB (bootstrapped by `tests/e2e/global-setup.ts`, with `reuseExistingServer: true`). Currently minimal (homepage smoke test). Caveat: when your own dev server is already on port 3000, `reuseExistingServer` silently reuses it and its DEV database, bypassing the test-DB wiring; free the port first for a true test-DB run.
|
- **E2E tests**: `tests/e2e/*.e2e.spec.ts` — Playwright with a project named "vivaldi" that launches the **Vivaldi binary** (`/usr/bin/vivaldi`, headless args in `playwright.config.ts`) — not plain Chromium. Override the binary with `E2E_BROWSER_PATH` (e.g. Playwright's bundled chromium) when the installed Vivaldi crashes under automation; the default stays Vivaldi. The `webServer` config auto-starts `pnpm dev` with `DATABASE_URI` pointed at the test DB (bootstrapped by `tests/e2e/global-setup.ts`, with `reuseExistingServer: true`). `global-setup` also seeds the shared e2e login: `dev` / `Test123` (developer legacy role + a superuser Roles doc for permission-gated UI + the "J. Fraley" rank so the dashboard heading matches). E2E runs serially (`workers: 1`, one webpack dev server) with generous `timeout`/`expect` timeouts because first navigation to a route triggers a cold webpack compile. Currently: tours smoke (auto-start, dismissal, replay), frontend, operations, reservations, supply boxes, OPORD recall, session banner. Caveat: when your own dev server is already on port 3000, `reuseExistingServer` silently reuses it and its DEV database, bypassing the test-DB wiring; free the port first for a true test-DB run.
|
||||||
- Run a single integration test: `bun run vitest run tests/int/api.int.spec.ts`
|
- Run a single integration test: `bun run vitest run tests/int/api.int.spec.ts`
|
||||||
- Run a single e2e test: `bun run playwright test tests/e2e/frontend.e2e.spec.ts`
|
- Run a single e2e test: `bun run playwright test tests/e2e/frontend.e2e.spec.ts`
|
||||||
|
|
||||||
|
|
@ -307,7 +307,8 @@ Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full
|
||||||
|
|
||||||
`src/collections/wiki/`: `wiki-pages`, `wiki-revisions`, `wiki-templates`. A user-maintained field guide for campaigns, characters, places, and the stories around them. Admin group: **Wiki**.
|
`src/collections/wiki/`: `wiki-pages`, `wiki-revisions`, `wiki-templates`. A user-maintained field guide for campaigns, characters, places, and the stories around them. Admin group: **Wiki**.
|
||||||
|
|
||||||
- **WikiPages**: `title` (text, required), `slug` (text, required, unique + indexed, generated by the service layer from the title), `category` (select, required: Campaign/World/Lore/Characters/Plot/Media/Guides/Meta), `tags` (text hasMany), `body` (textarea, required, markdown source), `lockdown` (checkbox, default false), `lockedBy`/`lockedAt` (→ users / date), `lastEditor` (→ users). Access: read/create/update any logged-in user; delete requires intelligence qualification (`hasIntelligenceQualification`).
|
- **WikiPages**: `title` (text, required), `slug` (text, required, unique + indexed, generated by the service layer from the title), `category` (select, required: Campaign/World/Lore/Characters/Plot/Media/Guides/Meta/Documentation), `tags` (text hasMany), `body` (textarea, required, markdown source), `lockdown` (checkbox, default false), `lockedBy`/`lockedAt` (→ users / date), `lastEditor` (→ users). Access: read/create/update any logged-in user EXCEPT the **Documentation** category, where create/update is restricted to admin/developer (enforced twice: collection access functions for the admin-panel/REST path AND the wiki server actions, because the service layer runs with `overrideAccess`); delete requires intelligence qualification (`hasIntelligenceQualification`). A plain user cannot escalate a page into Documentation via update either (the update access check consults the incoming category AND the stored doc's category).
|
||||||
|
- **Documentation category**: in-depth, non-developer guides for the app's core systems (logistics and storage rules, banking, market and negotiation, shipments, base management, the map). Content lives as markdown files in `src/tools/seed/documentation/` (first H1 becomes the page title); `bun run src/tools/seed/seedDocumentation.ts` upserts them into `wiki-pages` (idempotent by slug, safe to re-run after edits). Migration `20260930_162200_add_documentation_wiki_category` adds the enum value. The wiki editor only offers Documentation in the category picker to admin/developer users; non-privileged users opening a Documentation page's edit URL get a "maintained by admins and developers" notice instead of the editor. Seeded pages have no `lastEditor`; the wiki index and detail header display **System** as the author for editor-less Documentation pages (other categories keep the "Unknown editor" fallback).
|
||||||
- **WikiRevisions**: `page` (→ wiki-pages), `revisionNumber` (number, min 1), `titleSnapshot`/`contentSnapshot`, `editor` (→ users), `summary`, `type` (`create`/`edit`/`restore`), `restoredFromRevision`. Access: read logged-in; create/update/delete `false` (written internally via `overrideAccess`).
|
- **WikiRevisions**: `page` (→ wiki-pages), `revisionNumber` (number, min 1), `titleSnapshot`/`contentSnapshot`, `editor` (→ users), `summary`, `type` (`create`/`edit`/`restore`), `restoredFromRevision`. Access: read logged-in; create/update/delete `false` (written internally via `overrideAccess`).
|
||||||
- **WikiTemplates**: `name` (unique), `body` (textarea, snippet with `{{param}}` placeholders), `description`. Read logged-in; write admin/developer.
|
- **WikiTemplates**: `name` (unique), `body` (textarea, snippet with `{{param}}` placeholders), `description`. Read logged-in; write admin/developer.
|
||||||
- **Service lib**: `src/lib/wiki/`.
|
- **Service lib**: `src/lib/wiki/`.
|
||||||
|
|
@ -362,6 +363,20 @@ A Discord bot living in `src/bot/`, run as a standalone long-running process via
|
||||||
- **Notifications / announcements (feature 3)**: `notificationBridge` polls `user-notifications` (cursor = last seen id; cap 5 DMs/tick) and DMs opted-in users (`preferences.discord.enabled`, not in `mutedTypes`). `/announce` (staff only) posts an announcement embed. New notify sites partially done: banking emits `finance:deposit`; shipments has no notify site yet.
|
- **Notifications / announcements (feature 3)**: `notificationBridge` polls `user-notifications` (cursor = last seen id; cap 5 DMs/tick) and DMs opted-in users (`preferences.discord.enabled`, not in `mutedTypes`). `/announce` (staff only) posts an announcement embed. New notify sites partially done: banking emits `finance:deposit`; shipments has no notify site yet.
|
||||||
- **Remaining polish**: the web preferences UI does not yet expose the `preferences.discord` toggles — they exist on the Users collection and are read by the bridge, but are currently only settable in the Payload admin panel.
|
- **Remaining polish**: the web preferences UI does not yet expose the `preferences.discord` toggles — they exist on the Users collection and are read by the bridge, but are currently only settable in the Payload admin panel.
|
||||||
|
|
||||||
|
## Guided Tours
|
||||||
|
|
||||||
|
Permission-aware, per-page onboarding tours. Tours are defined in code per page; no DB collections involved.
|
||||||
|
|
||||||
|
- **Registry**: `src/lib/tours/registry.ts` — `TOURS` array of `TourDefinition` (`id`, exact `path`, `steps`). Add a page's tour there; the auto-start + header replay control pick it up automatically. Pure module (client-safe).
|
||||||
|
- **Steps**: brief, keyed to stable selectors (`data-tour="..."` attributes placed in the page markup; pages use a shared `data-tour="page-header"` anchor for their header block). A step may carry `showWhen: (gates) => boolean` to hide itself from users who cannot access the feature it describes. `visibleTourSteps` (`src/lib/tours/types.ts`) filters per visitor.
|
||||||
|
- **Gates**: `TourGateContext` mirrors the sidebar's visibility inputs (`hasIntelligence`, `hasLogistics`, `hasAdminPanel`, `statisticsEnabled`, `helpdeskEnabled`); the `(frontend)` layout resolves them server-side and passes them to `TourHost`.
|
||||||
|
- **Engine**: `TourHost` (`src/components/frontend/tours/`) is mounted in the `(frontend)` layout for logged-in users only. It auto-starts the current route's tour the FIRST time a given browser visits the page; `GuidedTour` renders a spotlight + card (missing selectors fall back to a centered card; late-mounting targets are re-anchored via MutationObserver). Navigating away kills the running tour.
|
||||||
|
- **Dismissals**: per-browser localStorage (`ptf:tour:dismissals`, tour id → dismissedAt, permanent) via `src/lib/tours/dismissals.ts` (mirrors announcement dismissals). Dismissal happens on Skip/Close/Done. A dismissed tour never auto-starts again.
|
||||||
|
- **Skip all (opt-out)**: the tour card's "Skip all tours on this device" sets a per-browser flag (`ptf:tours:opted-out`) that suppresses auto-start entirely; tours become opt-in via the header replay control. The Account page has a "Guided Tours" toggle (`ToursPreference`) to re-enable. Replay always works regardless of the flag.
|
||||||
|
- **Replay**: `TourReplayButton` (help icon in `SiteHeader`, rendered only on routes that have a tour) dispatches `ptf:tour:replay`; `TourHost` re-runs the tour regardless of dismissal or opt-out. Any user can replay.
|
||||||
|
- **Coverage**: dashboard, roster, Friday Ops, event log, world map, ops overview + ledger, structures, shipments, banking, market, wiki, awards, locker. Remaining pages join by adding registry entries + anchors (reservations and supply boxes deliberately have no tour yet: their e2e specs interact with the page, and the tour overlay blocks clicks).
|
||||||
|
- **Tests**: `tests/int/tours.int.spec.ts` (step filtering by gates, registry invariants incl. dash-clean copy, dismissal storage) and `tests/e2e/tours.e2e.spec.ts` (auto-start, dismissal persistence across reload, replay).
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
Username-based login (no email login). Users log in via Payload admin with `username` only.
|
Username-based login (no email login). Users log in via Payload admin with `username` only.
|
||||||
|
|
|
||||||
37
CHANGELOG.md
37
CHANGELOG.md
|
|
@ -1,5 +1,42 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
# v0.2.58
|
||||||
|
|
||||||
|
The wiki gains a proper manual, and the app starts teaching itself. New Documentation pages explain how each system works in plain language, and major pages can walk you through themselves on first visit.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Documentation**: a new Documentation category in the wiki collects in-depth guides for logistics and storage rules, banking, the market and its negotiation flow, shipments, base management, and the map. Everyone can read them; only admins and developers can write them. If a page looks out of date, contact the staff team.
|
||||||
|
- **Guided tours**: major pages now greet first-time visitors with a short walkthrough that highlights the important parts of the screen. Steps respect your access, so you only see what you can actually use. Replay the current page's tour any time from the header help icon.
|
||||||
|
- **Skip all tours**: the tour window has a "Skip all tours on this device" action, and the Account page holds an "Auto-start page tours" toggle, so tours are opt-in rather than opt-out. Replaying a tour from the header always stays available.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Documentation authorship**: seeded Documentation pages show "System" as their author instead of "Unknown editor".
|
||||||
|
- **Wiki callouts**: the note and warning icons sit vertically centered in their callout boxes instead of floating above the text line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.2.57
|
||||||
|
|
||||||
|
Op prep gets a queue: claim the mission role you want before op night. Map editing also becomes permission-based piece by piece, so mapping rights can be handed out without giving away full control.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Mission role sign-ups**: you can now queue for a specific role on an upcoming mission. Meeting the role's minimum rank and MOS lets you claim one of its slots first come, first served, and everyone else waits in a numbered line. When a slot holder changes their RSVP to no or withdraws, the next person in line moves up automatically and gets a notification. Role promotions can be muted in your notification settings, and staff can still remove sign-ups.
|
||||||
|
- **Role slots and descriptions**: mission roles now declare how many slots they offer, and a role can carry a formatted description with headings, lists, and links, shown under the role name in the roles table and the sign-up panel.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **Map permissions**: drawing roads, zones, and resource nodes, and placing structures, are now separate permissions rather than one blanket map grant. Staff can give mapping rights to more people without handing over the rest of the map tools.
|
||||||
|
- **AI compose**: drafts now come from a larger Gemma model, and replies that arrive wrapped in code fences get cleaned up, so fields fill with prose instead of raw JSON.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Map editing was re-cut into four permissions: Author Roads, Author Zones, Author Nodes, and Place Structures. A role that could edit maps before needs the relevant ones granted again in the admin panel before its members can edit, and structure placement keeps requiring the logistics qualification on top.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# v0.2.55
|
# v0.2.55
|
||||||
|
|
||||||
Book view goes touch: turn pages with a swipe.
|
Book view goes touch: turn pages with a swipe.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.57",
|
"version": "0.2.58",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,30 @@ import { resolveTestDatabaseUri } from "./tests/test-db";
|
||||||
*/
|
*/
|
||||||
const testDatabaseUri = resolveTestDatabaseUri();
|
const testDatabaseUri = resolveTestDatabaseUri();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-server realities: first navigation to a route triggers a webpack
|
||||||
|
* compile that can take far longer than the defaults. Generous timeouts keep
|
||||||
|
* the suite honest on cold compiles without each spec carrying its own waits.
|
||||||
|
*/
|
||||||
|
const testTimeout = 180_000;
|
||||||
|
const expectTimeout = 30_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
|
/* Each test may take up to testTimeout (cold dev-server compiles). */
|
||||||
|
timeout: testTimeout,
|
||||||
|
expect: {
|
||||||
|
timeout: expectTimeout,
|
||||||
|
},
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: 1,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
|
@ -42,7 +55,10 @@ export default defineConfig({
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
executablePath: "/usr/bin/vivaldi",
|
// Default: the unit's actual browser (Vivaldi). E2E_BROWSER_PATH
|
||||||
|
// overrides the binary, e.g. when the installed Vivaldi crashes
|
||||||
|
// under automation; set it to Playwright's bundled chromium.
|
||||||
|
executablePath: process.env.E2E_BROWSER_PATH || "/usr/bin/vivaldi",
|
||||||
args: [
|
args: [
|
||||||
"--headless=new",
|
"--headless=new",
|
||||||
"--no-sandbox",
|
"--no-sandbox",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type { AccountAssignment } from "@/components/frontend/transfers/transfer
|
||||||
import { ChangePasswordForm } from "@/components/frontend/account/ChangePasswordForm";
|
import { ChangePasswordForm } from "@/components/frontend/account/ChangePasswordForm";
|
||||||
import { DiscordUsernameRequestForm } from "@/components/frontend/account/DiscordUsernameRequestForm";
|
import { DiscordUsernameRequestForm } from "@/components/frontend/account/DiscordUsernameRequestForm";
|
||||||
import { PreferencesForm } from "@/components/frontend/account/PreferencesForm";
|
import { PreferencesForm } from "@/components/frontend/account/PreferencesForm";
|
||||||
|
import { ToursPreference } from "@/components/frontend/account/ToursPreference";
|
||||||
import { RequestTransferDialog } from "@/components/frontend/account/RequestTransferDialog";
|
import { RequestTransferDialog } from "@/components/frontend/account/RequestTransferDialog";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
|
|
@ -188,6 +189,18 @@ export default async function AccountPage() {
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ export default async function AwardsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
<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-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">
|
<div className="flex items-center gap-2">
|
||||||
<MedalIcon className="size-4 text-muted-foreground" />
|
<MedalIcon className="size-4 text-muted-foreground" />
|
||||||
<h1 className="text-2xl font-bold tracking-tight uppercase">Awards</h1>
|
<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.
|
on the fly and are not catalogued here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<Button asChild size="sm">
|
||||||
<Link href="/awards/design">Design a ribbon</Link>
|
<Link href="/awards/design">Design a ribbon</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const metadata: Metadata = {
|
||||||
export default function EventLogPage() {
|
export default function EventLogPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6">
|
<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>
|
<h1 className="text-2xl font-semibold tracking-tight">Event Log</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
The full game event ledger: system actions and GM narrative events. Filter by type,
|
The full game event ledger: system actions and GM narrative events. Filter by type,
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ export default async function FridayCalendarPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5">
|
<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
|
<FridayCalendar
|
||||||
entries={entries}
|
entries={entries}
|
||||||
remainingSlots={remainingSlots}
|
remainingSlots={remainingSlots}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { ImpersonationBanner } from "@/components/frontend/impersonation/Imperso
|
||||||
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
|
import { ShimLoader } from "@/components/frontend/shims/ShimLoader";
|
||||||
import { AnnouncementHost } from "@/components/frontend/announcements/AnnouncementHost";
|
import { AnnouncementHost } from "@/components/frontend/announcements/AnnouncementHost";
|
||||||
import type { AnnouncementEntry } from "@/lib/announcements/evaluate";
|
import type { AnnouncementEntry } from "@/lib/announcements/evaluate";
|
||||||
|
import { TourHost } from "@/components/frontend/tours/TourHost";
|
||||||
import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate";
|
import { DevDashboardGate } from "@/components/frontend/dev/DevDashboardGate";
|
||||||
|
|
||||||
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
// 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}
|
userRoleDocIds={userRoleDocIds}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{user && (
|
||||||
|
<TourHost
|
||||||
|
gates={{
|
||||||
|
hasIntelligence: isIntelligence,
|
||||||
|
hasLogistics: isLogistics,
|
||||||
|
hasAdminPanel: isAdmin,
|
||||||
|
statisticsEnabled,
|
||||||
|
helpdeskEnabled,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export default async function LockerPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<LockKeyholeIcon className="size-5 text-muted-foreground" />
|
<LockKeyholeIcon className="size-5 text-muted-foreground" />
|
||||||
<h1 className="text-lg font-semibold">Personal Locker</h1>
|
<h1 className="text-lg font-semibold">Personal Locker</h1>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export default async function BankingPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<LandmarkIcon className="size-5 text-muted-foreground" />
|
<LandmarkIcon className="size-5 text-muted-foreground" />
|
||||||
<h1 className="text-lg font-semibold">Banking</h1>
|
<h1 className="text-lg font-semibold">Banking</h1>
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export default async function MarketPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<StoreIcon className="size-5 text-muted-foreground" />
|
<StoreIcon className="size-5 text-muted-foreground" />
|
||||||
<h1 className="text-lg font-semibold">Market</h1>
|
<h1 className="text-lg font-semibold">Market</h1>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export default async function LogisticsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<Building2Icon className="size-5 text-muted-foreground" />
|
<Building2Icon className="size-5 text-muted-foreground" />
|
||||||
<h1 className="text-lg font-semibold">Structures</h1>
|
<h1 className="text-lg font-semibold">Structures</h1>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default async function ShipmentsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<TruckIcon className="size-5 text-muted-foreground" />
|
<TruckIcon className="size-5 text-muted-foreground" />
|
||||||
<h1 className="text-lg font-semibold">Shipments</h1>
|
<h1 className="text-lg font-semibold">Shipments</h1>
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export default async function MapPickerPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[240px] flex-1 flex-col">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<MapIcon className="size-4 text-muted-foreground" />
|
<MapIcon className="size-4 text-muted-foreground" />
|
||||||
<h1 className="text-sm font-semibold uppercase tracking-[0.2em]">World Map</h1>
|
<h1 className="text-sm font-semibold uppercase tracking-[0.2em]">World Map</h1>
|
||||||
|
|
@ -76,7 +76,7 @@ export default async function MapPickerPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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) => {
|
{maps.map((map) => {
|
||||||
const campaign =
|
const campaign =
|
||||||
map.campaign && typeof map.campaign === "object" ? (map.campaign as Campaign) : null;
|
map.campaign && typeof map.campaign === "object" ? (map.campaign as Campaign) : null;
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,11 @@ export default async function OperationsLedgerPage({
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
<OperationsRealtimeRefresher />
|
<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">
|
<p className="max-w-2xl text-sm text-white/70">
|
||||||
Every validated, rejected, and dead-lettered operation event with its derived effects.
|
Every validated, rejected, and dead-lettered operation event with its derived effects.
|
||||||
Rejected and dead-lettered entries are retained as evidence and never applied.
|
Rejected and dead-lettered entries are retained as evidence and never applied.
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default async function OperationsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
<OperationsRealtimeRefresher />
|
<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">
|
<p className="max-w-2xl text-sm text-white/70">
|
||||||
Live and historical operation activity: extraction returns, settlements, readiness
|
Live and historical operation activity: extraction returns, settlements, readiness
|
||||||
aggregates, and zone pressure. Every effect here is provenance-linked to a validated
|
aggregates, and zone pressure. Every effect here is provenance-linked to a validated
|
||||||
|
|
|
||||||
|
|
@ -331,17 +331,21 @@ export default async function HomePage() {
|
||||||
widgetNodes.set("recent-events", <RecentEvents events={events} currentUserId={userId} />);
|
widgetNodes.set("recent-events", <RecentEvents events={events} currentUserId={userId} />);
|
||||||
widgetNodes.set(
|
widgetNodes.set(
|
||||||
"quick-stats",
|
"quick-stats",
|
||||||
|
<div data-tour="dashboard-quick-stats">
|
||||||
<QuickStats
|
<QuickStats
|
||||||
activeCampaigns={campaignsRes.totalDocs}
|
activeCampaigns={campaignsRes.totalDocs}
|
||||||
totalMissions={missionsCountRes.totalDocs}
|
totalMissions={missionsCountRes.totalDocs}
|
||||||
totalStructures={structuresRes.totalDocs}
|
totalStructures={structuresRes.totalDocs}
|
||||||
totalFactions={factionsRes.totalDocs}
|
totalFactions={factionsRes.totalDocs}
|
||||||
totalMembers={profilesRes.totalDocs}
|
totalMembers={profilesRes.totalDocs}
|
||||||
/>,
|
/>
|
||||||
|
</div>,
|
||||||
);
|
);
|
||||||
widgetNodes.set(
|
widgetNodes.set(
|
||||||
"wallet",
|
"wallet",
|
||||||
<WalletWidget account={walletAccount} currencyConfig={currencyConfig} />,
|
<div data-tour="dashboard-wallet">
|
||||||
|
<WalletWidget account={walletAccount} currencyConfig={currencyConfig} />
|
||||||
|
</div>,
|
||||||
);
|
);
|
||||||
if (negotiations.length > 0) {
|
if (negotiations.length > 0) {
|
||||||
widgetNodes.set(
|
widgetNodes.set(
|
||||||
|
|
@ -367,10 +371,7 @@ export default async function HomePage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (hasIntelligence) {
|
if (hasIntelligence) {
|
||||||
widgetNodes.set(
|
widgetNodes.set("upcoming-missions", <UpcomingMissions missions={upcomingMissions} />);
|
||||||
"upcoming-missions",
|
|
||||||
<UpcomingMissions missions={upcomingMissions} />,
|
|
||||||
);
|
|
||||||
widgetNodes.set("active-campaigns", <ActiveCampaigns campaigns={activeCampaigns} />);
|
widgetNodes.set("active-campaigns", <ActiveCampaigns campaigns={activeCampaigns} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,7 +391,7 @@ export default async function HomePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<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">
|
<h1 className="text-lg font-semibold">
|
||||||
Welcome back, {rankData.name}. {/*{profileUser?.displayName ?? user.username}*/}
|
Welcome back, {rankData.name}. {/*{profileUser?.displayName ?? user.username}*/}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export default async function RosterPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<UsersIcon className="size-5 text-muted-foreground" />
|
<UsersIcon className="size-5 text-muted-foreground" />
|
||||||
<h1 className="text-lg font-semibold">Unit Roster</h1>
|
<h1 className="text-lg font-semibold">Unit Roster</h1>
|
||||||
|
|
|
||||||
|
|
@ -541,17 +541,16 @@
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown .note .callout-icon {
|
.wiki-markdown .note .callout-icon,
|
||||||
|
.wiki-markdown .warning .callout-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.75rem;
|
left: 0.75rem;
|
||||||
top: 1.125rem;
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-markdown .warning .callout-icon {
|
.wiki-markdown .warning .callout-icon {
|
||||||
position: absolute;
|
|
||||||
left: 0.75rem;
|
|
||||||
top: 1.125rem;
|
|
||||||
color: var(--color-destructive);
|
color: var(--color-destructive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -565,6 +564,37 @@
|
||||||
background: color-mix(in oklch, var(--color-destructive) 8%, transparent);
|
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 rich-text typography (scoped to .lexical-content); Tailwind preflight resets headings */
|
||||||
.lexical-content {
|
.lexical-content {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|
@ -3591,4 +3621,3 @@
|
||||||
.book-view-body [style*="text-align"] {
|
.book-view-body [style*="text-align"] {
|
||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { LockdownBanner } from "@/components/frontend/wiki/LockdownBanner";
|
import { LockdownBanner } from "@/components/frontend/wiki/LockdownBanner";
|
||||||
import { WikiEditor } from "@/components/frontend/wiki/WikiEditor";
|
import { WikiEditor } from "@/components/frontend/wiki/WikiEditor";
|
||||||
import { listTemplates, templateMap } from "@/lib/wiki/service";
|
import { listTemplates, templateMap } from "@/lib/wiki/service";
|
||||||
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Edit wiki page: Polaris Task Force",
|
title: "Edit wiki page: Polaris Task Force",
|
||||||
|
|
@ -74,6 +75,31 @@ export default async function EditWikiPage({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canEditDocumentation = hasRoles(["admin"], user);
|
||||||
|
if (page.category === "Documentation" && !canEditDocumentation) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-5">
|
||||||
|
<Link
|
||||||
|
href={`/wiki/${page.slug}`}
|
||||||
|
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-3" />
|
||||||
|
Back to page
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PencilIcon className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Edit {page.title}</h1>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||||
|
Documentation pages are maintained by admins and developers. If something here is out
|
||||||
|
of date, contact the staff team.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [templates, pagesResult, hasEditedRevisions] = await Promise.all([
|
const [templates, pagesResult, hasEditedRevisions] = await Promise.all([
|
||||||
listTemplates(payload),
|
listTemplates(payload),
|
||||||
payload.find({
|
payload.find({
|
||||||
|
|
@ -132,6 +158,7 @@ export default async function EditWikiPage({
|
||||||
templateHtmlMap={templateHtmlMap(templates)}
|
templateHtmlMap={templateHtmlMap(templates)}
|
||||||
existingPages={existingPages}
|
existingPages={existingPages}
|
||||||
isFirstEdit={!hasEditedRevisions}
|
isFirstEdit={!hasEditedRevisions}
|
||||||
|
canEditDocumentation={canEditDocumentation}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,11 @@ export default async function WikiDetailPage({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<WikiContent prepared={prepared} scopeClasses={scopeClasses} htmlTemplates={templateHtmlMap} />
|
<WikiContent
|
||||||
|
prepared={prepared}
|
||||||
|
scopeClasses={scopeClasses}
|
||||||
|
htmlTemplates={templateHtmlMap}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -218,7 +222,15 @@ export default async function WikiDetailPage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1 text-right text-xs text-muted-foreground">
|
<div className="flex shrink-0 flex-col items-end gap-1 text-right text-xs text-muted-foreground">
|
||||||
<span>Last edited by {relationName(page.lastEditor)}</span>
|
{/* Seeded Documentation pages are written by the system, not a user. */}
|
||||||
|
<span>
|
||||||
|
Last edited by{" "}
|
||||||
|
{page.lastEditor != null
|
||||||
|
? relationName(page.lastEditor)
|
||||||
|
: page.category === "Documentation"
|
||||||
|
? "System"
|
||||||
|
: "Unknown editor"}
|
||||||
|
</span>
|
||||||
<time dateTime={page.updatedAt}>{formatDate(page.updatedAt)}</time>
|
<time dateTime={page.updatedAt}>{formatDate(page.updatedAt)}</time>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -227,7 +239,11 @@ export default async function WikiDetailPage({
|
||||||
{lockdown && <LockdownBanner />}
|
{lockdown && <LockdownBanner />}
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<WikiContent prepared={prepared} scopeClasses={scopeClasses} htmlTemplates={templateHtmlMap} />
|
<WikiContent
|
||||||
|
prepared={prepared}
|
||||||
|
scopeClasses={scopeClasses}
|
||||||
|
htmlTemplates={templateHtmlMap}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<WikiToolbar
|
<WikiToolbar
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import type { User, WikiPage } from "@/payload-types";
|
import type { User, WikiPage } from "@/payload-types";
|
||||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||||
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,6 +22,17 @@ interface ActionResult<T = undefined> {
|
||||||
data?: T;
|
data?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documentation-category pages are curated admin/developer content. The wiki
|
||||||
|
* service layer runs with overrideAccess, so this rule must be enforced here
|
||||||
|
* in addition to the collection access functions.
|
||||||
|
*/
|
||||||
|
function documentationEditError(user: User, effectiveCategory: string | undefined): string | null {
|
||||||
|
if (effectiveCategory !== "Documentation") return null;
|
||||||
|
if (hasRoles(["admin"], user)) return null;
|
||||||
|
return "Only admins and developers can create or edit Documentation pages.";
|
||||||
|
}
|
||||||
|
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
const payloadConfig = await config;
|
const payloadConfig = await config;
|
||||||
const payload = await getPayload({ config: payloadConfig });
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
|
@ -70,6 +82,8 @@ export async function createWikiPage(input: {
|
||||||
}): Promise<ActionResult<{ id: number; slug: string }>> {
|
}): Promise<ActionResult<{ id: number; slug: string }>> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
|
const docError = documentationEditError(user, input.category);
|
||||||
|
if (docError) return { success: false, error: docError };
|
||||||
const page = await createPage(payload, user, input);
|
const page = await createPage(payload, user, input);
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.WikiPageCreate,
|
type: EventTypes.WikiPageCreate,
|
||||||
|
|
@ -101,6 +115,8 @@ export async function updateWikiPage(input: {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
const page = await fetchPage(payload, input.id);
|
const page = await fetchPage(payload, input.id);
|
||||||
if (!page) return { success: false, error: "Wiki page not found." };
|
if (!page) return { success: false, error: "Wiki page not found." };
|
||||||
|
const docError = documentationEditError(user, input.category ?? page.category);
|
||||||
|
if (docError) return { success: false, error: docError };
|
||||||
const updated = await updatePage(payload, user, page, input);
|
const updated = await updatePage(payload, user, page, input);
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.WikiPageEdit,
|
type: EventTypes.WikiPageEdit,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { getPayload } from "payload";
|
||||||
import { ArrowLeftIcon, FilePlus2Icon } from "lucide-react";
|
import { ArrowLeftIcon, FilePlus2Icon } from "lucide-react";
|
||||||
import { WikiEditor } from "@/components/frontend/wiki/WikiEditor";
|
import { WikiEditor } from "@/components/frontend/wiki/WikiEditor";
|
||||||
import { listTemplates, templateMap } from "@/lib/wiki/service";
|
import { listTemplates, templateMap } from "@/lib/wiki/service";
|
||||||
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "New wiki page (Polaris Task Force)",
|
title: "New wiki page (Polaris Task Force)",
|
||||||
|
|
@ -90,6 +91,7 @@ export default async function NewWikiPage({
|
||||||
templateHtmlMap={templateHtmlMap(templates)}
|
templateHtmlMap={templateHtmlMap(templates)}
|
||||||
existingPages={existingPages}
|
existingPages={existingPages}
|
||||||
isFirstEdit={!hasEditedRevisions}
|
isFirstEdit={!hasEditedRevisions}
|
||||||
|
canEditDocumentation={hasRoles(["admin"], user)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,13 @@ export default async function WikiPage() {
|
||||||
category: page.category,
|
category: page.category,
|
||||||
tags: page.tags ?? [],
|
tags: page.tags ?? [],
|
||||||
updatedAt: page.updatedAt,
|
updatedAt: page.updatedAt,
|
||||||
lastEditorName: relationName(page.lastEditor),
|
// Seeded Documentation pages are written by the system, not a user.
|
||||||
|
lastEditorName:
|
||||||
|
page.lastEditor != null
|
||||||
|
? relationName(page.lastEditor)
|
||||||
|
: page.category === "Documentation"
|
||||||
|
? "System"
|
||||||
|
: "Unknown editor",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,20 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||||
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documentation-category pages are curated admin/developer content (in-depth
|
||||||
|
* system docs), unlike the rest of the wiki where any logged-in user can
|
||||||
|
* write. Read stays open to all logged-in users; delete/lock remain under the
|
||||||
|
* existing wiki moderator rules (intelligence qualification).
|
||||||
|
*/
|
||||||
|
function canEditDocumentationCategory(user: Parameters<typeof hasRoles>[1]): boolean {
|
||||||
|
return hasRoles(["admin"], user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDocumentationCategory(category: unknown): boolean {
|
||||||
|
return typeof category === "string" && category === "Documentation";
|
||||||
|
}
|
||||||
|
|
||||||
export const WikiPages: CollectionConfig = {
|
export const WikiPages: CollectionConfig = {
|
||||||
slug: "wiki-pages",
|
slug: "wiki-pages",
|
||||||
|
|
@ -12,8 +27,39 @@ export const WikiPages: CollectionConfig = {
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: ({ req }) => !!req.user,
|
read: ({ req }) => !!req.user,
|
||||||
create: ({ req }) => !!req.user,
|
create: ({ req, data }) => {
|
||||||
update: ({ req }) => !!req.user,
|
if (!req.user) return false;
|
||||||
|
if (isDocumentationCategory(data?.category)) {
|
||||||
|
return canEditDocumentationCategory(req.user);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
update: async ({ req, id, data }) => {
|
||||||
|
if (!req.user) return false;
|
||||||
|
// Check BOTH the incoming category and the existing doc's category: a
|
||||||
|
// plain user must not edit an existing Documentation page, and must not
|
||||||
|
// escalate their own page into Documentation either.
|
||||||
|
if (isDocumentationCategory(data?.category)) {
|
||||||
|
return canEditDocumentationCategory(req.user);
|
||||||
|
}
|
||||||
|
if (typeof id === "number") {
|
||||||
|
try {
|
||||||
|
const existing = (await req.payload.findByID({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as { category?: string } | null;
|
||||||
|
if (isDocumentationCategory(existing?.category)) {
|
||||||
|
return canEditDocumentationCategory(req.user);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unknown id: let the operation proceed and fail validation later.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
delete: async ({ req }) => {
|
delete: async ({ req }) => {
|
||||||
return await hasIntelligenceQualification(req.payload, req.user);
|
return await hasIntelligenceQualification(req.payload, req.user);
|
||||||
},
|
},
|
||||||
|
|
@ -47,6 +93,7 @@ export const WikiPages: CollectionConfig = {
|
||||||
{ label: "Media", value: "Media" },
|
{ label: "Media", value: "Media" },
|
||||||
{ label: "Guides", value: "Guides" },
|
{ label: "Guides", value: "Guides" },
|
||||||
{ label: "Meta", value: "Meta" },
|
{ label: "Meta", value: "Meta" },
|
||||||
|
{ label: "Documentation", value: "Documentation" },
|
||||||
],
|
],
|
||||||
index: true,
|
index: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center p-5 gap-6 bg-background">
|
<div className="min-h-screen flex flex-col items-center justify-center p-5 gap-6 bg-background">
|
||||||
<div className="flex flex-col items-center gap-3 text-center max-w-md">
|
<div className="flex flex-col items-center gap-3 text-center max-w-md">
|
||||||
|
<h1 className="sr-only">Polaris Task Force</h1>
|
||||||
<div className="w-[260px] pb-2 [&_img]:w-full [&_img]:h-auto">
|
<div className="w-[260px] pb-2 [&_img]:w-full [&_img]:h-auto">
|
||||||
<AppLogo />
|
<AppLogo />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { NotificationsBell } from "@/components/frontend/notifications/NotificationsBell";
|
import { NotificationsBell } from "@/components/frontend/notifications/NotificationsBell";
|
||||||
|
import { TourReplayButton } from "@/components/frontend/tours/TourReplayButton";
|
||||||
import { Command } from "lucide-react";
|
import { Command } from "lucide-react";
|
||||||
|
|
||||||
export const SiteHeader = () => {
|
export const SiteHeader = () => {
|
||||||
|
|
@ -12,7 +13,8 @@ export const SiteHeader = () => {
|
||||||
<Command className="h-3 w-3" />
|
<Command className="h-3 w-3" />
|
||||||
<span>K</span>
|
<span>K</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<TourReplayButton />
|
||||||
<NotificationsBell />
|
<NotificationsBell />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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;
|
label.length > 8 ? `${label.slice(0, 8)}…` : label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6" data-tour="banking-overview">
|
||||||
<WalletHero
|
<WalletHero
|
||||||
account={myAccount ?? null}
|
account={myAccount ?? null}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export function ShipmentsList({ shipments }: ShipmentsListProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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) => (
|
{statusFilters.map((f) => (
|
||||||
<Button
|
<Button
|
||||||
key={f.value ?? "all"}
|
key={f.value ?? "all"}
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,16 @@ export function PageShell({
|
||||||
title,
|
title,
|
||||||
meta,
|
meta,
|
||||||
children,
|
children,
|
||||||
|
dataTour,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
meta?: string;
|
meta?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/** Optional stable anchor for the guided-tour system. */
|
||||||
|
dataTour?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<header className="flex flex-col gap-1">
|
||||||
<h1 className="text-xl font-bold tracking-tight text-foreground">{title}</h1>
|
<h1 className="text-xl font-bold tracking-tight text-foreground">{title}</h1>
|
||||||
{meta ? <ClassificationStrip left={meta} right="Polaris // Ops" /> : null}
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,12 @@ interface WikiEditorProps {
|
||||||
readonly templateHtmlMap?: Record<string, string>;
|
readonly templateHtmlMap?: Record<string, string>;
|
||||||
readonly existingPages: readonly { title: string }[];
|
readonly existingPages: readonly { title: string }[];
|
||||||
readonly isFirstEdit?: boolean;
|
readonly isFirstEdit?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the current user may use the Documentation category (admin and
|
||||||
|
* developer roles). The server enforces this too; this only shapes the
|
||||||
|
* category picker.
|
||||||
|
*/
|
||||||
|
readonly canEditDocumentation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWikiCategory(value: string): value is WikiCategory {
|
function isWikiCategory(value: string): value is WikiCategory {
|
||||||
|
|
@ -70,6 +76,7 @@ export function WikiEditor({
|
||||||
templateHtmlMap = {},
|
templateHtmlMap = {},
|
||||||
existingPages,
|
existingPages,
|
||||||
isFirstEdit = false,
|
isFirstEdit = false,
|
||||||
|
canEditDocumentation = false,
|
||||||
}: WikiEditorProps) {
|
}: WikiEditorProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
@ -173,11 +180,18 @@ export function WikiEditor({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{WIKI_CATEGORIES.map((option) => (
|
{WIKI_CATEGORIES.map((option) => {
|
||||||
|
if (option === "Documentation" && !canEditDocumentation) {
|
||||||
|
// Keep the option visible-but-disabled when the page being
|
||||||
|
// edited is already Documentation, so the value renders.
|
||||||
|
if (category !== "Documentation") return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
<SelectItem key={option} value={option}>
|
<SelectItem key={option} value={option}>
|
||||||
{option}
|
{option}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export function WikiIndex({ pages, isModerator }: WikiIndexProps) {
|
||||||
}, [category, pages, search, tagFilter]);
|
}, [category, pages, search, tagFilter]);
|
||||||
|
|
||||||
return (
|
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 justify-between gap-3">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<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);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ export const WIKI_CATEGORIES = [
|
||||||
"Media",
|
"Media",
|
||||||
"Guides",
|
"Guides",
|
||||||
"Meta",
|
"Meta",
|
||||||
|
"Documentation",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WikiCategory = (typeof WIKI_CATEGORIES)[number];
|
export type WikiCategory = (typeof WIKI_CATEGORIES)[number];
|
||||||
|
|
|
||||||
35067
src/migrations/20260930_162200_add_documentation_wiki_category.json
Normal file
35067
src/migrations/20260930_162200_add_documentation_wiki_category.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TYPE "public"."enum_wiki_pages_category" ADD VALUE 'Documentation';`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
// PostgreSQL cannot remove enum values; the down migration is a no-op.
|
||||||
|
await db.execute(sql``);
|
||||||
|
}
|
||||||
|
|
@ -94,6 +94,7 @@ import * as migration_20260925_235150_add_mission_role_signups from './20260925_
|
||||||
import * as migration_20260926_030859_add_mission_role_description from './20260926_030859_add_mission_role_description';
|
import * as migration_20260926_030859_add_mission_role_description from './20260926_030859_add_mission_role_description';
|
||||||
import * as migration_20260926_044319_add_mission_role_description_richtext from './20260926_044319_add_mission_role_description_richtext';
|
import * as migration_20260926_044319_add_mission_role_description_richtext from './20260926_044319_add_mission_role_description_richtext';
|
||||||
import * as migration_20260928_021138_change_ai_model from './20260928_021138_change_ai_model';
|
import * as migration_20260928_021138_change_ai_model from './20260928_021138_change_ai_model';
|
||||||
|
import * as migration_20260930_162200_add_documentation_wiki_category from './20260930_162200_add_documentation_wiki_category';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -576,4 +577,9 @@ export const migrations = [
|
||||||
down: migration_20260928_021138_change_ai_model.down,
|
down: migration_20260928_021138_change_ai_model.down,
|
||||||
name: '20260928_021138_change_ai_model'
|
name: '20260928_021138_change_ai_model'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260930_162200_add_documentation_wiki_category.up,
|
||||||
|
down: migration_20260930_162200_add_documentation_wiki_category.down,
|
||||||
|
name: '20260930_162200_add_documentation_wiki_category'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -902,6 +902,7 @@ export const enum_wiki_pages_category = pgEnum("enum_wiki_pages_category", [
|
||||||
"Media",
|
"Media",
|
||||||
"Guides",
|
"Guides",
|
||||||
"Meta",
|
"Meta",
|
||||||
|
"Documentation",
|
||||||
]);
|
]);
|
||||||
export const enum_wiki_revisions_type = pgEnum("enum_wiki_revisions_type", [
|
export const enum_wiki_revisions_type = pgEnum("enum_wiki_revisions_type", [
|
||||||
"create",
|
"create",
|
||||||
|
|
|
||||||
|
|
@ -4080,7 +4080,7 @@ export interface WikiPage {
|
||||||
* URL slug. Generated by the service layer from the title.
|
* URL slug. Generated by the service layer from the title.
|
||||||
*/
|
*/
|
||||||
slug: string;
|
slug: string;
|
||||||
category: 'Campaign' | 'World' | 'Lore' | 'Characters' | 'Plot' | 'Media' | 'Guides' | 'Meta';
|
category: 'Campaign' | 'World' | 'Lore' | 'Characters' | 'Plot' | 'Media' | 'Guides' | 'Meta' | 'Documentation';
|
||||||
tags?: string[] | null;
|
tags?: string[] | null;
|
||||||
/**
|
/**
|
||||||
* Markdown source. Rendered by the frontend.
|
* Markdown source. Rendered by the frontend.
|
||||||
|
|
|
||||||
60
src/tools/seed/documentation/banking.md
Normal file
60
src/tools/seed/documentation/banking.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Banking
|
||||||
|
|
||||||
|
The banking system handles money for people, factions, and the unit itself. You find it under **Logistics**, then **Banking**, in the sidebar. All accounts are held in the unit's main currency, configured by command in Game Rules, so the name and display labels you see on amounts follow that setup.
|
||||||
|
|
||||||
|
## Account types
|
||||||
|
|
||||||
|
There are three kinds of accounts:
|
||||||
|
|
||||||
|
- **Personal**: your own wallet. Every player can have one, and one is created automatically the first time you need it (for example when you receive your first payment). It starts at zero.
|
||||||
|
- **Treasury**: the unit's shared pool. Market sales of vendor stock flow into it, and staff salaries and structure maintenance are paid out of it (see [[Base Management]]).
|
||||||
|
- **Faction**: a shared pool owned by a specific faction rather than the unit as a whole.
|
||||||
|
|
||||||
|
## Who can create and manage accounts
|
||||||
|
|
||||||
|
- You can always create your **own personal account** from the Banking page. You cannot create personal accounts for other people.
|
||||||
|
- **Treasury and faction accounts require a manager** to create or touch. A manager is an admin, a developer, or a logistics-qualified member.
|
||||||
|
- Personal accounts are managed by their **owner or a manager**. Nobody else can deposit, withdraw, or transfer from your account.
|
||||||
|
|
||||||
|
## Everyday operations
|
||||||
|
|
||||||
|
Three actions cover daily use, all available from an account's detail page:
|
||||||
|
|
||||||
|
- **Deposit**: move funds from outside the system into an account.
|
||||||
|
- **Withdraw**: take funds out of an account.
|
||||||
|
- **Transfer**: move funds between two accounts in one step.
|
||||||
|
|
||||||
|
Transfers require both a source and a destination account, amounts must be positive, and both accounts must be open. A **frozen** or **closed** account rejects every transaction, and a source account must hold enough to cover the amount plus any fee.
|
||||||
|
|
||||||
|
## Transaction numbers
|
||||||
|
|
||||||
|
Every completed transaction gets a unique **transaction number** automatically, something like `TXN-MD3A9F-K7Q2`. You will see it on the transaction record alongside the type (deposit, withdrawal, transfer, payment, fee, salary, or adjustment), the accounts involved, the amount, any fee, and a memo. Quote this number whenever you need to point at a specific payment, for example when asking staff to trace a purchase from the [[Market and Negotiation]] or a shipment expense from [[Shipments]].
|
||||||
|
|
||||||
|
## Ledger entries
|
||||||
|
|
||||||
|
Behind each transaction, the system writes **one ledger entry per affected account**. Each entry records:
|
||||||
|
|
||||||
|
- the account it belongs to,
|
||||||
|
- a **signed amount**: positive for money in (a credit), negative for money out (a debit),
|
||||||
|
- the resulting balance after the entry,
|
||||||
|
- the transaction it belongs to and the time it happened.
|
||||||
|
|
||||||
|
So a 500 credit transfer from Alice to Bob produces two ledger entries: one at Alice's account for -500 and one at Bob's account for +500, both referencing the same transaction number. The ledger is the audit trail; balances are never adjusted without one.
|
||||||
|
|
||||||
|
## Finding your wallet
|
||||||
|
|
||||||
|
Open **Logistics**, then **Banking**. Your personal account appears as your wallet card at the top of the overview, showing your balance and quick actions. If you have never had one, opening the page creates it for you on the spot. From the same page you can open any account you are allowed to see, review its transaction history, and read its ledger table.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Balances are maintained by the transaction engine itself. Nobody edits a balance by hand; every change is a transaction with ledger entries, which is what makes the numbers trustworthy.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Account type | Who can create it | Who can manage it |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Personal | Its owner only | Owner or a manager |
|
||||||
|
| Treasury | A manager | Managers only |
|
||||||
|
| Faction | A manager | Managers only |
|
||||||
|
|
||||||
|
Manager here means admin, developer, or logistics-qualified member.
|
||||||
62
src/tools/seed/documentation/base-management.md
Normal file
62
src/tools/seed/documentation/base-management.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Base Management
|
||||||
|
|
||||||
|
Structures are not self-running. They need staff, they cost money, they eat upkeep materials, and they can be improved. All of that lives under the umbrella of base management, on the page of each structure (open it from **Logistics**, then **Structures**). Money side effects land in [[Banking]] accounts, and materials move through [[Logistics and Storage Rules]].
|
||||||
|
|
||||||
|
## Staffing
|
||||||
|
|
||||||
|
Two kinds of workers keep a structure running:
|
||||||
|
|
||||||
|
- **Named NPC staff** fill specific positions (administrative, technical, and other slots, each with its own cap per structure). You hire a particular NPC into a titled role; they can hold only one job at a time.
|
||||||
|
- **Labor headcount** blocks of workers by trade (for example builders or loaders). Instead of individuals, you set a headcount number per labor classification, capped by what the structure accepts and by how many workers of that trade are available. Paying above the market rate widens the pool you can draw from.
|
||||||
|
|
||||||
|
## The hiring gate
|
||||||
|
|
||||||
|
Staff hiring can be switched off globally by command through a flag in Game Rules. When it is off, every hire and headcount change is refused with a clear message, usually with a reason attached. This is deliberate: hiring is frozen between turns or during events, not broken.
|
||||||
|
|
||||||
|
## Salaries and the base tick
|
||||||
|
|
||||||
|
A periodic job called the **base tick** runs in the background. Each pass it pays every active salary out of the unit's **treasury account**, one transaction per staffing record, with the memo showing who or what was paid.
|
||||||
|
|
||||||
|
When the treasury cannot cover a salary, nothing is silently dropped: the payment is recorded as a **salary unpaid** event on the structure's event log, so commanders can see exactly which workers went unpaid and why. The same applies to maintenance below.
|
||||||
|
|
||||||
|
## Maintenance and upkeep
|
||||||
|
|
||||||
|
- **Maintenance cost**: every structure with a maintenance cost has it charged from the treasury each tick. Failure to pay is logged as a maintenance unpaid event.
|
||||||
|
- **Upkeep materials**: blueprints can require resources (fuel, parts, rations). Each tick the structure consumes what it needs from its storage (grid plus void). If storage falls short, the shortfall is flagged with an **upkeep shortage** event naming the resource, what it has, and what it needs.
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
Upkeep draws from stored materials, not from the treasury. A full bank account does not save a depot that ran out of spare parts; keep the supply line running with [[Shipments]].
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Upgrades
|
||||||
|
|
||||||
|
Many blueprints list a structure they **upgrade into**. Upgrading requires the listed materials to be present in the structure's storage. When you trigger the upgrade, the materials are consumed and the structure's type is swapped to the upgraded blueprint. The structure keeps its identity and location; it just becomes the better version.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
Structures can install **modules**: add-ons such as extra storage capacity or production boosts. Installing a module consumes its listed materials from the structure's storage. Removing one does **not** refund materials, so treat uninstalling as a last resort. Active modules add effects such as:
|
||||||
|
|
||||||
|
- extra storage capacity in kilograms,
|
||||||
|
- a multiplier on production output,
|
||||||
|
- extra grid storage slots.
|
||||||
|
|
||||||
|
## Construction
|
||||||
|
|
||||||
|
Newly placed structures (and some upgrades) do not start fully built. Delivering the blueprint's required materials to the site **starts a timed construction**: the site shows a completion timestamp, and when it passes, the structure flips to operational. Crew matters: the blueprint lists a required crew, and the size of your labor workforce (weighted by the trades' efficiency) scales how fast the clock runs. An understaffed site still builds, but slower.
|
||||||
|
|
||||||
|
You can watch construction progress on [[The Map]], where building sites show a progress bar.
|
||||||
|
|
||||||
|
## Compound hubs
|
||||||
|
|
||||||
|
Structures can be attached to a **compound hub** as child buildings. Operational children pool their storage into the hub, so a compound acts as one big warehouse. The map folds child buildings into their hub marker and the hub shows how many buildings it contains.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Topic | Where the resources come from |
|
||||||
|
| --- | --- |
|
||||||
|
| Salaries | Treasury account, paid by the base tick |
|
||||||
|
| Maintenance | Treasury account, per structure per tick |
|
||||||
|
| Upkeep | Structure storage (grid + void) |
|
||||||
|
| Upgrades and modules | Structure storage, consumed on install |
|
||||||
|
| Construction | Delivered materials, then a timed build |
|
||||||
|
| Compound storage | Pooled into the hub |
|
||||||
48
src/tools/seed/documentation/logistics-and-storage-rules.md
Normal file
48
src/tools/seed/documentation/logistics-and-storage-rules.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Logistics and Storage Rules
|
||||||
|
|
||||||
|
Every base, depot, and outpost in the game world is a **structure**. Structures hold resources (fuel, ammo, building materials, food, and so on) in their storage, and nearly everything else in the logistics loop depends on them: shipments deliver into them, construction and upgrades consume from them, and upkeep is drawn from them each cycle.
|
||||||
|
|
||||||
|
You can view and manage a structure's storage by opening its page under **Logistics** in the sidebar. From there you can deposit resources you are carrying, withdraw them back out, and transfer stock between structures.
|
||||||
|
|
||||||
|
## How deposits work
|
||||||
|
|
||||||
|
When you deposit a resource, the system checks it against the structure's storage rules before anything moves. Storage comes in two flavors, and both count the same way:
|
||||||
|
|
||||||
|
- **Grid storage**: items placed in visible storage slots.
|
||||||
|
- **Void storage**: overflow stock tracked without a grid position.
|
||||||
|
|
||||||
|
When the rules calculate how much of a resource is "stored here", they add grid and void together. Hiding items in void storage does not dodge a cap.
|
||||||
|
|
||||||
|
## The three storage rules
|
||||||
|
|
||||||
|
Structures (defined by their blueprint type) can have three kinds of restrictions. They are always checked in the same order, and the first rule that fails rejects the deposit:
|
||||||
|
|
||||||
|
1. **Prohibited items.** The blueprint lists specific resources that may never be stored here. If your item is on the prohibited list, it is refused outright, no matter what.
|
||||||
|
2. **Whitelist mode.** If the structure's "restrict to allowed" switch is on, only resources explicitly on its allowed list can be stored. Anything not on the list is refused. You will see an amber "whitelist mode" banner on the structure page when this is active, and the storage dialog simply filters out items you cannot deposit.
|
||||||
|
3. **Per-item caps.** Each entry on the allowed list can carry a maximum quantity. If the structure already holds that much (grid plus void combined), further deposits of that item are refused until some is consumed or shipped out.
|
||||||
|
|
||||||
|
## When a deposit is rejected
|
||||||
|
|
||||||
|
A rejected deposit simply does not happen: nothing is lost and nothing moves. You get a clear message explaining why, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
This resource is prohibited from storage at this structure.
|
||||||
|
This structure only accepts items on its allowed storage list: "Diesel" is not permitted.
|
||||||
|
Diesel is limited to 5,000 units here. You can add at most 1,200 more.
|
||||||
|
```
|
||||||
|
|
||||||
|
The same checks apply when transferring resources between structures, and again when a shipment tries to deliver (see [[Shipments]]). The storage dialog caps your deposit to whatever fits, so you usually will not hit these errors by accident; they mostly appear when the rules changed after stock was already en route.
|
||||||
|
|
||||||
|
## Where to see caps
|
||||||
|
|
||||||
|
Open a structure page and open its storage dialog. Each item shows how much is stored and, where a cap is defined, what that cap is. Items that are not permitted are filtered out or marked when whitelist mode is on. Structure detail pages also show the amber whitelist banner mentioned above.
|
||||||
|
|
||||||
|
Storage rules matter well beyond hauling crates: construction, upgrades, and modules all consume materials from a structure's storage (see [[Base Management]]), and market purchases land in your personal locker rather than a structure (see [[Market and Negotiation]]). Keeping the right stock in the right place is the core loop of [[Logistics and Storage Rules]] as a whole.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Rule | Checked | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Prohibited list | First | Listed items can never be stored here |
|
||||||
|
| Whitelist mode | Second | Only allowed-list items pass when restrict-to-allowed is on |
|
||||||
|
| Per-item cap | Last | Stored amount (grid + void) may not exceed the listed cap |
|
||||||
62
src/tools/seed/documentation/market-and-negotiation.md
Normal file
62
src/tools/seed/documentation/market-and-negotiation.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Market and Negotiation
|
||||||
|
|
||||||
|
The market is the unit's flea market: a board of fixed-price listings where players sell gear from their personal locker and NPC vendors restock consumables and equipment. Prices are paid through the banking system, and purchases land directly in your locker. Open it under **Logistics**, then **Market**.
|
||||||
|
|
||||||
|
## Listing items from your locker
|
||||||
|
|
||||||
|
To sell something, open **Create listing** on the market page. Three conditions must hold:
|
||||||
|
|
||||||
|
1. The item type must be marked **tradeable**.
|
||||||
|
2. It must be **approved and live** (item types still going through review cannot be listed; the dialog disables them with a "not approved for trading yet" hint).
|
||||||
|
3. The locker entry must be **clean**: no attached equipment, no applied skin. Selling an entry with attachments would destroy them, so those entries are not eligible.
|
||||||
|
|
||||||
|
You set an asking price and optionally a minimum price. If your minimum is below your asking price, the listing is **negotiable**: buyers can make offers and you decide. Your listing stays up for **30 days**, after which it expires and the stock returns to your locker automatically.
|
||||||
|
|
||||||
|
## Buying outright
|
||||||
|
|
||||||
|
For any listing, hit **Buy** to pay the asking price. The funds move through banking (see [[Banking]]), the item is credited to your locker, and the listing closes. If a seller has several units in stock, you can pick a **partial quantity**; the listing stays active until the last unit sells.
|
||||||
|
|
||||||
|
## Making offers
|
||||||
|
|
||||||
|
For negotiable listings, use **Make offer** to propose a lower per-unit price. This works the same way against players and NPC vendors, but the replies differ:
|
||||||
|
|
||||||
|
- **Player sellers** get a notification and accept, reject, or counter at their leisure.
|
||||||
|
- **NPC vendors** answer immediately, in character, through a chat panel.
|
||||||
|
|
||||||
|
## How NPC haggling works
|
||||||
|
|
||||||
|
From your side of the table, vendor haggling follows predictable rules:
|
||||||
|
|
||||||
|
- Vendors **counter-offer** rather than accept lowball numbers outright. Their counters come down in steps as they concede toward your price.
|
||||||
|
- A vendor **never goes below their floor**. Every listing has a hidden minimum, and counters stop there no matter how long you haggle.
|
||||||
|
- **Your offers must keep rising.** Repeating an amount, or offering less than before, is rejected outright. Tiny increases (offering one credit more on an expensive item) are also treated as time-wasting.
|
||||||
|
- Vendors have a **patience meter**, visible while you bargain. Lowball offers and stall attempts push it up quickly, and reasonable offers let it relax slightly. If the meter fills, the negotiation thread **closes**, and from that point your only option is to buy at the full asking price.
|
||||||
|
|
||||||
|
The practical takeaway: open with a serious number, raise your offers in meaningful steps, and stop before the patience bar runs out.
|
||||||
|
|
||||||
|
## Expirations
|
||||||
|
|
||||||
|
Listings do not live forever:
|
||||||
|
|
||||||
|
- **Player listings** expire after 30 days and the stock returns to the seller's locker.
|
||||||
|
- **Auto-generated vendor listings** expire after 7 days and are replaced by fresh vendor stock on the next market cycle.
|
||||||
|
|
||||||
|
When a listing sells, expires, or is cancelled, any open negotiations on it are closed and the interested buyers are notified.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
Negotiations keep you informed through the in-app notification bell: offers received, counters, acceptances, rejections, withdrawals, sales, and expirations all generate a notification with a link back to the listing. Check it regularly while a haggle is live, because a counter from a player seller waits for your answer.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Payment always flows through your bank account. Make sure your personal wallet (see [[Banking]]) holds the funds before you offer; purchases of vendor stock are paid into the unit treasury, which in turn funds salaries and maintenance (see [[Base Management]]).
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Topic | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Player listing duration | 30 days |
|
||||||
|
| Vendor listing duration | 7 days |
|
||||||
|
| Eligible items | Tradeable, approved and live, clean (no attachments or skins) |
|
||||||
|
| Offer rules | Strictly rising offers; stalls and lowballs rejected |
|
||||||
|
| Patience meter full | Thread closes; only full-price purchase remains |
|
||||||
66
src/tools/seed/documentation/shipments.md
Normal file
66
src/tools/seed/documentation/shipments.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Shipments
|
||||||
|
|
||||||
|
Shipments move resources between structures. A truck of diesel from the fuel depot to a forward outpost, a load of timber to a construction site: that is a shipment. You create and track them under **Logistics**, then **Shipments**.
|
||||||
|
|
||||||
|
## Creating a shipment
|
||||||
|
|
||||||
|
A shipment needs four things:
|
||||||
|
|
||||||
|
1. **Origin**: a structure that stores the cargo (see [[Logistics and Storage Rules]]).
|
||||||
|
2. **Destination**: another structure on the same map.
|
||||||
|
3. **Cargo**: one or more resource or asset entries with amounts.
|
||||||
|
4. **Transport vehicle**: a deployed game vehicle to do the hauling.
|
||||||
|
|
||||||
|
When you confirm, the cargo is reserved, the vehicle is committed, and the shipment enters the queue.
|
||||||
|
|
||||||
|
## Distance, fuel, and time
|
||||||
|
|
||||||
|
For ground vehicles, the route follows the **road network** drawn on the map: the system finds the best road path from origin to destination, preferring faster roads (each road has a speed multiplier, so highways beat dirt tracks). The **distance** is the length of that road path. If the origin or destination cannot reach the road network, a ground shipment is refused with a clear error rather than guessed.
|
||||||
|
|
||||||
|
Air and sea vehicles travel in a straight line instead.
|
||||||
|
|
||||||
|
**Fuel cost** comes from the distance multiplied by the vehicle's consumption rate, and the **travel time** comes from the distance and the vehicle's speed. The vehicle's tank is drained continuously as it drives, so a long trip visibly consumes fuel while it runs.
|
||||||
|
|
||||||
|
## Shipment statuses
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| Pending | Created but not yet dispatched |
|
||||||
|
| Dispatched | The vehicle has set off |
|
||||||
|
| In transit | Underway; fuel is burning and progress is tracked |
|
||||||
|
| Arrived | At the destination, delivery being processed |
|
||||||
|
| Completed | Cargo delivered successfully |
|
||||||
|
| Cancelled | Called off before delivery |
|
||||||
|
| Failed | Delivery refused; cargo returned to origin |
|
||||||
|
| Stranded | The vehicle ran out of fuel mid-route |
|
||||||
|
|
||||||
|
## Live progress
|
||||||
|
|
||||||
|
Open **The Map** (see [[The Map]]) and enable the shipments layer: every active shipment shows as a marker moving along its route between dispatch time and estimated arrival. The shipment detail page also shows progress, and it refreshes automatically through the realtime feed.
|
||||||
|
|
||||||
|
## Arrival checks and bounced cargo
|
||||||
|
|
||||||
|
Delivery is **not guaranteed**. When a shipment arrives, the destination's storage rules are checked again. If the destination prohibits the item, is in whitelist mode without your item on the list, or the per-item cap has no room, the affected cargo **bounces back to the origin structure** and the shipment is marked **failed**, with the reason recorded. Nothing is destroyed, but the trip (and its fuel) is wasted, so check the destination's rules before you dispatch.
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
Storage caps count everything a structure holds, including stock that arrived while you were loading. A shipment that fit when planned can still bounce if the destination filled up in the meantime.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Auto-return
|
||||||
|
|
||||||
|
When creating a shipment you can tick **auto-return**. Once delivery completes, the vehicle automatically heads back to the origin as an empty return trip (fuel permitting), so it is back in position for the next job without a manual step. The return leg appears as its own shipment named after the original.
|
||||||
|
|
||||||
|
## Stranded shipments
|
||||||
|
|
||||||
|
If a vehicle's tank cannot cover the burn for the distance remaining, the shipment is marked **stranded** and the vehicle is flagged as damaged. Cargo stays where it stopped. Stranding usually means someone dispatched a vehicle without enough fuel for the round trip; refuel and recover the vehicle, then re-create the shipment.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Item | Detail |
|
||||||
|
| --- | --- |
|
||||||
|
| Ground route | Along roads, faster roads preferred |
|
||||||
|
| Air / sea route | Straight line |
|
||||||
|
| Fuel cost | Distance times vehicle consumption rate |
|
||||||
|
| Storage rules | Re-checked on arrival; refusals bounce cargo to origin |
|
||||||
|
| Auto-return | Empty trip back to origin after delivery |
|
||||||
|
| Stranded | Out of fuel mid-route; vehicle flagged damaged |
|
||||||
72
src/tools/seed/documentation/the-map.md
Normal file
72
src/tools/seed/documentation/the-map.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# The Map
|
||||||
|
|
||||||
|
The map screen is the unit's picture of the theater: where structures stand, what resources sit nearby, how roads connect them, and where shipments currently are. Open it from the sidebar entry **Map**. You first pick a map from a grid of available maps (the unit's world map plus mission-specific maps), then the map view itself opens.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
The toolbar lets you toggle each layer on and off:
|
||||||
|
|
||||||
|
- **Structures**: bases, depots, outposts.
|
||||||
|
- **Resource nodes**: places where resources can be gathered or extracted.
|
||||||
|
- **Roads**: the network ground vehicles actually drive along.
|
||||||
|
- **Zones**: colored areas such as water, territory borders, or named compounds.
|
||||||
|
- **Shipments**: cargo currently in transit (see [[Shipments]]).
|
||||||
|
|
||||||
|
## Coordinates are meters
|
||||||
|
|
||||||
|
The map uses a meter-based grid per map, not latitude and longitude. The origin sits at the top-left corner, and the y axis runs **southward**, matching Arma's convention. Any distance you see (placement ranges, road lengths, aura radii) is plain meters on that grid.
|
||||||
|
|
||||||
|
## Structure pins
|
||||||
|
|
||||||
|
Structures render as status-colored pins:
|
||||||
|
|
||||||
|
- **Emerald building icon**: operational.
|
||||||
|
- **Blue hammer**: under construction, with a progress bar.
|
||||||
|
- **Amber package**: awaiting materials before construction can start.
|
||||||
|
|
||||||
|
Popovers on each pin link straight to the structure's detail page, where staffing and storage live (see [[Base Management]] and [[Logistics and Storage Rules]]).
|
||||||
|
|
||||||
|
## Placing new structures
|
||||||
|
|
||||||
|
Members with the logistics qualification can enter **placement mode** and click the map to site a new structure. A dialog collects the blueprint, name, and faction. The server validates every placement:
|
||||||
|
|
||||||
|
- **Water rules**: land blueprints cannot sit in water zones, and water blueprints (docks and the like) need a water zone. With no zones drawn on the map, everything is allowed.
|
||||||
|
- **Proximity**: blueprints that depend on resources must sit within the configured range of a matching resource node, and some blueprints must keep their distance from other structures.
|
||||||
|
|
||||||
|
Placement is refused with a clear error when a rule fails, so experimenting on the map is safe.
|
||||||
|
|
||||||
|
## Roads and travel speed
|
||||||
|
|
||||||
|
Roads are drawn as lines and carry a **speed multiplier** that says how fast vehicles move along them. Ground shipments route along these roads, and faster roads win when the system picks a route. That same routing produces the distance and fuel figures on [[Shipments]].
|
||||||
|
|
||||||
|
## Zones are informational
|
||||||
|
|
||||||
|
Zones mostly exist to describe the world: water, land, territory and border lines, or generic areas like named compounds. **Only water blocks placement**; territory and border zones never restrict anything, they inform.
|
||||||
|
|
||||||
|
## Compounds
|
||||||
|
|
||||||
|
A structure can be attached as a **child** of a compound hub. On the map, children fold into their hub: their labels are hidden and the hub marker reports the building count. The hub page shows the pooled storage of all operational children.
|
||||||
|
|
||||||
|
## Shipment markers
|
||||||
|
|
||||||
|
Active shipments appear as markers that **move along their routes** between dispatch time and estimated arrival, so you can watch a convoy make progress without opening the shipment page. The layer refreshes automatically as the game tick runs.
|
||||||
|
|
||||||
|
## Labels
|
||||||
|
|
||||||
|
The map draws text labels so you are not hovering constantly:
|
||||||
|
|
||||||
|
- **Structure names** persist, with a priority system that hides weaker labels in crowded clusters; zoom in and the suppressed ones appear.
|
||||||
|
- **Road names** follow the road's curve. Paved roads are always labeled, while dirt roads and trails only get labels once you are zoomed in close.
|
||||||
|
- **Zone names** render near the center of each polygon.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Feature | Detail |
|
||||||
|
| --- | --- |
|
||||||
|
| Coordinates | Meters per map, origin top-left, y runs south |
|
||||||
|
| Operational structure | Emerald building pin |
|
||||||
|
| Under construction | Blue hammer pin with progress bar |
|
||||||
|
| Awaiting materials | Amber package pin |
|
||||||
|
| Placement | Logistics-qualified users; water and proximity rules enforced |
|
||||||
|
| Zones | Informational, except water which blocks land placement |
|
||||||
|
| Ground routing | Along roads, weighted by speed multiplier |
|
||||||
97
src/tools/seed/seedDocumentation.ts
Normal file
97
src/tools/seed/seedDocumentation.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { slugify } from "@/lib/wiki/slugify";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the Documentation wiki category with in-depth, non-developer guides
|
||||||
|
* for the app's core systems. Idempotent: pages are keyed by slug; existing
|
||||||
|
* pages get their body updated when the markdown changed, missing pages are
|
||||||
|
* created. Run with: bun run src/tools/seed/seedDocumentation.ts
|
||||||
|
*
|
||||||
|
* Content lives as plain markdown files in ./documentation/ next to this
|
||||||
|
* script; the first H1 line of each file becomes the page title.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface DocFile {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDocFile(fileName: string, raw: string): DocFile {
|
||||||
|
const normalized = raw.replace(/\r\n/g, "\n").trim();
|
||||||
|
const match = /^# (.+)$/m.exec(normalized);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`${fileName}: first heading (H1) is missing; cannot derive a page title.`);
|
||||||
|
}
|
||||||
|
const title = match[1].trim();
|
||||||
|
const body = normalized.slice(normalized.indexOf("\n") + 1).trim();
|
||||||
|
return { title, slug: slugify(title), body };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
|
||||||
|
const docsDir = new URL("./documentation/", import.meta.url);
|
||||||
|
const fileNames = (await readdir(docsDir)).filter((name) => name.endsWith(".md")).sort();
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let unchanged = 0;
|
||||||
|
|
||||||
|
for (const fileName of fileNames) {
|
||||||
|
const raw = await readFile(new URL(fileName, docsDir), "utf8");
|
||||||
|
const doc = parseDocFile(fileName, raw);
|
||||||
|
|
||||||
|
const existing = await payload.find({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
where: { slug: { equals: doc.slug } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const current = existing.docs[0];
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
if (current.title === doc.title && current.body === doc.body) {
|
||||||
|
unchanged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await payload.update({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
id: current.id,
|
||||||
|
data: {
|
||||||
|
title: doc.title,
|
||||||
|
body: doc.body,
|
||||||
|
category: "Documentation",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
payload.logger.info(`[seed-documentation] updated: ${doc.title}`);
|
||||||
|
} else {
|
||||||
|
await payload.create({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
data: {
|
||||||
|
title: doc.title,
|
||||||
|
slug: doc.slug,
|
||||||
|
category: "Documentation",
|
||||||
|
tags: ["documentation"],
|
||||||
|
body: doc.body,
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
payload.logger.info(`[seed-documentation] created: ${doc.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.logger.info(
|
||||||
|
`[seed-documentation] done: ${created} created, ${updated} updated, ${unchanged} unchanged (${fileNames.length} files).`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// CLI script: the Payload pool keeps the event loop alive after completion;
|
||||||
|
// exit explicitly (same convention as the other seed tools and bins).
|
||||||
|
process.exit(0);
|
||||||
|
|
@ -3,35 +3,40 @@ import { test, expect } from "@playwright/test";
|
||||||
test("banner shows countdown + extend button", async ({ page, context }) => {
|
test("banner shows countdown + extend button", async ({ page, context }) => {
|
||||||
// Craft near-expiry token and set it as cookie
|
// Craft near-expiry token and set it as cookie
|
||||||
const { SignJWT } = await import("jose");
|
const { SignJWT } = await import("jose");
|
||||||
|
// IMPORTANT: this must be the RUNTIME payload secret, not .env's
|
||||||
|
// PAYLOAD_SECRET. A previous session confirmed the Payload AI plugin
|
||||||
|
// rewrites the secret at runtime; the value below is stable for this repo
|
||||||
|
// (verified empirically by signing a login token and verifying it).
|
||||||
const SECRET = "f80003be79c28ca283c2cbfde2984356";
|
const SECRET = "f80003be79c28ca283c2cbfde2984356";
|
||||||
const key = new TextEncoder().encode(SECRET);
|
const key = new TextEncoder().encode(SECRET);
|
||||||
|
|
||||||
// Login to get a real sid
|
// Login to get a real session, then re-sign the EXACT same claims with a
|
||||||
|
// short exp (echoing all claims keeps Payload's token verification happy).
|
||||||
const loginRes = await fetch("http://localhost:3000/api/users/login", {
|
const loginRes = await fetch("http://localhost:3000/api/users/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username: "dev", password: "Test123" }),
|
body: JSON.stringify({ username: "dev", password: "Test123" }),
|
||||||
});
|
});
|
||||||
const data = await loginRes.json();
|
const data = await loginRes.json();
|
||||||
const payload = JSON.parse(Buffer.from(data.token.split(".")[1], "base64url").toString());
|
const claims = JSON.parse(Buffer.from(data.token.split(".")[1], "base64url").toString());
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const near = await new SignJWT({
|
const near = await new SignJWT({ ...claims, iat: now, exp: now + 600 })
|
||||||
id: 23, collection: "users", email: payload.email, sid: payload.sid, iat: now, exp: now + 600,
|
|
||||||
})
|
|
||||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||||
.setIssuedAt(now)
|
.setIssuedAt(now)
|
||||||
.setExpirationTime(now + 600)
|
.setExpirationTime(now + 600)
|
||||||
.sign(key);
|
.sign(key);
|
||||||
|
|
||||||
// Set cookie
|
// Set cookie
|
||||||
await context.addCookies([{
|
await context.addCookies([
|
||||||
|
{
|
||||||
name: "payload-token",
|
name: "payload-token",
|
||||||
value: near,
|
value: near,
|
||||||
domain: "localhost",
|
domain: "localhost",
|
||||||
path: "/",
|
path: "/",
|
||||||
expires: now + 600,
|
expires: now + 600,
|
||||||
}]);
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// Navigate to home
|
// Navigate to home
|
||||||
await page.goto("http://localhost:3000/");
|
await page.goto("http://localhost:3000/");
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ test.describe("Frontend", () => {
|
||||||
await page.getByLabel("Username").fill("dev");
|
await page.getByLabel("Username").fill("dev");
|
||||||
await page.getByLabel("Password").fill("Test123");
|
await page.getByLabel("Password").fill("Test123");
|
||||||
await page.getByRole("button", { name: "Log in" }).click();
|
await page.getByRole("button", { name: "Log in" }).click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Welcome back, J. Fraley");
|
await expect(page.getByRole("heading", { level: 1 })).toContainText("Welcome back, J. Fraley");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,129 @@
|
||||||
import { bootstrapTestDatabase } from "../test-db";
|
import { bootstrapTestDatabase } from "../test-db";
|
||||||
|
import { resolveTestDatabaseUri } from "../test-db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playwright global setup: ensure the dedicated test database exists, is
|
* Playwright global setup: ensure the dedicated test database exists, is
|
||||||
* migrated, seeded and emptied before the dev server (started by webServer
|
* migrated, seeded and emptied before the dev server (started by webServer
|
||||||
* with DATABASE_URI pointed at it) serves the e2e suite.
|
* with DATABASE_URI pointed at it) serves the e2e suite. Then seed the shared
|
||||||
|
* e2e login ("dev" / "Test123"): every authed spec logs in with it, and the
|
||||||
|
* dashboard smoke expects the "Welcome back, J. Fraley." heading.
|
||||||
*/
|
*/
|
||||||
export default async function globalSetup(): Promise<void> {
|
export default async function globalSetup(): Promise<void> {
|
||||||
await bootstrapTestDatabase();
|
await bootstrapTestDatabase();
|
||||||
|
|
||||||
|
// Seed against the TEST database: the payload config reads DATABASE_URI at
|
||||||
|
// import time, and .env points at the dev database.
|
||||||
|
process.env.DATABASE_URI = resolveTestDatabaseUri();
|
||||||
|
|
||||||
|
const { getPayload } = await import("payload");
|
||||||
|
const { default: config } = await import("@payload-config");
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
|
||||||
|
// Rank named after the dev user so the dashboard heading reads
|
||||||
|
// "Welcome back, J. Fraley." (the heading renders the rank's name).
|
||||||
|
let rank = (
|
||||||
|
await payload.find({
|
||||||
|
collection: "ranks",
|
||||||
|
where: { name: { equals: "J. Fraley" } },
|
||||||
|
limit: 1,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
).docs[0];
|
||||||
|
if (!rank) {
|
||||||
|
rank = await payload.create({
|
||||||
|
collection: "ranks",
|
||||||
|
data: {
|
||||||
|
name: "J. Fraley",
|
||||||
|
abbreviation: "JF",
|
||||||
|
description: "E2E baseline rank",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Superuser role doc: e2e specs exercise permission-gated UI (e.g. the
|
||||||
|
// reservations "New Reservation" trigger needs operation-reservations:create),
|
||||||
|
// and permission resolution reads roleDocs -> Roles collection.
|
||||||
|
const superuserRole =
|
||||||
|
(
|
||||||
|
await payload.find({
|
||||||
|
collection: "roles",
|
||||||
|
where: { slug: { equals: "e2e-superuser" } },
|
||||||
|
limit: 1,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
).docs[0] ??
|
||||||
|
(await payload.create({
|
||||||
|
collection: "roles",
|
||||||
|
data: { name: "E2E Superuser", slug: "e2e-superuser", isSuperuser: true },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const existingUser = (
|
||||||
|
await payload.find({
|
||||||
|
collection: "users",
|
||||||
|
where: { username: { equals: "dev" } },
|
||||||
|
limit: 1,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
).docs[0];
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
username: "dev",
|
||||||
|
discordUsername: "dev",
|
||||||
|
displayName: "J. Fraley",
|
||||||
|
steamId: "76561190000000000",
|
||||||
|
password: "Test123",
|
||||||
|
roles: ["developer"] as ("developer" | "admin" | "user" | "guest" | "trusted")[],
|
||||||
|
roleDocs: [superuserRole.id],
|
||||||
|
};
|
||||||
|
if (existingUser) {
|
||||||
|
await payload.update({
|
||||||
|
collection: "users",
|
||||||
|
id: existingUser.id,
|
||||||
|
data: { ...userData, password: "Test123" },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: (
|
||||||
|
await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: existingUser.id } },
|
||||||
|
limit: 1,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
).docs[0].id,
|
||||||
|
data: { rank: rank.id },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const user = await payload.create({
|
||||||
|
collection: "users",
|
||||||
|
data: userData,
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
const profile = (
|
||||||
|
await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id } },
|
||||||
|
limit: 1,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
).docs[0];
|
||||||
|
if (profile) {
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profile.id,
|
||||||
|
data: { rank: rank.id },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,26 @@ test.describe("Create reservation dialog", () => {
|
||||||
await page.getByLabel("Username").fill("dev");
|
await page.getByLabel("Username").fill("dev");
|
||||||
await page.getByLabel("Password").fill("Test123");
|
await page.getByLabel("Password").fill("Test123");
|
||||||
await page.getByRole("button", { name: "Log in" }).click();
|
await page.getByRole("button", { name: "Log in" }).click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
|
||||||
|
|
||||||
await page.goto("/operations/reservations");
|
await page.goto("/operations/reservations");
|
||||||
await expect(page.getByText("Reservations")).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Reservations", level: 1 })).toBeVisible();
|
||||||
// The trigger renders for a user holding operation-reservations:create.
|
// The trigger renders for a user holding operation-reservations:create.
|
||||||
await expect(page.getByRole("button", { name: "New Reservation" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "New Reservation" })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "New Reservation" }).click();
|
await page.getByRole("button", { name: "New Reservation" }).click();
|
||||||
|
|
||||||
|
// Hydration race: a pre-hydration click is swallowed, and once the dialog
|
||||||
|
// opens the trigger goes aria-hidden behind the overlay, which strands
|
||||||
|
// Playwright's click retries. Force the dispatch and verify the dialog
|
||||||
|
// opened, retrying briefly if the first dispatch landed too early.
|
||||||
|
const trigger = page.getByRole("button", { name: "New Reservation" });
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
if (await dialog.isVisible().catch(() => false)) break;
|
||||||
|
await trigger.click({ force: true }).catch(() => {});
|
||||||
|
await page.waitForTimeout(750);
|
||||||
|
}
|
||||||
|
|
||||||
// Core fields are present.
|
// Core fields are present.
|
||||||
await expect(page.getByLabel("Reservation key")).toBeVisible();
|
await expect(page.getByLabel("Reservation key")).toBeVisible();
|
||||||
await expect(page.getByLabel("Operation id")).toBeVisible();
|
await expect(page.getByLabel("Operation id")).toBeVisible();
|
||||||
|
|
@ -45,6 +58,7 @@ test.describe("Create reservation dialog", () => {
|
||||||
await page.getByLabel("Username").fill("dev");
|
await page.getByLabel("Username").fill("dev");
|
||||||
await page.getByLabel("Password").fill("Test123");
|
await page.getByLabel("Password").fill("Test123");
|
||||||
await page.getByRole("button", { name: "Log in" }).click();
|
await page.getByRole("button", { name: "Log in" }).click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
|
||||||
|
|
||||||
await page.goto("/operations/reservations");
|
await page.goto("/operations/reservations");
|
||||||
await page.getByRole("button", { name: "New Reservation" }).click();
|
await page.getByRole("button", { name: "New Reservation" }).click();
|
||||||
|
|
@ -63,11 +77,10 @@ test.describe("Create reservation dialog", () => {
|
||||||
await page.getByLabel("Username").fill("dev");
|
await page.getByLabel("Username").fill("dev");
|
||||||
await page.getByLabel("Password").fill("Test123");
|
await page.getByLabel("Password").fill("Test123");
|
||||||
await page.getByRole("button", { name: "Log in" }).click();
|
await page.getByRole("button", { name: "Log in" }).click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
|
||||||
|
|
||||||
await page.goto("/operations/reservations");
|
await page.goto("/operations/reservations");
|
||||||
// Either a reserved row (with its Cancel control) or the empty state renders.
|
// Either a reserved row (with its Cancel control) or the empty state renders.
|
||||||
await expect(
|
await expect(page.getByText("No reservations match this view.")).toBeVisible();
|
||||||
page.getByText("No reservations match this view."),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,10 @@ test.describe("Operations surfaces", () => {
|
||||||
await page.getByLabel("Username").fill("dev");
|
await page.getByLabel("Username").fill("dev");
|
||||||
await page.getByLabel("Password").fill("Test123");
|
await page.getByLabel("Password").fill("Test123");
|
||||||
await page.getByRole("button", { name: "Log in" }).click();
|
await page.getByRole("button", { name: "Log in" }).click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
|
||||||
|
|
||||||
await page.goto("/operations");
|
await page.goto("/operations");
|
||||||
await expect(page.getByRole("heading", { name: "Operations" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Operations", level: 1 })).toBeVisible();
|
||||||
await expect(page.getByText("Recent operations")).toBeVisible();
|
await expect(page.getByText("Recent operations")).toBeVisible();
|
||||||
await shot(page, "operations-overview-authed");
|
await shot(page, "operations-overview-authed");
|
||||||
await expect(page.getByText("My extractions")).toBeVisible();
|
await expect(page.getByText("My extractions")).toBeVisible();
|
||||||
|
|
@ -36,6 +37,7 @@ test.describe("Operations surfaces", () => {
|
||||||
await page.getByLabel("Username").fill("dev");
|
await page.getByLabel("Username").fill("dev");
|
||||||
await page.getByLabel("Password").fill("Test123");
|
await page.getByLabel("Password").fill("Test123");
|
||||||
await page.getByRole("button", { name: "Log in" }).click();
|
await page.getByRole("button", { name: "Log in" }).click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
|
||||||
|
|
||||||
await page.goto("/operations/ledger?status=dead-letter");
|
await page.goto("/operations/ledger?status=dead-letter");
|
||||||
await expect(page.getByRole("heading", { name: "Operations ledger" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Operations ledger" })).toBeVisible();
|
||||||
|
|
@ -48,11 +50,10 @@ test.describe("Operations surfaces", () => {
|
||||||
await page.getByLabel("Username").fill("dev");
|
await page.getByLabel("Username").fill("dev");
|
||||||
await page.getByLabel("Password").fill("Test123");
|
await page.getByLabel("Password").fill("Test123");
|
||||||
await page.getByRole("button", { name: "Log in" }).click();
|
await page.getByRole("button", { name: "Log in" }).click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
|
||||||
|
|
||||||
await page.goto("/operations/definitely-not-a-real-operation");
|
await page.goto("/operations/definitely-not-a-real-operation");
|
||||||
await expect(
|
await expect(page.getByText("No operation events are recorded under")).toBeVisible();
|
||||||
page.getByText("No operation events are recorded under"),
|
|
||||||
).toBeVisible();
|
|
||||||
await shot(page, "operations-aar-unknown-empty");
|
await shot(page, "operations-aar-unknown-empty");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ async function login(page: Page): Promise<void> {
|
||||||
|
|
||||||
async function playOpordRound(page: Page): Promise<void> {
|
async function playOpordRound(page: Page): Promise<void> {
|
||||||
await page.goto(`/minigames/opord?seed=${FIXED_SEED}`);
|
await page.goto(`/minigames/opord?seed=${FIXED_SEED}`);
|
||||||
await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "OPORD Recall", level: 1 })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Receive briefing" }).click();
|
await page.getByRole("button", { name: "Receive briefing" }).click();
|
||||||
await expect(page.getByText("Study at your own pace")).toBeVisible();
|
await expect(page.getByText("Study at your own pace")).toBeVisible();
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,11 @@ test.describe("Supply box management surface", () => {
|
||||||
await page.getByLabel("Username").fill("dev");
|
await page.getByLabel("Username").fill("dev");
|
||||||
await page.getByLabel("Password").fill("Test123");
|
await page.getByLabel("Password").fill("Test123");
|
||||||
await page.getByRole("button", { name: "Log in" }).click();
|
await page.getByRole("button", { name: "Log in" }).click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
|
||||||
|
|
||||||
await page.goto("/logistics/supply-boxes");
|
await page.goto("/logistics/supply-boxes");
|
||||||
await expect(page.getByText("Supply Boxes")).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Supply Boxes" })).toBeVisible();
|
||||||
// Either a registered box row or the empty state renders.
|
// Either a registered box row or the empty state renders.
|
||||||
await expect(
|
await expect(page.getByText("No supply boxes have been registered yet.")).toBeVisible();
|
||||||
page.getByText("No supply boxes have been registered yet."),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
330
tests/int/wiki-documentation.int.spec.ts
Normal file
330
tests/int/wiki-documentation.int.spec.ts
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
import { getPayload, Payload } from "payload";
|
||||||
|
import config from "@/payload.config";
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import type { User, WikiPage } from "@/payload-types";
|
||||||
|
import { WIKI_CATEGORIES } from "@/lib/wiki/categories";
|
||||||
|
import { WikiPages } from "@/collections/wiki/WikiPages";
|
||||||
|
import { slugify } from "@/lib/wiki/slugify";
|
||||||
|
|
||||||
|
let payload: Payload;
|
||||||
|
|
||||||
|
const RUN = `wikidoc-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User deletion trips FK constraints unless the hook-provisioned personal bank
|
||||||
|
* account and profile are removed first (same helper as wiki.int.spec).
|
||||||
|
*/
|
||||||
|
const deleteUserWithRelations = async (pg: Payload, id: number): Promise<void> => {
|
||||||
|
const accounts = await pg
|
||||||
|
.find({
|
||||||
|
collection: "bank-accounts",
|
||||||
|
where: { ownerUser: { equals: id } },
|
||||||
|
limit: 5,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
for (const account of accounts?.docs ?? []) {
|
||||||
|
await pg
|
||||||
|
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
const profiles = await pg
|
||||||
|
.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: id } },
|
||||||
|
limit: 5,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
for (const profile of profiles?.docs ?? []) {
|
||||||
|
await pg
|
||||||
|
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
await pg.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Wiki Documentation category", () => {
|
||||||
|
let plainUser: User;
|
||||||
|
let adminUser: User;
|
||||||
|
let developerUser: User;
|
||||||
|
const userIds: number[] = [];
|
||||||
|
const pageIds: number[] = [];
|
||||||
|
|
||||||
|
const makeUser = async (username: string, roles: User["roles"]): Promise<User> => {
|
||||||
|
const u = (await payload.create({
|
||||||
|
collection: "users",
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
discordUsername: username,
|
||||||
|
displayName: "DOC TEST",
|
||||||
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||||
|
password: "Test123",
|
||||||
|
roles,
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as User;
|
||||||
|
userIds.push(u.id);
|
||||||
|
return u;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
payload = await getPayload({ config: payloadConfig });
|
||||||
|
|
||||||
|
plainUser = await makeUser(`${RUN}-plain`, ["user"]);
|
||||||
|
adminUser = await makeUser(`${RUN}-admin`, ["admin"]);
|
||||||
|
developerUser = await makeUser(`${RUN}-dev`, ["developer"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of pageIds) {
|
||||||
|
const revisions = await payload
|
||||||
|
.find({
|
||||||
|
collection: "wiki-revisions",
|
||||||
|
where: { page: { equals: id } },
|
||||||
|
limit: 500,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
for (const revision of revisions?.docs ?? []) {
|
||||||
|
await payload
|
||||||
|
.delete({ collection: "wiki-revisions", id: revision.id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
await payload.delete({ collection: "wiki-pages", id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of userIds) {
|
||||||
|
await deleteUserWithRelations(payload, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("categories list", () => {
|
||||||
|
it("includes Documentation in the shared category registry", () => {
|
||||||
|
expect(WIKI_CATEGORIES).toContain("Documentation");
|
||||||
|
expect(WIKI_CATEGORIES).toHaveLength(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the collection select options in sync with the registry", () => {
|
||||||
|
const optionValues = WikiPages.fields.filter(
|
||||||
|
(field) => "name" in field && field.name === "category",
|
||||||
|
);
|
||||||
|
const options = (optionValues[0] as unknown as { options: Array<{ value: string }> }).options;
|
||||||
|
expect(options.map((option) => option.value)).toEqual([...WIKI_CATEGORIES]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create gating", () => {
|
||||||
|
it("a plain user cannot create a Documentation page", async () => {
|
||||||
|
await expect(
|
||||||
|
payload.create({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} Doc`,
|
||||||
|
slug: slugify(`${RUN} Doc`),
|
||||||
|
category: "Documentation",
|
||||||
|
body: "Body",
|
||||||
|
},
|
||||||
|
user: plainUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin can create a Documentation page", async () => {
|
||||||
|
const page = (await payload.create({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} Admin Doc`,
|
||||||
|
slug: slugify(`Admin Doc`),
|
||||||
|
category: "Documentation",
|
||||||
|
body: "Body",
|
||||||
|
},
|
||||||
|
user: adminUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
})) as unknown as WikiPage;
|
||||||
|
pageIds.push(page.id);
|
||||||
|
expect(page.category).toBe("Documentation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a developer can create a Documentation page", async () => {
|
||||||
|
const page = (await payload.create({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} Dev Doc`,
|
||||||
|
slug: slugify(`Dev Doc`),
|
||||||
|
category: "Documentation",
|
||||||
|
body: "Body",
|
||||||
|
},
|
||||||
|
user: developerUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
})) as unknown as WikiPage;
|
||||||
|
pageIds.push(page.id);
|
||||||
|
expect(page.category).toBe("Documentation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a plain user can still create pages in other categories", async () => {
|
||||||
|
const page = (await payload.create({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} User Lore`,
|
||||||
|
slug: slugify(`User Lore`),
|
||||||
|
category: "Lore",
|
||||||
|
body: "Body",
|
||||||
|
},
|
||||||
|
user: plainUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
})) as unknown as WikiPage;
|
||||||
|
pageIds.push(page.id);
|
||||||
|
expect(page.category).toBe("Lore");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a guest cannot create anything", async () => {
|
||||||
|
await expect(
|
||||||
|
payload.create({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} Guest`,
|
||||||
|
slug: slugify(`${RUN} Guest`),
|
||||||
|
category: "Guides",
|
||||||
|
body: "Body",
|
||||||
|
},
|
||||||
|
overrideAccess: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update gating", () => {
|
||||||
|
it("a plain user cannot edit an existing Documentation page", async () => {
|
||||||
|
const page = (await payload.create({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} Locked Doc`,
|
||||||
|
slug: slugify(`Locked Doc`),
|
||||||
|
category: "Documentation",
|
||||||
|
body: "v1",
|
||||||
|
},
|
||||||
|
user: adminUser,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as WikiPage;
|
||||||
|
pageIds.push(page.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
payload.update({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
id: page.id,
|
||||||
|
data: { body: "v2 by plain user" },
|
||||||
|
user: plainUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a developer can edit an existing Documentation page", async () => {
|
||||||
|
const page = (await payload.create({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} Dev Edit Doc`,
|
||||||
|
slug: slugify(`Dev Edit Doc`),
|
||||||
|
category: "Documentation",
|
||||||
|
body: "v1",
|
||||||
|
},
|
||||||
|
user: adminUser,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as WikiPage;
|
||||||
|
pageIds.push(page.id);
|
||||||
|
|
||||||
|
const updated = (await payload.update({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
id: page.id,
|
||||||
|
data: { body: "v2 by developer" },
|
||||||
|
user: developerUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
})) as unknown as WikiPage;
|
||||||
|
expect(updated.body).toBe("v2 by developer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a plain user cannot escalate another category into Documentation", async () => {
|
||||||
|
const page = (await payload.create({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} Escalate`,
|
||||||
|
slug: slugify(`Escalate`),
|
||||||
|
category: "Guides",
|
||||||
|
body: "Body",
|
||||||
|
},
|
||||||
|
user: plainUser,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as WikiPage;
|
||||||
|
pageIds.push(page.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
payload.update({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
id: page.id,
|
||||||
|
data: { category: "Documentation" },
|
||||||
|
user: plainUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a plain user can still edit pages in other categories", async () => {
|
||||||
|
const page = (await payload.create({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
data: {
|
||||||
|
title: `${RUN} Still Editable`,
|
||||||
|
slug: slugify(`Still Editable`),
|
||||||
|
category: "Meta",
|
||||||
|
body: "v1",
|
||||||
|
},
|
||||||
|
user: plainUser,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as WikiPage;
|
||||||
|
pageIds.push(page.id);
|
||||||
|
|
||||||
|
const updated = (await payload.update({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
id: page.id,
|
||||||
|
data: { body: "v2" },
|
||||||
|
user: plainUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
})) as unknown as WikiPage;
|
||||||
|
expect(updated.body).toBe("v2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("read gating", () => {
|
||||||
|
it("any logged-in user can read Documentation pages", async () => {
|
||||||
|
const result = await payload.find({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
where: { category: { equals: "Documentation" } },
|
||||||
|
user: plainUser,
|
||||||
|
overrideAccess: false,
|
||||||
|
});
|
||||||
|
expect(result.docs.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a guest is forbidden from reading wiki pages", async () => {
|
||||||
|
await expect(
|
||||||
|
payload.find({
|
||||||
|
collection: "wiki-pages",
|
||||||
|
where: { category: { equals: "Documentation" } },
|
||||||
|
overrideAccess: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/not allowed/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("slug integration", () => {
|
||||||
|
it("slugifies Documentation titles the same as any other category", () => {
|
||||||
|
expect(slugify("Logistics and Storage Rules")).toBe("logistics-and-storage-rules");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue