mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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:
@@ -29,6 +29,7 @@ import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
import HelpPanel from './components/Help/HelpPanel'
|
||||
// Notice action registrations (side-effect imports):
|
||||
import './pages/Trips/noticeActions.js'
|
||||
import { managedRoutes } from './managed'
|
||||
@@ -381,6 +382,7 @@ export default function App() {
|
||||
return (
|
||||
<TranslationProvider>
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:help-panel" fallback={null}><HelpPanel /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { searchHelp } from './help'
|
||||
|
||||
describe('searchHelp', () => {
|
||||
it('asks /api/help/search with the query and limit and unwraps the hits', async () => {
|
||||
let url = ''
|
||||
server.use(
|
||||
http.get('/api/help/search', ({ request }) => {
|
||||
url = request.url
|
||||
return HttpResponse.json({ hits: [{ slug: 'Home', title: 'Home', section: '', anchor: null, heading: null, snippet: 'x', score: 1 }] })
|
||||
}),
|
||||
)
|
||||
const hits = await searchHelp('cover image', undefined, 3)
|
||||
expect(hits).toHaveLength(1)
|
||||
const params = new URL(url).searchParams
|
||||
expect(params.get('q')).toBe('cover image')
|
||||
expect(params.get('limit')).toBe('3')
|
||||
})
|
||||
|
||||
it('rejects with the server error', async () => {
|
||||
server.use(http.get('/api/help/search', () => HttpResponse.json({ error: 'A search query is required' }, { status: 400 })))
|
||||
await expect(searchHelp('')).rejects.toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
/** One hit of the full-text search over the bundled wiki (`GET /api/help/search`). */
|
||||
export interface HelpSearchHit {
|
||||
slug: string
|
||||
title: string
|
||||
section: string
|
||||
anchor: string | null
|
||||
heading: string | null
|
||||
snippet: string
|
||||
score: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the docs. Online-only by nature: the pages live on the server, and a
|
||||
* help lookup is never queued for later. Pass a signal so a superseded query
|
||||
* can be dropped instead of racing the newer one.
|
||||
*/
|
||||
export function searchHelp(query: string, signal?: AbortSignal, limit = 8): Promise<HelpSearchHit[]> {
|
||||
return apiClient
|
||||
.get<{ hits: HelpSearchHit[] }>('/help/search', { params: { q: query, limit }, signal })
|
||||
.then(r => r.data.hits)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useHelpContext } from '../../hooks/useHelpContext'
|
||||
|
||||
/**
|
||||
* Renders nothing; registers the screen it sits in with the help center. A
|
||||
* page body may not call hooks itself (see pages/PATTERN.md), so this is the
|
||||
* form a `*Page.tsx` uses.
|
||||
*/
|
||||
export default function HelpAnchor({ id }: { id: string }): null {
|
||||
useHelpContext(id)
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* The one pill the help center is built from: section markers, counters,
|
||||
* breadcrumbs, step numbers. Tones map onto the appearance tokens so a user's
|
||||
* accent reaches every badge, and a badge can nest another (`count`) for the
|
||||
* "3 steps" pattern without a second component.
|
||||
*/
|
||||
export type HelpBadgeTone = 'neutral' | 'accent' | 'soft' | 'success' | 'warning' | 'outline'
|
||||
|
||||
const TONES: Record<HelpBadgeTone, string> = {
|
||||
neutral: 'bg-surface-tertiary text-content-secondary',
|
||||
accent: 'bg-accent text-accent-text',
|
||||
soft: 'bg-accent-subtle text-accent-on',
|
||||
success: 'bg-success-soft text-success',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
outline: 'border border-edge bg-surface-card text-content-secondary',
|
||||
}
|
||||
|
||||
/** The nested counter inverts against its parent so it reads as a badge in a badge. */
|
||||
const COUNT_TONES: Record<HelpBadgeTone, string> = {
|
||||
neutral: 'bg-surface-card text-content',
|
||||
accent: 'bg-accent-text text-accent',
|
||||
soft: 'bg-surface-card text-content',
|
||||
success: 'bg-surface-card text-success',
|
||||
warning: 'bg-surface-card text-warning',
|
||||
outline: 'bg-surface-tertiary text-content',
|
||||
}
|
||||
|
||||
export default function HelpBadge({
|
||||
tone = 'neutral',
|
||||
icon: Icon,
|
||||
count,
|
||||
uppercase = false,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
tone?: HelpBadgeTone
|
||||
icon?: LucideIcon
|
||||
/** A nested badge on the right, e.g. a number. */
|
||||
count?: React.ReactNode
|
||||
uppercase?: boolean
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 h-7 rounded-full pl-2.5 text-caption font-semibold leading-none whitespace-nowrap ${
|
||||
count === undefined ? 'pr-2.5' : 'pr-1'
|
||||
} ${uppercase ? 'uppercase tracking-[0.08em]' : ''} ${TONES[tone]} ${className}`}
|
||||
>
|
||||
{Icon && <Icon className="w-3.5 h-3.5 flex-shrink-0" />}
|
||||
{children}
|
||||
{count !== undefined && (
|
||||
<span className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-caption font-bold ${COUNT_TONES[tone]}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
|
||||
/**
|
||||
* The `?` in the navbar. Same 32px footprint as the theme toggle beside it;
|
||||
* lit while the panel is open so the two read as one control.
|
||||
*/
|
||||
export default function HelpButton({ className }: { className?: string } = {}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const open = useHelpStore(s => s.open)
|
||||
const toggleHelp = useHelpStore(s => s.toggleHelp)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleHelp}
|
||||
title={t('help.center.button')}
|
||||
aria-label={t('help.center.button')}
|
||||
aria-pressed={open}
|
||||
data-help-button
|
||||
className={
|
||||
className ??
|
||||
`hidden sm:flex relative w-8 h-8 p-2 rounded-lg items-center justify-center flex-shrink-0 transition-colors ${
|
||||
open ? 'bg-surface-selected text-content' : 'text-content-muted hover:bg-surface-hover'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { FileText, ArrowUpRight } from 'lucide-react'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
|
||||
/**
|
||||
* A row that leads into the full Help & Docs page. The panel closes on the
|
||||
* way: the docs page has its own navigation and the two side by side would
|
||||
* fight for the width.
|
||||
*/
|
||||
export default function HelpDocLink({ to, title, subtitle }: { to: string; title: string; subtitle?: string }): React.ReactElement {
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={closeHelp}
|
||||
className="group flex items-center gap-2.5 rounded-lg px-2 py-1.5 -mx-2 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-content-faint flex-shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-body text-content-secondary group-hover:text-content truncate transition-colors">{title}</span>
|
||||
{subtitle && <span className="block text-caption text-content-faint truncate">{subtitle}</span>}
|
||||
</span>
|
||||
<ArrowUpRight className="w-3.5 h-3.5 text-content-faint opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
import { getHelpGuide } from '../../help/registry'
|
||||
import HelpGuideView from './HelpGuideView'
|
||||
|
||||
const initial = useHelpStore.getState()
|
||||
|
||||
beforeEach(() => {
|
||||
useHelpStore.setState(initial, true)
|
||||
})
|
||||
|
||||
describe('HelpGuideView: links', () => {
|
||||
it('offers the in-app link of a guide that has one and closes the dialog on the way', () => {
|
||||
useHelpStore.setState({ open: true })
|
||||
render(<HelpGuideView guide={getHelpGuide('widgets')!} />)
|
||||
const link = screen.getByRole('link', { name: /Open Appearance settings/ })
|
||||
expect(link).toHaveAttribute('href', '/settings?tab=appearance')
|
||||
fireEvent.click(link)
|
||||
expect(useHelpStore.getState().open).toBe(false)
|
||||
})
|
||||
|
||||
it('lists every step and has no link for a quick guide', () => {
|
||||
render(<HelpGuideView guide={getHelpGuide('delete-trip')!} />)
|
||||
expect(screen.getAllByLabelText(/^Step \d$/)).toHaveLength(getHelpGuide('delete-trip')!.steps)
|
||||
expect(screen.queryByRole('link', { name: /Open Appearance settings/ })).toBeNull()
|
||||
expect(screen.getByRole('link', { name: /Open in Help & Docs/ })).toHaveAttribute('href', '/help/My-Trips-Dashboard#per-trip-actions')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { CheckCircle2, Lightbulb, ArrowUpRight, ChevronRight, Maximize2, ListOrdered, Camera, Link2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore, docTitle } from '../../store/helpStore'
|
||||
import { guideKey, guideStepKey, guideTipKey, helpMedia, docsRoute, getHelpGuide, getHelpContext, ctxKey, helpContextTrail } from '../../help/registry'
|
||||
import type { HelpGuide } from '../../help/types'
|
||||
import { helpIcon } from './helpIcons'
|
||||
import HelpBadge from './HelpBadge'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import HelpSizeChip from './HelpSizeChip'
|
||||
import { useScreenRoute } from './useScreenRoute'
|
||||
|
||||
/**
|
||||
* One guide, top to bottom: a header card that says where you are and how big
|
||||
* the task is, the steps as a numbered rail with a picture each, what the
|
||||
* reader ends up with, the things worth knowing, and where to go next.
|
||||
*/
|
||||
export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const openLightbox = useHelpStore(s => s.openLightbox)
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
const docIndex = useHelpStore(s => s.docIndex)
|
||||
const openGuide = useHelpStore(s => s.openGuide)
|
||||
const title = t(guideKey(guide.id, 'title'))
|
||||
const screen = getHelpContext(guide.context)
|
||||
const steps = Array.from({ length: guide.steps }, (_, i) => i + 1)
|
||||
const tips = Array.from({ length: guide.tips }, (_, i) => t(guideTipKey(guide.id, i + 1)))
|
||||
const related = (guide.related ?? []).map(getHelpGuide).filter((g): g is HelpGuide => g !== null)
|
||||
const Icon = helpIcon(guide.icon)
|
||||
const ScreenIcon = screen ? helpIcon(screen.icon) : Icon
|
||||
const screenRoute = useScreenRoute(screen?.route)
|
||||
|
||||
return (
|
||||
<div className="trek-help-view flex flex-col gap-6">
|
||||
{/* Where you are, what this is, how big it is */}
|
||||
<header className="rounded-2xl border border-edge bg-surface-card p-5 sm:p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{screen && helpContextTrail(screen).map((c, i, all) => (
|
||||
<React.Fragment key={c.id}>
|
||||
{i > 0 && <ChevronRight className="w-3.5 h-3.5 text-content-faint" />}
|
||||
<HelpBadge tone="outline" icon={i === all.length - 1 ? ScreenIcon : undefined}>{t(ctxKey(c.id, 'title'))}</HelpBadge>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<ChevronRight className="w-3.5 h-3.5 text-content-faint" />
|
||||
<HelpBadge tone="outline">{t('help.center.howTo')}</HelpBadge>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="w-14 h-14 rounded-2xl bg-accent text-accent-text flex items-center justify-center flex-shrink-0 shadow-card">
|
||||
<Icon className="w-6 h-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1.5">
|
||||
<h1 className="text-title font-bold text-content leading-tight">{title}</h1>
|
||||
<p className="text-body text-content-secondary leading-relaxed">{t(guideKey(guide.id, 'goal'))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-edge-secondary">
|
||||
<HelpSizeChip size={guide.size} />
|
||||
<HelpBadge tone="neutral" icon={ListOrdered} count={guide.steps}>{t('help.center.stepsLabel')}</HelpBadge>
|
||||
{guide.media.steps && <HelpBadge tone="neutral" icon={Camera} count={guide.steps}>{t('help.center.screenshot')}</HelpBadge>}
|
||||
{/* One way out of the header: into the screen. The docs link waits at the bottom. */}
|
||||
{screen && screenRoute && (
|
||||
<Link to={screenRoute} onClick={closeHelp} className="ml-auto">
|
||||
<HelpBadge tone="accent" icon={ArrowUpRight} className="hover:bg-accent-hover transition-colors">
|
||||
{t('help.center.goToScreen', { screen: t(ctxKey(screen.id, 'title')) })}
|
||||
</HelpBadge>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Steps, on a rail */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpBadge tone="accent" icon={ListOrdered} uppercase count={guide.steps}>{t('help.center.stepsLabel')}</HelpBadge>
|
||||
</div>
|
||||
<ol className="relative flex flex-col gap-3">
|
||||
{/* The rail: runs behind the step numbers from the first to the last. */}
|
||||
<span className="absolute left-[27px] top-6 bottom-6 w-px bg-edge" aria-hidden="true" />
|
||||
{steps.map(n => {
|
||||
const text = t(guideStepKey(guide.id, n))
|
||||
const src = helpMedia.step(guide.id, n)
|
||||
const alt = t('help.center.imageAlt', { n, title })
|
||||
return (
|
||||
<li
|
||||
key={n}
|
||||
className={`trek-help-step relative grid grid-cols-1 gap-4 rounded-2xl border border-edge bg-surface-card p-3 ${
|
||||
guide.media.steps ? 'md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 items-start">
|
||||
<span
|
||||
aria-label={t('help.center.step', { n })}
|
||||
className="trek-help-step-no relative z-[1] w-8 h-8 rounded-full bg-surface-card text-content text-caption font-bold flex items-center justify-center flex-shrink-0 border border-edge"
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-2">
|
||||
<HelpBadge tone="outline" uppercase className="self-start">
|
||||
{t('help.center.stepOf', { n, total: guide.steps })}
|
||||
</HelpBadge>
|
||||
<p className="rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content leading-relaxed">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
{guide.media.steps && (
|
||||
<figure className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox({ src, alt })}
|
||||
className="trek-help-shot group relative block w-full rounded-xl overflow-hidden border border-edge-secondary bg-surface-tertiary"
|
||||
aria-label={alt}
|
||||
>
|
||||
<img src={src} alt="" loading="lazy" decoding="async" className="block w-full aspect-[16/10] object-cover" />
|
||||
<span className="absolute bottom-2 left-2">
|
||||
<HelpBadge tone="accent" icon={Camera} count={n}>{t('help.center.screenshot')}</HelpBadge>
|
||||
</span>
|
||||
<span className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-inverse text-inverse-text flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-elevated">
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
</figure>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Result */}
|
||||
<section
|
||||
className={`rounded-2xl border border-edge bg-surface-card p-4 grid grid-cols-1 gap-4 items-start ${
|
||||
guide.media.result ? 'md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<HelpBadge tone="success" icon={CheckCircle2} uppercase className="self-start">{t('help.center.result')}</HelpBadge>
|
||||
<p className="rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content leading-relaxed">{t(guideKey(guide.id, 'result'))}</p>
|
||||
</div>
|
||||
{guide.media.result && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox({ src: helpMedia.result(guide.id), alt: t(guideKey(guide.id, 'result')) })}
|
||||
className="trek-help-shot group relative rounded-xl overflow-hidden border border-edge-secondary bg-surface-tertiary"
|
||||
>
|
||||
<img src={helpMedia.result(guide.id)} alt="" loading="lazy" decoding="async" className="block w-full aspect-video object-cover" />
|
||||
<span className="absolute bottom-2 left-2">
|
||||
<HelpBadge tone="success" icon={CheckCircle2}>{t('help.center.result')}</HelpBadge>
|
||||
</span>
|
||||
<span className="absolute bottom-2 right-2 w-8 h-8 rounded-full bg-inverse text-inverse-text flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-elevated">
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Tips */}
|
||||
{tips.length > 0 && (
|
||||
<section className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
|
||||
<HelpBadge tone="warning" icon={Lightbulb} uppercase count={tips.length} className="self-start">{t('help.center.tips')}</HelpBadge>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{tips.map((tip, i) => (
|
||||
<li key={i} className="flex gap-3 rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content-secondary leading-relaxed">
|
||||
<span className="w-6 h-6 rounded-full bg-warning-soft text-warning text-caption font-bold flex items-center justify-center flex-shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{tip}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Go there / docs / related */}
|
||||
{(guide.link || guide.docs || related.length > 0) && (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{(guide.link || guide.docs) && (
|
||||
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
|
||||
<HelpBadge tone="neutral" icon={Link2} uppercase className="self-start">{t('help.center.docsSection')}</HelpBadge>
|
||||
{guide.link && (
|
||||
<Link
|
||||
to={guide.link}
|
||||
onClick={closeHelp}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-accent text-accent-text text-body font-semibold px-4 py-2.5 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{t(guideKey(guide.id, 'link'))}
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
{guide.docs && (
|
||||
<HelpDocLink to={docsRoute(guide.docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, guide.docs.slug)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{related.length > 0 && (
|
||||
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
|
||||
<HelpBadge tone="neutral" icon={ChevronRight} uppercase count={related.length} className="self-start">{t('help.center.related')}</HelpBadge>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{related.map(g => {
|
||||
const RelIcon = helpIcon(g.icon)
|
||||
return (
|
||||
<li key={g.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openGuide(g.id)}
|
||||
className="group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<span className="w-7 h-7 rounded-md bg-surface-tertiary text-content-muted flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:text-accent-text transition-colors">
|
||||
<RelIcon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary group-hover:text-content truncate transition-colors">
|
||||
{t(guideKey(g.id, 'title'))}
|
||||
</span>
|
||||
{/* A guide on another screen says which, so the jump is no surprise. */}
|
||||
{g.context === guide.context ? (
|
||||
<HelpBadge tone="neutral" count={g.steps}>{t('help.center.stepsLabel')}</HelpBadge>
|
||||
) : (
|
||||
<HelpBadge tone="outline" icon={ArrowUpRight}>{t(ctxKey(g.context, 'title'))}</HelpBadge>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { ArrowRight, ArrowUpRight, BookOpen, LayoutList, ListOrdered, Compass, ChevronRight, ChevronDown, Camera, LayoutGrid } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore, docTitle } from '../../store/helpStore'
|
||||
import { ctxKey, ctxBulletKey, guideKey, guidesFor, helpMedia, docsRoute, helpContextTrail, childHelpContexts } from '../../help/registry'
|
||||
import type { HelpContext, HelpGuide } from '../../help/types'
|
||||
import { helpIcon } from './helpIcons'
|
||||
import HelpBadge from './HelpBadge'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import HelpSizeChip from './HelpSizeChip'
|
||||
import { useScreenRoute } from './useScreenRoute'
|
||||
|
||||
/**
|
||||
* The overview of a screen: what it is, what is on it, and a card per guide.
|
||||
* With no registered context (a screen that has no help yet) it says so and
|
||||
* leaves the search box as the way forward.
|
||||
*/
|
||||
export default function HelpHome({ context }: { context: HelpContext | null }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const docIndex = useHelpStore(s => s.docIndex)
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
// Folded until asked for: the guides below matter more, the tabs are one click away.
|
||||
const [subScreensOpen, setSubScreensOpen] = useState(false)
|
||||
const screenRoute = useScreenRoute(context?.route)
|
||||
|
||||
if (!context) {
|
||||
return (
|
||||
<div className="trek-help-view flex flex-col items-center text-center gap-2 py-24">
|
||||
<BookOpen className="w-8 h-8 text-content-faint" />
|
||||
<p className="text-subtitle font-semibold text-content">{t('help.center.noContext')}</p>
|
||||
<p className="text-body text-content-muted max-w-[320px]">{t('help.center.noContextHint')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const guides = guidesFor(context)
|
||||
const bullets = Array.from({ length: context.bullets }, (_, i) => t(ctxBulletKey(context.id, i + 1)))
|
||||
const children = childHelpContexts(context.id)
|
||||
const Icon = helpIcon(context.icon)
|
||||
|
||||
return (
|
||||
<div className="trek-help-view flex flex-col gap-6">
|
||||
{/* Which screen, what it is, what help there is */}
|
||||
<header className="rounded-2xl border border-edge bg-surface-card p-5 sm:p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<HelpBadge tone="outline" icon={Compass}>{t('help.center.screens')}</HelpBadge>
|
||||
{helpContextTrail(context).slice(0, -1).map(parent => (
|
||||
<React.Fragment key={parent.id}>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-content-faint" />
|
||||
<HelpBadge tone="outline">{t(ctxKey(parent.id, 'title'))}</HelpBadge>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<ChevronRight className="w-3.5 h-3.5 text-content-faint" />
|
||||
<HelpBadge tone="outline">{t('help.center.overview')}</HelpBadge>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="w-14 h-14 rounded-2xl bg-accent text-accent-text flex items-center justify-center flex-shrink-0 shadow-card">
|
||||
<Icon className="w-6 h-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1.5">
|
||||
<h1 className="text-title font-bold text-content leading-tight">{t(ctxKey(context.id, 'title'))}</h1>
|
||||
<p className="text-body text-content-secondary leading-relaxed">{t(ctxKey(context.id, 'summary'))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-edge-secondary">
|
||||
<HelpBadge tone="accent" icon={ListOrdered} count={guides.length}>{t('help.center.searchGuides')}</HelpBadge>
|
||||
{context.docs.length > 0 && <HelpBadge tone="neutral" icon={BookOpen} count={context.docs.length}>{t('help.center.searchDocs')}</HelpBadge>}
|
||||
{screenRoute && (
|
||||
<Link to={screenRoute} onClick={closeHelp} className="ml-auto">
|
||||
<HelpBadge tone="accent" icon={ArrowUpRight} className="hover:bg-accent-hover transition-colors">
|
||||
{t('help.center.goToScreen', { screen: t(ctxKey(context.id, 'title')) })}
|
||||
</HelpBadge>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Screens under this one (the tabs of Settings, the journal of Journey) */}
|
||||
{children.length > 0 && (
|
||||
<section className="rounded-2xl border border-edge bg-surface-card flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubScreensOpen(o => !o)}
|
||||
aria-expanded={subScreensOpen}
|
||||
className="flex items-center gap-3 p-4 text-left rounded-2xl hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<HelpBadge tone="neutral" icon={LayoutGrid} uppercase count={children.length}>{t('help.center.subScreensLabel')}</HelpBadge>
|
||||
<span className="min-w-0 flex-1 text-caption text-content-muted truncate">
|
||||
{children.map(child => t(ctxKey(child.id, 'title'))).join(' · ')}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-content-faint flex-shrink-0 transition-transform ${subScreensOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{subScreensOpen && (
|
||||
<ul className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2 px-4 pb-4">
|
||||
{children.map(child => (
|
||||
<li key={child.id}>
|
||||
<SubScreenCard screen={child} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* The screen itself */}
|
||||
{context.hero && (
|
||||
<figure className="relative rounded-2xl overflow-hidden border border-edge-secondary bg-surface-tertiary shadow-card">
|
||||
<img
|
||||
src={helpMedia.hero(context.id)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="block w-full aspect-video object-cover"
|
||||
/>
|
||||
<span className="absolute bottom-3 left-3">
|
||||
<HelpBadge tone="accent" icon={Camera}>{t(ctxKey(context.id, 'title'))}</HelpBadge>
|
||||
</span>
|
||||
</figure>
|
||||
)}
|
||||
|
||||
{/* What is on it */}
|
||||
{bullets.length > 0 && (
|
||||
<section className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
|
||||
<HelpBadge tone="neutral" icon={LayoutList} uppercase count={bullets.length} className="self-start">{t('help.center.onThisScreen')}</HelpBadge>
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{bullets.map((text, i) => (
|
||||
<li key={i} className="flex gap-3 rounded-xl bg-surface-tertiary px-3.5 py-3 text-body text-content-secondary leading-relaxed">
|
||||
<span className="w-6 h-6 rounded-full bg-accent text-accent-text text-caption font-bold flex items-center justify-center flex-shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Guides */}
|
||||
{guides.length > 0 && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<HelpBadge tone="accent" icon={ListOrdered} uppercase count={guides.length} className="self-start">{t('help.center.howTo')}</HelpBadge>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{guides.map(g => (
|
||||
<li key={g.id}>
|
||||
<GuideCard guide={g} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Docs */}
|
||||
{context.docs.length > 0 && (
|
||||
<section className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-2">
|
||||
<HelpBadge tone="neutral" icon={BookOpen} uppercase count={context.docs.length} className="self-start">{t('help.center.docsSection')}</HelpBadge>
|
||||
<ul className="flex flex-col">
|
||||
{context.docs.map(link => (
|
||||
<li key={`${link.slug}#${link.anchor ?? ''}`}>
|
||||
<HelpDocLink to={docsRoute(link)} title={docTitle(docIndex, link.slug)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One sub-screen: opens its overview in place; the one the reader is on says so. */
|
||||
function SubScreenCard({ screen }: { screen: HelpContext }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const browse = useHelpStore(s => s.browse)
|
||||
const contextId = useHelpStore(s => s.contextId)
|
||||
const current = screen.id === contextId
|
||||
const Icon = helpIcon(screen.icon)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => browse(current ? null : screen.id)}
|
||||
className="group w-full h-full flex items-center gap-3 rounded-xl border border-edge bg-surface-secondary px-3 py-2.5 text-left hover:border-content-faint hover:bg-surface-card transition-colors"
|
||||
>
|
||||
<span className="w-9 h-9 rounded-lg bg-surface-card text-content-secondary flex items-center justify-center flex-shrink-0 group-hover:bg-accent group-hover:text-accent-text transition-colors">
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||
<span className="text-body font-semibold text-content truncate">{t(ctxKey(screen.id, 'title'))}</span>
|
||||
<span className="text-caption text-content-muted">{t('help.center.guidesCount', { count: screen.guides.length })}</span>
|
||||
</span>
|
||||
{current ? <ThisScreenChip /> : <ArrowRight className="w-4 h-4 text-content-faint transition-transform group-hover:translate-x-0.5 group-hover:text-content" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The marker for the screen the reader is actually on. */
|
||||
export function ThisScreenChip(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-accent text-accent-text px-2 py-1 text-caption font-semibold leading-none whitespace-nowrap flex-shrink-0">
|
||||
{t('help.center.thisScreen')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionLabel({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <h3 className="text-caption font-semibold uppercase tracking-[0.1em] text-content-faint">{children}</h3>
|
||||
}
|
||||
|
||||
export function GuideCard({ guide }: { guide: HelpGuide }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const openGuide = useHelpStore(s => s.openGuide)
|
||||
const Icon = helpIcon(guide.icon)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openGuide(guide.id)}
|
||||
className="group w-full h-full flex flex-col gap-3 rounded-2xl border border-edge bg-surface-card p-4 text-left hover:border-content-faint hover:shadow-elevated transition-[border-color,box-shadow,transform] duration-200 ease-[var(--ease-out-quint)] hover:-translate-y-px"
|
||||
>
|
||||
<span className="flex items-center justify-between">
|
||||
<span className="w-10 h-10 rounded-xl bg-surface-tertiary text-content-secondary flex items-center justify-center group-hover:bg-accent group-hover:text-accent-text transition-colors">
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HelpSizeChip size={guide.size} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex flex-col gap-1 flex-1">
|
||||
<span className="text-body font-semibold text-content leading-snug">{t(guideKey(guide.id, 'title'))}</span>
|
||||
<span className="text-caption text-content-muted leading-relaxed">{t(guideKey(guide.id, 'goal'))}</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between pt-3 border-t border-edge-secondary">
|
||||
<HelpBadge tone="neutral" count={guide.steps}>{t('help.center.stepsLabel')}</HelpBadge>
|
||||
<ArrowRight className="w-4 h-4 text-content-faint transition-transform group-hover:translate-x-0.5 group-hover:text-content" />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
|
||||
/** A step picture enlarged over the help dialog; click anywhere or Escape to close. */
|
||||
export default function HelpLightbox(): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const lightbox = useHelpStore(s => s.lightbox)
|
||||
const close = useHelpStore(s => s.openLightbox)
|
||||
if (!lightbox) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="trek-help-lightbox fixed inset-0 z-[var(--z-overlay)] flex items-center justify-center p-4 sm:p-8 bg-[var(--overlay)] backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={lightbox.alt}
|
||||
onClick={() => close(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => close(null)}
|
||||
aria-label={t('help.center.close')}
|
||||
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-inverse text-inverse-text flex items-center justify-center shadow-elevated hover:scale-105 transition-transform"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<figure className="trek-help-lightbox-card max-w-[min(1400px,94vw)] max-h-[90vh] flex flex-col gap-3" onClick={e => e.stopPropagation()}>
|
||||
<img src={lightbox.src} alt={lightbox.alt} className="max-h-[84vh] w-auto object-contain rounded-xl border border-edge shadow-modal bg-surface-card" />
|
||||
{lightbox.alt && <figcaption className="text-caption text-center text-inverse-text opacity-80">{lightbox.alt}</figcaption>}
|
||||
</figure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Search, LifeBuoy, ArrowUpRight, ChevronsUpDown, ChevronDown, ChevronRight, Check, MapPin, CornerDownRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore, docTitle } from '../../store/helpStore'
|
||||
import { ctxKey, guideKey, guidesFor, docsRoute, topLevelHelpContexts, childHelpContexts, helpContextTrail } from '../../help/registry'
|
||||
import type { HelpContext, HelpGuide } from '../../help/types'
|
||||
import { helpIcon } from './helpIcons'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import { ThisScreenChip } from './HelpHome'
|
||||
|
||||
const GITHUB_DISCUSSIONS = 'https://github.com/liketrek/TREK/discussions/new?category=ideas'
|
||||
const DISCORD = 'https://discord.gg/NhZBDSd4qW'
|
||||
|
||||
/**
|
||||
* Left pane of the help dialog: search, the current screen with its guides,
|
||||
* the docs that go with it, and the ways to reach a human.
|
||||
*/
|
||||
export default function HelpNav({ context, currentId, activeGuideId }: {
|
||||
/** The screen whose help is shown. */
|
||||
context: HelpContext | null
|
||||
/** The screen the reader is actually on, for the "this screen" marker. */
|
||||
currentId: string | null
|
||||
activeGuideId: string | null
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const query = useHelpStore(s => s.query)
|
||||
const setQuery = useHelpStore(s => s.setQuery)
|
||||
const closeGuide = useHelpStore(s => s.closeGuide)
|
||||
const docIndex = useHelpStore(s => s.docIndex)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus({ preventScroll: true })
|
||||
}, [])
|
||||
|
||||
const guides = context ? guidesFor(context) : []
|
||||
const overviewActive = !activeGuideId && !query.trim()
|
||||
// The tabs of the screen on show, or of its parent when the reader is on one of them.
|
||||
const family = context ? childHelpContexts(context.parent ?? context.id) : []
|
||||
// Folded by default: the guides matter more, the tabs are one click away.
|
||||
const [familyOpen, setFamilyOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col min-h-0 border-b md:border-b-0 md:border-r border-edge bg-surface-secondary" aria-label={t('help.center.title')}>
|
||||
<div className="px-5 pt-5 pb-3 flex items-center gap-2.5">
|
||||
<span className="w-8 h-8 rounded-lg flex items-center justify-center bg-accent text-accent-text">
|
||||
<LifeBuoy className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="text-subtitle font-bold text-content leading-none">{t('help.center.title')}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-4">
|
||||
<label className="relative block">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-content-faint pointer-events-none" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('help.center.searchPlaceholder')}
|
||||
aria-label={t('help.center.searchPlaceholder')}
|
||||
className="w-full rounded-xl bg-surface-card text-content text-body pl-9 pr-3 py-2.5 outline-none border border-edge focus:border-content-faint transition-colors placeholder:text-content-faint"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="trek-help-scroll flex-1 min-h-0 overflow-y-auto px-3 pb-3 flex flex-col gap-5">
|
||||
<section className="flex flex-col gap-1">
|
||||
<NavLabel>{t('help.center.screens')}</NavLabel>
|
||||
<ScreenSwitcher shown={context} currentId={currentId} />
|
||||
</section>
|
||||
{context ? (
|
||||
<>
|
||||
{family.length > 0 && (
|
||||
<section className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFamilyOpen(o => !o)}
|
||||
aria-expanded={familyOpen}
|
||||
className="flex items-center gap-1.5 px-2 pb-1 text-left text-caption font-semibold uppercase tracking-[0.1em] text-content-faint hover:text-content transition-colors"
|
||||
>
|
||||
<span className="flex-1">{t('help.center.subScreensLabel')}</span>
|
||||
<span className="text-caption font-bold tabular-nums normal-case tracking-normal">{family.length}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${familyOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{familyOpen && (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{family.map(screen => (
|
||||
<li key={screen.id}>
|
||||
<NavScreen screen={screen} shown={screen.id === context.id} current={screen.id === currentId} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-1">
|
||||
<NavLabel>{t('help.center.overview')}</NavLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { closeGuide(); setQuery('') }}
|
||||
aria-current={overviewActive ? 'page' : undefined}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors ${
|
||||
overviewActive ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<span className="text-body font-semibold truncate">{t(ctxKey(context.id, 'title'))}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{guides.length > 0 && (
|
||||
<section className="flex flex-col gap-1">
|
||||
<NavLabel>{t('help.center.howTo')}</NavLabel>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{guides.map(g => (
|
||||
<li key={g.id}>
|
||||
<NavGuide guide={g} active={g.id === activeGuideId} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{context.docs.length > 0 && (
|
||||
<section className="flex flex-col gap-1 px-2">
|
||||
<NavLabel>{t('help.center.docsSection')}</NavLabel>
|
||||
<ul className="flex flex-col">
|
||||
{context.docs.map(link => (
|
||||
<li key={`${link.slug}#${link.anchor ?? ''}`}>
|
||||
<HelpDocLink to={docsRoute(link)} title={docTitle(docIndex, link.slug)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="px-2 text-body text-content-muted">{t('help.center.noContext')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="px-4 py-4 border-t border-edge flex flex-col gap-2">
|
||||
<span className="px-1 text-caption font-semibold uppercase tracking-[0.1em] text-content-faint">{t('help.center.feedback')}</span>
|
||||
<a
|
||||
href={GITHUB_DISCUSSIONS}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-xl bg-accent text-accent-text px-3.5 py-2.5 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<GitHubMark className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="flex-1 text-body font-semibold">{t('help.center.feedbackLink')}</span>
|
||||
<ArrowUpRight className="w-4 h-4 opacity-70 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
{/* Discord blurple is a brand colour, deliberately not a theme token. */}
|
||||
<a
|
||||
href={DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-xl bg-[#5865F2] text-white px-3.5 py-2.5 hover:bg-[#4752C4] transition-colors" // theme-lint-disable
|
||||
>
|
||||
<DiscordMark className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="flex-1 text-body font-semibold">{t('help.center.discord')}</span>
|
||||
<ArrowUpRight className="w-4 h-4 opacity-70 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
</footer>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick which screen's help to read without leaving the one you are on. The
|
||||
* current screen is marked; choosing it again returns the dialog to following
|
||||
* the screen the reader is on.
|
||||
*/
|
||||
/**
|
||||
* Which screen the panel shows, and the list to pick another. Screens with
|
||||
* sub-screens (Journey and its journal, Settings and its tabs) fold: one group
|
||||
* is open at a time, and the group of the screen on show starts open.
|
||||
*/
|
||||
function ScreenSwitcher({ shown, currentId }: { shown: HelpContext | null; currentId: string | null }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const browse = useHelpStore(s => s.browse)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const screens = topLevelHelpContexts()
|
||||
const ShownIcon = shown ? helpIcon(shown.icon) : MapPin
|
||||
const trail = shown ? helpContextTrail(shown) : []
|
||||
|
||||
// Click outside or Escape closes the list; Escape must not bubble into the dialog's own close.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false) }
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) } }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const choose = (id: string) => {
|
||||
browse(id === currentId ? null : id)
|
||||
setOpen(false)
|
||||
}
|
||||
const toggleList = () => {
|
||||
setOpen(o => {
|
||||
if (!o) setOpenGroup(trail.length > 1 ? trail[0].id : null)
|
||||
return !o
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleList}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2.5 rounded-xl border border-edge bg-surface-card px-3 py-2.5 text-left hover:border-content-faint transition-colors"
|
||||
>
|
||||
<span className="w-7 h-7 rounded-md bg-accent text-accent-text flex items-center justify-center flex-shrink-0">
|
||||
<ShownIcon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-body font-semibold text-content truncate">
|
||||
{shown ? trail.map(c => t(ctxKey(c.id, 'title'))).join(' › ') : t('help.center.noContext')}
|
||||
</span>
|
||||
</span>
|
||||
{shown && shown.id === currentId && <ThisScreenChip />}
|
||||
<ChevronsUpDown className="w-4 h-4 text-content-faint flex-shrink-0" />
|
||||
</button>
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label={t('help.center.screens')}
|
||||
className="trek-help-menu absolute left-2 right-2 top-full mt-1.5 z-10 flex flex-col gap-0.5 rounded-xl border border-edge bg-surface-card p-1.5 shadow-elevated"
|
||||
>
|
||||
{screens.map(screen => {
|
||||
const children = childHelpContexts(screen.id)
|
||||
const expanded = openGroup === screen.id
|
||||
return (
|
||||
<li key={screen.id} className="flex flex-col gap-0.5">
|
||||
<ScreenRow
|
||||
screen={screen}
|
||||
selected={screen.id === shown?.id}
|
||||
current={screen.id === currentId}
|
||||
onPick={() => choose(screen.id)}
|
||||
group={children.length > 0 ? { expanded, count: children.length, toggle: () => setOpenGroup(expanded ? null : screen.id) } : undefined}
|
||||
/>
|
||||
{children.length > 0 && expanded && (
|
||||
<ul role="group" className="flex flex-col gap-0.5 pl-3">
|
||||
{children.map(child => (
|
||||
<li key={child.id}>
|
||||
<ScreenRow
|
||||
screen={child}
|
||||
selected={child.id === shown?.id}
|
||||
current={child.id === currentId}
|
||||
onPick={() => choose(child.id)}
|
||||
nested
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One screen in the switcher list; a group row carries the fold toggle on its right. */
|
||||
function ScreenRow({
|
||||
screen, selected, current, onPick, group, nested = false,
|
||||
}: {
|
||||
screen: HelpContext
|
||||
selected: boolean
|
||||
current: boolean
|
||||
onPick: () => void
|
||||
group?: { expanded: boolean; count: number; toggle: () => void }
|
||||
nested?: boolean
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const Icon = helpIcon(screen.icon)
|
||||
const Fold = group?.expanded ? ChevronDown : ChevronRight
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`flex items-center rounded-lg transition-colors ${
|
||||
selected ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<button type="button" onClick={onPick} className="min-w-0 flex-1 flex items-center gap-2.5 px-2 py-2 text-left">
|
||||
{nested ? (
|
||||
<CornerDownRight className="w-3.5 h-3.5 flex-shrink-0 text-content-faint" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4 flex-shrink-0 text-content-muted" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 text-body truncate">{t(ctxKey(screen.id, 'title'))}</span>
|
||||
{current && <ThisScreenChip />}
|
||||
{selected && <Check className="w-4 h-4 flex-shrink-0" />}
|
||||
</button>
|
||||
{group && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); group.toggle() }}
|
||||
aria-expanded={group.expanded}
|
||||
aria-label={t('help.center.subScreens', { count: group.count })}
|
||||
className="flex items-center gap-1 rounded-md mr-1.5 px-1.5 py-1 text-caption font-semibold text-content-faint hover:bg-surface-tertiary hover:text-content transition-colors"
|
||||
>
|
||||
{group.count}
|
||||
<Fold className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A sibling or child screen in the nav; picking it browses that screen's overview. */
|
||||
function NavScreen({ screen, shown, current }: { screen: HelpContext; shown: boolean; current: boolean }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const browse = useHelpStore(s => s.browse)
|
||||
const Icon = helpIcon(screen.icon)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => browse(current ? null : screen.id)}
|
||||
aria-current={shown ? 'page' : undefined}
|
||||
className={`group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors ${
|
||||
shown ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-6 h-6 rounded-md flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
shown ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted group-hover:bg-accent group-hover:text-accent-text'
|
||||
}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-body truncate">{t(ctxKey(screen.id, 'title'))}</span>
|
||||
{current && <MapPin className="w-3.5 h-3.5 text-content-faint flex-shrink-0" aria-label={t('help.center.thisScreen')} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NavLabel({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <h3 className="px-2 pb-1 text-caption font-semibold uppercase tracking-[0.1em] text-content-faint">{children}</h3>
|
||||
}
|
||||
|
||||
function NavGuide({ guide, active }: { guide: HelpGuide; active: boolean }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const openGuide = useHelpStore(s => s.openGuide)
|
||||
const Icon = helpIcon(guide.icon)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openGuide(guide.id)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`group w-full flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors ${
|
||||
active ? 'bg-surface-selected text-content' : 'text-content-secondary hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-muted group-hover:text-content'
|
||||
}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-body truncate">{t(guideKey(guide.id, 'title'))}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** GitHub's mark, from their brand kit; lucide dropped its brand icons. */
|
||||
function GitHubMark({ className }: { className?: string }): React.ReactElement {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56v-2.17c-3.2.7-3.87-1.37-3.87-1.37-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.19 1.76 1.19 1.03 1.76 2.69 1.25 3.35.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.04 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.78 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.58.23 2.75.11 3.04.74.81 1.19 1.83 1.19 3.09 0 4.41-2.69 5.38-5.25 5.67.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Discord's mark, the same path the navbar's profile menu uses. */
|
||||
function DiscordMark({ className }: { className?: string }): React.ReactElement {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { render, screen, fireEvent, waitFor, within, act } from '../../../tests/helpers/render'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
import HelpPanel from './HelpPanel'
|
||||
import HelpButton from './HelpButton'
|
||||
import HelpAnchor from './HelpAnchor'
|
||||
|
||||
/**
|
||||
* The dialog end to end against the real registry and the English catalogue:
|
||||
* open it from the navbar button, read the overview, open a guide, search,
|
||||
* close it again. The wiki index and search are served by msw.
|
||||
*/
|
||||
|
||||
const initial = useHelpStore.getState()
|
||||
|
||||
beforeEach(() => {
|
||||
useHelpStore.setState(initial, true)
|
||||
server.use(
|
||||
http.get('/api/help/index', () =>
|
||||
HttpResponse.json({ sections: [{ title: 'Planning', pages: [{ slug: 'Creating-a-Trip', title: 'Creating a Trip' }, { slug: 'My-Trips-Dashboard', title: 'My Trips Dashboard' }] }] }),
|
||||
),
|
||||
http.get('/api/help/search', ({ request }) => {
|
||||
const q = new URL(request.url).searchParams.get('q') ?? ''
|
||||
if (q === 'zzqx') return HttpResponse.json({ hits: [] })
|
||||
return HttpResponse.json({ hits: [{ slug: 'Calendar-Feeds', title: 'Calendar Feeds', section: 'Planning', anchor: 'turning-a-feed-on', heading: 'Turning a feed on', snippet: 'Click Enable calendar subscription…', score: 9 }] })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function mount(): void {
|
||||
render(
|
||||
<>
|
||||
<HelpButton />
|
||||
<HelpAnchor id="dashboard" />
|
||||
<HelpPanel />
|
||||
</>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('HelpPanel', () => {
|
||||
it('stays out of the DOM until opened, then shows the screen overview', () => {
|
||||
mount()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Help' })
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeInTheDocument()
|
||||
// Guides appear in the navigation and as cards.
|
||||
expect(screen.getAllByRole('button', { name: /Create a trip/ }).length).toBeGreaterThanOrEqual(2)
|
||||
expect(screen.getByRole('button', { name: 'Help for this screen' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('opens a guide with its steps, result and tips, and goes back to the overview', async () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Create a trip/ })[0])
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/Click Create New Trip\./)).toBeInTheDocument()
|
||||
expect(screen.getByText(/takes over the boarding pass/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Dates can be changed later/)).toBeInTheDocument()
|
||||
// A picture for every step.
|
||||
expect(screen.getAllByRole('button', { name: /Step \d of/ })).toHaveLength(5)
|
||||
// Doc link resolves its title from the index once that has loaded.
|
||||
await waitFor(() => expect(screen.getAllByText('Creating a Trip').length).toBeGreaterThan(0))
|
||||
// Related guide opens in place (the last match is the one under "Related"; the nav lists it too).
|
||||
const related = screen.getAllByRole('button', { name: 'Set a cover photo' })
|
||||
fireEvent.click(related[related.length - 1])
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Set a cover photo' })).toBeInTheDocument()
|
||||
// The overview entry in the nav returns home.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard' }))
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enlarges a step picture in the lightbox and closes it on click', () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Edit a trip/ })[0])
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Step 1 of/ })[0])
|
||||
const box = screen.getByRole('dialog', { name: /Step 1 of/ })
|
||||
expect(box.querySelector('img')).toHaveAttribute('src', '/help-media/edit-trip/step-1.webp')
|
||||
fireEvent.click(box)
|
||||
expect(screen.queryByRole('dialog', { name: /Step 1 of/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('searches guides locally and the docs on the server', async () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
const box = screen.getByRole('searchbox')
|
||||
fireEvent.change(box, { target: { value: 'calendar' } })
|
||||
// Local guide match right away…
|
||||
expect(screen.getAllByRole('button', { name: /Subscribe to all trips in your calendar/ }).length).toBeGreaterThan(0)
|
||||
// …and the doc hit once the server answers.
|
||||
await waitFor(() => expect(screen.getByText('Calendar Feeds · Turning a feed on')).toBeInTheDocument())
|
||||
expect(screen.getByRole('link', { name: /Calendar Feeds · Turning a feed on/ })).toHaveAttribute('href', '/help/Calendar-Feeds#turning-a-feed-on')
|
||||
})
|
||||
|
||||
it('finds a guide by its words in any order, stop words and accents aside, title first', () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
const box = screen.getByRole('searchbox')
|
||||
fireEvent.change(box, { target: { value: 'how do I create trip' } })
|
||||
// The result cards sit in the main pane's "Guides" list, the nav lists guides too.
|
||||
const first = () => screen.getByRole('main').querySelector('ul li button')!
|
||||
expect(first()).toHaveAccessibleName(/Create a trip/)
|
||||
fireEvent.change(box, { target: { value: 'Trip Créate' } })
|
||||
expect(first()).toHaveAccessibleName(/Create a trip/)
|
||||
})
|
||||
|
||||
it('searches from inside a guide too, and returns to the guide once the box is cleared', () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Create a trip/ })[0])
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument()
|
||||
const box = screen.getByRole('searchbox')
|
||||
fireEvent.change(box, { target: { value: 'calendar' } })
|
||||
expect(screen.queryByRole('heading', { level: 1, name: 'Create a trip' })).toBeNull()
|
||||
expect(screen.getAllByRole('button', { name: /Subscribe to all trips in your calendar/ }).length).toBeGreaterThan(0)
|
||||
fireEvent.change(box, { target: { value: '' } })
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists the sub-screens of a screen folded on its overview and in the nav, and offers the way into the screen', () => {
|
||||
render(
|
||||
<>
|
||||
<HelpButton />
|
||||
<HelpAnchor id="settings" />
|
||||
<HelpPanel />
|
||||
</>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
// Folded: the names are listed in one line, the cards are not there yet.
|
||||
const nav = screen.getByRole('navigation', { name: 'Help' })
|
||||
const fold = screen.getAllByRole('button', { name: /Sub-screens/ }).find(b => !nav.contains(b))!
|
||||
expect(fold).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByRole('button', { name: /Appearance.*guides/ })).toBeNull()
|
||||
fireEvent.click(fold)
|
||||
expect(fold).toHaveAttribute('aria-expanded', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Appearance.*guides/ }))
|
||||
expect(useHelpStore.getState().browseId).toBe('settings-appearance')
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Appearance' })).toBeInTheDocument()
|
||||
// On a tab, the nav lists its siblings with the shown one marked, and the header leads into the tab.
|
||||
// The nav folds them too; open it, then the siblings are listed with the shown one marked.
|
||||
// (The overview entry is also named "Appearance"; the sibling rows are the ones under "Sub-screens".)
|
||||
fireEvent.click(within(nav).getByRole('button', { name: /Sub-screens/ }))
|
||||
const rows = within(nav).getAllByRole('button', { name: 'Appearance' })
|
||||
expect(rows.some(b => b.getAttribute('aria-current') === 'page')).toBe(true)
|
||||
expect(within(nav).getByRole('button', { name: 'Map' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Go to Appearance' })).toHaveAttribute('href', '/settings?tab=appearance')
|
||||
})
|
||||
|
||||
it('says so when nothing matches', async () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'zzqx' } })
|
||||
await waitFor(() => expect(screen.getByText('Nothing found for “zzqx”.')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('closes on the close button, on the backdrop and on Escape', async () => {
|
||||
mount()
|
||||
const open = () => fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close help' }))
|
||||
expect(useHelpStore.getState().open).toBe(false)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('dialog').parentElement!)
|
||||
expect(useHelpStore.getState().open).toBe(false)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
|
||||
open()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(useHelpStore.getState().open).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves Escape to a modal the reader opened while following a guide', () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
const modal = document.createElement('div')
|
||||
modal.className = 'trek-modal-backdrop'
|
||||
document.body.appendChild(modal)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(useHelpStore.getState().open).toBe(true)
|
||||
modal.remove()
|
||||
})
|
||||
|
||||
it('shows the empty state on a screen without help', () => {
|
||||
render(
|
||||
<>
|
||||
<HelpButton />
|
||||
<HelpPanel />
|
||||
</>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
expect(screen.getAllByText('No guide for this screen yet.').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('clears the screen when its anchor unmounts', () => {
|
||||
const { unmount } = render(<HelpAnchor id="dashboard" />)
|
||||
expect(useHelpStore.getState().contextId).toBe('dashboard')
|
||||
act(() => unmount())
|
||||
expect(useHelpStore.getState().contextId).toBeNull()
|
||||
})
|
||||
|
||||
it('folds sub-screens under their screen in the switcher, one group open at a time', () => {
|
||||
render(
|
||||
<>
|
||||
<HelpButton />
|
||||
<HelpAnchor id="journey-detail" />
|
||||
<HelpPanel />
|
||||
</>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' }))
|
||||
// The trigger names the trail, and the list opens with the journal's group unfolded.
|
||||
const trigger = screen.getByRole('button', { name: /Journey › Journal/ })
|
||||
fireEvent.click(trigger)
|
||||
const list = screen.getByRole('listbox', { name: 'Screens' })
|
||||
expect(list).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: /Journal/ })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('option', { name: /Studio/ })).toBeInTheDocument()
|
||||
// Folding the group hides the sub-screens; the top-level screens stay.
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 sub-screens' }))
|
||||
expect(screen.queryByRole('option', { name: /Studio/ })).toBeNull()
|
||||
expect(screen.getByRole('option', { name: /Atlas/ })).toBeInTheDocument()
|
||||
// Picking a sub-screen browses to it; the breadcrumb on the overview shows the trail.
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 sub-screens' }))
|
||||
fireEvent.click(screen.getByRole('option', { name: /Studio/ }).querySelector('button')!)
|
||||
expect(useHelpStore.getState().browseId).toBe('journey-studio')
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Studio' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not clear a screen another anchor has already taken over', () => {
|
||||
const { unmount } = render(<HelpAnchor id="dashboard" />)
|
||||
act(() => useHelpStore.getState().setContext('settings'))
|
||||
act(() => unmount())
|
||||
expect(useHelpStore.getState().contextId).toBe('settings')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
import { useHelpShortcut } from '../../hooks/useHelpShortcut'
|
||||
import { getHelpContext, getHelpGuide } from '../../help/registry'
|
||||
import HelpNav from './HelpNav'
|
||||
import HelpHome from './HelpHome'
|
||||
import HelpGuideView from './HelpGuideView'
|
||||
import HelpSearchResults from './HelpSearchResults'
|
||||
import HelpLightbox from './HelpLightbox'
|
||||
import './help.css'
|
||||
|
||||
/** How long the closing animation runs before the dialog leaves the DOM. */
|
||||
const CLOSE_MS = 160
|
||||
|
||||
/**
|
||||
* The help center: one large dialog over the app. Navigation and search on the
|
||||
* left, the screen overview or the open guide on the right. Escape is handled
|
||||
* by useHelpShortcut; the `?` in the navbar is the only way in.
|
||||
*/
|
||||
export default function HelpPanel(): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useHelpStore(s => s.open)
|
||||
const contextId = useHelpStore(s => s.contextId)
|
||||
const browseId = useHelpStore(s => s.browseId)
|
||||
const guideId = useHelpStore(s => s.guideId)
|
||||
const query = useHelpStore(s => s.query)
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
const loadDocIndex = useHelpStore(s => s.loadDocIndex)
|
||||
useHelpShortcut()
|
||||
|
||||
// Keep the dialog mounted for the length of the exit animation.
|
||||
const [state, setState] = useState<'closed' | 'open' | 'closing'>(open ? 'open' : 'closed')
|
||||
const wasOpen = useRef(open)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
wasOpen.current = true
|
||||
setState('open')
|
||||
return
|
||||
}
|
||||
if (!wasOpen.current) return
|
||||
wasOpen.current = false
|
||||
setState('closing')
|
||||
const id = window.setTimeout(() => setState('closed'), CLOSE_MS)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [open])
|
||||
|
||||
// Doc titles for the links; one fetch per session.
|
||||
useEffect(() => {
|
||||
if (open) void loadDocIndex()
|
||||
}, [open, loadDocIndex])
|
||||
|
||||
// The page behind must not scroll while the dialog is up.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.body.style.overflow = prev
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Focus back to the `?` when the dialog closes.
|
||||
useEffect(() => {
|
||||
if (open) return
|
||||
const btn = document.querySelector<HTMLElement>('[data-help-button]')
|
||||
if (btn && document.activeElement === document.body) btn.focus({ preventScroll: true })
|
||||
}, [open])
|
||||
|
||||
// Every view change starts at the top of the content pane.
|
||||
const shownId = browseId ?? contextId
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo?.({ top: 0 })
|
||||
}, [guideId, query, shownId])
|
||||
|
||||
if (state === 'closed') return null
|
||||
|
||||
const context = getHelpContext(shownId)
|
||||
const guide = getHelpGuide(guideId)
|
||||
// Typing wins over whatever was open: a search from inside a guide shows
|
||||
// results, and clearing the box returns to that guide.
|
||||
const searching = query.trim().length > 0
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className="trek-help-backdrop fixed inset-0 z-[var(--z-overlay)] flex items-center justify-center p-3 sm:p-5 bg-[var(--overlay)] backdrop-blur-sm"
|
||||
data-state={state === 'open' ? 'open' : 'closing'}
|
||||
role="presentation"
|
||||
onClick={closeHelp}
|
||||
>
|
||||
<div
|
||||
className="trek-help-dialog relative w-[min(1520px,100%)] h-[min(960px,100%)] grid grid-cols-1 md:grid-cols-[320px_minmax(0,1fr)] rounded-2xl border border-edge bg-surface-card shadow-modal overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('help.center.title')}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
|
||||
|
||||
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeHelp}
|
||||
aria-label={t('help.center.close')}
|
||||
className="absolute top-4 right-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
||||
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
|
||||
{searching ? (
|
||||
<HelpSearchResults key="search" />
|
||||
) : guide ? (
|
||||
<HelpGuideView key={guide.id} guide={guide} />
|
||||
) : (
|
||||
<HelpHome key={context?.id ?? 'none'} context={context} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HelpLightbox />
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { Loader2, SearchX } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useHelpStore } from '../../store/helpStore'
|
||||
import { allHelpGuides, guideKey, guideStepKey, guideTipKey, ctxKey } from '../../help/registry'
|
||||
import type { HelpGuide } from '../../help/types'
|
||||
import { GuideCard, SectionLabel } from './HelpHome'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
|
||||
/** Guides shown for a query, at most. The docs list is capped by the server. */
|
||||
const MAX_GUIDES = 8
|
||||
|
||||
/** Words that carry no meaning on their own; "how do I create a trip" is "create trip". */
|
||||
const STOP = new Set(['a', 'an', 'the', 'to', 'of', 'in', 'on', 'for', 'and', 'or', 'how', 'do', 'i', 'my', 'is', 'it', 'with'])
|
||||
|
||||
/** Lower-case, accents stripped, so "Fjorde" finds "fjorde" and "cafe" finds "Café". */
|
||||
const fold = (text: string): string => text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
|
||||
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
/** The query as the words that matter, or all of them when only stop words were typed. */
|
||||
function queryWords(query: string): string[] {
|
||||
const words = fold(query).split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
||||
const meaningful = words.filter(w => !STOP.has(w))
|
||||
return meaningful.length > 0 ? meaningful : words
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a guide against the query using the words the reader sees, in their
|
||||
* language: title, goal, the screen it belongs to, steps, tips and result.
|
||||
* Every word of the query has to occur somewhere; a word in the title counts
|
||||
* most, and a title that holds all of them ranks first.
|
||||
*/
|
||||
function scoreGuide(guide: HelpGuide, words: string[], t: (key: string) => string): number {
|
||||
if (words.length === 0) return 0
|
||||
const fields: { text: string; weight: number }[] = [
|
||||
{ text: fold(t(guideKey(guide.id, 'title'))), weight: 6 },
|
||||
{ text: fold(t(guideKey(guide.id, 'goal'))), weight: 3 },
|
||||
{ text: fold(t(ctxKey(guide.context, 'title'))), weight: 2 },
|
||||
{ text: fold(t(guideKey(guide.id, 'result'))), weight: 1 },
|
||||
]
|
||||
for (let n = 1; n <= guide.steps; n++) fields.push({ text: fold(t(guideStepKey(guide.id, n))), weight: 1 })
|
||||
for (let n = 1; n <= guide.tips; n++) fields.push({ text: fold(t(guideTipKey(guide.id, n))), weight: 1 })
|
||||
let score = 0
|
||||
for (const word of words) {
|
||||
let best = 0
|
||||
for (const f of fields) {
|
||||
if (!f.text.includes(word)) continue
|
||||
// A whole word beats a fragment: "trip" in "trips" is fine, "rip" in "trip" is not much.
|
||||
const whole = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(word)}([^\\p{L}\\p{N}]|$)`, 'u').test(f.text)
|
||||
best = Math.max(best, whole ? f.weight * 2 : f.weight)
|
||||
}
|
||||
if (best === 0) return 0
|
||||
score += best
|
||||
}
|
||||
if (words.every(w => fields[0].text.includes(w))) score += 10
|
||||
return score
|
||||
}
|
||||
|
||||
/** Search across every registered guide and the docs; both lists live here. */
|
||||
export default function HelpSearchResults(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const query = useHelpStore(s => s.query)
|
||||
const status = useHelpStore(s => s.searchStatus)
|
||||
const docHits = useHelpStore(s => s.docHits)
|
||||
const q = query.trim()
|
||||
|
||||
const guides = useMemo(() => {
|
||||
const words = queryWords(q)
|
||||
return allHelpGuides()
|
||||
.map(g => ({ g, score: scoreGuide(g, words, t) }))
|
||||
.filter(x => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_GUIDES)
|
||||
.map(x => x.g)
|
||||
}, [q, t])
|
||||
|
||||
const nothing = guides.length === 0 && status === 'done' && docHits.length === 0
|
||||
|
||||
return (
|
||||
<div className="trek-help-view flex flex-col gap-8">
|
||||
<h1 className="text-title font-bold text-content leading-tight">{query.trim()}</h1>
|
||||
{guides.length > 0 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionLabel>{t('help.center.searchGuides')}</SectionLabel>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{guides.map(g => (
|
||||
<li key={g.id}>
|
||||
<GuideCard guide={g} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionLabel>{t('help.center.searchDocs')}</SectionLabel>
|
||||
{status === 'loading' && <Loader2 className="w-3.5 h-3.5 animate-spin text-content-faint" />}
|
||||
</div>
|
||||
{status === 'error' && <p className="text-body text-content-muted">{t('help.center.searchError')}</p>}
|
||||
{docHits.length > 0 && (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{docHits.map(hit => (
|
||||
<li key={`${hit.slug}#${hit.anchor ?? ''}`}>
|
||||
<HelpDocLink
|
||||
to={`/help/${hit.slug}${hit.anchor ? `#${hit.anchor}` : ''}`}
|
||||
title={hit.heading ? `${hit.title} · ${hit.heading}` : hit.title}
|
||||
subtitle={hit.snippet}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{nothing && (
|
||||
<div className="flex flex-col items-center text-center gap-2 py-10">
|
||||
<SearchX className="w-6 h-6 text-content-faint" />
|
||||
<p className="text-body text-content-muted">{t('help.center.searchEmpty', { query: query.trim() })}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { HelpGuideSize } from '../../help/types'
|
||||
import HelpBadge from './HelpBadge'
|
||||
|
||||
/** "Quick", "Guide" or "Walkthrough": how much of a task the guide covers. */
|
||||
export default function HelpSizeChip({ size }: { size: HelpGuideSize }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const label = size === 'quick' ? t('help.center.quick') : size === 'tour' ? t('help.center.tour') : t('help.center.guide')
|
||||
return (
|
||||
<HelpBadge tone="accent" icon={Zap}>
|
||||
{label}
|
||||
</HelpBadge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Help center: the floating panel and its lightbox. Colors and sizes come from
|
||||
the appearance tokens; this file only holds motion and the few shapes
|
||||
Tailwind cannot express tidily. */
|
||||
|
||||
@keyframes trek-help-in {
|
||||
from { opacity: 0; transform: translateY(14px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes trek-help-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(10px) scale(0.985); }
|
||||
}
|
||||
@keyframes trek-help-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes trek-help-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes trek-help-rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes trek-help-pop {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.trek-help-backdrop[data-state='open'] {
|
||||
animation: trek-help-fade 180ms ease-out;
|
||||
}
|
||||
.trek-help-backdrop[data-state='closing'] {
|
||||
animation: trek-help-fade-out 160ms ease-in forwards;
|
||||
}
|
||||
.trek-help-backdrop[data-state='open'] > .trek-help-dialog {
|
||||
animation: trek-help-in 280ms var(--ease-out-quint);
|
||||
}
|
||||
.trek-help-backdrop[data-state='closing'] > .trek-help-dialog {
|
||||
animation: trek-help-out 160ms ease-in forwards;
|
||||
}
|
||||
|
||||
.trek-help-menu {
|
||||
animation: trek-help-pop 160ms var(--ease-out-quint);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
/* Views slide up softly when the reader moves between home, search and a guide. */
|
||||
.trek-help-view {
|
||||
animation: trek-help-rise 220ms var(--ease-out-quint);
|
||||
}
|
||||
|
||||
.trek-help-lightbox {
|
||||
animation: trek-help-fade 160ms ease-out;
|
||||
}
|
||||
.trek-help-lightbox > .trek-help-lightbox-card {
|
||||
animation: trek-help-pop 220ms var(--ease-out-quint);
|
||||
}
|
||||
|
||||
/* Step pictures: a hairline frame so light screenshots keep an edge on light
|
||||
surfaces, and a gentle lift on hover to say "click to enlarge". */
|
||||
.trek-help-shot {
|
||||
transition: transform 200ms var(--ease-out-quint), box-shadow 200ms var(--ease-out-quint);
|
||||
}
|
||||
.trek-help-shot:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
/* A thin scrollbar inside the panel, matching the app's own. */
|
||||
.trek-help-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
[data-reduce-motion] .trek-help-backdrop,
|
||||
[data-reduce-motion] .trek-help-backdrop > .trek-help-dialog,
|
||||
[data-reduce-motion] .trek-help-view,
|
||||
[data-reduce-motion] .trek-help-menu,
|
||||
[data-reduce-motion] .trek-help-lightbox,
|
||||
[data-reduce-motion] .trek-help-lightbox > .trek-help-lightbox-card {
|
||||
animation: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-help-backdrop,
|
||||
.trek-help-backdrop > .trek-help-dialog,
|
||||
.trek-help-view,
|
||||
.trek-help-menu,
|
||||
.trek-help-lightbox,
|
||||
.trek-help-lightbox > .trek-help-lightbox-card {
|
||||
animation: none;
|
||||
}
|
||||
.trek-help-shot { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Plus, Pencil, Image, Copy, Archive, Trash2, SlidersHorizontal, CalendarPlus, LayoutDashboard, Coins, Clock, Compass,
|
||||
CalendarCheck, CircleDot, Hourglass, Calculator, CalendarRange, Building2, Flag, GraduationCap, CalendarOff, CalendarDays,
|
||||
Repeat, UserPlus, Share2, Globe, MapPin, Search, Star, Eye, LandPlot, BarChart3, MinusCircle, Landmark,
|
||||
DownloadCloud, CheckCircle2, PanelRight, Tags, Upload, FileInput, Library, Bookmark, BookOpen, Sparkles, ThumbsUp, Images, Link,
|
||||
Book, Palette, Map, Bell, Plug, CloudOff, User, Lock, ShieldCheck, Fingerprint, KeyRound,
|
||||
Route, Users, UserRound, Crown, PanelLeftClose, Undo2, Ticket, Train, ListChecks, Wallet, FolderOpen, MessagesSquare,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { HelpIcon } from '../../help/types'
|
||||
|
||||
/** The registry names icons; the components draw them. Unknown names fall back to the compass. */
|
||||
const ICONS: Record<HelpIcon, LucideIcon> = {
|
||||
plus: Plus,
|
||||
pencil: Pencil,
|
||||
image: Image,
|
||||
copy: Copy,
|
||||
archive: Archive,
|
||||
trash: Trash2,
|
||||
filter: SlidersHorizontal,
|
||||
calendar: CalendarPlus,
|
||||
layout: LayoutDashboard,
|
||||
coins: Coins,
|
||||
clock: Clock,
|
||||
compass: Compass,
|
||||
calendarCheck: CalendarCheck,
|
||||
halfDay: CircleDot,
|
||||
hourglass: Hourglass,
|
||||
calculator: Calculator,
|
||||
calendarRange: CalendarRange,
|
||||
building: Building2,
|
||||
flag: Flag,
|
||||
graduationCap: GraduationCap,
|
||||
calendarOff: CalendarOff,
|
||||
calendarDays: CalendarDays,
|
||||
repeat: Repeat,
|
||||
userPlus: UserPlus,
|
||||
share: Share2,
|
||||
globe: Globe,
|
||||
mapPin: MapPin,
|
||||
search: Search,
|
||||
star: Star,
|
||||
eye: Eye,
|
||||
regions: LandPlot,
|
||||
chart: BarChart3,
|
||||
minusCircle: MinusCircle,
|
||||
landmark: Landmark,
|
||||
download: DownloadCloud,
|
||||
checkCircle: CheckCircle2,
|
||||
panelRight: PanelRight,
|
||||
tags: Tags,
|
||||
upload: Upload,
|
||||
fileInput: FileInput,
|
||||
library: Library,
|
||||
bookmark: Bookmark,
|
||||
bookOpen: BookOpen,
|
||||
sparkles: Sparkles,
|
||||
thumbsUp: ThumbsUp,
|
||||
gallery: Images,
|
||||
link: Link,
|
||||
book: Book,
|
||||
sliders: SlidersHorizontal,
|
||||
palette: Palette,
|
||||
map: Map,
|
||||
bell: Bell,
|
||||
plug: Plug,
|
||||
cloudOff: CloudOff,
|
||||
user: User,
|
||||
lock: Lock,
|
||||
shield: ShieldCheck,
|
||||
fingerprint: Fingerprint,
|
||||
key: KeyRound,
|
||||
route: Route,
|
||||
users: Users,
|
||||
userRound: UserRound,
|
||||
crown: Crown,
|
||||
panelLeft: PanelLeftClose,
|
||||
undo: Undo2,
|
||||
ticket: Ticket,
|
||||
train: Train,
|
||||
listChecks: ListChecks,
|
||||
wallet: Wallet,
|
||||
folder: FolderOpen,
|
||||
messages: MessagesSquare,
|
||||
}
|
||||
|
||||
export function helpIcon(name: HelpIcon): LucideIcon {
|
||||
return ICONS[name] ?? Compass
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
/**
|
||||
* A screen's route ready to follow. A trip screen's route carries `:id`, which
|
||||
* only the address of the trip the reader is on can fill in; browsed from
|
||||
* elsewhere, such a screen has no way in and the button stays away.
|
||||
*/
|
||||
export function useScreenRoute(route: string | undefined): string | null {
|
||||
const { pathname } = useLocation()
|
||||
if (!route) return null
|
||||
if (!route.includes(':id')) return route
|
||||
const match = /^\/trips\/(\d+)/.exec(pathname)
|
||||
return match ? route.replace(':id', match[1]) : null
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
|
||||
onInvited: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [users, setUsers] = useState<{ id: number; username: string; email: string; avatar?: string | null }[]>([])
|
||||
const [users, setUsers] = useState<{ id: number; username: string; email?: string; avatar?: string | null }[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
const [role, setRole] = useState<'editor' | 'viewer'>('viewer')
|
||||
@@ -26,7 +26,8 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
|
||||
if (existingUserIds.includes(u.id)) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
|
||||
// The directory lists username and avatar only; email is not sent to non-admins.
|
||||
return u.username.toLowerCase().includes(q) || (u.email ?? '').toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
const handleInvite = async () => {
|
||||
|
||||
@@ -197,8 +197,9 @@ describe('Navbar', () => {
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }), updateSetting });
|
||||
render(<Navbar />);
|
||||
// Find the dark mode toggle button by title attribute
|
||||
const toggleBtn = document.querySelector('button[title]') as HTMLElement;
|
||||
// Find the dark mode toggle button by title attribute. The help button
|
||||
// before it carries a title too, hence the exclusion.
|
||||
const toggleBtn = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await user.click(toggleBtn);
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'dark');
|
||||
@@ -278,7 +279,7 @@ describe('Navbar', () => {
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }), updateSetting });
|
||||
render(<Navbar />);
|
||||
const toggleBtn = document.querySelector('button[title]') as HTMLElement;
|
||||
const toggleBtn = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
||||
expect(toggleBtn).toBeTruthy();
|
||||
await user.click(toggleBtn);
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'light');
|
||||
@@ -456,7 +457,7 @@ describe('Navbar styling and menu details', () => {
|
||||
fireEvent.mouseLeave(share);
|
||||
expect(share.style.background).toBe('var(--bg-card)');
|
||||
|
||||
const theme = document.querySelector('button[title]') as HTMLElement;
|
||||
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
||||
fireEvent.mouseEnter(theme);
|
||||
expect(theme.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(theme);
|
||||
@@ -467,7 +468,7 @@ describe('Navbar styling and menu details', () => {
|
||||
const updateSetting = vi.fn(async () => {});
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }), updateSetting });
|
||||
render(<Navbar />);
|
||||
const theme = document.querySelector('button[title]') as HTMLElement;
|
||||
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -490,7 +491,7 @@ describe('Navbar styling and menu details', () => {
|
||||
const updateSetting = vi.fn(async () => {});
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }), updateSetting });
|
||||
const { unmount } = render(<Navbar />);
|
||||
const theme = document.querySelector('button[title]') as HTMLElement;
|
||||
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -553,7 +554,7 @@ describe('Navbar styling and menu details', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }), updateSetting });
|
||||
render(<Navbar />);
|
||||
|
||||
fireEvent.click(document.querySelector('button[title]') as HTMLElement);
|
||||
fireEvent.click(document.querySelector('button[title]:not([data-help-button])') as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'dark'));
|
||||
document.documentElement.classList.remove('trek-theme-transitioning');
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
import { visibleManagedNavItems } from '../../managed'
|
||||
|
||||
@@ -234,6 +235,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
|
||||
{user && <HelpButton />}
|
||||
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
||||
<button type="button" onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex relative w-8 h-8 items-center justify-center text-content-muted"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TrimField } from './TrimField'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
import './bookFontFaces'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
|
||||
/**
|
||||
* The Studio shell: top bar, page rail, workbench, inspector.
|
||||
@@ -205,6 +206,8 @@ function StudioBar({
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -461,8 +461,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle, o
|
||||
placeholder={t('members.selectUser')}
|
||||
options={[
|
||||
{ value: '', label: t('members.selectUser') },
|
||||
// As a string: the select compares values strictly, and the chosen id is
|
||||
// kept as a string, so a number here left the trigger on its placeholder.
|
||||
...availableUsers.map(u => ({
|
||||
value: u.id,
|
||||
value: String(u.id),
|
||||
label: u.username,
|
||||
})),
|
||||
]}
|
||||
|
||||
@@ -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' }]),
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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' }]),
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}` : ''}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useHelpStore } from '../store/helpStore'
|
||||
|
||||
/**
|
||||
* Tell the help center which screen is showing. Call it from the screen's data
|
||||
* hook, or render `<HelpAnchor id>` from a page body; either way the context
|
||||
* is set while the screen is mounted and cleared when it goes away, unless
|
||||
* another screen has already taken over.
|
||||
*/
|
||||
export function useHelpContext(id: string): void {
|
||||
const setContext = useHelpStore(s => s.setContext)
|
||||
useEffect(() => {
|
||||
setContext(id)
|
||||
return () => {
|
||||
if (useHelpStore.getState().contextId === id) setContext(null)
|
||||
}
|
||||
}, [id, setContext])
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useHelpStore } from '../store/helpStore'
|
||||
|
||||
/**
|
||||
* `Escape` closes the help dialog (the lightbox first, when one is open). The
|
||||
* `?` in the navbar is the only way in: a hotkey would fire while someone is
|
||||
* typing a question mark into a note, and the help is one click away anyway.
|
||||
*/
|
||||
export function useHelpShortcut(): void {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || e.defaultPrevented) return
|
||||
const { open, closeHelp, lightbox, openLightbox } = useHelpStore.getState()
|
||||
if (!open) return
|
||||
if (lightbox) openLightbox(null)
|
||||
// A modal the reader opened while following a guide keeps its own Escape.
|
||||
else if (!document.querySelector('.trek-modal-backdrop')) closeHelp()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import BackupPanel from '../components/Admin/BackupPanel'
|
||||
import GitHubPanel from '../components/Admin/GitHubPanel'
|
||||
import AddonManager from '../components/Admin/AddonManager'
|
||||
import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import { getHelpContext } from '../help/registry'
|
||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||
import AdminPluginsPanel from '../components/Admin/AdminPluginsPanel'
|
||||
@@ -79,8 +81,11 @@ function AdminPageDesktop(): React.ReactElement {
|
||||
})),
|
||||
]
|
||||
|
||||
// Every tab is its own help screen; a tab without one falls back to the admin overview.
|
||||
const helpId = getHelpContext(`admin-${activeTab}`) ? `admin-${activeTab}` : 'admin'
|
||||
return (
|
||||
<PageShell background="var(--bg-secondary)">
|
||||
<HelpAnchor id={helpId} />
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
|
||||
@@ -16,11 +16,17 @@ import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
||||
import AtlasLayerToggle from './atlas/AtlasLayerToggle'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
|
||||
export default function AtlasPage(): React.ReactElement {
|
||||
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
|
||||
// chunk of its own instead of a dead limb in this one.
|
||||
return <AtlasPageDesktop />
|
||||
return (
|
||||
<>
|
||||
<HelpAnchor id="atlas" />
|
||||
<AtlasPageDesktop />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AtlasPageDesktop(): React.ReactElement {
|
||||
|
||||
@@ -20,12 +20,18 @@ import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal
|
||||
import { useCollections } from './collections/useCollections'
|
||||
import EmptyState from '../components/shared/EmptyState'
|
||||
import '../styles/dashboard.css'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import '../styles/collections.css'
|
||||
|
||||
export default function CollectionsPage(): React.ReactElement {
|
||||
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
|
||||
// chunk of its own instead of a dead limb in this one.
|
||||
return <CollectionsPageDesktop />
|
||||
return (
|
||||
<>
|
||||
<HelpAnchor id="collections" />
|
||||
<CollectionsPageDesktop />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionsPageDesktop(): React.ReactElement {
|
||||
|
||||
@@ -10,6 +10,7 @@ import PlaceAvatar from '../components/shared/PlaceAvatar'
|
||||
import EmptyState from '../components/shared/EmptyState'
|
||||
import { Skeleton, SpotlightSkeleton, TripCardSkeleton } from '../components/shared/Skeleton'
|
||||
import MobileTopBar from '../components/Layout/MobileTopBar'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import { useDashboard } from './dashboard/useDashboard'
|
||||
import {
|
||||
type DashboardTrip, type HeroBundle, type TravelStats, type UpcomingReservation,
|
||||
@@ -150,6 +151,7 @@ function DashboardPageDesktop(): React.ReactElement {
|
||||
styling instead of inheriting the dashboard scope's font and the
|
||||
`.trek-dash button` reset (which shifted the bell icon + menu items). */}
|
||||
<Navbar />
|
||||
<HelpAnchor id="dashboard" />
|
||||
<div className="trek-dash trek-dash-shell">
|
||||
{demoMode && <DemoBanner />}
|
||||
<div className="trek-dash-scroll">
|
||||
|
||||
@@ -25,11 +25,17 @@ import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView'
|
||||
import { EntryEditor } from '../components/Journey/JourneyDetailPageEntryEditor'
|
||||
import { AddTripDialog } from '../components/Journey/JourneyDetailPageAddTripDialog'
|
||||
import { JourneySettingsDialog } from '../components/Journey/JourneyDetailPageSettingsDialog'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
|
||||
export default function JourneyDetailPage() {
|
||||
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
|
||||
// chunk of its own instead of a dead limb in this one.
|
||||
return <JourneyDetailPageDesktop />
|
||||
return (
|
||||
<>
|
||||
<HelpAnchor id="journey-detail" />
|
||||
<JourneyDetailPageDesktop />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function JourneyDetailPageDesktop() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { Journey } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
import { useJourney } from './journey/useJourney'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -25,7 +26,12 @@ function pickGradient(id: number): string {
|
||||
export default function JourneyPage() {
|
||||
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
|
||||
// chunk of its own instead of a dead limb in this one.
|
||||
return <JourneyPageDesktop />
|
||||
return (
|
||||
<>
|
||||
<HelpAnchor id="journey" />
|
||||
<JourneyPageDesktop />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function JourneyPageDesktop() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import StudioShell from '../components/Studio/StudioShell'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
|
||||
/**
|
||||
* TREK Studio, the Journey book designer.
|
||||
@@ -10,5 +11,10 @@ import StudioShell from '../components/Studio/StudioShell'
|
||||
* user never sees it.
|
||||
*/
|
||||
export default function JourneyStudioPage() {
|
||||
return <StudioShell />
|
||||
return (
|
||||
<>
|
||||
<HelpAnchor id="journey-studio" />
|
||||
<StudioShell />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import NotificationsTab from '../components/Settings/NotificationsTab'
|
||||
import IntegrationsTab from '../components/Settings/IntegrationsTab'
|
||||
import AccountTab from '../components/Settings/AccountTab'
|
||||
import AboutTab from '../components/Settings/AboutTab'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import { getHelpContext } from '../help/registry'
|
||||
import OfflineTab from '../components/Settings/OfflineTab'
|
||||
import PluginSettingsTab from '../components/Settings/PluginSettingsTab'
|
||||
import { usePluginStore } from '../store/pluginStore'
|
||||
@@ -26,6 +28,8 @@ function SettingsPageDesktop(): React.ReactElement {
|
||||
// Page = wiring container: addon/version loading + active-tab state in the hook.
|
||||
const { hasIntegrations, appVersion, activeTab, setActiveTab, managed } = useSettings()
|
||||
const hasPlugins = usePluginStore(s => s.plugins.length > 0)
|
||||
// Every tab is its own help screen; a tab without one falls back to the settings overview.
|
||||
const helpId = getHelpContext(`settings-${activeTab}`) ? `settings-${activeTab}` : 'settings'
|
||||
|
||||
const tabs: PageSidebarTab[] = [
|
||||
{ id: 'display', label: t('settings.tabs.display'), icon: SlidersHorizontal },
|
||||
@@ -53,6 +57,7 @@ function SettingsPageDesktop(): React.ReactElement {
|
||||
<PageShell background="var(--bg-secondary)">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<HelpAnchor id={helpId} />
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-surface-tertiary">
|
||||
<Settings className="w-5 h-5 text-content-secondary" />
|
||||
|
||||
@@ -35,6 +35,8 @@ import { lazyWithRetry } from '../utils/lazyWithRetry'
|
||||
import { getDayBookendHotels } from '../utils/dayOrder'
|
||||
import TripWarningsBanner from '../components/Planner/TripWarningsBanner'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import { getHelpContext } from '../help/registry'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
@@ -245,6 +247,11 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
||||
)
|
||||
}
|
||||
|
||||
/** The tab ids are historical; the help screens carry the names the tabs show. */
|
||||
const TRIP_TAB_HELP: Record<string, string> = {
|
||||
transports: 'transports', buchungen: 'bookings', listen: 'lists', finanzplan: 'costs', dateien: 'files', collab: 'collab', roadtrip: 'roadtrip',
|
||||
}
|
||||
|
||||
export default function TripPlannerPage(): React.ReactElement | null {
|
||||
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
|
||||
// chunk of its own instead of a dead limb in this one.
|
||||
@@ -380,8 +387,17 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
const mapInsetLeft = leftPanelPx ? leftPanelPx + 10 : 0
|
||||
const mapInsetRight = rightPanelPx ? rightPanelPx + 10 : 0
|
||||
|
||||
// The trip is a family of help screens: the frame, then one per tab, and on
|
||||
// the plan one per overlay that is open. A screen that has no help yet falls
|
||||
// back to the frame.
|
||||
const helpFor = (id: string) => (getHelpContext(id) ? id : 'trip')
|
||||
const helpId = activeTab === 'plan'
|
||||
? helpFor(roadtripActive ? 'trip-roadtrip' : selectedPlace ? 'trip-place' : showDayDetail ? 'trip-day-detail' : 'trip')
|
||||
: helpFor(`trip-${TRIP_TAB_HELP[activeTab] ?? activeTab}`)
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
||||
<HelpAnchor id={helpId} />
|
||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
||||
|
||||
<div className="bg-surface-elevated border-b border-edge-faint" style={{
|
||||
|
||||
@@ -9,12 +9,18 @@ import VacayStats from '../components/Vacay/VacayStats'
|
||||
import VacaySettings from '../components/Vacay/VacaySettings'
|
||||
import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react'
|
||||
import Modal from '../components/shared/Modal'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import { useVacay } from './vacay/useVacay'
|
||||
|
||||
export default function VacayPage(): React.ReactElement {
|
||||
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
|
||||
// chunk of its own instead of a dead limb in this one.
|
||||
return <VacayPageDesktop />
|
||||
return (
|
||||
<>
|
||||
<HelpAnchor id="vacay" />
|
||||
<VacayPageDesktop />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function VacayPageDesktop(): React.ReactElement {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { useHelpStore, docTitle } from './helpStore'
|
||||
|
||||
const initial = useHelpStore.getState()
|
||||
|
||||
const HIT = { slug: 'Creating-a-Trip', title: 'Creating a Trip', section: 'Planning', anchor: 'fields', heading: 'Fields', snippet: 'Give the trip a name…', score: 12 }
|
||||
|
||||
beforeEach(() => {
|
||||
useHelpStore.setState(initial, true)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** Let the debounce elapse and the request settle. */
|
||||
async function flush(ms = 300): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(ms)
|
||||
}
|
||||
|
||||
describe('helpStore: dialog and navigation', () => {
|
||||
it('opens, closes and toggles', () => {
|
||||
const s = useHelpStore.getState()
|
||||
expect(s.open).toBe(false)
|
||||
s.openHelp()
|
||||
expect(useHelpStore.getState().open).toBe(true)
|
||||
s.toggleHelp()
|
||||
expect(useHelpStore.getState().open).toBe(false)
|
||||
s.toggleHelp()
|
||||
expect(useHelpStore.getState().open).toBe(true)
|
||||
s.closeHelp()
|
||||
expect(useHelpStore.getState().open).toBe(false)
|
||||
})
|
||||
|
||||
it('closing clears the search and the lightbox but keeps the guide for next time', () => {
|
||||
const s = useHelpStore.getState()
|
||||
s.openGuide('create-trip')
|
||||
s.openLightbox({ src: '/x.webp', alt: 'x' })
|
||||
useHelpStore.setState({ query: 'trip', docHits: [HIT], searchStatus: 'done' })
|
||||
s.closeHelp()
|
||||
const after = useHelpStore.getState()
|
||||
expect(after.open).toBe(false)
|
||||
expect(after.lightbox).toBeNull()
|
||||
expect(after.query).toBe('')
|
||||
expect(after.docHits).toEqual([])
|
||||
expect(after.searchStatus).toBe('idle')
|
||||
expect(after.guideId).toBe('create-trip')
|
||||
})
|
||||
|
||||
it('records the screen and keeps an open guide across a screen change while the dialog is open', () => {
|
||||
const s = useHelpStore.getState()
|
||||
s.setContext('dashboard')
|
||||
s.openGuide('widgets')
|
||||
s.setContext('settings')
|
||||
const now = useHelpStore.getState()
|
||||
expect(now.contextId).toBe('settings')
|
||||
expect(now.guideId).toBe('widgets')
|
||||
s.setContext(null)
|
||||
expect(useHelpStore.getState().contextId).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a leftover guide when another screen is reached with the dialog closed', () => {
|
||||
const s = useHelpStore.getState()
|
||||
s.setContext('dashboard')
|
||||
s.openGuide('create-trip')
|
||||
s.closeHelp()
|
||||
s.setContext('vacay')
|
||||
const now = useHelpStore.getState()
|
||||
expect(now.contextId).toBe('vacay')
|
||||
expect(now.guideId).toBeNull()
|
||||
})
|
||||
|
||||
it('opening a guide opens the dialog and drops a pending search', async () => {
|
||||
const s = useHelpStore.getState()
|
||||
s.setQuery('cover')
|
||||
expect(useHelpStore.getState().searchStatus).toBe('loading')
|
||||
s.openGuide('cover-image')
|
||||
const now = useHelpStore.getState()
|
||||
expect(now.open).toBe(true)
|
||||
expect(now.guideId).toBe('cover-image')
|
||||
expect(now.query).toBe('')
|
||||
expect(now.searchStatus).toBe('idle')
|
||||
await flush()
|
||||
expect(useHelpStore.getState().docHits).toEqual([])
|
||||
})
|
||||
|
||||
it('closeGuide returns to the overview and closes the lightbox', () => {
|
||||
const s = useHelpStore.getState()
|
||||
s.openGuide('create-trip')
|
||||
s.openLightbox({ src: '/x.webp', alt: 'x' })
|
||||
s.closeGuide()
|
||||
expect(useHelpStore.getState().guideId).toBeNull()
|
||||
expect(useHelpStore.getState().lightbox).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpStore: search', () => {
|
||||
it('debounces, then asks the server and stores the hits', async () => {
|
||||
const seen: string[] = []
|
||||
server.use(
|
||||
http.get('/api/help/search', ({ request }) => {
|
||||
seen.push(new URL(request.url).searchParams.get('q') ?? '')
|
||||
return HttpResponse.json({ hits: [HIT] })
|
||||
}),
|
||||
)
|
||||
const s = useHelpStore.getState()
|
||||
s.setQuery('c')
|
||||
s.setQuery('co')
|
||||
s.setQuery('cover')
|
||||
expect(useHelpStore.getState().searchStatus).toBe('loading')
|
||||
await flush()
|
||||
expect(seen).toEqual(['cover'])
|
||||
expect(useHelpStore.getState().docHits).toEqual([HIT])
|
||||
expect(useHelpStore.getState().searchStatus).toBe('done')
|
||||
})
|
||||
|
||||
it('a blank query clears the results without a request', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/api/help/search', () => { calls++; return HttpResponse.json({ hits: [HIT] }) }))
|
||||
const s = useHelpStore.getState()
|
||||
s.setQuery('trip')
|
||||
await flush()
|
||||
expect(useHelpStore.getState().docHits).toHaveLength(1)
|
||||
s.setQuery(' ')
|
||||
await flush()
|
||||
expect(useHelpStore.getState().docHits).toEqual([])
|
||||
expect(useHelpStore.getState().searchStatus).toBe('idle')
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('a slow older answer never overwrites a newer one', async () => {
|
||||
server.use(
|
||||
http.get('/api/help/search', async ({ request }) => {
|
||||
const q = new URL(request.url).searchParams.get('q')
|
||||
if (q === 'old') {
|
||||
await delay(500)
|
||||
return HttpResponse.json({ hits: [{ ...HIT, title: 'OLD' }] })
|
||||
}
|
||||
return HttpResponse.json({ hits: [{ ...HIT, title: 'NEW' }] })
|
||||
}),
|
||||
)
|
||||
const s = useHelpStore.getState()
|
||||
s.setQuery('old')
|
||||
await flush(250)
|
||||
s.setQuery('new')
|
||||
await flush(1000)
|
||||
expect(useHelpStore.getState().docHits.map(h => h.title)).toEqual(['NEW'])
|
||||
})
|
||||
|
||||
it('reports a failed search instead of pretending there are no docs', async () => {
|
||||
server.use(http.get('/api/help/search', () => HttpResponse.json({ error: 'boom' }, { status: 502 })))
|
||||
useHelpStore.getState().setQuery('trip')
|
||||
await flush()
|
||||
expect(useHelpStore.getState().searchStatus).toBe('error')
|
||||
expect(useHelpStore.getState().docHits).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpStore: doc index', () => {
|
||||
it('loads the table of contents once and resolves titles from it', async () => {
|
||||
let calls = 0
|
||||
server.use(
|
||||
http.get('/api/help/index', () => {
|
||||
calls++
|
||||
return HttpResponse.json({ sections: [{ title: 'Planning', pages: [{ slug: 'Creating-a-Trip', title: 'Creating a Trip' }] }] })
|
||||
}),
|
||||
)
|
||||
await useHelpStore.getState().loadDocIndex()
|
||||
await useHelpStore.getState().loadDocIndex()
|
||||
expect(calls).toBe(1)
|
||||
const index = useHelpStore.getState().docIndex
|
||||
expect(docTitle(index, 'Creating-a-Trip')).toBe('Creating a Trip')
|
||||
expect(docTitle(index, 'Dashboard-Widgets')).toBe('Dashboard Widgets')
|
||||
expect(docTitle(null, 'My-Trips-Dashboard')).toBe('My Trips Dashboard')
|
||||
})
|
||||
|
||||
it('falls back to slugs when the index cannot be loaded', async () => {
|
||||
server.use(http.get('/api/help/index', () => HttpResponse.json({}, { status: 500 })))
|
||||
await useHelpStore.getState().loadDocIndex()
|
||||
expect(useHelpStore.getState().docIndex).toEqual([])
|
||||
expect(docTitle(useHelpStore.getState().docIndex, 'Calendar-Feeds')).toBe('Calendar Feeds')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { create } from 'zustand'
|
||||
import axios from 'axios'
|
||||
import { searchHelp, type HelpSearchHit } from '../api/help'
|
||||
import { helpApi, type HelpNavSection } from '../api/client'
|
||||
|
||||
/**
|
||||
* State of the contextual help panel: whether it is open, which screen it is
|
||||
* for, which guide is being read, and the search that runs across guides and
|
||||
* docs. Screens set the context on mount (`useHelpContext`); the panel reads it.
|
||||
*/
|
||||
|
||||
export type HelpSearchStatus = 'idle' | 'loading' | 'done' | 'error'
|
||||
|
||||
/** One picture enlarged over the dialog. */
|
||||
export interface HelpLightbox {
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
|
||||
interface HelpState {
|
||||
open: boolean
|
||||
/** The screen the reader is on, as registered by that screen. */
|
||||
contextId: string | null
|
||||
/**
|
||||
* A screen the reader chose to browse in the dialog, other than the one they
|
||||
* are on. Null means "follow the current screen"; opening the dialog resets it.
|
||||
*/
|
||||
browseId: string | null
|
||||
/** The guide currently open inside the panel, if any. */
|
||||
guideId: string | null
|
||||
lightbox: HelpLightbox | null
|
||||
query: string
|
||||
searchStatus: HelpSearchStatus
|
||||
docHits: HelpSearchHit[]
|
||||
/** Wiki table of contents, loaded once so doc links can show real titles. */
|
||||
docIndex: HelpNavSection[] | null
|
||||
|
||||
openHelp: () => void
|
||||
closeHelp: () => void
|
||||
toggleHelp: () => void
|
||||
setContext: (id: string | null) => void
|
||||
/** Browse another screen's help from the switcher; null returns to the current screen. */
|
||||
browse: (id: string | null) => void
|
||||
openGuide: (id: string) => void
|
||||
closeGuide: () => void
|
||||
openLightbox: (content: HelpLightbox | null) => void
|
||||
setQuery: (query: string) => void
|
||||
loadDocIndex: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Wait this long after the last keystroke before asking the server. */
|
||||
const SEARCH_DEBOUNCE_MS = 220
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchAbort: AbortController | null = null
|
||||
|
||||
function cancelSearch(): void {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
searchAbort?.abort()
|
||||
searchAbort = null
|
||||
}
|
||||
|
||||
export const useHelpStore = create<HelpState>((set, get) => ({
|
||||
open: false,
|
||||
contextId: null,
|
||||
browseId: null,
|
||||
guideId: null,
|
||||
lightbox: null,
|
||||
query: '',
|
||||
searchStatus: 'idle',
|
||||
docHits: [],
|
||||
docIndex: null,
|
||||
|
||||
openHelp: () => set({ open: true, browseId: null }),
|
||||
closeHelp: () => {
|
||||
cancelSearch()
|
||||
set({ open: false, lightbox: null, query: '', searchStatus: 'idle', docHits: [] })
|
||||
},
|
||||
toggleHelp: () => (get().open ? get().closeHelp() : get().openHelp()),
|
||||
|
||||
setContext: (id) => {
|
||||
const { contextId, open } = get()
|
||||
if (contextId === id) return
|
||||
// While the dialog is open a guide survives a screen change on purpose:
|
||||
// the "widgets" guide walks the reader from the dashboard into Settings,
|
||||
// and closing it the moment they arrive would defeat the point. With the
|
||||
// dialog closed, arriving on another screen means the next open should
|
||||
// show that screen, not a guide left over from the last one.
|
||||
set(open ? { contextId: id } : { contextId: id, guideId: null })
|
||||
},
|
||||
|
||||
browse: (id) => {
|
||||
cancelSearch()
|
||||
set({ browseId: id, guideId: null, lightbox: null, query: '', searchStatus: 'idle', docHits: [] })
|
||||
},
|
||||
|
||||
openGuide: (id) => {
|
||||
cancelSearch()
|
||||
set({ guideId: id, open: true, query: '', searchStatus: 'idle', docHits: [] })
|
||||
},
|
||||
closeGuide: () => set({ guideId: null, lightbox: null }),
|
||||
openLightbox: (content) => set({ lightbox: content }),
|
||||
|
||||
setQuery: (query) => {
|
||||
cancelSearch()
|
||||
const q = query.trim()
|
||||
if (!q) {
|
||||
set({ query, searchStatus: 'idle', docHits: [] })
|
||||
return
|
||||
}
|
||||
set({ query, searchStatus: 'loading' })
|
||||
searchTimer = setTimeout(() => {
|
||||
const controller = new AbortController()
|
||||
searchAbort = controller
|
||||
searchHelp(q, controller.signal)
|
||||
.then((hits) => {
|
||||
if (controller.signal.aborted) return
|
||||
set({ docHits: hits, searchStatus: 'done' })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted || axios.isCancel(err)) return
|
||||
set({ docHits: [], searchStatus: 'error' })
|
||||
})
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
},
|
||||
|
||||
loadDocIndex: async () => {
|
||||
if (get().docIndex) return
|
||||
try {
|
||||
const { sections } = await helpApi.index()
|
||||
set({ docIndex: sections })
|
||||
} catch {
|
||||
// Titles fall back to the slug; the links still work.
|
||||
set({ docIndex: [] })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
/** Title of a wiki page from the loaded index, or a readable form of its slug. */
|
||||
export function docTitle(index: HelpNavSection[] | null, slug: string): string {
|
||||
for (const section of index ?? []) {
|
||||
const page = section.pages.find(p => p.slug === slug)
|
||||
if (page) return page.title
|
||||
}
|
||||
return slug.replace(/-/g, ' ')
|
||||
}
|
||||
Reference in New Issue
Block a user