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:
+33
-2
@@ -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])
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user