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:
@@ -3401,3 +3401,11 @@ body.app-tour-active .app-tour-target-active {
|
||||
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;
|
||||
}
|
||||
|
||||
+46
-11
@@ -77,7 +77,7 @@ function App() {
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole>('OWNER')
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLogbookId) {
|
||||
@@ -91,15 +91,24 @@ function App() {
|
||||
}
|
||||
|
||||
const cachedRole = activeLogbookRecord.collaborationRole
|
||||
// Fail-closed for write UI until role is known: do not assume WRITE
|
||||
setActiveAccessRole(
|
||||
cachedRole
|
||||
? parseCollaborationRole(cachedRole, `logbook ${activeLogbookId}`)
|
||||
: 'WRITE'
|
||||
cachedRole ? parseCollaborationRole(cachedRole, `logbook ${activeLogbookId}`) : null
|
||||
)
|
||||
|
||||
getLogbookAccess(activeLogbookId).then((access) => {
|
||||
if (access) setActiveAccessRole(access.role)
|
||||
let cancelled = false
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -188,12 +197,20 @@ function App() {
|
||||
`${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
void (async () => {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const session = await checkServerSession()
|
||||
if (cancelled) return
|
||||
|
||||
if (session.authenticated && session.userId) {
|
||||
localStorage.setItem('active_userid', session.userId)
|
||||
}
|
||||
|
||||
const savedUser = localStorage.getItem('active_username')
|
||||
const key = getActiveMasterKey()
|
||||
if (session.authenticated && savedUser && key) {
|
||||
@@ -205,7 +222,16 @@ function App() {
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.warn('Session restore failed:', err)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -224,7 +250,8 @@ function App() {
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: setTourFeedbackOpen
|
||||
})
|
||||
}, [registerNavigation])
|
||||
|
||||
@@ -391,7 +418,12 @@ function App() {
|
||||
|
||||
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) {
|
||||
return (
|
||||
@@ -420,12 +452,12 @@ function App() {
|
||||
<div className="app-title-area">
|
||||
<div className="app-title-row">
|
||||
<h2>{activeLogbookTitle}</h2>
|
||||
{activeAccessRole !== 'OWNER' && (
|
||||
{activeAccessRole && activeAccessRole !== 'OWNER' && (
|
||||
<LogbookRoleBadge role={activeAccessRole} />
|
||||
)}
|
||||
</div>
|
||||
<p className="app-subtitle">
|
||||
{activeAccessRole !== 'OWNER'
|
||||
{activeAccessRole && activeAccessRole !== 'OWNER'
|
||||
? t('dashboard.section_shared_hint')
|
||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
||||
</p>
|
||||
@@ -454,6 +486,9 @@ function App() {
|
||||
<FeedbackHeaderButton
|
||||
logbookId={activeLogbookId}
|
||||
logbookTitle={activeLogbookTitle}
|
||||
tourOpen={tourFeedbackOpen}
|
||||
onTourOpenChange={setTourFeedbackOpen}
|
||||
tourHighlight={isActive && currentStepId === 'nav_feedback'}
|
||||
/>
|
||||
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
|
||||
@@ -30,7 +30,8 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: () => {}
|
||||
})
|
||||
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
|
||||
|
||||
|
||||
@@ -6,31 +6,45 @@ import FeedbackModal from './FeedbackModal.tsx'
|
||||
interface FeedbackHeaderButtonProps {
|
||||
logbookId?: string | null
|
||||
logbookTitle?: string | null
|
||||
tourOpen?: boolean
|
||||
onTourOpenChange?: (open: boolean) => void
|
||||
tourHighlight?: boolean
|
||||
}
|
||||
|
||||
export default function FeedbackHeaderButton({
|
||||
logbookId,
|
||||
logbookTitle
|
||||
logbookTitle,
|
||||
tourOpen = false,
|
||||
onTourOpenChange,
|
||||
tourHighlight = false
|
||||
}: FeedbackHeaderButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [userOpen, setUserOpen] = useState(false)
|
||||
const open = tourOpen || userOpen
|
||||
|
||||
const handleClose = () => {
|
||||
setUserOpen(false)
|
||||
onTourOpenChange?.(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => setUserOpen(true)}
|
||||
title={t('feedback.button_title')}
|
||||
aria-label={t('feedback.button_title')}
|
||||
data-tour="feedback-button"
|
||||
>
|
||||
<MessageSquarePlus size={18} />
|
||||
</button>
|
||||
<FeedbackModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={handleClose}
|
||||
logbookId={logbookId}
|
||||
logbookTitle={logbookTitle}
|
||||
tourMode={tourHighlight}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ interface FeedbackModalProps {
|
||||
onClose: () => void
|
||||
logbookId?: string | null
|
||||
logbookTitle?: string | null
|
||||
tourMode?: boolean
|
||||
}
|
||||
|
||||
type SubmitState = 'idle' | 'submitting' | 'success' | 'error'
|
||||
@@ -18,7 +19,8 @@ export default function FeedbackModal({
|
||||
open,
|
||||
onClose,
|
||||
logbookId,
|
||||
logbookTitle
|
||||
logbookTitle,
|
||||
tourMode = false
|
||||
}: FeedbackModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [category, setCategory] = useState<FeedbackCategory>('general')
|
||||
@@ -97,14 +99,20 @@ export default function FeedbackModal({
|
||||
if (!open) return null
|
||||
|
||||
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="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
|
||||
type="button"
|
||||
className="registration-disclaimer__close feedback-modal__close"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || tourMode}
|
||||
aria-label={t('feedback.cancel')}
|
||||
>
|
||||
<X size={18} />
|
||||
@@ -187,7 +195,7 @@ export default function FeedbackModal({
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={onClose}
|
||||
disabled={submitState === 'submitting'}
|
||||
disabled={submitState === 'submitting' || tourMode}
|
||||
>
|
||||
{t('feedback.cancel')}
|
||||
</button>
|
||||
|
||||
@@ -310,7 +310,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
}, [accountStats])
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="form-card" data-tour="stats-dashboard">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
|
||||
@@ -27,11 +27,14 @@ export type TourStepId =
|
||||
| '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 {
|
||||
@@ -55,7 +58,7 @@ interface AppTourContextValue {
|
||||
requestStartAfterLogin: () => void
|
||||
}
|
||||
|
||||
const STEP_ORDER: TourStepId[] = [
|
||||
const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'welcome',
|
||||
'nav_logs',
|
||||
'entry_list',
|
||||
@@ -63,16 +66,28 @@ const STEP_ORDER: TourStepId[] = [
|
||||
'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_crew: '[data-tour="nav-crew"]',
|
||||
nav_stats: '[data-tour="stats-dashboard"]',
|
||||
nav_feedback: '[data-tour="feedback-form"]'
|
||||
}
|
||||
|
||||
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
||||
@@ -86,7 +101,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const demoContextRef = useRef<DemoTourContext | null>(null)
|
||||
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 => {
|
||||
return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId()
|
||||
@@ -111,16 +127,29 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
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 }) => {
|
||||
@@ -142,12 +171,18 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
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 = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
|
||||
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)
|
||||
@@ -162,12 +197,13 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
}, [dismissTour, stepIndex])
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (stepIndex + 1 >= STEP_ORDER.length) {
|
||||
const order = getStepOrder(isDemoTour)
|
||||
if (stepIndex + 1 >= order.length) {
|
||||
dismissTour('completed', stepIndex)
|
||||
return
|
||||
}
|
||||
setStepIndex(stepIndex + 1)
|
||||
}, [dismissTour, stepIndex])
|
||||
}, [dismissTour, isDemoTour, stepIndex])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setStepIndex((current) => Math.max(0, current - 1))
|
||||
@@ -175,11 +211,11 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const stepId = STEP_ORDER[stepIndex]
|
||||
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
||||
if (!stepId) return
|
||||
applyStepSideEffects(stepId)
|
||||
scrollToCurrentTarget(stepId)
|
||||
}, [isActive, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||
|
||||
const restartTour = useCallback(() => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
@@ -220,7 +256,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
isDemoTour,
|
||||
currentStepId,
|
||||
currentStepIndex: stepIndex,
|
||||
totalSteps: STEP_ORDER.length,
|
||||
totalSteps: stepOrder.length,
|
||||
startTour,
|
||||
stopTour,
|
||||
restartTour,
|
||||
@@ -244,6 +280,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
skipTour,
|
||||
startTour,
|
||||
stepIndex,
|
||||
stepOrder.length,
|
||||
stopTour
|
||||
]
|
||||
)
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"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": {
|
||||
"title": "Willkommen an Bord!",
|
||||
@@ -497,9 +497,17 @@
|
||||
"title": "Crew-Liste",
|
||||
"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": {
|
||||
"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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"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": {
|
||||
"title": "Welcome aboard!",
|
||||
@@ -497,9 +497,17 @@
|
||||
"title": "Crew list",
|
||||
"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": {
|
||||
"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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user