feat: Onboarding-Tour um Benutzerprofil erweitern

Profil-Schritte auf dem Dashboard und in den Einstellungen, entry_open
highlightet nur die Karte ohne Editor, Finish verweist auf Benutzerprofil.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 13:04:02 +02:00
co-authored by Cursor
parent c215cd8b15
commit 520ba766a3
8 changed files with 169 additions and 12 deletions
+33 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import './App.css' import './App.css'
import { DialogProvider } from './components/ModalDialog.tsx' import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx' import AuthOnboarding from './components/AuthOnboarding.tsx'
@@ -69,6 +69,12 @@ function App() {
const [isSyncing, setIsSyncing] = useState(false) const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false) const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false) const [showUserProfile, setShowUserProfile] = useState(false)
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
id: activeLogbookId,
title: activeLogbookTitle
})
activeLogbookRef.current = { id: activeLogbookId, title: activeLogbookTitle }
// Viewer mode for read-only shared links // Viewer mode for read-only shared links
const [isViewerMode, setIsViewerMode] = useState(false) const [isViewerMode, setIsViewerMode] = useState(false)
@@ -313,7 +319,32 @@ function App() {
registerNavigation({ registerNavigation({
setActiveTab, setActiveTab,
setSelectedEntryId: setTourSelectedEntryId, setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: setTourFeedbackOpen setFeedbackOpen: setTourFeedbackOpen,
setProfileOpen: setShowUserProfile,
setLogbookActive: (active) => {
if (active) {
const saved = tourLogbookRef.current
const id = saved?.id ?? localStorage.getItem('active_logbook_id')
const title = saved?.title ?? localStorage.getItem('active_logbook_title')
if (id && title) {
setActiveLogbookId(id)
setActiveLogbookTitle(title)
localStorage.setItem('active_logbook_id', id)
localStorage.setItem('active_logbook_title', title)
}
return
}
const { id, title } = activeLogbookRef.current
if (id && title) {
tourLogbookRef.current = { id, title }
}
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
}) })
}, [registerNavigation]) }, [registerNavigation])
+7 -1
View File
@@ -4,6 +4,7 @@ import { X, ChevronLeft, ChevronRight } from 'lucide-react'
import { import {
getTourStepCopy, getTourStepCopy,
getTourTargetSelector, getTourTargetSelector,
getTourTargetRetryDelay,
isCenteredTourStep, isCenteredTourStep,
useAppTour useAppTour
} from '../context/AppTourContext.tsx' } from '../context/AppTourContext.tsx'
@@ -89,10 +90,15 @@ export default function AppTourOverlay() {
updateSpotlight() updateSpotlight()
window.addEventListener('resize', updateSpotlight) window.addEventListener('resize', updateSpotlight)
window.addEventListener('scroll', updateSpotlight, true) window.addEventListener('scroll', updateSpotlight, true)
const timer = window.setTimeout(updateSpotlight, 120) const retryDelay = getTourTargetRetryDelay(currentStepId)
const timer = window.setTimeout(updateSpotlight, retryDelay)
const retryTimer = retryDelay > 120
? window.setTimeout(updateSpotlight, retryDelay + 180)
: undefined
return () => { return () => {
window.clearTimeout(timer) window.clearTimeout(timer)
if (retryTimer !== undefined) window.clearTimeout(retryTimer)
window.removeEventListener('resize', updateSpotlight) window.removeEventListener('resize', updateSpotlight)
window.removeEventListener('scroll', updateSpotlight, true) window.removeEventListener('scroll', updateSpotlight, true)
} }
@@ -314,6 +314,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
onClick={onOpenProfile} onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })} title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })} aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
> >
<User size={18} aria-hidden="true" /> <User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span> <span className="skipper-badge__name">{username}</span>
@@ -443,6 +443,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</section> </section>
) : profile ? ( ) : profile ? (
<> <>
<div data-tour="profile-preferences">
<section className="form-card"> <section className="form-card">
<div className="form-header"> <div className="form-header">
<User size={24} className="form-icon" /> <User size={24} className="form-icon" />
@@ -484,6 +485,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</section> </section>
<UserProfilePreferences userId={profile.userId} /> <UserProfilePreferences userId={profile.userId} />
</div>
<section className="member-editor-card glass"> <section className="member-editor-card glass">
<div className="profile-section-header"> <div className="profile-section-header">
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import {
DEMO_EXCLUDED_STEPS,
DEMO_STEP_ORDER,
FULL_STEP_ORDER,
tourStepOpensEntry
} from './AppTourContext.tsx'
describe('AppTourContext step order', () => {
it('includes profile steps before finish in full tour', () => {
const profileIndex = FULL_STEP_ORDER.indexOf('nav_profile')
const prefsIndex = FULL_STEP_ORDER.indexOf('profile_preferences')
const finishIndex = FULL_STEP_ORDER.indexOf('finish')
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
expect(prefsIndex).toBe(profileIndex + 1)
expect(finishIndex).toBe(prefsIndex + 1)
expect(FULL_STEP_ORDER).toHaveLength(12)
})
it('excludes profile, stats and feedback from demo tour', () => {
for (const step of DEMO_EXCLUDED_STEPS) {
expect(DEMO_STEP_ORDER).not.toContain(step)
}
expect(DEMO_STEP_ORDER).toContain('finish')
expect(DEMO_STEP_ORDER).toHaveLength(FULL_STEP_ORDER.length - DEMO_EXCLUDED_STEPS.length)
})
it('only opens entry editor on entry_track step', () => {
expect(tourStepOpensEntry('entry_open')).toBe(false)
expect(tourStepOpensEntry('entry_list')).toBe(false)
expect(tourStepOpensEntry('entry_track')).toBe(true)
})
})
+74 -7
View File
@@ -29,12 +29,16 @@ export type TourStepId =
| 'nav_crew' | 'nav_crew'
| 'nav_stats' | 'nav_stats'
| 'nav_feedback' | 'nav_feedback'
| 'nav_profile'
| 'profile_preferences'
| '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 setFeedbackOpen: (open: boolean) => void
setLogbookActive: (active: boolean) => void
setProfileOpen: (open: boolean) => void
} }
interface DemoTourContext { interface DemoTourContext {
@@ -58,7 +62,7 @@ interface AppTourContextValue {
requestStartAfterLogin: () => void requestStartAfterLogin: () => void
} }
const FULL_STEP_ORDER: TourStepId[] = [ export const FULL_STEP_ORDER: TourStepId[] = [
'welcome', 'welcome',
'nav_logs', 'nav_logs',
'entry_list', 'entry_list',
@@ -68,12 +72,33 @@ const FULL_STEP_ORDER: TourStepId[] = [
'nav_crew', 'nav_crew',
'nav_stats', 'nav_stats',
'nav_feedback', 'nav_feedback',
'nav_profile',
'profile_preferences',
'finish' 'finish'
] ]
/** Public demo has no stats/feedback UI — skip those steps. */ /** Public demo has no stats/feedback/profile UI — skip those steps. */
const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback'] export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id)) 'nav_stats',
'nav_feedback',
'nav_profile',
'profile_preferences'
]
export const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter(
(id) => !DEMO_EXCLUDED_STEPS.includes(id)
)
const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
'nav_logs',
'entry_list',
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'nav_stats',
'nav_feedback'
])
function getStepOrder(demoMode: boolean): TourStepId[] { function getStepOrder(demoMode: boolean): TourStepId[] {
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
@@ -87,7 +112,20 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
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_stats: '[data-tour="stats-dashboard"]',
nav_feedback: '[data-tour="feedback-form"]' nav_feedback: '[data-tour="feedback-form"]',
nav_profile: '[data-tour="nav-profile"]',
profile_preferences: '[data-tour="profile-preferences"]'
}
/** Whether a tour step opens the first log entry editor (not the list card). */
export function tourStepOpensEntry(stepId: TourStepId): boolean {
return stepId === 'entry_track'
}
export function getTourTargetDelay(stepId: TourStepId): number {
if (stepId === 'nav_feedback') return 180
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
return 0
} }
const AppTourContext = createContext<AppTourContextValue | null>(null) const AppTourContext = createContext<AppTourContextValue | null>(null)
@@ -112,13 +150,24 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const nav = navigationRef.current const nav = navigationRef.current
if (!nav) return if (!nav) return
if (LOGBOOK_TOUR_STEPS.has(stepId)) {
nav.setProfileOpen(false)
nav.setLogbookActive(true)
}
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') { if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
nav.setActiveTab('logs') nav.setActiveTab('logs')
} }
if (stepId === 'entry_open' || stepId === 'entry_track') {
if (stepId === 'entry_list' || stepId === 'entry_open') {
nav.setSelectedEntryId(null)
} else if (tourStepOpensEntry(stepId)) {
const firstEntryId = resolveFirstEntryId() const firstEntryId = resolveFirstEntryId()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId) if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
} else if (LOGBOOK_TOUR_STEPS.has(stepId)) {
nav.setSelectedEntryId(null)
} }
if (stepId === 'nav_vessel') { if (stepId === 'nav_vessel') {
nav.setSelectedEntryId(null) nav.setSelectedEntryId(null)
nav.setActiveTab('vessel') nav.setActiveTab('vessel')
@@ -137,13 +186,22 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
} else { } else {
nav.setFeedbackOpen(false) nav.setFeedbackOpen(false)
} }
if (stepId === 'nav_profile') {
nav.setProfileOpen(false)
nav.setLogbookActive(false)
}
if (stepId === 'profile_preferences') {
nav.setLogbookActive(false)
nav.setProfileOpen(true)
}
}, [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
const delayMs = stepId === 'nav_feedback' ? 180 : 0 const delayMs = getTourTargetDelay(stepId)
window.setTimeout(() => { window.setTimeout(() => {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
const el = document.querySelector(selector) const el = document.querySelector(selector)
@@ -173,6 +231,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps) trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
const nav = navigationRef.current const nav = navigationRef.current
if (nav && !tourModeRef.current.demoMode) { if (nav && !tourModeRef.current.demoMode) {
nav.setProfileOpen(false)
nav.setLogbookActive(true)
nav.setSelectedEntryId(null) nav.setSelectedEntryId(null)
nav.setActiveTab('stats') nav.setActiveTab('stats')
} }
@@ -183,6 +243,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
tourModeRef.current = { demoMode: false } tourModeRef.current = { demoMode: false }
navigationRef.current?.setFeedbackOpen(false) navigationRef.current?.setFeedbackOpen(false)
navigationRef.current?.setProfileOpen(false)
setIsDemoTour(false) setIsDemoTour(false)
setIsActive(false) setIsActive(false)
setStepIndex(0) setStepIndex(0)
@@ -321,3 +382,9 @@ export function getTourTargetSelector(stepId: TourStepId | null): string | null
export function isCenteredTourStep(stepId: TourStepId | null): boolean { export function isCenteredTourStep(stepId: TourStepId | null): boolean {
return stepId === 'welcome' || stepId === 'finish' return stepId === 'welcome' || stepId === 'finish'
} }
export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
if (stepId === 'profile_preferences') return 300
if (stepId === 'nav_profile') return 200
return 120
}
+9 -1
View File
@@ -672,9 +672,17 @@
"title": "Feedback senden", "title": "Feedback senden",
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken auch nach der Tour jederzeit über das Symbol oben rechts." "body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken auch nach der Tour jederzeit über das Symbol oben rechts."
}, },
"nav_profile": {
"title": "Dein Benutzerprofil",
"body": "Über den Skipper-Button oben erreichst du dein persönliches Profil unabhängig vom aktuellen Logbuch."
},
"profile_preferences": {
"title": "Konto & Darstellung",
"body": "Hier verwaltest du deine Konto-Identität, Theme und Hell/Dunkel-Modus. Die App-Tour kannst du jederzeit erneut starten. Passkeys und Sicherheitseinstellungen findest du weiter unten im Profil."
},
"finish": { "finish": {
"title": "Alles klar!", "title": "Alles klar!",
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!" "body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit im Benutzerprofil erneut starten. Gute Fahrt!"
} }
} }
}, },
+9 -1
View File
@@ -672,9 +672,17 @@
"title": "Send 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." "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."
}, },
"nav_profile": {
"title": "Your user profile",
"body": "Tap the skipper button at the top to open your personal profile — independent of the current logbook."
},
"profile_preferences": {
"title": "Account & appearance",
"body": "Manage your account identity, theme, and light/dark mode here. You can restart the app tour anytime. Passkeys and security settings are further down on the profile page."
},
"finish": { "finish": {
"title": "You're all set!", "title": "You're all set!",
"body": "You'll land on the statistics dashboard next. 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 from your user profile. Fair winds!"
} }
} }
}, },