1437b75c2f
Neue Tour-Schritte für Statistik-Dashboard und Feedback-Formular, Hinweis zum Löschen der Demo-Einträge und Landung auf Statistik nach Abschluss. Rollenauflösung bei geteilten Logbüchern fail-closed bis die Rolle bekannt ist. Co-authored-by: Cursor <cursoragent@cursor.com>
324 lines
9.3 KiB
TypeScript
324 lines
9.3 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode
|
|
} from 'react'
|
|
import {
|
|
clearTourCompleted,
|
|
isTourCompleted,
|
|
markTourCompleted,
|
|
resolveTourUserId
|
|
} from '../services/appTourStorage.js'
|
|
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
|
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
|
|
|
export type AppTab = 'vessel' | 'crew' | 'logs' | 'stats' | 'settings'
|
|
|
|
export type TourStepId =
|
|
| 'welcome'
|
|
| 'nav_logs'
|
|
| 'entry_list'
|
|
| 'entry_open'
|
|
| 'entry_track'
|
|
| 'nav_vessel'
|
|
| 'nav_crew'
|
|
| 'nav_stats'
|
|
| 'nav_feedback'
|
|
| 'finish'
|
|
|
|
interface TourNavigation {
|
|
setActiveTab: (tab: AppTab) => void
|
|
setSelectedEntryId: (entryId: string | null) => void
|
|
setFeedbackOpen: (open: boolean) => void
|
|
}
|
|
|
|
interface DemoTourContext {
|
|
firstEntryId: string
|
|
}
|
|
|
|
interface AppTourContextValue {
|
|
isActive: boolean
|
|
isDemoTour: boolean
|
|
currentStepId: TourStepId | null
|
|
currentStepIndex: number
|
|
totalSteps: number
|
|
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
|
|
stopTour: () => void
|
|
restartTour: () => void
|
|
nextStep: () => void
|
|
prevStep: () => void
|
|
skipTour: () => void
|
|
registerNavigation: (navigation: TourNavigation) => void
|
|
registerDemoTourContext: (context: DemoTourContext | null) => void
|
|
requestStartAfterLogin: () => void
|
|
}
|
|
|
|
const FULL_STEP_ORDER: TourStepId[] = [
|
|
'welcome',
|
|
'nav_logs',
|
|
'entry_list',
|
|
'entry_open',
|
|
'entry_track',
|
|
'nav_vessel',
|
|
'nav_crew',
|
|
'nav_stats',
|
|
'nav_feedback',
|
|
'finish'
|
|
]
|
|
|
|
/** Public demo has no stats/feedback UI — skip those steps. */
|
|
const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback']
|
|
const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id))
|
|
|
|
function getStepOrder(demoMode: boolean): TourStepId[] {
|
|
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
|
|
}
|
|
|
|
const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
|
nav_logs: '[data-tour="nav-logs"]',
|
|
entry_list: '[data-tour="entry-list"]',
|
|
entry_open: '[data-tour="entry-first"]',
|
|
entry_track: '[data-tour="entry-track"]',
|
|
nav_vessel: '[data-tour="nav-vessel"]',
|
|
nav_crew: '[data-tour="nav-crew"]',
|
|
nav_stats: '[data-tour="stats-dashboard"]',
|
|
nav_feedback: '[data-tour="feedback-form"]'
|
|
}
|
|
|
|
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
|
|
|
export function AppTourProvider({ children }: { children: ReactNode }) {
|
|
const [isActive, setIsActive] = useState(false)
|
|
const [stepIndex, setStepIndex] = useState(0)
|
|
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
|
|
const [isDemoTour, setIsDemoTour] = useState(false)
|
|
const navigationRef = useRef<TourNavigation | null>(null)
|
|
const demoContextRef = useRef<DemoTourContext | null>(null)
|
|
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
|
|
|
|
const stepOrder = getStepOrder(isDemoTour)
|
|
const currentStepId = isActive ? stepOrder[stepIndex] ?? null : null
|
|
|
|
const resolveFirstEntryId = useCallback((): string | null => {
|
|
return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId()
|
|
}, [])
|
|
|
|
const applyStepSideEffects = useCallback((stepId: TourStepId) => {
|
|
const nav = navigationRef.current
|
|
if (!nav) return
|
|
|
|
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
|
|
nav.setActiveTab('logs')
|
|
}
|
|
if (stepId === 'entry_open' || stepId === 'entry_track') {
|
|
const firstEntryId = resolveFirstEntryId()
|
|
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
|
}
|
|
if (stepId === 'nav_vessel') {
|
|
nav.setSelectedEntryId(null)
|
|
nav.setActiveTab('vessel')
|
|
}
|
|
if (stepId === 'nav_crew') {
|
|
nav.setSelectedEntryId(null)
|
|
nav.setActiveTab('crew')
|
|
}
|
|
if (stepId === 'nav_stats') {
|
|
nav.setSelectedEntryId(null)
|
|
nav.setActiveTab('stats')
|
|
}
|
|
if (stepId === 'nav_feedback') {
|
|
nav.setSelectedEntryId(null)
|
|
nav.setFeedbackOpen(true)
|
|
} else {
|
|
nav.setFeedbackOpen(false)
|
|
}
|
|
}, [resolveFirstEntryId])
|
|
|
|
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
|
if (!stepId) return
|
|
const selector = TARGET_BY_STEP[stepId]
|
|
if (!selector) return
|
|
const delayMs = stepId === 'nav_feedback' ? 180 : 0
|
|
window.setTimeout(() => {
|
|
window.requestAnimationFrame(() => {
|
|
const el = document.querySelector(selector)
|
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
|
})
|
|
}, delayMs)
|
|
}, [])
|
|
|
|
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
|
const demoMode = options?.demoMode === true
|
|
const userId = resolveTourUserId({ demoMode })
|
|
if (!userId) return
|
|
if (!options?.force && isTourCompleted(userId)) return
|
|
|
|
tourModeRef.current = { demoMode }
|
|
setIsDemoTour(demoMode)
|
|
setStepIndex(0)
|
|
setIsActive(true)
|
|
}, [])
|
|
|
|
const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => {
|
|
const userId = resolveTourUserId({ demoMode: tourModeRef.current.demoMode })
|
|
if (userId) markTourCompleted(userId)
|
|
|
|
const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined
|
|
if (outcome === 'completed') {
|
|
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
|
|
const nav = navigationRef.current
|
|
if (nav && !tourModeRef.current.demoMode) {
|
|
nav.setSelectedEntryId(null)
|
|
nav.setActiveTab('stats')
|
|
}
|
|
} else {
|
|
const step = getStepOrder(tourModeRef.current.demoMode)[stepIndexAtDismiss] ?? 'welcome'
|
|
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
|
|
}
|
|
|
|
tourModeRef.current = { demoMode: false }
|
|
navigationRef.current?.setFeedbackOpen(false)
|
|
setIsDemoTour(false)
|
|
setIsActive(false)
|
|
setStepIndex(0)
|
|
}, [])
|
|
|
|
const stopTour = useCallback(() => {
|
|
dismissTour('skipped', stepIndex)
|
|
}, [dismissTour, stepIndex])
|
|
|
|
const skipTour = useCallback(() => {
|
|
dismissTour('skipped', stepIndex)
|
|
}, [dismissTour, stepIndex])
|
|
|
|
const nextStep = useCallback(() => {
|
|
const order = getStepOrder(isDemoTour)
|
|
if (stepIndex + 1 >= order.length) {
|
|
dismissTour('completed', stepIndex)
|
|
return
|
|
}
|
|
setStepIndex(stepIndex + 1)
|
|
}, [dismissTour, isDemoTour, stepIndex])
|
|
|
|
const prevStep = useCallback(() => {
|
|
setStepIndex((current) => Math.max(0, current - 1))
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!isActive) return
|
|
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
|
if (!stepId) return
|
|
applyStepSideEffects(stepId)
|
|
scrollToCurrentTarget(stepId)
|
|
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
|
|
|
const restartTour = useCallback(() => {
|
|
const userId = localStorage.getItem('active_userid')
|
|
if (!userId) return
|
|
clearTourCompleted(userId)
|
|
startTour({ force: true })
|
|
}, [startTour])
|
|
|
|
const registerNavigation = useCallback((navigation: TourNavigation) => {
|
|
navigationRef.current = navigation
|
|
}, [])
|
|
|
|
const registerDemoTourContext = useCallback((context: DemoTourContext | null) => {
|
|
demoContextRef.current = context
|
|
}, [])
|
|
|
|
const requestStartAfterLogin = useCallback(() => {
|
|
setPendingAfterLogin(true)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!pendingAfterLogin) return
|
|
const userId = localStorage.getItem('active_userid')
|
|
if (!userId || isTourCompleted(userId)) {
|
|
setPendingAfterLogin(false)
|
|
return
|
|
}
|
|
const timer = window.setTimeout(() => {
|
|
startTour({ force: true })
|
|
setPendingAfterLogin(false)
|
|
}, 400)
|
|
return () => window.clearTimeout(timer)
|
|
}, [pendingAfterLogin, startTour])
|
|
|
|
const value = useMemo<AppTourContextValue>(
|
|
() => ({
|
|
isActive,
|
|
isDemoTour,
|
|
currentStepId,
|
|
currentStepIndex: stepIndex,
|
|
totalSteps: stepOrder.length,
|
|
startTour,
|
|
stopTour,
|
|
restartTour,
|
|
nextStep,
|
|
prevStep,
|
|
skipTour,
|
|
registerNavigation,
|
|
registerDemoTourContext,
|
|
requestStartAfterLogin
|
|
}),
|
|
[
|
|
currentStepId,
|
|
isActive,
|
|
isDemoTour,
|
|
nextStep,
|
|
prevStep,
|
|
registerDemoTourContext,
|
|
registerNavigation,
|
|
requestStartAfterLogin,
|
|
restartTour,
|
|
skipTour,
|
|
startTour,
|
|
stepIndex,
|
|
stepOrder.length,
|
|
stopTour
|
|
]
|
|
)
|
|
|
|
return <AppTourContext.Provider value={value}>{children}</AppTourContext.Provider>
|
|
}
|
|
|
|
export function useAppTour(): AppTourContextValue {
|
|
const ctx = useContext(AppTourContext)
|
|
if (!ctx) {
|
|
throw new Error('useAppTour must be used within AppTourProvider')
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
export function getTourStepCopy(
|
|
stepId: TourStepId,
|
|
t: (key: string) => string,
|
|
options?: { demoMode?: boolean }
|
|
): { title: string; body: string } {
|
|
if (stepId === 'welcome' && options?.demoMode) {
|
|
return {
|
|
title: t('tour.steps.welcome_public.title'),
|
|
body: t('tour.steps.welcome_public.body')
|
|
}
|
|
}
|
|
return {
|
|
title: t(`tour.steps.${stepId}.title`),
|
|
body: t(`tour.steps.${stepId}.body`)
|
|
}
|
|
}
|
|
|
|
export function getTourTargetSelector(stepId: TourStepId | null): string | null {
|
|
if (!stepId) return null
|
|
return TARGET_BY_STEP[stepId] ?? null
|
|
}
|
|
|
|
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
|
return stepId === 'welcome' || stepId === 'finish'
|
|
}
|