From 53eee9a3adb7620a06ab9f9d5e3b2695bfa8e805 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sat, 30 May 2026 10:11:53 +0200 Subject: [PATCH] Add public read-only demo at /demo without account. Let visitors explore ship data, crew, and sample log entries from the login page, with onboarding tour support and a fix for GPS track rendering when fileType is missing. Co-authored-by: Cursor --- client/package-lock.json | 4 +- client/package.json | 3 + client/src/App.tsx | 22 ++ client/src/components/AppTourOverlay.tsx | 3 +- client/src/components/AuthOnboarding.tsx | 10 + client/src/components/DemoViewer.tsx | 148 +++++++++++ client/src/components/LogEntryEditor.tsx | 9 +- client/src/context/AppTourContext.tsx | 57 +++- client/src/i18n/locales/de.json | 10 +- client/src/i18n/locales/en.json | 10 +- client/src/services/analytics.ts | 3 +- client/src/services/appTourStorage.ts | 9 + client/src/services/demoLogbook.ts | 197 +------------- client/src/services/demoLogbookData.ts | 318 +++++++++++++++++++++++ docs/plausible-events.md | 5 +- 15 files changed, 602 insertions(+), 206 deletions(-) create mode 100644 client/src/components/DemoViewer.tsx create mode 100644 client/src/services/demoLogbookData.ts diff --git a/client/package-lock.json b/client/package-lock.json index cc2f065..bf2e852 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -36,6 +36,9 @@ "typescript-eslint": "^8.59.2", "vite": "^8.0.12", "vite-plugin-pwa": "^1.3.0" + }, + "optionalDependencies": { + "@rolldown/binding-linux-x64-gnu": "^1.0.2" } }, "node_modules/@apideck/better-ajv-errors": { @@ -2096,7 +2099,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/client/package.json b/client/package.json index c1c80d7..ed5cce8 100644 --- a/client/package.json +++ b/client/package.json @@ -38,5 +38,8 @@ "typescript-eslint": "^8.59.2", "vite": "^8.0.12", "vite-plugin-pwa": "^1.3.0" + }, + "optionalDependencies": { + "@rolldown/binding-linux-x64-gnu": "^1.0.2" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index df142f2..590b247 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -23,6 +23,7 @@ import { } from './services/appearance.js' import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js' import ReadOnlyViewer from './components/ReadOnlyViewer.tsx' +import DemoViewer from './components/DemoViewer.tsx' import PwaInstallPrompt from './components/PwaInstallPrompt.tsx' import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx' import AppFooter from './components/AppFooter.tsx' @@ -57,6 +58,9 @@ function App() { const [shareToken, setShareToken] = useState('') const [shareKey, setShareKey] = useState('') + // Public demo mode (no account required) + const [isDemoMode, setIsDemoMode] = useState(false) + const syncQueueCount = useLiveQuery( () => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(), [activeLogbookId] @@ -138,6 +142,11 @@ function App() { const params = new URLSearchParams(window.location.search) const hashParams = new URLSearchParams(window.location.hash.substring(1)) + if (window.location.pathname === '/demo') { + setIsDemoMode(true) + return + } + if (window.location.pathname === '/share' && params.has('token') && hashParams.has('key')) { setShareToken(params.get('token') || '') setShareKey(hashParams.get('key') || '') @@ -234,6 +243,19 @@ function App() { i18n.changeLanguage(nextLang) } + const handleExitDemo = () => { + window.history.replaceState({}, document.title, '/') + setIsDemoMode(false) + } + + if (isDemoMode) { + return ( +
+ +
+ ) + } + if (isViewerMode) { return (
diff --git a/client/src/components/AppTourOverlay.tsx b/client/src/components/AppTourOverlay.tsx index ac0dc4d..edfd2a3 100644 --- a/client/src/components/AppTourOverlay.tsx +++ b/client/src/components/AppTourOverlay.tsx @@ -25,6 +25,7 @@ export default function AppTourOverlay() { const { t } = useTranslation() const { isActive, + isDemoTour, currentStepId, currentStepIndex, totalSteps, @@ -104,7 +105,7 @@ export default function AppTourOverlay() { if (!isActive || !currentStepId) return null - const { title, body } = getTourStepCopy(currentStepId, t) + const { title, body } = getTourStepCopy(currentStepId, t, { demoMode: isDemoTour }) const centered = isCenteredTourStep(currentStepId) const tooltipStyle = centered diff --git a/client/src/components/AuthOnboarding.tsx b/client/src/components/AuthOnboarding.tsx index 4dd07d1..f5dcb7f 100644 --- a/client/src/components/AuthOnboarding.tsx +++ b/client/src/components/AuthOnboarding.tsx @@ -523,6 +523,16 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
+ + {/* Registration form */}
diff --git a/client/src/components/DemoViewer.tsx b/client/src/components/DemoViewer.tsx new file mode 100644 index 0000000..eed087d --- /dev/null +++ b/client/src/components/DemoViewer.tsx @@ -0,0 +1,148 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import VesselForm from './VesselForm.tsx' +import CrewForm from './CrewForm.tsx' +import LogEntriesList from './LogEntriesList.tsx' +import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react' +import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js' +import { useAppTour, type AppTab } from '../context/AppTourContext.tsx' +import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' + +interface DemoViewerProps { + onExit: () => void +} + +export default function DemoViewer({ onExit }: DemoViewerProps) { + const { t, i18n } = useTranslation() + const { registerNavigation, registerDemoTourContext, startTour } = useAppTour() + const [activeTab, setActiveTab] = useState('logs') + const [tourSelectedEntryId, setTourSelectedEntryId] = useState(null) + const [fixture, setFixture] = useState(() => buildPublicDemoFixture()) + + useEffect(() => { + trackPlausibleEvent(PlausibleEvents.DEMO_OPENED) + }, []) + + useEffect(() => { + setFixture(buildPublicDemoFixture()) + }, [i18n.language]) + + useEffect(() => { + registerNavigation({ + setActiveTab, + setSelectedEntryId: setTourSelectedEntryId + }) + registerDemoTourContext({ firstEntryId: fixture.firstEntryId }) + + const timer = window.setTimeout(() => { + startTour({ force: true, demoMode: true }) + }, 400) + + return () => { + window.clearTimeout(timer) + registerDemoTourContext(null) + } + }, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId]) + + const toggleLanguage = () => { + const nextLang = i18n.language.startsWith('de') ? 'en' : 'de' + i18n.changeLanguage(nextLang) + } + + const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture + + return ( +
+
+ +
+
+ +
+
+

{title}

+ {t('demo.badge')} +
+

+ + {t('demo.public_banner')} +

+
+
+ +
+ + +
+
+ +
+ + +
+ {activeTab === 'logs' && ( + + )} + + {activeTab === 'vessel' && ( + + )} + + {activeTab === 'crew' && ( + + )} +
+
+
+ ) +} diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 931df14..fd113c0 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -426,7 +426,12 @@ export default function LogEntryEditor({ const loadTrack = async () => { if (readOnly && preloadedTrack) { - setSavedTrack(preloadedTrack) + setSavedTrack({ + waypoints: preloadedTrack.waypoints ?? [], + gpxContent: preloadedTrack.gpxContent ?? '', + filename: preloadedTrack.filename ?? 'track.gpx', + fileType: preloadedTrack.fileType ?? 'gpx' + }) return } try { @@ -1367,7 +1372,7 @@ export default function LogEntryEditor({ {savedTrack.filename || 'track'} - {savedTrack.fileType.toUpperCase()} + {(savedTrack.fileType ?? 'gpx').toUpperCase()} {savedTrack.waypoints.length > 0 && ( <> · {savedTrack.waypoints.length} {t('logs.track_upload_points')} )} diff --git a/client/src/context/AppTourContext.tsx b/client/src/context/AppTourContext.tsx index 4181f52..ef1f8ad 100644 --- a/client/src/context/AppTourContext.tsx +++ b/client/src/context/AppTourContext.tsx @@ -11,7 +11,8 @@ import { import { clearTourCompleted, isTourCompleted, - markTourCompleted + markTourCompleted, + resolveTourUserId } from '../services/appTourStorage.js' import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' @@ -33,18 +34,24 @@ interface TourNavigation { setSelectedEntryId: (entryId: string | null) => void } +interface DemoTourContext { + firstEntryId: string +} + interface AppTourContextValue { isActive: boolean + isDemoTour: boolean currentStepId: TourStepId | null currentStepIndex: number totalSteps: number - startTour: (options?: { force?: boolean }) => void + startTour: (options?: { force?: boolean; demoMode?: boolean }) => void stopTour: () => void restartTour: () => void nextStep: () => void prevStep: () => void skipTour: () => void registerNavigation: (navigation: TourNavigation) => void + registerDemoTourContext: (context: DemoTourContext | null) => void requestStartAfterLogin: () => void } @@ -74,10 +81,17 @@ export function AppTourProvider({ children }: { children: ReactNode }) { const [isActive, setIsActive] = useState(false) const [stepIndex, setStepIndex] = useState(0) const [pendingAfterLogin, setPendingAfterLogin] = useState(false) + const [isDemoTour, setIsDemoTour] = useState(false) const navigationRef = useRef(null) + const demoContextRef = useRef(null) + const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false }) const currentStepId = isActive ? STEP_ORDER[stepIndex] ?? null : null + const resolveFirstEntryId = useCallback((): string | null => { + return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId() + }, []) + const applyStepSideEffects = useCallback((stepId: TourStepId) => { const nav = navigationRef.current if (!nav) return @@ -86,7 +100,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) { nav.setActiveTab('logs') } if (stepId === 'entry_open' || stepId === 'entry_track') { - const firstEntryId = getStoredDemoFirstEntryId() + const firstEntryId = resolveFirstEntryId() if (firstEntryId) nav.setSelectedEntryId(firstEntryId) } if (stepId === 'nav_vessel') { @@ -97,7 +111,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) { nav.setSelectedEntryId(null) nav.setActiveTab('crew') } - }, []) + }, [resolveFirstEntryId]) const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => { if (!stepId) return @@ -109,24 +123,32 @@ export function AppTourProvider({ children }: { children: ReactNode }) { }) }, []) - const startTour = useCallback((options?: { force?: boolean }) => { - const userId = localStorage.getItem('active_userid') + const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => { + const demoMode = options?.demoMode === true + const userId = resolveTourUserId({ demoMode }) if (!userId) return if (!options?.force && isTourCompleted(userId)) return + tourModeRef.current = { demoMode } + setIsDemoTour(demoMode) setStepIndex(0) setIsActive(true) }, []) const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => { - const userId = localStorage.getItem('active_userid') + const userId = resolveTourUserId({ demoMode: tourModeRef.current.demoMode }) if (userId) markTourCompleted(userId) + + const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined if (outcome === 'completed') { - trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED) + trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps) } else { const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome' - trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step }) + trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps }) } + + tourModeRef.current = { demoMode: false } + setIsDemoTour(false) setIsActive(false) setStepIndex(0) }, []) @@ -170,6 +192,10 @@ export function AppTourProvider({ children }: { children: ReactNode }) { navigationRef.current = navigation }, []) + const registerDemoTourContext = useCallback((context: DemoTourContext | null) => { + demoContextRef.current = context + }, []) + const requestStartAfterLogin = useCallback(() => { setPendingAfterLogin(true) }, []) @@ -191,6 +217,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ isActive, + isDemoTour, currentStepId, currentStepIndex: stepIndex, totalSteps: STEP_ORDER.length, @@ -201,13 +228,16 @@ export function AppTourProvider({ children }: { children: ReactNode }) { prevStep, skipTour, registerNavigation, + registerDemoTourContext, requestStartAfterLogin }), [ currentStepId, isActive, + isDemoTour, nextStep, prevStep, + registerDemoTourContext, registerNavigation, requestStartAfterLogin, restartTour, @@ -231,8 +261,15 @@ export function useAppTour(): AppTourContextValue { export function getTourStepCopy( stepId: TourStepId, - t: (key: string) => string + t: (key: string) => string, + options?: { demoMode?: boolean } ): { title: string; body: string } { + if (stepId === 'welcome' && options?.demoMode) { + return { + title: t('tour.steps.welcome_public.title'), + body: t('tour.steps.welcome_public.body') + } + } return { title: t(`tour.steps.${stepId}.title`), body: t(`tour.steps.${stepId}.body`) diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 08b539e..d8d61c3 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -38,6 +38,7 @@ "error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.", "error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfen Sie Ihren Wiederherstellungsschlüssel.", "or_register": "oder Registrieren", + "explore_demo": "Demo ohne Account erkunden", "username_placeholder": "Benutzername / Skippername", "processing": "Verarbeitung...", "help": "Hilfe", @@ -383,7 +384,10 @@ }, "demo": { "logbook_title": "Demo-Logbuch Ostsee", - "badge": "Demo" + "badge": "Demo", + "public_banner": "Schreibgeschützte Demo-Ansicht", + "cta_register": "Account erstellen", + "back_to_login": "Zur Anmeldung" }, "stats": { "title": "Statistik", @@ -429,6 +433,10 @@ "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." }, + "welcome_public": { + "title": "Willkommen an Bord!", + "body": "Erkunden Sie unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese kurze Tour zeigt Ihnen Schiffsdaten, Crew und Logbucheinträge." + }, "nav_logs": { "title": "Logbucheinträge", "body": "Hier verwalten Sie Ihre Reisetage – Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks." diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index b0f52ca..fc4b7ab 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -38,6 +38,7 @@ "error_incorrect_recovery": "Incorrect recovery phrase. Decryption failed.", "error_decryption_failed": "Decryption failed. Please check your recovery phrase.", "or_register": "or register", + "explore_demo": "Explore demo without account", "username_placeholder": "Username / Skipper Name", "processing": "Processing...", "help": "Help", @@ -383,7 +384,10 @@ }, "demo": { "logbook_title": "Baltic Sea Demo Logbook", - "badge": "Demo" + "badge": "Demo", + "public_banner": "Read-only demo view", + "cta_register": "Create account", + "back_to_login": "Back to login" }, "stats": { "title": "Statistics", @@ -429,6 +433,10 @@ "title": "Welcome aboard!", "body": "We created a demo logbook with three travel days in Kiel Bay. This short tour shows you the key features." }, + "welcome_public": { + "title": "Welcome aboard!", + "body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries." + }, "nav_logs": { "title": "Log entries", "body": "Manage your travel days here – departure, destination, weather, tank levels, and GPS tracks." diff --git a/client/src/services/analytics.ts b/client/src/services/analytics.ts index 0c29c52..5e59797 100644 --- a/client/src/services/analytics.ts +++ b/client/src/services/analytics.ts @@ -19,7 +19,8 @@ export const PlausibleEvents = { CSV_SHARED: 'CSV Shared', PHOTO_UPLOADED: 'Photo Uploaded', BACKUP_EXPORTED: 'Backup Exported', - BACKUP_RESTORED: 'Backup Restored' + BACKUP_RESTORED: 'Backup Restored', + DEMO_OPENED: 'Demo Opened' } as const export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] diff --git a/client/src/services/appTourStorage.ts b/client/src/services/appTourStorage.ts index 03c3c06..827b748 100644 --- a/client/src/services/appTourStorage.ts +++ b/client/src/services/appTourStorage.ts @@ -1,3 +1,5 @@ +export const PUBLIC_DEMO_TOUR_USER_ID = '__public_demo__' + export function getTourCompletedKey(userId: string): string { return `app_tour_completed_${userId}` } @@ -14,3 +16,10 @@ export function markTourCompleted(userId: string): void { export function clearTourCompleted(userId: string): void { localStorage.removeItem(getTourCompletedKey(userId)) } + +export function resolveTourUserId(options?: { demoMode?: boolean }): string | null { + const activeUserId = localStorage.getItem('active_userid') + if (activeUserId) return activeUserId + if (options?.demoMode) return PUBLIC_DEMO_TOUR_USER_ID + return null +} diff --git a/client/src/services/demoLogbook.ts b/client/src/services/demoLogbook.ts index 6efcfd8..b80e67d 100644 --- a/client/src/services/demoLogbook.ts +++ b/client/src/services/demoLogbook.ts @@ -3,14 +3,13 @@ import { db } from './db.js' import { getActiveMasterKey } from './auth.js' import { getLogbookKey } from './logbookKeys.js' import { encryptJson } from './crypto.js' -import { parseTrackFile } from './trackUpload.js' import { syncLogbook } from './sync.js' -import { computeTrackStats } from '../utils/trackStats.js' import i18n from '../i18n/index.js' - -import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw' -import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw' -import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw' +import { + buildDemoCrewRecords, + buildDemoEntryPayloads, + buildDemoYachtData +} from './demoLogbookData.js' export const SEED_DEMO_FLAG = 'seed_demo_logbook' @@ -22,120 +21,6 @@ export function getDemoFirstEntryStorageKey(userId: string): string { return `demo_first_entry_id_${userId}` } -interface DemoDaySpec { - date: string - dayOfTravel: string - departure: string - destination: string - gpx: string - filename: string - freshwater: { morning: number; refilled: number; evening: number; consumption: number } - fuel: { morning: number; refilled: number; evening: number; consumption: number } - events: Array> -} - -function buildDemoDays(): DemoDaySpec[] { - const isDe = i18n.language.startsWith('de') - return [ - { - date: '2026-05-29', - dayOfTravel: '1', - departure: isDe ? 'Kiel' : 'Kiel', - destination: isDe ? 'Laboe' : 'Laboe', - gpx: kielLaboeGpx, - filename: 'kiel-laboe.gpx', - freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 }, - fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 }, - events: [ - { - time: '10:15', - mgk: '042', - rwk: '038', - windDirection: isDe ? 'NW' : 'NW', - windStrength: '4 Bft', - seaState: isDe ? 'leicht bewegt' : 'slight', - sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', - remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie' - }, - { - time: '11:20', - mgk: '030', - rwk: '028', - windDirection: 'N', - windStrength: '3 Bft', - seaState: isDe ? 'ruhig' : 'calm', - sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', - remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe' - } - ] - }, - { - date: '2026-05-30', - dayOfTravel: '2', - departure: 'Laboe', - destination: 'Damp', - gpx: laboeDampGpx, - filename: 'laboe-damp.gpx', - freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 }, - fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 }, - events: [ - { - time: '09:00', - mgk: '055', - rwk: '050', - windDirection: 'NE', - windStrength: '3 Bft', - seaState: isDe ? 'leicht bewegt' : 'slight', - sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', - remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe' - }, - { - time: '12:30', - mgk: '075', - rwk: '068', - windDirection: 'E', - windStrength: '4 Bft', - seaState: isDe ? 'mäßig bewegt' : 'moderate', - sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', - remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage' - } - ] - }, - { - date: '2026-05-31', - dayOfTravel: '3', - departure: 'Damp', - destination: isDe ? 'Schleimünde' : 'Schleimünde', - gpx: dampSchleimuendeGpx, - filename: 'damp-schleimuende.gpx', - freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 }, - fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 }, - events: [ - { - time: '08:30', - mgk: '290', - rwk: '285', - windDirection: 'W', - windStrength: '4 Bft', - seaState: isDe ? 'mäßig bewegt' : 'moderate', - sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', - remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei' - }, - { - time: '14:00', - mgk: '310', - rwk: '305', - windDirection: 'NW', - windStrength: '3 Bft', - seaState: isDe ? 'leicht bewegt' : 'slight', - sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', - remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde' - } - ] - } - ] -} - async function putEncryptedRecord( logbookId: string, key: ArrayBuffer, @@ -194,44 +79,12 @@ async function putEncryptedRecord( } async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise { - const isDe = i18n.language.startsWith('de') - const yachtData = { - name: isDe ? 'Seeadler' : 'Seeadler', - vesselType: isDe ? 'Segelyacht' : 'Sailing yacht', - lengthM: 12.5, - draftM: 1.9, - airDraftM: 18, - homePort: 'Kiel', - charterCompany: '', - owner: isDe ? 'Demo Skipper' : 'Demo Skipper', - registrationNumber: 'D-KI 1234', - callSign: 'DA1234', - atis: '', - mmsi: '', - sails: isDe - ? ['Großsegel', 'Genua', 'Spinnaker'] - : ['Mainsail', 'Genoa', 'Spinnaker'], - photo: null - } - + const yachtData = buildDemoYachtData() await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now) - const crewId = crypto.randomUUID() - const crewData = { - name: isDe ? 'Anna Müller' : 'Anna Müller', - address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel', - birthDate: '1988-04-12', - phone: '+49 431 123456', - nationality: isDe ? 'Deutsch' : 'German', - passportNumber: 'C01X00T47', - bloodType: 'A+', - allergies: '', - diseases: '', - role: 'crew', - photo: null + for (const crew of buildDemoCrewRecords()) { + await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now) } - - await putEncryptedRecord(logbookId, key, 'crew', crewId, crewData, now) } export interface DemoSeedResult { @@ -273,42 +126,12 @@ export async function seedDemoLogbookIfNeeded(): Promise const now = new Date().toISOString() await seedYachtAndCrew(logbookId, key, now) - const days = buildDemoDays() + const entryPayloads = buildDemoEntryPayloads() let firstEntryId = '' - for (const day of days) { - const entryId = crypto.randomUUID() + for (const { entryId, entryPayload, trackData } of entryPayloads) { if (!firstEntryId) firstEntryId = entryId - - const { waypoints } = parseTrackFile(day.gpx, day.filename) - const stats = computeTrackStats(waypoints) - - const entryPayload: Record = { - date: day.date, - dayOfTravel: day.dayOfTravel, - departure: day.departure, - destination: day.destination, - freshwater: { ...day.freshwater }, - fuel: { ...day.fuel }, - signSkipper: '', - signCrew: '', - events: day.events - } - - if (stats) { - entryPayload.trackDistanceNm = stats.distanceNm - entryPayload.trackSpeedMaxKn = stats.speedMaxKn - entryPayload.trackSpeedAvgKn = stats.speedAvgKn - } - await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now) - - const trackData = { - waypoints, - gpxContent: day.gpx, - filename: day.filename, - fileType: 'gpx' - } await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now) } diff --git a/client/src/services/demoLogbookData.ts b/client/src/services/demoLogbookData.ts new file mode 100644 index 0000000..7309b14 --- /dev/null +++ b/client/src/services/demoLogbookData.ts @@ -0,0 +1,318 @@ +import { parseTrackFile } from './trackUpload.js' +import { computeTrackStats } from '../utils/trackStats.js' +import i18n from '../i18n/index.js' + +import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw' +import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw' +import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw' + +/** Stable ID for the first demo travel day (public demo tour highlight). */ +export const PUBLIC_DEMO_FIRST_ENTRY_ID = 'a0000001-0000-4000-8000-000000000001' + +const PUBLIC_DEMO_ENTRY_IDS = [ + PUBLIC_DEMO_FIRST_ENTRY_ID, + 'a0000001-0000-4000-8000-000000000002', + 'a0000001-0000-4000-8000-000000000003' +] as const + +const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010' + +export interface DemoDaySpec { + date: string + dayOfTravel: string + departure: string + destination: string + gpx: string + filename: string + freshwater: { morning: number; refilled: number; evening: number; consumption: number } + fuel: { morning: number; refilled: number; evening: number; consumption: number } + events: Array> +} + +export interface DemoCrewRecord { + payloadId: string + data: { + name: string + address: string + birthDate: string + phone: string + nationality: string + passportNumber: string + bloodType: string + allergies: string + diseases: string + role: 'skipper' | 'crew' + photo: string | null + } +} + +export interface PublicDemoFixture { + title: string + yacht: Record + crews: DemoCrewRecord[] + entries: Array & { payloadId: string }> + gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string }> + photos: never[] + firstEntryId: string +} + +export function buildDemoDays(): DemoDaySpec[] { + const isDe = i18n.language.startsWith('de') + return [ + { + date: '2026-05-29', + dayOfTravel: '1', + departure: 'Kiel', + destination: 'Laboe', + gpx: kielLaboeGpx, + filename: 'kiel-laboe.gpx', + freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 }, + fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 }, + events: [ + { + time: '10:15', + mgk: '042', + rwk: '038', + windDirection: 'NW', + windStrength: '4 Bft', + seaState: isDe ? 'leicht bewegt' : 'slight', + sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', + remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie' + }, + { + time: '11:20', + mgk: '030', + rwk: '028', + windDirection: 'N', + windStrength: '3 Bft', + seaState: isDe ? 'ruhig' : 'calm', + sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', + remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe' + } + ] + }, + { + date: '2026-05-30', + dayOfTravel: '2', + departure: 'Laboe', + destination: 'Damp', + gpx: laboeDampGpx, + filename: 'laboe-damp.gpx', + freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 }, + fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 }, + events: [ + { + time: '09:00', + mgk: '055', + rwk: '050', + windDirection: 'NE', + windStrength: '3 Bft', + seaState: isDe ? 'leicht bewegt' : 'slight', + sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', + remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe' + }, + { + time: '12:30', + mgk: '075', + rwk: '068', + windDirection: 'E', + windStrength: '4 Bft', + seaState: isDe ? 'mäßig bewegt' : 'moderate', + sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', + remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage' + } + ] + }, + { + date: '2026-05-31', + dayOfTravel: '3', + departure: 'Damp', + destination: 'Schleimünde', + gpx: dampSchleimuendeGpx, + filename: 'damp-schleimuende.gpx', + freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 }, + fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 }, + events: [ + { + time: '08:30', + mgk: '290', + rwk: '285', + windDirection: 'W', + windStrength: '4 Bft', + seaState: isDe ? 'mäßig bewegt' : 'moderate', + sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', + remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei' + }, + { + time: '14:00', + mgk: '310', + rwk: '305', + windDirection: 'NW', + windStrength: '3 Bft', + seaState: isDe ? 'leicht bewegt' : 'slight', + sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa', + remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde' + } + ] + } + ] +} + +export function buildDemoYachtData(): Record { + const isDe = i18n.language.startsWith('de') + return { + name: 'Seeadler', + vesselType: isDe ? 'Segelyacht' : 'Sailing yacht', + lengthM: 12.5, + draftM: 1.9, + airDraftM: 18, + homePort: 'Kiel', + charterCompany: '', + owner: 'Demo Skipper', + registrationNumber: 'D-KI 1234', + callSign: 'DA1234', + atis: '', + mmsi: '', + sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'], + photo: null + } +} + +export function buildDemoCrewRecords(): DemoCrewRecord[] { + const isDe = i18n.language.startsWith('de') + return [ + { + payloadId: 'skipper', + data: { + name: 'Demo Skipper', + address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel', + birthDate: '1980-06-15', + phone: '+49 431 987654', + nationality: isDe ? 'Deutsch' : 'German', + passportNumber: 'C12X34Y56', + bloodType: '0+', + allergies: '', + diseases: '', + role: 'skipper', + photo: null + } + }, + { + payloadId: PUBLIC_DEMO_CREW_MEMBER_ID, + data: { + name: 'Anna Müller', + address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel', + birthDate: '1988-04-12', + phone: '+49 431 123456', + nationality: isDe ? 'Deutsch' : 'German', + passportNumber: 'C01X00T47', + bloodType: 'A+', + allergies: '', + diseases: '', + role: 'crew', + photo: null + } + } + ] +} + +export function buildPublicDemoFixture(): PublicDemoFixture { + const title = i18n.t('demo.logbook_title') + const yacht = buildDemoYachtData() + const crews = buildDemoCrewRecords() + const days = buildDemoDays() + const entries: PublicDemoFixture['entries'] = [] + const gpsTracks: PublicDemoFixture['gpsTracks'] = [] + + days.forEach((day, index) => { + const entryId = PUBLIC_DEMO_ENTRY_IDS[index] ?? crypto.randomUUID() + const { waypoints } = parseTrackFile(day.gpx, day.filename) + const stats = computeTrackStats(waypoints) + + const entryPayload: Record = { + payloadId: entryId, + date: day.date, + dayOfTravel: day.dayOfTravel, + departure: day.departure, + destination: day.destination, + freshwater: { ...day.freshwater }, + fuel: { ...day.fuel }, + signSkipper: '', + signCrew: '', + events: day.events + } + + if (stats) { + entryPayload.trackDistanceNm = stats.distanceNm + entryPayload.trackSpeedMaxKn = stats.speedMaxKn + entryPayload.trackSpeedAvgKn = stats.speedAvgKn + } + + entries.push(entryPayload as PublicDemoFixture['entries'][number]) + + gpsTracks.push({ + entryId, + waypoints, + filename: day.filename, + gpxContent: day.gpx, + fileType: 'gpx' + }) + }) + + return { + title, + yacht, + crews, + entries, + gpsTracks, + photos: [], + firstEntryId: PUBLIC_DEMO_FIRST_ENTRY_ID + } +} + +export function getPublicDemoFirstEntryId(): string { + return PUBLIC_DEMO_FIRST_ENTRY_ID +} + +/** Payloads for encrypted seeding (without payloadId on entries). */ +export function buildDemoEntryPayloads(): Array<{ + entryId: string + entryPayload: Record + trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string } +}> { + const days = buildDemoDays() + return days.map((day) => { + const entryId = crypto.randomUUID() + const { waypoints } = parseTrackFile(day.gpx, day.filename) + const stats = computeTrackStats(waypoints) + + const entryPayload: Record = { + date: day.date, + dayOfTravel: day.dayOfTravel, + departure: day.departure, + destination: day.destination, + freshwater: { ...day.freshwater }, + fuel: { ...day.fuel }, + signSkipper: '', + signCrew: '', + events: day.events + } + + if (stats) { + entryPayload.trackDistanceNm = stats.distanceNm + entryPayload.trackSpeedMaxKn = stats.speedMaxKn + entryPayload.trackSpeedAvgKn = stats.speedAvgKn + } + + return { + entryId, + entryPayload, + trackData: { + waypoints, + gpxContent: day.gpx, + filename: day.filename, + fileType: 'gpx' + } + } + }) +} diff --git a/docs/plausible-events.md b/docs/plausible-events.md index 850661a..ac154ea 100644 --- a/docs/plausible-events.md +++ b/docs/plausible-events.md @@ -24,8 +24,9 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri | Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — | | Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` | | Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — | -| Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | — | -| Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`) | +| Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | `mode`: `demo` (optional, bei Public-Demo) | +| Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`), optional `mode`: `demo` | +| Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — | | Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — | | Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — | | PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |