feat(client): Onboarding-Tour um Statistik und Feedback erweitern

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>
This commit is contained in:
2026-05-30 14:03:46 +02:00
parent 7d75e74679
commit 1437b75c2f
9 changed files with 172 additions and 53 deletions
+8
View File
@@ -3401,3 +3401,11 @@ body.app-tour-active .app-tour-target-active {
gap: 6px; gap: 6px;
} }
body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
z-index: 9990;
pointer-events: none;
}
body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
pointer-events: none;
}
+60 -25
View File
@@ -77,7 +77,7 @@ function App() {
[activeLogbookId] [activeLogbookId]
) )
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole>('OWNER') const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
useEffect(() => { useEffect(() => {
if (!activeLogbookId) { if (!activeLogbookId) {
@@ -91,15 +91,24 @@ function App() {
} }
const cachedRole = activeLogbookRecord.collaborationRole const cachedRole = activeLogbookRecord.collaborationRole
// Fail-closed for write UI until role is known: do not assume WRITE
setActiveAccessRole( setActiveAccessRole(
cachedRole cachedRole ? parseCollaborationRole(cachedRole, `logbook ${activeLogbookId}`) : null
? parseCollaborationRole(cachedRole, `logbook ${activeLogbookId}`)
: 'WRITE'
) )
getLogbookAccess(activeLogbookId).then((access) => { let cancelled = false
if (access) setActiveAccessRole(access.role) getLogbookAccess(activeLogbookId)
}) .then((access) => {
if (cancelled || !access) return
setActiveAccessRole(access.role)
})
.catch((err) => {
console.warn('Failed to resolve logbook access role:', err)
})
return () => {
cancelled = true
}
}, [activeLogbookId, activeLogbookRecord]) }, [activeLogbookId, activeLogbookRecord])
useEffect(() => { useEffect(() => {
@@ -188,24 +197,41 @@ function App() {
`${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}` `${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`
) )
} }
}, [])
void (async () => { useEffect(() => {
const session = await checkServerSession() let cancelled = false
if (session.authenticated && session.userId) {
localStorage.setItem('active_userid', session.userId) ;(async () => {
} try {
const savedUser = localStorage.getItem('active_username') const session = await checkServerSession()
const key = getActiveMasterKey() if (cancelled) return
if (session.authenticated && savedUser && key) {
setIsAuthenticated(true) if (session.authenticated && session.userId) {
const savedLogbookId = localStorage.getItem('active_logbook_id') localStorage.setItem('active_userid', session.userId)
const savedLogbookTitle = localStorage.getItem('active_logbook_title') }
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId) const savedUser = localStorage.getItem('active_username')
setActiveLogbookTitle(savedLogbookTitle) const key = getActiveMasterKey()
if (session.authenticated && savedUser && key) {
setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
}
} catch (err) {
if (!cancelled) {
console.warn('Session restore failed:', err)
} }
} }
})() })()
return () => {
cancelled = true
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -224,7 +250,8 @@ function App() {
useEffect(() => { useEffect(() => {
registerNavigation({ registerNavigation({
setActiveTab, setActiveTab,
setSelectedEntryId: setTourSelectedEntryId setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: setTourFeedbackOpen
}) })
}, [registerNavigation]) }, [registerNavigation])
@@ -391,7 +418,12 @@ function App() {
const pwaInstallBanner = <PwaInstallPrompt variant="banner" /> const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
const logbookReadOnly = activeAccessRole === 'READ' const sharedLogbook =
activeLogbookRecord === undefined ? null : activeLogbookRecord.isShared === 1
const logbookReadOnly =
activeLogbookId != null &&
(sharedLogbook === null || sharedLogbook) &&
activeAccessRole !== 'WRITE'
if (!activeLogbookId) { if (!activeLogbookId) {
return ( return (
@@ -420,12 +452,12 @@ function App() {
<div className="app-title-area"> <div className="app-title-area">
<div className="app-title-row"> <div className="app-title-row">
<h2>{activeLogbookTitle}</h2> <h2>{activeLogbookTitle}</h2>
{activeAccessRole !== 'OWNER' && ( {activeAccessRole && activeAccessRole !== 'OWNER' && (
<LogbookRoleBadge role={activeAccessRole} /> <LogbookRoleBadge role={activeAccessRole} />
)} )}
</div> </div>
<p className="app-subtitle"> <p className="app-subtitle">
{activeAccessRole !== 'OWNER' {activeAccessRole && activeAccessRole !== 'OWNER'
? t('dashboard.section_shared_hint') ? t('dashboard.section_shared_hint')
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`} : `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
</p> </p>
@@ -454,6 +486,9 @@ function App() {
<FeedbackHeaderButton <FeedbackHeaderButton
logbookId={activeLogbookId} logbookId={activeLogbookId}
logbookTitle={activeLogbookTitle} logbookTitle={activeLogbookTitle}
tourOpen={tourFeedbackOpen}
onTourOpenChange={setTourFeedbackOpen}
tourHighlight={isActive && currentStepId === 'nav_feedback'}
/> />
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}> <button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
+2 -1
View File
@@ -30,7 +30,8 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
useEffect(() => { useEffect(() => {
registerNavigation({ registerNavigation({
setActiveTab, setActiveTab,
setSelectedEntryId: setTourSelectedEntryId setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: () => {}
}) })
registerDemoTourContext({ firstEntryId: fixture.firstEntryId }) registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
+18 -4
View File
@@ -6,31 +6,45 @@ import FeedbackModal from './FeedbackModal.tsx'
interface FeedbackHeaderButtonProps { interface FeedbackHeaderButtonProps {
logbookId?: string | null logbookId?: string | null
logbookTitle?: string | null logbookTitle?: string | null
tourOpen?: boolean
onTourOpenChange?: (open: boolean) => void
tourHighlight?: boolean
} }
export default function FeedbackHeaderButton({ export default function FeedbackHeaderButton({
logbookId, logbookId,
logbookTitle logbookTitle,
tourOpen = false,
onTourOpenChange,
tourHighlight = false
}: FeedbackHeaderButtonProps) { }: FeedbackHeaderButtonProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [userOpen, setUserOpen] = useState(false)
const open = tourOpen || userOpen
const handleClose = () => {
setUserOpen(false)
onTourOpenChange?.(false)
}
return ( return (
<> <>
<button <button
type="button" type="button"
className="btn-icon" className="btn-icon"
onClick={() => setOpen(true)} onClick={() => setUserOpen(true)}
title={t('feedback.button_title')} title={t('feedback.button_title')}
aria-label={t('feedback.button_title')} aria-label={t('feedback.button_title')}
data-tour="feedback-button"
> >
<MessageSquarePlus size={18} /> <MessageSquarePlus size={18} />
</button> </button>
<FeedbackModal <FeedbackModal
open={open} open={open}
onClose={() => setOpen(false)} onClose={handleClose}
logbookId={logbookId} logbookId={logbookId}
logbookTitle={logbookTitle} logbookTitle={logbookTitle}
tourMode={tourHighlight}
/> />
</> </>
) )
+13 -5
View File
@@ -10,6 +10,7 @@ interface FeedbackModalProps {
onClose: () => void onClose: () => void
logbookId?: string | null logbookId?: string | null
logbookTitle?: string | null logbookTitle?: string | null
tourMode?: boolean
} }
type SubmitState = 'idle' | 'submitting' | 'success' | 'error' type SubmitState = 'idle' | 'submitting' | 'success' | 'error'
@@ -18,7 +19,8 @@ export default function FeedbackModal({
open, open,
onClose, onClose,
logbookId, logbookId,
logbookTitle logbookTitle,
tourMode = false
}: FeedbackModalProps) { }: FeedbackModalProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [category, setCategory] = useState<FeedbackCategory>('general') const [category, setCategory] = useState<FeedbackCategory>('general')
@@ -97,14 +99,20 @@ export default function FeedbackModal({
if (!open) return null if (!open) return null
return ( return (
<div className="disclaimer-modal-overlay" onClick={isBusy ? undefined : onClose}> <div
className={`disclaimer-modal-overlay${tourMode ? ' feedback-modal-overlay--tour' : ''}`}
onClick={isBusy || tourMode ? undefined : onClose}
>
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}> <div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal"> <div
className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal"
data-tour="feedback-form"
>
<button <button
type="button" type="button"
className="registration-disclaimer__close feedback-modal__close" className="registration-disclaimer__close feedback-modal__close"
onClick={onClose} onClick={onClose}
disabled={isBusy} disabled={isBusy || tourMode}
aria-label={t('feedback.cancel')} aria-label={t('feedback.cancel')}
> >
<X size={18} /> <X size={18} />
@@ -187,7 +195,7 @@ export default function FeedbackModal({
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={onClose} onClick={onClose}
disabled={submitState === 'submitting'} disabled={submitState === 'submitting' || tourMode}
> >
{t('feedback.cancel')} {t('feedback.cancel')}
</button> </button>
+1 -1
View File
@@ -310,7 +310,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
}, [accountStats]) }, [accountStats])
return ( return (
<div className="form-card"> <div className="form-card" data-tour="stats-dashboard">
<div className="form-header"> <div className="form-header">
<BarChart2 size={24} className="form-icon" /> <BarChart2 size={24} className="form-icon" />
<div> <div>
+50 -13
View File
@@ -27,11 +27,14 @@ export type TourStepId =
| 'entry_track' | 'entry_track'
| 'nav_vessel' | 'nav_vessel'
| 'nav_crew' | 'nav_crew'
| 'nav_stats'
| 'nav_feedback'
| 'finish' | 'finish'
interface TourNavigation { interface TourNavigation {
setActiveTab: (tab: AppTab) => void setActiveTab: (tab: AppTab) => void
setSelectedEntryId: (entryId: string | null) => void setSelectedEntryId: (entryId: string | null) => void
setFeedbackOpen: (open: boolean) => void
} }
interface DemoTourContext { interface DemoTourContext {
@@ -55,7 +58,7 @@ interface AppTourContextValue {
requestStartAfterLogin: () => void requestStartAfterLogin: () => void
} }
const STEP_ORDER: TourStepId[] = [ const FULL_STEP_ORDER: TourStepId[] = [
'welcome', 'welcome',
'nav_logs', 'nav_logs',
'entry_list', 'entry_list',
@@ -63,16 +66,28 @@ const STEP_ORDER: TourStepId[] = [
'entry_track', 'entry_track',
'nav_vessel', 'nav_vessel',
'nav_crew', 'nav_crew',
'nav_stats',
'nav_feedback',
'finish' '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>> = { const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
nav_logs: '[data-tour="nav-logs"]', nav_logs: '[data-tour="nav-logs"]',
entry_list: '[data-tour="entry-list"]', entry_list: '[data-tour="entry-list"]',
entry_open: '[data-tour="entry-first"]', entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]', entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]', nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]' nav_crew: '[data-tour="nav-crew"]',
nav_stats: '[data-tour="stats-dashboard"]',
nav_feedback: '[data-tour="feedback-form"]'
} }
const AppTourContext = createContext<AppTourContextValue | null>(null) const AppTourContext = createContext<AppTourContextValue | null>(null)
@@ -86,7 +101,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const demoContextRef = useRef<DemoTourContext | null>(null) const demoContextRef = useRef<DemoTourContext | null>(null)
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false }) const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null const stepOrder = getStepOrder(isDemoTour)
const currentStepId = isActive ? stepOrder[stepIndex] ?? null : null
const resolveFirstEntryId = useCallback((): string | null => { const resolveFirstEntryId = useCallback((): string | null => {
return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId() return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId()
@@ -111,16 +127,29 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null) nav.setSelectedEntryId(null)
nav.setActiveTab('crew') 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]) }, [resolveFirstEntryId])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => { const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return if (!stepId) return
const selector = TARGET_BY_STEP[stepId] const selector = TARGET_BY_STEP[stepId]
if (!selector) return if (!selector) return
window.requestAnimationFrame(() => { const delayMs = stepId === 'nav_feedback' ? 180 : 0
const el = document.querySelector(selector) window.setTimeout(() => {
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }) 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 startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
@@ -142,12 +171,18 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined
if (outcome === 'completed') { if (outcome === 'completed') {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps) trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
const nav = navigationRef.current
if (nav && !tourModeRef.current.demoMode) {
nav.setSelectedEntryId(null)
nav.setActiveTab('stats')
}
} else { } else {
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome' const step = getStepOrder(tourModeRef.current.demoMode)[stepIndexAtDismiss] ?? 'welcome'
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps }) trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
} }
tourModeRef.current = { demoMode: false } tourModeRef.current = { demoMode: false }
navigationRef.current?.setFeedbackOpen(false)
setIsDemoTour(false) setIsDemoTour(false)
setIsActive(false) setIsActive(false)
setStepIndex(0) setStepIndex(0)
@@ -162,12 +197,13 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
}, [dismissTour, stepIndex]) }, [dismissTour, stepIndex])
const nextStep = useCallback(() => { const nextStep = useCallback(() => {
if (stepIndex + 1 >= STEP_ORDER.length) { const order = getStepOrder(isDemoTour)
if (stepIndex + 1 >= order.length) {
dismissTour('completed', stepIndex) dismissTour('completed', stepIndex)
return return
} }
setStepIndex(stepIndex + 1) setStepIndex(stepIndex + 1)
}, [dismissTour, stepIndex]) }, [dismissTour, isDemoTour, stepIndex])
const prevStep = useCallback(() => { const prevStep = useCallback(() => {
setStepIndex((current) => Math.max(0, current - 1)) setStepIndex((current) => Math.max(0, current - 1))
@@ -175,11 +211,11 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (!isActive) return if (!isActive) return
const stepId = STEP_ORDER[stepIndex] const stepId = getStepOrder(isDemoTour)[stepIndex]
if (!stepId) return if (!stepId) return
applyStepSideEffects(stepId) applyStepSideEffects(stepId)
scrollToCurrentTarget(stepId) scrollToCurrentTarget(stepId)
}, [isActive, stepIndex, applyStepSideEffects, scrollToCurrentTarget]) }, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
const restartTour = useCallback(() => { const restartTour = useCallback(() => {
const userId = localStorage.getItem('active_userid') const userId = localStorage.getItem('active_userid')
@@ -220,7 +256,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
isDemoTour, isDemoTour,
currentStepId, currentStepId,
currentStepIndex: stepIndex, currentStepIndex: stepIndex,
totalSteps: STEP_ORDER.length, totalSteps: stepOrder.length,
startTour, startTour,
stopTour, stopTour,
restartTour, restartTour,
@@ -244,6 +280,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
skipTour, skipTour,
startTour, startTour,
stepIndex, stepIndex,
stepOrder.length,
stopTour stopTour
] ]
) )
+10 -2
View File
@@ -467,7 +467,7 @@
"steps": { "steps": {
"welcome": { "welcome": {
"title": "Willkommen an Bord!", "title": "Willkommen an Bord!",
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen." "body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für Sie angelegt. Die Beispieleinträge können Sie jederzeit löschen, wenn Sie mit dem eigenen Logbuch starten möchten. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
}, },
"welcome_public": { "welcome_public": {
"title": "Willkommen an Bord!", "title": "Willkommen an Bord!",
@@ -497,9 +497,17 @@
"title": "Crew-Liste", "title": "Crew-Liste",
"body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu." "body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu."
}, },
"nav_stats": {
"title": "Statistik-Dashboard",
"body": "Hier sehen Sie Reisedistanzen, Verbrauch, Routenkarten und Antriebsanteile automatisch aus Ihren Logbucheinträgen berechnet."
},
"nav_feedback": {
"title": "Feedback senden",
"body": "Über dieses Formular können Sie Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken auch nach der Tour jederzeit über das Symbol oben rechts."
},
"finish": { "finish": {
"title": "Alles klar!", "title": "Alles klar!",
"body": "Sie können die Tour jederzeit unter Einstellungen erneut starten. Gute Fahrt!" "body": "Sie landen gleich im Statistik-Dashboard. Die Tour können Sie jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
} }
} }
} }
+10 -2
View File
@@ -467,7 +467,7 @@
"steps": { "steps": {
"welcome": { "welcome": {
"title": "Welcome aboard!", "title": "Welcome aboard!",
"body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features." "body": "We created a demo logbook with three travel days in Kiel Bay. You can delete the sample entries anytime when you're ready to start your own logbook. This short tour shows you the key features."
}, },
"welcome_public": { "welcome_public": {
"title": "Welcome aboard!", "title": "Welcome aboard!",
@@ -497,9 +497,17 @@
"title": "Crew list", "title": "Crew list",
"body": "Manage crew members and assign them to travel days later." "body": "Manage crew members and assign them to travel days later."
}, },
"nav_stats": {
"title": "Statistics dashboard",
"body": "View travel distances, consumption, route maps, and propulsion breakdown — calculated automatically from your log entries."
},
"nav_feedback": {
"title": "Send feedback",
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
},
"finish": { "finish": {
"title": "You're all set!", "title": "You're all set!",
"body": "You can restart the tour anytime in Settings. Fair winds!" "body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
} }
} }
} }