From 1437b75c2f0f1cc8d2ac9f45143171f05382832c Mon Sep 17 00:00:00 2001 From: elpatron Date: Sat, 30 May 2026 14:03:46 +0200 Subject: [PATCH] feat(client): Onboarding-Tour um Statistik und Feedback erweitern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.css | 8 ++ client/src/App.tsx | 85 +++++++++++++------ client/src/components/DemoViewer.tsx | 3 +- .../src/components/FeedbackHeaderButton.tsx | 22 ++++- client/src/components/FeedbackModal.tsx | 18 ++-- client/src/components/StatsDashboard.tsx | 2 +- client/src/context/AppTourContext.tsx | 63 +++++++++++--- client/src/i18n/locales/de.json | 12 ++- client/src/i18n/locales/en.json | 12 ++- 9 files changed, 172 insertions(+), 53 deletions(-) diff --git a/client/src/App.css b/client/src/App.css index 30ced04..e8fcbec 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 5beea97..9f8c3a6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -77,7 +77,7 @@ function App() { [activeLogbookId] ) - const [activeAccessRole, setActiveAccessRole] = useState('OWNER') + const [activeAccessRole, setActiveAccessRole] = useState('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,24 +197,41 @@ function App() { `${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}` ) } + }, []) - void (async () => { - const session = await checkServerSession() - 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) { - setIsAuthenticated(true) - const savedLogbookId = localStorage.getItem('active_logbook_id') - const savedLogbookTitle = localStorage.getItem('active_logbook_title') - if (savedLogbookId && savedLogbookTitle) { - setActiveLogbookId(savedLogbookId) - setActiveLogbookTitle(savedLogbookTitle) + 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) { + 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(() => { @@ -224,7 +250,8 @@ function App() { useEffect(() => { registerNavigation({ setActiveTab, - setSelectedEntryId: setTourSelectedEntryId + setSelectedEntryId: setTourSelectedEntryId, + setFeedbackOpen: setTourFeedbackOpen }) }, [registerNavigation]) @@ -391,7 +418,12 @@ function App() { const pwaInstallBanner = - 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() {

{activeLogbookTitle}

- {activeAccessRole !== 'OWNER' && ( + {activeAccessRole && activeAccessRole !== 'OWNER' && ( )}

- {activeAccessRole !== 'OWNER' + {activeAccessRole && activeAccessRole !== 'OWNER' ? t('dashboard.section_shared_hint') : `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}

@@ -454,6 +486,9 @@ function App() { setOpen(false)} + onClose={handleClose} logbookId={logbookId} logbookTitle={logbookTitle} + tourMode={tourHighlight} /> ) diff --git a/client/src/components/FeedbackModal.tsx b/client/src/components/FeedbackModal.tsx index 826f34b..d0deee5 100644 --- a/client/src/components/FeedbackModal.tsx +++ b/client/src/components/FeedbackModal.tsx @@ -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('general') @@ -97,14 +99,20 @@ export default function FeedbackModal({ if (!open) return null return ( -
+
event.stopPropagation()}> -
+
diff --git a/client/src/components/StatsDashboard.tsx b/client/src/components/StatsDashboard.tsx index 5533f96..6f36ef1 100644 --- a/client/src/components/StatsDashboard.tsx +++ b/client/src/components/StatsDashboard.tsx @@ -310,7 +310,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa }, [accountStats]) return ( -
+
diff --git a/client/src/context/AppTourContext.tsx b/client/src/context/AppTourContext.tsx index ef1f8ad..e7a44b6 100644 --- a/client/src/context/AppTourContext.tsx +++ b/client/src/context/AppTourContext.tsx @@ -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> = { 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(null) @@ -86,7 +101,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) { const demoContextRef = useRef(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 - window.requestAnimationFrame(() => { - const el = document.querySelector(selector) - el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }) - }) + 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 ] ) diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index d680d22..55f8901 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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!" } } } diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index fa3cb14..45b6fd2 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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!" } } }