feat: Demo-Logbuch und Onboarding-Tour bei Registrierung

Neue Nutzer erhalten automatisch ein Demo-Logbuch mit drei Ostsee-Reisetagen
und eine interaktive App-Tour; die Tour kann in den Einstellungen erneut gestartet werden.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 17:59:02 +02:00
co-authored by Cursor
parent 646d316a36
commit 0da855381d
20 changed files with 5549 additions and 23 deletions
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useLayoutEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
import {
getTourStepCopy,
getTourTargetSelector,
isCenteredTourStep,
useAppTour
} from '../context/AppTourContext.tsx'
interface SpotlightRect {
top: number
left: number
width: number
height: number
}
export default function AppTourOverlay() {
const { t } = useTranslation()
const {
isActive,
currentStepId,
currentStepIndex,
totalSteps,
nextStep,
prevStep,
skipTour
} = useAppTour()
const [spotlight, setSpotlight] = useState<SpotlightRect | null>(null)
useLayoutEffect(() => {
if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) {
setSpotlight(null)
return
}
const updateSpotlight = () => {
const selector = getTourTargetSelector(currentStepId)
if (!selector) {
setSpotlight(null)
return
}
const el = document.querySelector(selector)
if (!el) {
setSpotlight(null)
return
}
const rect = el.getBoundingClientRect()
const padding = 8
setSpotlight({
top: Math.max(8, rect.top - padding),
left: Math.max(8, rect.left - padding),
width: rect.width + padding * 2,
height: rect.height + padding * 2
})
}
updateSpotlight()
window.addEventListener('resize', updateSpotlight)
window.addEventListener('scroll', updateSpotlight, true)
const timer = window.setTimeout(updateSpotlight, 120)
return () => {
window.clearTimeout(timer)
window.removeEventListener('resize', updateSpotlight)
window.removeEventListener('scroll', updateSpotlight, true)
}
}, [currentStepId, isActive])
useEffect(() => {
if (!isActive) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') skipTour()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [isActive, skipTour])
if (!isActive || !currentStepId) return null
const { title, body } = getTourStepCopy(currentStepId, t)
const centered = isCenteredTourStep(currentStepId)
const tooltipStyle = centered
? undefined
: spotlight
? {
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12),
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
maxWidth: '420px'
}
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
return (
<div className="app-tour-root" role="dialog" aria-modal="true" aria-label={title}>
<div className="app-tour-backdrop" onClick={skipTour} />
{!centered && spotlight && (
<div
className="app-tour-spotlight"
style={{
top: spotlight.top,
left: spotlight.left,
width: spotlight.width,
height: spotlight.height
}}
/>
)}
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
<X size={18} />
</button>
<p className="app-tour-progress">
{t('tour.progress', { current: currentStepIndex + 1, total: totalSteps })}
</p>
<h3 className="app-tour-title">{title}</h3>
<p className="app-tour-body">{body}</p>
<div className="app-tour-actions">
<button
type="button"
className="app-tour-link"
onClick={skipTour}
>
{t('tour.skip')}
</button>
<div className="app-tour-nav">
<button
type="button"
className="btn secondary app-tour-nav-btn"
onClick={prevStep}
disabled={currentStepIndex === 0}
>
<ChevronLeft size={16} />
{t('tour.back')}
</button>
<button type="button" className="btn primary app-tour-nav-btn" onClick={nextStep}>
{currentStepIndex === totalSteps - 1 ? t('tour.finish') : t('tour.next')}
{currentStepIndex < totalSteps - 1 && <ChevronRight size={16} />}
</button>
</div>
</div>
</div>
</div>
)
}
+26 -4
View File
@@ -27,6 +27,9 @@ interface LogEntriesListProps {
preloadedEntries?: any[]
preloadedPhotos?: any[]
preloadedGpsTracks?: any[]
controlledSelectedEntryId?: string | null
onSelectedEntryIdChange?: (id: string | null) => void
highlightEntryId?: string | null
}
interface DecryptedEntryItem {
@@ -44,12 +47,26 @@ export default function LogEntriesList({
preloadedYacht,
preloadedEntries,
preloadedPhotos,
preloadedGpsTracks
preloadedGpsTracks,
controlledSelectedEntryId,
onSelectedEntryIdChange,
highlightEntryId
}: LogEntriesListProps) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
const [internalSelectedEntryId, setInternalSelectedEntryId] = useState<string | null>(null)
const isEntrySelectionControlled = onSelectedEntryIdChange !== undefined
const selectedEntryId = isEntrySelectionControlled
? (controlledSelectedEntryId ?? null)
: internalSelectedEntryId
const setSelectedEntryId = (entryId: string | null) => {
if (isEntrySelectionControlled) {
onSelectedEntryIdChange?.(entryId)
} else {
setInternalSelectedEntryId(entryId)
}
}
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -356,9 +373,14 @@ export default function LogEntriesList({
{entries.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
) : (
<div className="logbooks-grid">
<div className="logbooks-grid" data-tour="entry-list">
{entries.map((item) => (
<div key={item.id} className="logbook-card glass" onClick={() => setSelectedEntryId(item.id)}>
<div
key={item.id}
className="logbook-card glass"
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
onClick={() => setSelectedEntryId(item.id)}
>
<div className="card-icon">
<FileText size={24} />
</div>
+1 -1
View File
@@ -1326,7 +1326,7 @@ export default function LogEntryEditor({
</div>
{/* Track file upload */}
<div className="form-card">
<div className="form-card" data-tour="entry-track">
<div className="form-header">
<Upload size={20} className="form-icon" />
<h3>{t('logs.track_upload_title')}</h3>
@@ -209,6 +209,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
</span>
{lb.isDemo && (
<span className="demo-badge">{t('demo.badge')}</span>
)}
<span className="date-badge">
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
year: 'numeric',
+22 -1
View File
@@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import AccountDangerZone from './AccountDangerZone.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
interface SettingsFormProps {
logbookId?: string | null
@@ -30,6 +31,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
export default function SettingsForm({ logbookId }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
const { restartTour } = useAppTour()
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
@@ -365,6 +367,25 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
</div>
</div>
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Compass size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.tour_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.tour_desc')}
</p>
<button
type="button"
className="btn secondary"
onClick={() => restartTour()}
>
{t('settings.tour_restart')}
</button>
</div>
<div className="form-actions mt-4 mb-6">
{success && (
<div className="success-toast">