feat(help): in-app help center with per-screen guides and pictures

A help icon on every screen opens a panel with the screen's overview, its
sub-screens, task guides with a framed picture per step, search across guides
and the wiki, and a switch to any other screen's help. Guides exist for the
dashboard, Vacay, Atlas, Collections, Journey (list, journal, studio), Settings
(all tabs), Admin (all tabs) and the trip frame; the trip's own screens are
in progress. Pictures are produced by Playwright (client/e2e/help), texts in
all 23 languages.
This commit is contained in:
Maurice
2026-09-21 23:20:27 +02:00
parent 151e32107f
commit 4346d7f2c1
483 changed files with 47654 additions and 33 deletions
+95
View File
@@ -0,0 +1,95 @@
import type { HelpContext, HelpGuide } from '../types'
/**
* Help for the admin area (`/admin`): one route with tabs, so every tab is a
* screen under `admin`, anchored by the page for the tab that is open. The
* step actions that produce the pictures live in `e2e/help/admin.guide.ts`.
*/
const OVERVIEW = 'Admin-Panel-Overview'
const USERS = 'Admin-Users-and-Invites'
const ADDONS = 'Admin-Addons'
const CATEGORIES = 'Admin-Categories'
const PLUGINS = 'Admin-Plugins'
const STORAGE = 'Admin-Storage'
const NOTIFICATIONS = 'Notifications'
const BACKUP = 'Backups'
const MCP = 'Admin-MCP-Tokens'
const TEMPLATES = 'Admin-Packing-Templates'
const GITHUB = 'Admin-GitHub-Releases'
const guide = (
id: string, context: string, icon: HelpGuide['icon'], size: HelpGuide['size'], steps: number, tips: number,
docs: HelpGuide['docs'], related: string[], result = false,
): HelpGuide => ({ id, context, icon, size, steps, tips, media: { steps: true, result }, docs, related })
export const adminGuides: HelpGuide[] = [
// Users
guide('create-user', 'admin-users', 'userPlus', 'guide', 3, 2, { slug: USERS }, ['edit-user', 'invite-links'], true),
guide('edit-user', 'admin-users', 'pencil', 'quick', 2, 2, { slug: USERS }, ['create-user', 'delete-user']),
guide('invite-links', 'admin-users', 'link', 'guide', 3, 2, { slug: USERS }, ['create-user'], true),
guide('delete-user', 'admin-users', 'trash', 'quick', 1, 2, { slug: USERS }, ['edit-user']),
guide('permissions', 'admin-users', 'sliders', 'quick', 2, 2, { slug: 'Admin-Permissions' }, ['edit-user']),
// User Defaults
guide('default-map', 'admin-defaults', 'map', 'quick', 2, 2, { slug: OVERVIEW }, ['instance-keys']),
// Personalization
guide('packing-templates', 'admin-config', 'library', 'guide', 3, 2, { slug: TEMPLATES }, ['categories'], true),
guide('categories', 'admin-config', 'tags', 'quick', 2, 2, { slug: CATEGORIES }, ['packing-templates']),
// Related across screens: the Vacay guide that switches these holidays on.
guide('school-holiday-catalog', 'admin-config', 'graduationCap', 'quick', 2, 1, { slug: 'Vacay', anchor: 'manually-maintained-school-holidays' }, ['school-holidays', 'categories']),
// Settings
guide('auth-methods', 'admin-settings', 'lock', 'guide', 3, 2, { slug: OVERVIEW }, ['oidc']),
guide('oidc', 'admin-settings', 'shield', 'guide', 2, 2, { slug: 'OIDC-SSO' }, ['auth-methods']),
guide('instance-keys', 'admin-settings', 'key', 'guide', 3, 2, { slug: 'Places-and-Search' }, ['places-transit', 'default-map']),
guide('places-transit', 'admin-settings', 'search', 'quick', 2, 2, { slug: 'Places-and-Search' }, ['instance-keys']),
guide('file-types', 'admin-settings', 'fileInput', 'quick', 1, 1, { slug: 'Documents-and-Files' }, ['storage-backends']),
// Addons
guide('toggle-addon', 'admin-addons', 'sliders', 'quick', 2, 2, { slug: ADDONS }, ['install-plugin']),
// Plugins
guide('install-plugin', 'admin-plugins', 'plug', 'guide', 3, 2, { slug: PLUGINS }, ['toggle-addon']),
// Storage
guide('storage-backends', 'admin-storage', 'download', 'guide', 3, 2, { slug: STORAGE }, ['create-backup']),
// Notifications
guide('channels-instance', 'admin-notifications', 'bell', 'guide', 3, 2, { slug: NOTIFICATIONS, anchor: 'notification-channels' }, ['admin-channels']),
guide('admin-channels', 'admin-notifications', 'bell', 'quick', 2, 1, { slug: NOTIFICATIONS }, ['channels-instance']),
// MCP Access
guide('mcp-tokens-admin', 'admin-mcp-tokens', 'key', 'quick', 2, 2, { slug: MCP }, ['auth-methods']),
// GitHub
guide('release-history', 'admin-github', 'bookOpen', 'quick', 2, 2, { slug: GITHUB }, ['create-backup']),
// Backup
guide('create-backup', 'admin-backup', 'archive', 'guide', 3, 2, { slug: BACKUP }, ['auto-backup', 'storage-backends'], true),
guide('auto-backup', 'admin-backup', 'repeat', 'quick', 2, 2, { slug: BACKUP }, ['create-backup']),
// Audit
guide('audit-log', 'admin-audit', 'search', 'quick', 2, 2, { slug: 'Audit-Log' }, ['edit-user']),
]
const forContext = (id: string) => adminGuides.filter(g => g.context === id).map(g => g.id)
export const adminContext: HelpContext = {
id: 'admin',
route: '/admin',
icon: 'shield',
bullets: 5,
guides: [],
docs: [{ slug: OVERVIEW }, { slug: USERS }, { slug: ADDONS }],
hero: true,
}
const tab = (id: string, icon: HelpContext['icon'], bullets: number, docs: HelpContext['docs']): HelpContext => ({
id, parent: 'admin', route: `/admin?tab=${id.slice('admin-'.length)}`, icon, bullets, guides: forContext(id), docs, hero: true,
})
export const adminTabContexts: HelpContext[] = [
tab('admin-users', 'user', 4, [{ slug: USERS }, { slug: 'Admin-Permissions' }]),
tab('admin-defaults', 'map', 2, [{ slug: OVERVIEW }]),
tab('admin-config', 'sliders', 3, [{ slug: CATEGORIES }, { slug: TEMPLATES }]),
tab('admin-settings', 'lock', 4, [{ slug: OVERVIEW }, { slug: 'OIDC-SSO' }, { slug: 'Places-and-Search' }]),
tab('admin-addons', 'plug', 3, [{ slug: ADDONS }]),
tab('admin-plugins', 'plug', 3, [{ slug: PLUGINS }]),
tab('admin-storage', 'download', 3, [{ slug: STORAGE }]),
tab('admin-notifications', 'bell', 3, [{ slug: NOTIFICATIONS }]),
tab('admin-mcp-tokens', 'key', 2, [{ slug: MCP }]),
tab('admin-github', 'bookOpen', 2, [{ slug: GITHUB }, { slug: 'Updating' }]),
tab('admin-backup', 'archive', 3, [{ slug: BACKUP }]),
tab('admin-audit', 'search', 2, [{ slug: 'Audit-Log' }]),
]
+121
View File
@@ -0,0 +1,121 @@
import type { HelpContext, HelpGuide } from '../types'
/**
* Help for the Atlas addon (`/atlas`): the world map of visited countries,
* regions and the bucket list. The step actions that produce the pictures live
* in `e2e/help/atlas.guide.ts`, keyed by the same ids.
*/
const ATLAS = 'Atlas'
export const atlasGuides: HelpGuide[] = [
{
id: 'mark-country',
context: 'atlas',
icon: 'mapPin',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: ATLAS, anchor: 'marking-countries-as-visited' },
related: ['unmark-country', 'regions', 'bucket-country'],
},
{
id: 'unmark-country',
context: 'atlas',
icon: 'minusCircle',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: false },
docs: { slug: ATLAS, anchor: 'marking-countries-as-visited' },
related: ['mark-country'],
},
{
id: 'country-details',
context: 'atlas',
icon: 'search',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: true },
docs: { slug: ATLAS, anchor: 'statistics' },
related: ['stats', 'planned-countries'],
},
{
id: 'planned-countries',
context: 'atlas',
icon: 'eye',
size: 'quick',
steps: 2,
tips: 2,
media: { steps: true, result: true },
docs: { slug: ATLAS, anchor: 'visited-and-planned-countries' },
related: ['country-details', 'mark-country'],
},
{
id: 'regions',
context: 'atlas',
icon: 'regions',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: ATLAS, anchor: 'sub-national-regions' },
related: ['search-place', 'mark-country'],
},
{
id: 'search-place',
context: 'atlas',
icon: 'landmark',
size: 'guide',
steps: 3,
tips: 1,
media: { steps: true, result: true },
docs: { slug: ATLAS, anchor: 'marking-countries-as-visited' },
related: ['regions', 'bucket-place'],
},
{
id: 'bucket-country',
context: 'atlas',
icon: 'star',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: ATLAS, anchor: 'bucket-list' },
related: ['bucket-place', 'mark-country'],
},
{
id: 'bucket-place',
context: 'atlas',
icon: 'plus',
size: 'guide',
steps: 4,
tips: 1,
media: { steps: true, result: true },
docs: { slug: ATLAS, anchor: 'bucket-list' },
related: ['bucket-country'],
},
{
id: 'stats',
context: 'atlas',
icon: 'chart',
size: 'quick',
steps: 2,
tips: 2,
media: { steps: true, result: false },
docs: { slug: ATLAS, anchor: 'statistics' },
related: ['country-details'],
},
]
export const atlasContext: HelpContext = {
id: 'atlas',
route: '/atlas',
icon: 'globe',
bullets: 6,
guides: atlasGuides.map(g => g.id),
docs: [{ slug: ATLAS }, { slug: 'Dawarich' }, { slug: 'Admin-Addons' }],
hero: true,
}
+165
View File
@@ -0,0 +1,165 @@
import type { HelpContext, HelpGuide } from '../types'
/**
* Help for the Collections addon (`/collections`): the place library outside
* of any trip. The step actions that produce the pictures live in
* `e2e/help/collections.guide.ts`, keyed by the same ids.
*/
const COLLECTIONS = 'Collections'
export const collectionsGuides: HelpGuide[] = [
{
id: 'create-list',
context: 'collections',
icon: 'plus',
size: 'guide',
steps: 3,
tips: 1,
media: { steps: true, result: true },
docs: { slug: COLLECTIONS, anchor: 'lists' },
related: ['add-place', 'edit-list', 'import-from-trip'],
},
{
id: 'add-place',
context: 'collections',
icon: 'mapPin',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: COLLECTIONS, anchor: 'adding-places' },
related: ['import-from-trip', 'place-detail'],
},
{
id: 'import-from-trip',
context: 'collections',
icon: 'download',
size: 'guide',
steps: 4,
tips: 2,
media: { steps: true, result: true },
docs: { slug: COLLECTIONS, anchor: 'adding-places' },
related: ['add-place', 'copy-to-trip'],
},
{
id: 'place-status',
context: 'collections',
icon: 'checkCircle',
size: 'quick',
steps: 2,
tips: 2,
media: { steps: true, result: true },
docs: { slug: COLLECTIONS, anchor: 'place-status' },
related: ['place-detail', 'filter-select'],
},
{
id: 'place-detail',
context: 'collections',
icon: 'panelRight',
size: 'quick',
steps: 2,
tips: 2,
media: { steps: true, result: false },
docs: { slug: COLLECTIONS, anchor: 'place-detail' },
related: ['place-status', 'copy-to-trip', 'labels'],
},
{
id: 'labels',
context: 'collections',
icon: 'tags',
size: 'guide',
steps: 4,
tips: 2,
media: { steps: true, result: true },
docs: { slug: COLLECTIONS, anchor: 'custom-labels' },
related: ['filter-select', 'place-detail'],
},
{
id: 'filter-select',
context: 'collections',
icon: 'filter',
size: 'guide',
steps: 3,
tips: 1,
media: { steps: true, result: true },
docs: { slug: COLLECTIONS, anchor: 'filtering-and-bulk-actions' },
related: ['copy-to-trip', 'labels'],
},
{
id: 'copy-to-trip',
context: 'collections',
icon: 'copy',
size: 'guide',
steps: 3,
tips: 1,
media: { steps: true, result: true },
docs: { slug: COLLECTIONS, anchor: 'filtering-and-bulk-actions' },
related: ['filter-select', 'import-from-trip'],
},
{
id: 'share-list',
context: 'collections',
icon: 'userPlus',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: COLLECTIONS, anchor: 'sharing-a-list-fusion' },
related: ['export-list'],
},
{
id: 'export-list',
context: 'collections',
icon: 'upload',
size: 'quick',
steps: 2,
tips: 2,
media: { steps: true, result: false },
docs: { slug: COLLECTIONS, anchor: 'sending-a-list-to-someone-else' },
related: ['import-file', 'share-list'],
},
{
id: 'import-file',
context: 'collections',
icon: 'fileInput',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: COLLECTIONS, anchor: 'sending-a-list-to-someone-else' },
related: ['export-list', 'create-list'],
},
{
id: 'edit-list',
context: 'collections',
icon: 'pencil',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: true },
docs: { slug: COLLECTIONS, anchor: 'lists' },
related: ['create-list'],
},
{
id: 'all-saved',
context: 'collections',
icon: 'library',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: false },
docs: { slug: COLLECTIONS, anchor: 'lists' },
related: ['filter-select'],
},
]
export const collectionsContext: HelpContext = {
id: 'collections',
route: '/collections',
icon: 'bookmark',
bullets: 6,
guides: collectionsGuides.map(g => g.id),
docs: [{ slug: COLLECTIONS }, { slug: 'Admin-Categories' }, { slug: 'Admin-Addons' }],
hero: true,
}
+149
View File
@@ -0,0 +1,149 @@
import type { HelpContext, HelpGuide } from '../types'
/**
* Help for the desktop dashboard (`/dashboard`). The step actions that produce
* the pictures for these guides live in `e2e/help/dashboard.guide.ts` and are
* keyed by the same ids, so a step added here without an action there fails
* the media run.
*/
const DOCS = {
dashboard: { slug: 'My-Trips-Dashboard' },
creating: { slug: 'Creating-a-Trip' },
widgets: { slug: 'Dashboard-Widgets' },
feeds: { slug: 'Calendar-Feeds' },
} as const
export const dashboardGuides: HelpGuide[] = [
{
id: 'create-trip',
context: 'dashboard',
icon: 'plus',
size: 'guide',
steps: 5,
tips: 2,
media: { steps: true, result: true },
docs: DOCS.creating,
related: ['cover-image', 'edit-trip'],
},
{
id: 'edit-trip',
context: 'dashboard',
icon: 'pencil',
size: 'quick',
steps: 3,
tips: 1,
media: { steps: true, result: false },
docs: { slug: 'Creating-a-Trip', anchor: 'fields' },
related: ['cover-image', 'duplicate-trip'],
},
{
id: 'cover-image',
context: 'dashboard',
icon: 'image',
size: 'quick',
steps: 3,
tips: 1,
media: { steps: true, result: true },
docs: { slug: 'Creating-a-Trip', anchor: 'fields' },
related: ['edit-trip'],
},
{
id: 'duplicate-trip',
context: 'dashboard',
icon: 'copy',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: true },
docs: { slug: 'My-Trips-Dashboard', anchor: 'per-trip-actions' },
related: ['create-trip'],
},
{
id: 'archive-trip',
context: 'dashboard',
icon: 'archive',
size: 'quick',
steps: 3,
tips: 0,
media: { steps: true, result: false },
docs: { slug: 'My-Trips-Dashboard', anchor: 'archived-trips' },
related: ['delete-trip', 'filter-and-view'],
},
{
id: 'delete-trip',
context: 'dashboard',
icon: 'trash',
size: 'quick',
steps: 2,
tips: 0,
media: { steps: true, result: false },
docs: { slug: 'My-Trips-Dashboard', anchor: 'per-trip-actions' },
related: ['archive-trip'],
},
{
id: 'filter-and-view',
context: 'dashboard',
icon: 'filter',
size: 'quick',
steps: 2,
tips: 0,
media: { steps: true, result: true },
docs: { slug: 'My-Trips-Dashboard', anchor: 'view-modes' },
related: ['archive-trip'],
},
{
id: 'calendar-feed',
context: 'dashboard',
icon: 'calendar',
size: 'quick',
steps: 3,
tips: 1,
media: { steps: true, result: false },
docs: { slug: 'Calendar-Feeds', anchor: 'turning-a-feed-on' },
},
{
id: 'widgets',
context: 'dashboard',
icon: 'layout',
size: 'guide',
steps: 4,
tips: 0,
media: { steps: true, result: true },
docs: DOCS.widgets,
link: '/settings?tab=appearance',
related: ['currency-widget', 'timezones-widget'],
},
{
id: 'currency-widget',
context: 'dashboard',
icon: 'coins',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: false },
docs: { slug: 'Dashboard-Widgets', anchor: 'currency-converter' },
related: ['widgets'],
},
{
id: 'timezones-widget',
context: 'dashboard',
icon: 'clock',
size: 'quick',
steps: 2,
tips: 0,
media: { steps: true, result: false },
docs: { slug: 'Dashboard-Widgets', anchor: 'timezone-clock' },
related: ['widgets'],
},
]
export const dashboardContext: HelpContext = {
id: 'dashboard',
route: '/dashboard',
icon: 'layout',
bullets: 5,
guides: dashboardGuides.map(g => g.id),
docs: [DOCS.dashboard, DOCS.creating, DOCS.widgets, DOCS.feeds],
hero: true,
}
+336
View File
@@ -0,0 +1,336 @@
import type { HelpContext, HelpGuide } from '../types'
/**
* Help for the Journey addon: the list of journeys (`/journey`) and one open
* journal (`/journey/:id`) are two screens with their own guides. The step
* actions that produce the pictures live in `e2e/help/journey.guide.ts`,
* keyed by the same ids.
*/
const JOURNEY = 'Journey-Journal'
export const journeyGuides: HelpGuide[] = [
{
id: 'create-journey',
context: 'journey',
icon: 'plus',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'creating-a-journey' },
related: ['continue-writing', 'add-entry'],
},
{
id: 'open-journey',
context: 'journey',
icon: 'bookOpen',
size: 'quick',
steps: 1,
tips: 1,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'accessing-journey' },
related: ['continue-writing'],
},
{
id: 'continue-writing',
context: 'journey',
icon: 'pencil',
size: 'quick',
steps: 1,
tips: 1,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'getting-around-a-long-journal' },
related: ['add-entry', 'open-journey'],
},
]
export const journeyContext: HelpContext = {
id: 'journey',
route: '/journey',
icon: 'bookOpen',
bullets: 3,
guides: journeyGuides.map(g => g.id),
docs: [{ slug: JOURNEY }, { slug: 'Admin-Addons' }],
hero: true,
}
export const journalGuides: HelpGuide[] = [
{
id: 'add-entry',
context: 'journey-detail',
icon: 'plus',
size: 'guide',
steps: 4,
tips: 2,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'journal-entries' },
related: ['entry-photos', 'pros-cons', 'suggestions'],
},
{
id: 'entry-photos',
context: 'journey-detail',
icon: 'image',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'journal-entries' },
related: ['add-entry', 'gallery-map'],
},
{
id: 'suggestions',
context: 'journey-detail',
icon: 'sparkles',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'creating-a-journey' },
related: ['add-entry', 'link-trip'],
},
{
id: 'add-on-day',
context: 'journey-detail',
icon: 'calendar',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'getting-around-a-long-journal' },
related: ['add-entry'],
},
{
id: 'pros-cons',
context: 'journey-detail',
icon: 'thumbsUp',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'journal-entries' },
related: ['entry-fields', 'add-entry'],
},
{
id: 'search-journey',
context: 'journey-detail',
icon: 'search',
size: 'quick',
steps: 2,
tips: 2,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'getting-around-a-long-journal' },
related: ['gallery-map'],
},
{
id: 'gallery-map',
context: 'journey-detail',
icon: 'gallery',
size: 'quick',
steps: 2,
tips: 2,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'map-view' },
related: ['entry-photos', 'share-public'],
},
{
id: 'entry-fields',
context: 'journey-detail',
icon: 'filter',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'entry-fields' },
related: ['pros-cons'],
},
{
id: 'link-trip',
context: 'journey-detail',
icon: 'link',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'creating-a-journey' },
related: ['suggestions'],
},
{
id: 'share-public',
context: 'journey-detail',
icon: 'share',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'public-sharing' },
related: ['contributors', 'gallery-map'],
},
{
id: 'contributors',
context: 'journey-detail',
icon: 'userPlus',
size: 'guide',
steps: 3,
tips: 1,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'creating-a-journey' },
related: ['share-public'],
},
{
id: 'studio',
context: 'journey-detail',
icon: 'book',
size: 'quick',
steps: 2,
tips: 2,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['entry-photos'],
},
{
id: 'archive-journey',
context: 'journey-detail',
icon: 'archive',
size: 'quick',
steps: 2,
tips: 2,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'creating-a-journey' },
related: ['link-trip'],
},
]
export const journalContext: HelpContext = {
id: 'journey-detail',
parent: 'journey',
icon: 'bookOpen',
bullets: 6,
guides: journalGuides.map(g => g.id),
docs: [{ slug: JOURNEY }, { slug: 'Public-Share-Links' }],
hero: true,
}
/** TREK Studio, the photo-book designer that opens over a journal (`/journey/:id/studio`). */
export const studioGuides: HelpGuide[] = [
{
id: 'studio-auto-layout',
context: 'journey-studio',
icon: 'sparkles',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['studio-pages', 'studio-layouts'],
},
{
id: 'studio-pages',
context: 'journey-studio',
icon: 'book',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['studio-layouts', 'studio-spread-file'],
},
{
id: 'studio-layouts',
context: 'journey-studio',
icon: 'layout',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['studio-auto-layout', 'studio-content'],
},
{
id: 'studio-content',
context: 'journey-studio',
icon: 'image',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['studio-properties', 'studio-elements'],
},
{
id: 'studio-elements',
context: 'journey-studio',
icon: 'plus',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['studio-travel', 'studio-properties'],
},
{
id: 'studio-travel',
context: 'journey-studio',
icon: 'globe',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['studio-elements'],
},
{
id: 'studio-properties',
context: 'journey-studio',
icon: 'panelRight',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['studio-content', 'studio-layouts'],
},
{
id: 'studio-format',
context: 'journey-studio',
icon: 'filter',
size: 'quick',
steps: 2,
tips: 2,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['studio-export'],
},
{
id: 'studio-export',
context: 'journey-studio',
icon: 'upload',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['studio-format', 'studio-spread-file'],
},
{
id: 'studio-spread-file',
context: 'journey-studio',
icon: 'fileInput',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: false },
docs: { slug: JOURNEY, anchor: 'trek-studio' },
related: ['studio-export', 'studio-pages'],
},
]
export const studioContext: HelpContext = {
id: 'journey-studio',
parent: 'journey',
icon: 'book',
bullets: 6,
guides: studioGuides.map(g => g.id),
docs: [{ slug: JOURNEY, anchor: 'trek-studio' }],
hero: true,
}
+75
View File
@@ -0,0 +1,75 @@
import type { HelpContext, HelpGuide } from '../types'
/**
* Help for the user settings (`/settings`). The page is one route with tabs,
* so every tab is a screen of its own under `settings`; the page anchors the
* tab that is open. The step actions that produce the pictures live in
* `e2e/help/settings.guide.ts`, keyed by the same ids.
*/
const SETTINGS = 'User-Settings'
const GENERAL = 'Display-Settings'
const APPEARANCE = 'Appearance-Settings'
const MAP = 'Map-Settings'
const NOTIFICATIONS = 'Notifications'
const OFFLINE = 'Offline-Mode-and-PWA'
const guide = (
id: string, context: string, icon: HelpGuide['icon'], size: HelpGuide['size'], steps: number, tips: number,
docs: HelpGuide['docs'], related: string[], result = true,
): HelpGuide => ({ id, context, icon, size, steps, tips, media: { steps: true, result }, docs, related })
export const settingsGuides: HelpGuide[] = [
// General
guide('language-region', 'settings-display', 'globe', 'quick', 2, 2, { slug: GENERAL, anchor: 'language' }, ['travel-map-prefs', 'startup'], false),
guide('travel-map-prefs', 'settings-display', 'mapPin', 'quick', 2, 1, { slug: GENERAL, anchor: 'always-show-booking-routes' }, ['language-region', 'map-provider'], false),
guide('startup', 'settings-display', 'compass', 'quick', 2, 1, { slug: GENERAL, anchor: 'start-page' }, ['language-region'], false),
// Appearance
guide('theme-scheme', 'settings-appearance', 'palette', 'guide', 3, 2, { slug: APPEARANCE }, ['readability', 'dashboard-widgets']),
guide('readability', 'settings-appearance', 'eye', 'quick', 2, 2, { slug: APPEARANCE }, ['theme-scheme'], false),
guide('dashboard-widgets', 'settings-appearance', 'layout', 'quick', 2, 2, { slug: APPEARANCE }, ['theme-scheme'], false),
// Map
guide('map-provider', 'settings-map', 'map', 'guide', 3, 2, { slug: MAP }, ['travel-map-prefs'], false),
// Notifications
guide('notification-channels', 'settings-notifications', 'bell', 'guide', 3, 2, { slug: NOTIFICATIONS, anchor: 'notification-channels' }, [], false),
// Integrations
guide('photo-providers', 'settings-integrations', 'image', 'guide', 2, 2, { slug: 'Photo-Providers' }, ['api-keys'], false),
guide('api-keys', 'settings-integrations', 'key', 'guide', 2, 2, { slug: SETTINGS, anchor: 'integrations-tab' }, ['mcp-oauth'], false),
guide('mcp-oauth', 'settings-integrations', 'plug', 'guide', 3, 2, { slug: 'MCP-Setup' }, ['api-keys'], false),
// Offline
guide('offline-prepare', 'settings-offline', 'cloudOff', 'guide', 3, 2, { slug: OFFLINE, anchor: 'settings--offline' }, ['offline-conflicts'], false),
guide('offline-conflicts', 'settings-offline', 'repeat', 'quick', 2, 1, { slug: OFFLINE, anchor: 'settings--offline' }, ['offline-prepare'], false),
// Account
guide('profile', 'settings-account', 'user', 'quick', 2, 1, { slug: SETTINGS, anchor: 'account-tab-summary' }, ['password'], false),
guide('password', 'settings-account', 'lock', 'quick', 2, 1, { slug: SETTINGS, anchor: 'account-tab-summary' }, ['mfa'], false),
guide('mfa', 'settings-account', 'shield', 'guide', 3, 2, { slug: 'Two-Factor-Authentication' }, ['passkeys', 'password']),
// Passkeys only exist on https, which the media run is not: words only.
{ ...guide('passkeys', 'settings-account', 'fingerprint', 'quick', 2, 2, { slug: 'Passkeys' }, ['mfa'], false), media: { steps: false, result: false } },
guide('delete-account', 'settings-account', 'trash', 'quick', 1, 2, { slug: SETTINGS, anchor: 'account-tab-summary' }, ['profile'], false),
]
const forContext = (id: string) => settingsGuides.filter(g => g.context === id).map(g => g.id)
export const settingsContext: HelpContext = {
id: 'settings',
route: '/settings',
icon: 'sliders',
bullets: 4,
guides: [],
docs: [{ slug: SETTINGS }, { slug: GENERAL }, { slug: APPEARANCE }],
hero: true,
}
const tab = (id: string, icon: HelpContext['icon'], bullets: number, docs: HelpContext['docs']): HelpContext => ({
id, parent: 'settings', route: `/settings?tab=${id.slice('settings-'.length)}`, icon, bullets, guides: forContext(id), docs, hero: true,
})
export const settingsTabContexts: HelpContext[] = [
tab('settings-display', 'sliders', 3, [{ slug: GENERAL }]),
tab('settings-appearance', 'palette', 4, [{ slug: APPEARANCE }]),
tab('settings-map', 'map', 3, [{ slug: MAP }]),
tab('settings-notifications', 'bell', 3, [{ slug: NOTIFICATIONS }]),
tab('settings-integrations', 'plug', 4, [{ slug: SETTINGS, anchor: 'integrations-tab' }, { slug: 'MCP-Setup' }, { slug: 'Photo-Providers' }]),
tab('settings-offline', 'cloudOff', 4, [{ slug: OFFLINE }]),
tab('settings-account', 'user', 4, [{ slug: SETTINGS, anchor: 'account-tab-summary' }, { slug: 'Two-Factor-Authentication' }, { slug: 'Passkeys' }]),
]
+37
View File
@@ -0,0 +1,37 @@
import type { HelpContext, HelpGuide } from '../types'
/**
* Help for a trip (`/trips/:id`). The trip is a family of screens: this one is
* the frame (the tabs, the three columns of the plan, the people in the trip),
* the columns, the overlays and the other tabs each get their own screen under
* it, anchored by the page for whatever is open. The step actions that produce
* the pictures live in `e2e/help/trip.guide.ts`.
*/
const SHARING = 'Sharing-and-Collaboration'
const PLANNER = 'Trip-Planner'
const guide = (
id: string, icon: HelpGuide['icon'], size: HelpGuide['size'], steps: number, tips: number,
docs: HelpGuide['docs'], related: string[], result = false,
): HelpGuide => ({ id, context: 'trip', icon, size, steps, tips, media: { steps: true, result }, docs, related })
export const tripGuides: HelpGuide[] = [
guide('add-member', 'userPlus', 'quick', 3, 2, { slug: SHARING }, ['trip-invite-link', 'add-guest'], true),
guide('trip-invite-link', 'link', 'quick', 3, 2, { slug: SHARING }, ['add-member', 'invite-links']),
guide('add-guest', 'userRound', 'quick', 2, 2, { slug: SHARING, anchor: 'guests' }, ['add-member'], true),
guide('public-link', 'share', 'quick', 3, 2, { slug: SHARING, anchor: 'public-share-links' }, ['add-member']),
guide('transfer-ownership', 'crown', 'quick', 2, 2, { slug: SHARING }, ['add-member']),
guide('collapse-columns', 'panelLeft', 'quick', 3, 2, { slug: PLANNER }, []),
guide('undo-change', 'undo', 'quick', 1, 2, { slug: PLANNER }, []),
]
export const tripContext: HelpContext = {
id: 'trip',
route: '/trips/:id?tab=plan',
icon: 'route',
bullets: 6,
guides: tripGuides.map(g => g.id),
docs: [{ slug: PLANNER }, { slug: SHARING }],
hero: true,
}
+42
View File
@@ -0,0 +1,42 @@
import type { HelpContext, HelpGuide } from '../types'
/**
* Help for the days column of a trip's plan: the day cards, the stops on them,
* their order and times, notes, the day's route, and everything that leaves
* the plan (PDF, calendar, GPX). Bookings and transports show up here too, so
* one guide reads them and points at the screens that create them.
* A screen under `trip`; the step actions live in `e2e/help/trip-days.guide.ts`.
*/
const DAYS = 'Day-Plans-and-Notes'
const MAP = 'Map-Features'
const guide = (
id: string, icon: HelpGuide['icon'], size: HelpGuide['size'], steps: number, tips: number,
docs: HelpGuide['docs'], related: string[], result = false,
): HelpGuide => ({ id, context: 'trip-days', icon, size, steps, tips, media: { steps: true, result }, docs, related })
export const tripDaysGuides: HelpGuide[] = [
guide('read-day-plan', 'eye', 'guide', 5, 2, { slug: DAYS, anchor: 'day-timeline' }, ['bookings-in-plan', 'day-route']),
guide('place-onto-day', 'calendarCheck', 'guide', 4, 3, { slug: DAYS, anchor: 'assigning-places-to-a-day' }, ['place-to-open-day', 'reorder-stops', 'undo-change'], true),
guide('reorder-stops', 'repeat', 'quick', 4, 2, { slug: DAYS, anchor: 'assigning-places-to-a-day' }, ['place-onto-day', 'set-stop-times', 'lock-stop']),
guide('set-stop-times', 'clock', 'quick', 3, 3, { slug: DAYS, anchor: 'day-timeline' }, ['reorder-stops', 'edit-place']),
guide('remove-from-day', 'minusCircle', 'quick', 2, 2, { slug: DAYS, anchor: 'assigning-places-to-a-day' }, ['delete-place', 'undo-change']),
guide('lock-stop', 'lock', 'quick', 2, 2, { slug: DAYS, anchor: 'toolbar-actions' }, ['day-route', 'reorder-stops']),
guide('day-note', 'pencil', 'quick', 4, 2, { slug: DAYS, anchor: 'day-notes' }, ['read-day-plan'], true),
guide('day-route', 'route', 'guide', 5, 3, { slug: MAP, anchor: 'route-lines' }, ['lock-stop', 'read-day-plan']),
guide('manage-days', 'calendarDays', 'quick', 4, 2, { slug: DAYS, anchor: 'the-day-plan-sidebar' }, ['edit-trip']),
guide('bookings-in-plan', 'ticket', 'guide', 5, 2, { slug: DAYS, anchor: 'multi-day-reservations' }, ['read-day-plan']),
guide('export-plan', 'download', 'quick', 4, 3, { slug: MAP, anchor: 'exporting-a-trip-as-gpx' }, ['calendar-feed']),
]
export const tripDaysContext: HelpContext = {
id: 'trip-days',
parent: 'trip',
route: '/trips/:id?tab=plan',
icon: 'calendarDays',
bullets: 6,
guides: tripDaysGuides.map(g => g.id),
docs: [{ slug: DAYS }, { slug: MAP }],
hero: true,
}
+37
View File
@@ -0,0 +1,37 @@
import type { HelpContext, HelpGuide } from '../types'
/**
* Help for the places column of a trip's plan: bringing places in (by hand,
* from a file, from a shared list), finding them, changing and deleting them.
* A screen under `trip`; the step actions live in `e2e/help/trip-places.guide.ts`.
*/
const PLACES = 'Places-and-Search'
const IMPORT = { slug: PLACES, anchor: 'importing-places' }
const guide = (
id: string, icon: HelpGuide['icon'], size: HelpGuide['size'], steps: number, tips: number,
docs: HelpGuide['docs'], related: string[], result = false,
): HelpGuide => ({ id, context: 'trip-places', icon, size, steps, tips, media: { steps: true, result }, docs, related })
export const tripPlacesGuides: HelpGuide[] = [
guide('create-place', 'plus', 'guide', 5, 3, { slug: PLACES }, ['place-to-open-day', 'edit-place'], true),
guide('place-to-open-day', 'calendarCheck', 'quick', 3, 2, { slug: PLACES }, ['create-place']),
guide('filter-places', 'filter', 'quick', 4, 2, { slug: PLACES }, ['select-places']),
guide('edit-place', 'pencil', 'quick', 3, 2, { slug: PLACES }, ['create-place', 'delete-place']),
guide('delete-place', 'trash', 'quick', 2, 2, { slug: PLACES }, ['edit-place', 'select-places', 'undo-change']),
guide('select-places', 'checkCircle', 'quick', 4, 2, { slug: PLACES }, ['filter-places', 'delete-place'], true),
guide('import-places-file', 'fileInput', 'guide', 4, 2, IMPORT, ['import-places-list', 'filter-places'], true),
guide('import-places-list', 'upload', 'quick', 3, 2, IMPORT, ['import-places-file']),
]
export const tripPlacesContext: HelpContext = {
id: 'trip-places',
parent: 'trip',
route: '/trips/:id?tab=plan',
icon: 'mapPin',
bullets: 6,
guides: tripPlacesGuides.map(g => g.id),
docs: [{ slug: PLACES }, IMPORT],
hero: true,
}
+166
View File
@@ -0,0 +1,166 @@
import type { HelpContext, HelpGuide } from '../types'
/**
* Help for the Vacay addon (`/vacay`): the personal leave planner. The step
* actions that produce the pictures live in `e2e/help/vacay.guide.ts`, keyed
* by the same ids.
*/
const VACAY = 'Vacay'
export const vacayGuides: HelpGuide[] = [
{
id: 'log-day',
context: 'vacay',
icon: 'calendarCheck',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: VACAY, anchor: 'calendar-view' },
related: ['half-day', 'comp-day', 'entitlement'],
},
{
id: 'half-day',
context: 'vacay',
icon: 'halfDay',
size: 'quick',
steps: 3,
tips: 1,
media: { steps: true, result: true },
docs: { slug: VACAY, anchor: 'half-days-and-comp-days' },
related: ['log-day', 'comp-day'],
},
{
id: 'comp-day',
context: 'vacay',
icon: 'hourglass',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: true },
docs: { slug: VACAY, anchor: 'half-days-and-comp-days' },
related: ['log-day', 'half-day'],
},
{
id: 'entitlement',
context: 'vacay',
icon: 'calculator',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: true },
docs: { slug: VACAY, anchor: 'year-plans' },
related: ['carry-over', 'years'],
},
{
id: 'years',
context: 'vacay',
icon: 'calendarRange',
size: 'quick',
steps: 3,
tips: 0,
media: { steps: true, result: true },
docs: { slug: VACAY, anchor: 'year-plans' },
related: ['entitlement', 'leave-year'],
},
{
id: 'company-holidays',
context: 'vacay',
icon: 'building',
size: 'guide',
steps: 3,
tips: 1,
media: { steps: true, result: true },
docs: { slug: VACAY, anchor: 'calendar-view' },
related: ['public-holidays', 'log-day'],
},
{
id: 'public-holidays',
context: 'vacay',
icon: 'flag',
size: 'guide',
steps: 3,
tips: 1,
media: { steps: true, result: true },
docs: { slug: VACAY, anchor: 'calendar-view' },
related: ['school-holidays', 'company-holidays'],
},
{
id: 'school-holidays',
context: 'vacay',
icon: 'graduationCap',
size: 'guide',
steps: 3,
tips: 1,
media: { steps: true, result: true },
docs: { slug: VACAY, anchor: 'manually-maintained-school-holidays' },
// The catalogue those holidays come from is kept under Admin.
related: ['public-holidays', 'school-holiday-catalog'],
},
{
id: 'weekends',
context: 'vacay',
icon: 'calendarOff',
size: 'quick',
steps: 3,
tips: 0,
media: { steps: true, result: false },
docs: { slug: VACAY, anchor: 'calendar-view' },
related: ['leave-year'],
},
{
id: 'leave-year',
context: 'vacay',
icon: 'calendarDays',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: false },
docs: { slug: VACAY, anchor: 'leave-year' },
related: ['years', 'carry-over'],
},
{
id: 'carry-over',
context: 'vacay',
icon: 'repeat',
size: 'quick',
steps: 2,
tips: 1,
media: { steps: true, result: false },
docs: { slug: VACAY, anchor: 'year-plans' },
related: ['entitlement'],
},
{
id: 'invite',
context: 'vacay',
icon: 'userPlus',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: VACAY, anchor: 'inviting-collaborators' },
related: ['share-calendar'],
},
{
id: 'share-calendar',
context: 'vacay',
icon: 'share',
size: 'guide',
steps: 3,
tips: 2,
media: { steps: true, result: true },
docs: { slug: VACAY, anchor: 'sharing-your-calendar-view-only' },
related: ['invite'],
},
]
export const vacayContext: HelpContext = {
id: 'vacay',
route: '/vacay',
icon: 'calendarDays',
bullets: 5,
guides: vacayGuides.map(g => g.id),
docs: [{ slug: VACAY }, { slug: 'Admin-Addons' }],
hero: true,
}
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest'
import { existsSync } from 'node:fs'
import path from 'node:path'
import en from '@trek/shared/i18n/en'
import { dashboardGuides } from './contexts/dashboard'
import { vacayGuides } from './contexts/vacay'
import { atlasGuides } from './contexts/atlas'
import { collectionsGuides } from './contexts/collections'
import { journeyGuides, journalGuides, studioGuides } from './contexts/journey'
import { settingsGuides } from './contexts/settings'
import { adminGuides } from './contexts/admin'
import { tripGuides } from './contexts/trip'
import { tripPlacesGuides } from './contexts/tripPlaces'
import { tripDaysGuides } from './contexts/tripDays'
import {
HELP_CONTEXTS, HELP_GUIDES, getHelpContext, getHelpGuide, guidesFor, allHelpGuides,
ctxKey, ctxBulletKey, guideKey, guideStepKey, guideTipKey, helpMedia, docsRoute,
topLevelHelpContexts, childHelpContexts, helpContextTrail,
} from './registry'
/**
* The registry is data; these tests are the gate that keeps it honest: every
* registered screen and guide has its words in the canonical locale, its
* pictures on disk, and its cross-references pointing at things that exist.
* A guide that is registered but not written (or not recorded) fails here
* rather than rendering a raw key or a broken image to a reader.
*/
const strings = en as Record<string, unknown>
const has = (key: string) => typeof strings[key] === 'string' && (strings[key] as string).length > 0
const MEDIA_ROOT = path.join(process.cwd(), 'public', 'help-media')
const onDisk = (url: string) => existsSync(path.join(MEDIA_ROOT, url.replace(/^\/help-media\//, '')))
describe('help registry: structure', () => {
it('registers the dashboard with its guides', () => {
const ctx = getHelpContext('dashboard')
expect(ctx).not.toBeNull()
expect(ctx!.guides.length).toBeGreaterThan(0)
expect(guidesFor(ctx!).map(g => g.id)).toEqual(ctx!.guides)
})
it('answers null for an unknown or missing id', () => {
expect(getHelpContext('no-such-screen')).toBeNull()
expect(getHelpContext(null)).toBeNull()
expect(getHelpContext(undefined)).toBeNull()
expect(getHelpGuide('no-such-guide')).toBeNull()
expect(getHelpGuide(null)).toBeNull()
})
it('lists every guide of every context, and nothing that belongs nowhere', () => {
const listed = new Set([...HELP_CONTEXTS.values()].flatMap(c => c.guides))
for (const guide of allHelpGuides()) {
expect(listed.has(guide.id), `guide "${guide.id}" is not listed by any context`).toBe(true)
expect(HELP_CONTEXTS.has(guide.context), `guide "${guide.id}" names unknown context "${guide.context}"`).toBe(true)
expect(HELP_CONTEXTS.get(guide.context)!.guides).toContain(guide.id)
}
for (const ctx of HELP_CONTEXTS.values()) {
for (const id of ctx.guides) expect(HELP_GUIDES.has(id), `context "${ctx.id}" lists unknown guide "${id}"`).toBe(true)
}
})
it('hangs sub-screens under screens that exist, never under themselves', () => {
for (const ctx of HELP_CONTEXTS.values()) {
if (!ctx.parent) continue
expect(HELP_CONTEXTS.has(ctx.parent), `context "${ctx.id}" names unknown parent "${ctx.parent}"`).toBe(true)
expect(ctx.parent).not.toBe(ctx.id)
expect(helpContextTrail(ctx).map(c => c.id)).toEqual([ctx.parent, ctx.id])
}
expect(topLevelHelpContexts().every(c => !c.parent)).toBe(true)
expect(childHelpContexts('journey').map(c => c.id)).toEqual(['journey-detail', 'journey-studio'])
})
it('only relates guides to guides that exist', () => {
for (const guide of allHelpGuides()) {
for (const rel of guide.related ?? []) {
expect(HELP_GUIDES.has(rel), `guide "${guide.id}" relates to unknown "${rel}"`).toBe(true)
expect(rel).not.toBe(guide.id)
}
}
})
it('never registers two guides under one id (the pictures live by id)', () => {
// Every context file's list, before the registry folds them into a map.
const lists = [
dashboardGuides, vacayGuides, atlasGuides, collectionsGuides, journeyGuides, journalGuides, studioGuides,
settingsGuides, adminGuides, tripGuides, tripDaysGuides, tripPlacesGuides,
]
const ids = lists.flat().map(g => g.id)
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i)
expect(dupes).toEqual([])
expect(allHelpGuides()).toHaveLength(ids.length)
})
it('has sane counts on every guide', () => {
for (const guide of allHelpGuides()) {
expect(guide.steps, guide.id).toBeGreaterThan(0)
expect(guide.tips, guide.id).toBeGreaterThanOrEqual(0)
expect(['quick', 'guide', 'tour']).toContain(guide.size)
}
})
})
describe('help registry: every word exists in the canonical locale', () => {
it('contexts', () => {
for (const ctx of HELP_CONTEXTS.values()) {
expect(has(ctxKey(ctx.id, 'title')), ctxKey(ctx.id, 'title')).toBe(true)
expect(has(ctxKey(ctx.id, 'summary')), ctxKey(ctx.id, 'summary')).toBe(true)
for (let n = 1; n <= ctx.bullets; n++) expect(has(ctxBulletKey(ctx.id, n)), ctxBulletKey(ctx.id, n)).toBe(true)
expect(has(ctxBulletKey(ctx.id, ctx.bullets + 1)), 'no bullet beyond the declared count').toBe(false)
}
})
it('guides', () => {
for (const guide of allHelpGuides()) {
for (const part of ['title', 'goal', 'result'] as const) expect(has(guideKey(guide.id, part)), guideKey(guide.id, part)).toBe(true)
for (let n = 1; n <= guide.steps; n++) expect(has(guideStepKey(guide.id, n)), guideStepKey(guide.id, n)).toBe(true)
expect(has(guideStepKey(guide.id, guide.steps + 1)), `${guide.id}: no step beyond the declared count`).toBe(false)
for (let n = 1; n <= guide.tips; n++) expect(has(guideTipKey(guide.id, n)), guideTipKey(guide.id, n)).toBe(true)
expect(has(guideTipKey(guide.id, guide.tips + 1)), `${guide.id}: no tip beyond the declared count`).toBe(false)
expect(has(guideKey(guide.id, 'link')), `${guide.id}: link label iff link`).toBe(Boolean(guide.link))
}
})
})
describe('help registry: every promised picture is on disk', () => {
it('context heroes', () => {
for (const ctx of HELP_CONTEXTS.values()) {
if (ctx.hero) expect(onDisk(helpMedia.hero(ctx.id)), helpMedia.hero(ctx.id)).toBe(true)
}
})
it('guide steps and results', () => {
for (const guide of allHelpGuides()) {
if (guide.media.steps) {
for (let n = 1; n <= guide.steps; n++) expect(onDisk(helpMedia.step(guide.id, n)), helpMedia.step(guide.id, n)).toBe(true)
}
if (guide.media.result) expect(onDisk(helpMedia.result(guide.id)), helpMedia.result(guide.id)).toBe(true)
}
})
})
describe('help registry: helpers', () => {
it('builds media paths under /help-media as webp', () => {
expect(helpMedia.hero('dashboard')).toBe('/help-media/ctx/dashboard.webp')
expect(helpMedia.step('create-trip', 3)).toBe('/help-media/create-trip/step-3.webp')
expect(helpMedia.result('create-trip')).toBe('/help-media/create-trip/result.webp')
})
it('builds wiki routes with and without an anchor', () => {
expect(docsRoute({ slug: 'Creating-a-Trip' })).toBe('/help/Creating-a-Trip')
expect(docsRoute({ slug: 'Creating-a-Trip', anchor: 'fields' })).toBe('/help/Creating-a-Trip#fields')
})
})
+102
View File
@@ -0,0 +1,102 @@
import type { HelpContext, HelpGuide } from './types'
import { dashboardContext, dashboardGuides } from './contexts/dashboard'
import { vacayContext, vacayGuides } from './contexts/vacay'
import { atlasContext, atlasGuides } from './contexts/atlas'
import { collectionsContext, collectionsGuides } from './contexts/collections'
import { journeyContext, journeyGuides, journalContext, journalGuides, studioContext, studioGuides } from './contexts/journey'
import { settingsContext, settingsTabContexts, settingsGuides } from './contexts/settings'
import { adminContext, adminTabContexts, adminGuides } from './contexts/admin'
import { tripContext, tripGuides } from './contexts/trip'
import { tripPlacesContext, tripPlacesGuides } from './contexts/tripPlaces'
import { tripDaysContext, tripDaysGuides } from './contexts/tripDays'
/**
* Everything the help center knows, composed from one file per screen under
* `contexts/`. A screen announces which context it is with `<HelpAnchor id>`
* (or `useHelpContext`), and the panel looks the rest up here.
*/
// The order of the screen switcher: the trip right under My Trips, where it is opened from.
const CONTEXT_LIST: HelpContext[] = [
dashboardContext, tripContext, tripDaysContext, tripPlacesContext, vacayContext, atlasContext, collectionsContext, journeyContext, journalContext, studioContext,
settingsContext, ...settingsTabContexts, adminContext, ...adminTabContexts,
]
const GUIDE_LIST: HelpGuide[] = [
...dashboardGuides, ...vacayGuides, ...atlasGuides, ...collectionsGuides, ...journeyGuides, ...journalGuides, ...studioGuides,
...settingsGuides, ...adminGuides, ...tripGuides, ...tripDaysGuides, ...tripPlacesGuides,
]
export const HELP_CONTEXTS: ReadonlyMap<string, HelpContext> = new Map(CONTEXT_LIST.map(c => [c.id, c]))
export const HELP_GUIDES: ReadonlyMap<string, HelpGuide> = new Map(GUIDE_LIST.map(g => [g.id, g]))
export function getHelpContext(id: string | null | undefined): HelpContext | null {
return id ? (HELP_CONTEXTS.get(id) ?? null) : null
}
export function getHelpGuide(id: string | null | undefined): HelpGuide | null {
return id ? (HELP_GUIDES.get(id) ?? null) : null
}
/** The guides of a context, in the order the context lists them. */
export function guidesFor(context: HelpContext): HelpGuide[] {
return context.guides.map(id => HELP_GUIDES.get(id)).filter((g): g is HelpGuide => g !== undefined)
}
export function allHelpGuides(): HelpGuide[] {
return GUIDE_LIST
}
/** Every screen that has help, in registration order, for the screen switcher. */
export function allHelpContexts(): HelpContext[] {
return CONTEXT_LIST
}
/** The screens that stand on their own; their sub-screens hang under them in the switcher. */
export function topLevelHelpContexts(): HelpContext[] {
return CONTEXT_LIST.filter(c => !c.parent)
}
/** The sub-screens of a screen, in registration order. */
export function childHelpContexts(parentId: string): HelpContext[] {
return CONTEXT_LIST.filter(c => c.parent === parentId)
}
/** The screen and the screens above it, outermost first, for a breadcrumb. */
export function helpContextTrail(context: HelpContext): HelpContext[] {
const trail: HelpContext[] = [context]
let parent = context.parent ? HELP_CONTEXTS.get(context.parent) : undefined
while (parent && !trail.includes(parent)) {
trail.unshift(parent)
parent = parent.parent ? HELP_CONTEXTS.get(parent.parent) : undefined
}
return trail
}
// ── Catalogue keys ──────────────────────────────────────────────────────────
export const ctxKey = (id: string, part: 'title' | 'summary'): string => `help.ctx.${id}.${part}`
export const ctxBulletKey = (id: string, n: number): string => `help.ctx.${id}.bullet.${n}`
export const guideKey = (id: string, part: 'title' | 'goal' | 'result' | 'link'): string =>
`help.guide.${id}.${part}`
export const guideStepKey = (id: string, n: number): string => `help.guide.${id}.step.${n}`
export const guideTipKey = (id: string, n: number): string => `help.guide.${id}.tip.${n}`
// ── Media paths ─────────────────────────────────────────────────────────────
/**
* Generated artwork under `public/help-media/`. WebP and WebM on purpose: the
* service worker precaches `png`, and a couple of hundred help pictures do not
* belong in every visitor's first download.
*/
const MEDIA_BASE = '/help-media'
export const helpMedia = {
hero: (contextId: string): string => `${MEDIA_BASE}/ctx/${contextId}.webp`,
step: (guideId: string, n: number): string => `${MEDIA_BASE}/${guideId}/step-${n}.webp`,
result: (guideId: string): string => `${MEDIA_BASE}/${guideId}/result.webp`,
}
/** Wiki route for a doc link, with the heading anchor when the link names one. */
export function docsRoute(link: { slug: string; anchor?: string }): string {
return `/help/${link.slug}${link.anchor ? `#${link.anchor}` : ''}`
}
+144
View File
@@ -0,0 +1,144 @@
/**
* The help center's content model.
*
* Every screen ("mask") in TREK registers a help context; a context owns a set
* of guides. The words live in the i18n catalogue under `help.ctx.<id>.*` and
* `help.guide.<id>.*`, the pictures and walkthroughs under
* `public/help-media/`, generated by the Playwright run in `e2e/help/` from
* the same guide definitions. Nothing here is prose: this file only says what
* exists, so a guide that is registered but has no text or no picture fails a
* test instead of rendering a raw key.
*/
/** How much of a task a guide covers; drives the chip and the media it carries. */
export type HelpGuideSize = 'quick' | 'guide' | 'tour'
export interface HelpDocLink {
/** Wiki slug, e.g. `Creating-a-Trip`. */
slug: string
/** GitHub-style heading anchor inside that page, without the `#`. */
anchor?: string
}
export interface HelpGuideMedia {
/** One focus shot per step, `step-<n>.webp`. */
steps: boolean
/** The state after the last step, `result.webp`. */
result: boolean
}
/** Icon names the help components know how to draw (see components/Help/helpIcons.ts). */
export type HelpIcon =
| 'plus'
| 'pencil'
| 'image'
| 'copy'
| 'archive'
| 'trash'
| 'filter'
| 'calendar'
| 'layout'
| 'coins'
| 'clock'
| 'compass'
| 'calendarCheck'
| 'halfDay'
| 'hourglass'
| 'calculator'
| 'calendarRange'
| 'building'
| 'flag'
| 'graduationCap'
| 'calendarOff'
| 'calendarDays'
| 'repeat'
| 'userPlus'
| 'share'
| 'globe'
| 'mapPin'
| 'search'
| 'star'
| 'eye'
| 'regions'
| 'chart'
| 'minusCircle'
| 'landmark'
| 'download'
| 'checkCircle'
| 'panelRight'
| 'tags'
| 'upload'
| 'fileInput'
| 'library'
| 'bookmark'
| 'bookOpen'
| 'sparkles'
| 'thumbsUp'
| 'gallery'
| 'link'
| 'book'
| 'sliders'
| 'palette'
| 'map'
| 'bell'
| 'plug'
| 'cloudOff'
| 'user'
| 'lock'
| 'shield'
| 'fingerprint'
| 'key'
| 'route'
| 'users'
| 'userRound'
| 'crown'
| 'panelLeft'
| 'undo'
| 'ticket'
| 'train'
| 'listChecks'
| 'wallet'
| 'folder'
| 'messages'
export interface HelpGuide {
id: string
/** The context the guide belongs to; a guide may be reached from others via `related`. */
context: string
icon: HelpIcon
size: HelpGuideSize
/** Number of steps; the texts are `help.guide.<id>.step.1` … `.step.<steps>`. */
steps: number
/** Number of "good to know" notes; `help.guide.<id>.tip.1` … */
tips: number
media: HelpGuideMedia
/** Where the reference documentation continues. */
docs?: HelpDocLink
/** An in-app route the guide can take the reader to; label is `help.guide.<id>.link`. */
link?: string
related?: string[]
}
export interface HelpContext {
id: string
icon: HelpIcon
/**
* The screen this one lives under, for the screen switcher: a journal is
* reached from the Journey list, a settings tab from Settings. Top-level
* screens have none.
*/
parent?: string
/**
* Where the screen is in the app, for the "go there" button. Screens that
* need an id in the address (a journal, the studio) have none.
*/
route?: string
/** Number of "on this screen" bullets; `help.ctx.<id>.bullet.1` … */
bullets: number
/** Guide ids in display order. */
guides: string[]
/** Reference pages for the "in the docs" list. */
docs: HelpDocLink[]
/** Whether `help-media/ctx/<id>.webp` exists. */
hero: boolean
}