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
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 { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx'
@@ -69,6 +69,12 @@ function App() {
const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = 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
const [isViewerMode, setIsViewerMode] = useState(false)
@@ -313,7 +319,32 @@ function App() {
registerNavigation({
setActiveTab,
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])
+7 -1
View File
@@ -4,6 +4,7 @@ import { X, ChevronLeft, ChevronRight } from 'lucide-react'
import {
getTourStepCopy,
getTourTargetSelector,
getTourTargetRetryDelay,
isCenteredTourStep,
useAppTour
} from '../context/AppTourContext.tsx'
@@ -89,10 +90,15 @@ export default function AppTourOverlay() {
updateSpotlight()
window.addEventListener('resize', updateSpotlight)
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 () => {
window.clearTimeout(timer)
if (retryTimer !== undefined) window.clearTimeout(retryTimer)
window.removeEventListener('resize', updateSpotlight)
window.removeEventListener('scroll', updateSpotlight, true)
}
@@ -314,6 +314,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
@@ -443,6 +443,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</section>
) : profile ? (
<>
<div data-tour="profile-preferences">
<section className="form-card">
<div className="form-header">
<User size={24} className="form-icon" />
@@ -484,6 +485,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</section>
<UserProfilePreferences userId={profile.userId} />
</div>
<section className="member-editor-card glass">
<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_stats'
| 'nav_feedback'
| 'nav_profile'
| 'profile_preferences'
| 'finish'
interface TourNavigation {
setActiveTab: (tab: AppTab) => void
setSelectedEntryId: (entryId: string | null) => void
setFeedbackOpen: (open: boolean) => void
setLogbookActive: (active: boolean) => void
setProfileOpen: (open: boolean) => void
}
interface DemoTourContext {
@@ -58,7 +62,7 @@ interface AppTourContextValue {
requestStartAfterLogin: () => void
}
const FULL_STEP_ORDER: TourStepId[] = [
export const FULL_STEP_ORDER: TourStepId[] = [
'welcome',
'nav_logs',
'entry_list',
@@ -68,12 +72,33 @@ const FULL_STEP_ORDER: TourStepId[] = [
'nav_crew',
'nav_stats',
'nav_feedback',
'nav_profile',
'profile_preferences',
'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))
/** Public demo has no stats/feedback/profile UI — skip those steps. */
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
'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[] {
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_crew: '[data-tour="nav-crew"]',
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)
@@ -112,13 +150,24 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const nav = navigationRef.current
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') {
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()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
} else if (LOGBOOK_TOUR_STEPS.has(stepId)) {
nav.setSelectedEntryId(null)
}
if (stepId === 'nav_vessel') {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
@@ -137,13 +186,22 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
} else {
nav.setFeedbackOpen(false)
}
if (stepId === 'nav_profile') {
nav.setProfileOpen(false)
nav.setLogbookActive(false)
}
if (stepId === 'profile_preferences') {
nav.setLogbookActive(false)
nav.setProfileOpen(true)
}
}, [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
const delayMs = getTourTargetDelay(stepId)
window.setTimeout(() => {
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
@@ -173,6 +231,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
const nav = navigationRef.current
if (nav && !tourModeRef.current.demoMode) {
nav.setProfileOpen(false)
nav.setLogbookActive(true)
nav.setSelectedEntryId(null)
nav.setActiveTab('stats')
}
@@ -183,6 +243,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
tourModeRef.current = { demoMode: false }
navigationRef.current?.setFeedbackOpen(false)
navigationRef.current?.setProfileOpen(false)
setIsDemoTour(false)
setIsActive(false)
setStepIndex(0)
@@ -321,3 +382,9 @@ export function getTourTargetSelector(stepId: TourStepId | null): string | null
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
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",
"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": {
"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",
"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": {
"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!"
}
}
},