diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 88b08d0b..4363b4e3 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -33,6 +33,8 @@ interface Props { dark?: boolean activeMarkerId?: string | null onMarkerClick?: (id: string, type?: string) => void + fullScreen?: boolean + paddingBottom?: number } function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] { @@ -57,15 +59,20 @@ const MARKER_W = 28 const MARKER_H = 36 function markerSvg(index: number, highlighted: boolean, dark: boolean): string { + // Highlighted: inverted colors for contrast (black on light, white on dark) const fill = dark - ? (highlighted ? '#FAFAFA' : '#FAFAFA') - : (highlighted ? '#18181B' : '#18181B') + ? (highlighted ? '#FAFAFA' : '#A1A1AA') + : (highlighted ? '#18181B' : '#52525B') const textColor = dark ? (highlighted ? '#18181B' : '#18181B') : (highlighted ? '#fff' : '#fff') - const stroke = dark ? '#3F3F46' : '#fff' + const stroke = highlighted + ? (dark ? '#fff' : '#18181B') + : (dark ? '#3F3F46' : '#fff') const shadow = highlighted - ? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))' + ? (dark + ? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' + : 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))') : 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))' const label = String(index + 1) const scale = highlighted ? 1.2 : 1 @@ -82,7 +89,7 @@ function markerSvg(index: number, highlighted: boolean, dark: boolean): string { const EMPTY_TRAIL: { lat: number; lng: number }[] = [] const JourneyMap = forwardRef(function JourneyMap( - { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick }, + { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom }, ref ) { const stableTrail = trail || EMPTY_TRAIL @@ -138,7 +145,9 @@ const JourneyMap = forwardRef(function JourneyMap( highlightMarker(id) const marker = markersRef.current.get(id) if (marker && mapRef.current) { - mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 }) + try { + mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 }) + } catch { /* map not yet initialized */ } } }, []) @@ -156,7 +165,7 @@ const JourneyMap = forwardRef(function JourneyMap( const map = L.map(containerRef.current, { zoomControl: false, attributionControl: true, - scrollWheelZoom: false, + scrollWheelZoom: fullScreen ? true : false, dragging: true, touchZoom: true, }) @@ -185,8 +194,8 @@ const JourneyMap = forwardRef(function JourneyMap( coords.forEach(c => allCoords.push(c)) } - // route polyline — subtle dashed connection - if (items.length > 1) { + // route polyline — only in non-fullscreen (sidebar map) mode + if (!fullScreen && items.length > 1) { const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple) L.polyline(routeCoords, { color: dark ? '#71717A' : '#A1A1AA', @@ -229,7 +238,8 @@ const JourneyMap = forwardRef(function JourneyMap( try { map.invalidateSize() if (allCoords.length > 0) { - map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 }) + const pb = paddingBottom || 50 + map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 }) } else { map.setView([30, 0], 2) } @@ -245,7 +255,7 @@ const JourneyMap = forwardRef(function JourneyMap( mapRef.current = null markersRef.current.clear() } - }, [entries, stableTrail, dark, mapTileUrl]) + }, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom]) // react to activeMarkerId prop changes — runs after map is built useEffect(() => { diff --git a/client/src/components/Journey/MobileEntryCard.tsx b/client/src/components/Journey/MobileEntryCard.tsx new file mode 100644 index 00000000..0f29f87e --- /dev/null +++ b/client/src/components/Journey/MobileEntryCard.tsx @@ -0,0 +1,154 @@ +import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react' +import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' + +const MOOD_ICONS: Record = { + amazing: Laugh, + good: Smile, + neutral: Meh, + rough: Frown, +} + +const MOOD_COLORS: Record = { + amazing: 'text-pink-500', + good: 'text-amber-500', + neutral: 'text-zinc-400', + rough: 'text-violet-500', +} + +const WEATHER_ICONS: Record = { + sunny: Sun, + partly: CloudSun, + cloudy: Cloud, + rainy: CloudRain, + stormy: CloudLightning, + cold: Snowflake, +} + +function photoUrl(p: JourneyPhoto): string { + return `/api/photos/${p.photo_id}/thumbnail` +} + +function stripMarkdown(text: string): string { + return text + .replace(/[#*_~`>\[\]()!|-]/g, '') + .replace(/\n+/g, ' ') + .trim() +} + +interface Props { + entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null } + index: number + isActive: boolean + onClick: () => void + publicPhotoUrl?: (photoId: number) => string +} + +export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) { + const hasLocation = !!(entry.location_lat && entry.location_lng) + const hasPhotos = entry.photos && entry.photos.length > 0 + const firstPhoto = hasPhotos ? entry.photos![0] : null + const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null + const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : '' + const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null + + const thumbSrc = firstPhoto + ? publicPhotoUrl + ? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id) + : photoUrl(firstPhoto as JourneyPhoto) + : null + + const date = new Date(entry.entry_date + 'T00:00:00') + const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + + const storyPreview = entry.story ? stripMarkdown(entry.story) : '' + + return ( + + ) +} diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx new file mode 100644 index 00000000..0062d96e --- /dev/null +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -0,0 +1,218 @@ +import { useState } from 'react' +import { + X, Pencil, Trash2, MapPin, Clock, Camera, + Laugh, Smile, Meh, Frown, + Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, + ThumbsUp, ThumbsDown, ChevronDown, +} from 'lucide-react' +import JournalBody from './JournalBody' +import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' + +const MOOD_CONFIG: Record = { + amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, + good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, + neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' }, + rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' }, +} + +const WEATHER_CONFIG: Record = { + sunny: { icon: Sun, label: 'Sunny' }, + partly: { icon: CloudSun, label: 'Partly cloudy' }, + cloudy: { icon: Cloud, label: 'Cloudy' }, + rainy: { icon: CloudRain, label: 'Rainy' }, + stormy: { icon: CloudLightning, label: 'Stormy' }, + cold: { icon: Snowflake, label: 'Cold' }, +} + +function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string { + return `/api/photos/${p.photo_id}/${size}` +} + +interface Props { + entry: JourneyEntry + onClose: () => void + onEdit: () => void + onDelete: () => void + onPhotoClick: (photos: JourneyPhoto[], index: number) => void +} + +export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) { + const photos = entry.photos || [] + const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null + const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null + const prosArr = entry.pros_cons?.pros ?? [] + const consArr = entry.pros_cons?.cons ?? [] + const hasProscons = prosArr.length > 0 || consArr.length > 0 + + const date = new Date(entry.entry_date + 'T00:00:00') + const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' }) + + return ( +
+ {/* Top bar */} +
+ +
+ + +
+
+ + {/* Scrollable content */} +
+ + {/* Hero photo(s) */} + {photos.length > 0 && ( +
+ onPhotoClick(photos, 0)} + /> + {photos.length > 1 && ( +
+ + {photos.length} photos +
+ )} + {/* Photo strip for multiple photos */} + {photos.length > 1 && ( +
+ {photos.map((p, i) => ( + onPhotoClick(photos, i)} + /> + ))} +
+ )} +
+ )} + + {/* Content */} +
+ + {/* Date + time + location header */} +
+ {dateStr} + {entry.entry_time && ( + + + {entry.entry_time.slice(0, 5)} + + )} +
+ + {entry.location_name && ( +
+ + + {entry.location_name} + +
+ )} + + {/* Title */} + {entry.title && ( +

+ {entry.title} +

+ )} + + {/* Mood + Weather chips */} + {(mood || weather) && ( +
+ {mood && ( + + + {mood.label} + + )} + {weather && ( + + + {weather.label} + + )} +
+ )} + + {/* Story */} + {entry.story && ( +
+ +
+ )} + + {/* Tags */} + {entry.tags && entry.tags.length > 0 && ( +
+ {entry.tags.map((tag, i) => ( + + {tag} + + ))} +
+ )} + + {/* Pros & Cons */} + {hasProscons && ( +
+ {prosArr.length > 0 && ( +
+
+ Pros +
+
    + {prosArr.map((p, i) => ( +
  • + + {p} +
  • + ))} +
+
+ )} + {prosArr.length > 0 && consArr.length > 0 && ( +
+ )} + {consArr.length > 0 && ( +
+
+ Cons +
+
    + {consArr.map((c, i) => ( +
  • + {c} +
  • + ))} +
+
+ )} +
+ )} +
+
+
+ ) +} diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx new file mode 100644 index 00000000..a0f64df4 --- /dev/null +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -0,0 +1,194 @@ +import { useRef, useState, useEffect, useCallback } from 'react' +import { Plus } from 'lucide-react' +import JourneyMap from './JourneyMap' +import MobileEntryCard from './MobileEntryCard' +import type { JourneyMapHandle } from './JourneyMap' +import type { JourneyEntry } from '../../store/journeyStore' + +interface MapEntry { + id: string + lat: number + lng: number + title?: string | null + mood?: string | null + entry_date: string +} + +interface Props { + entries: JourneyEntry[] | any[] + mapEntries: MapEntry[] + trail?: { lat: number; lng: number }[] + dark?: boolean + readOnly?: boolean + onEntryClick: (entry: any) => void + onAddEntry?: () => void + publicPhotoUrl?: (photoId: number) => string +} + +export default function MobileMapTimeline({ + entries, + mapEntries, + trail, + dark, + readOnly, + onEntryClick, + onAddEntry, + publicPhotoUrl, +}: Props) { + const mapRef = useRef(null) + const carouselRef = useRef(null) + const [activeIndex, setActiveIndex] = useState(0) + const cardRefs = useRef>(new Map()) + + // Sync map focus when carousel scrolls (with guard for uninitialized map) + const syncMapToCarousel = useCallback((index: number) => { + const entry = entries[index] + if (!entry) return + + const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id)) + if (mapEntry) { + try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {} + } else { + try { mapRef.current?.highlightMarker(null) } catch {} + } + }, [entries, mapEntries]) + + // IntersectionObserver for instant snap detection + useEffect(() => { + const el = carouselRef.current + if (!el || entries.length === 0) return + + const observer = new IntersectionObserver( + (observed) => { + for (const o of observed) { + if (o.isIntersecting) { + const idx = Number(o.target.getAttribute('data-idx')) + if (!isNaN(idx)) { + setActiveIndex(idx) + syncMapToCarousel(idx) + } + } + } + }, + { root: el, threshold: 0.6 }, + ) + + cardRefs.current.forEach(node => observer.observe(node)) + return () => observer.disconnect() + }, [entries.length, syncMapToCarousel]) + + // Scroll carousel to entry when map marker is clicked + const handleMarkerClick = useCallback((id: string) => { + const idx = entries.findIndex((e: any) => String(e.id) === id) + if (idx === -1) return + setActiveIndex(idx) + + const el = carouselRef.current + if (!el) return + const cardWidth = 272 + el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' }) + }, [entries]) + + // Initial map focus — delay to let Leaflet initialize and fitBounds + useEffect(() => { + if (entries.length > 0) { + const timer = setTimeout(() => syncMapToCarousel(0), 500) + return () => clearTimeout(timer) + } + }, [entries.length]) + + const activeEntryId = entries[activeIndex] + ? String(entries[activeIndex].id) + : null + + if (entries.length === 0) { + return ( +
+ + {!readOnly && onAddEntry && ( +
+ +
+ )} +
+ ) + } + + return ( +
+ {/* Full-screen map */} + + + {/* Bottom carousel */} +
+
+ {entries.map((entry: any, i: number) => ( +
{ if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }} + style={{ scrollSnapAlign: 'center' }} + > + onEntryClick(entry)} + publicPhotoUrl={publicPhotoUrl} + /> +
+ ))} +
+
+ + {/* FAB: add entry — top right */} + {!readOnly && onAddEntry && ( +
+ +
+ )} +
+ ) +} diff --git a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx index 9607f48e..f7b030c3 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx @@ -112,8 +112,9 @@ describe('ModalRenderer', () => { }); it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => { - const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } }); - const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' }); + // CTA is only shown on the last page; navigate there first + const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' }); + const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } }); useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true }); const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss'); @@ -121,6 +122,12 @@ describe('ModalRenderer', () => { await flushGraceDelay(); + // Navigate to last page + await act(async () => { + fireEvent.click(screen.getByLabelText('Go to notice 2')); + }); + await flushGraceDelay(); + const ctaBtn = screen.getByRole('button', { name: 'Go to trips' }); await act(async () => { fireEvent.click(ctaBtn); @@ -299,17 +306,22 @@ describe('ModalRenderer', () => { expect(screen.getByText('Notice A')).toBeTruthy(); expect(screen.getByText('1 / 3')).toBeTruthy(); - // Dismiss notice A — store shrinks, parent re-renders with [B, C] + // Navigate to last page where X button is available await act(async () => { - fireEvent.click(screen.getByLabelText('Dismiss')); - useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true }); - rerender(); + fireEvent.click(screen.getByLabelText('Go to notice 3')); }); await flushGraceDelay(); - // Must show B (idx=0), not C (idx=1 — the old buggy behavior) - expect(screen.getByText('Notice B')).toBeTruthy(); - expect(screen.getByText('1 / 2')).toBeTruthy(); + // Dismiss all from last page — store shrinks + await act(async () => { + fireEvent.click(screen.getByLabelText('Dismiss')); + useSystemNoticeStore.setState({ notices: [], loaded: true }); + rerender(); + }); + await flushGraceDelay(); + + // All dismissed — modal should be gone + expect(screen.queryByRole('dialog')).toBeNull(); }); it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => { @@ -321,6 +333,12 @@ describe('ModalRenderer', () => { render(); await flushGraceDelay(); + // X button only appears on the last page — navigate there + await act(async () => { + fireEvent.click(screen.getByLabelText('Go to notice 2')); + }); + await flushGraceDelay(); + await act(async () => { fireEvent.click(screen.getByLabelText('Dismiss')); }); @@ -330,7 +348,7 @@ describe('ModalRenderer', () => { expect(dismissSpy).toHaveBeenCalledTimes(2); }); - it('FE-SN-MODAL-018: ESC key dismisses all notices when current is dismissible', async () => { + it('FE-SN-MODAL-018: ESC key dismisses all notices when on last page', async () => { const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' }); const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' }); useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true }); @@ -339,6 +357,12 @@ describe('ModalRenderer', () => { render(); await flushGraceDelay(); + // ESC only works on last page — navigate there first + await act(async () => { + fireEvent.click(screen.getByLabelText('Go to notice 2')); + }); + await flushGraceDelay(); + await act(async () => { fireEvent.keyDown(document, { key: 'Escape' }); }); diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index 10d0a13f..e010bc24 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -62,6 +62,7 @@ interface ContentProps { function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) { const { t } = useTranslation(); + const isLastPage = total <= 1 || currentPage === total - 1; const DefaultIcon = SEVERITY_ICONS[notice.severity] ?? Info; const LucideIcon: React.ElementType = notice.icon @@ -70,8 +71,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, return (
- {/* Dismiss X button */} - {notice.dismissible && ( + {/* Dismiss X button — only on last page so users read all notices */} + {notice.dismissible && isLastPage && ( + ) : ctaLabel ? ( + +
+
+ )} + +
{/* Back link — desktop */} @@ -298,11 +353,17 @@ export default function JourneyDetailPage() { {/* View Controls */}
- {[ - { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, - { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, - { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, - ].map(v => ( + {(isMobile + ? [ + { id: 'timeline' as const, icon: MapPin, label: t('journey.detail.journeyTab') || 'Journey' }, + { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, + ] + : [ + { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, + { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, + { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, + ] + ).map(v => ( ))}
- {view === 'timeline' && ( + {(!isMobile ? view === 'timeline' : view !== 'gallery') && ( )}
- {/* Timeline */} - {view === 'timeline' && ( + {/* Timeline (desktop only — mobile uses fullscreen combined view above) */} + {!isMobile && view === 'timeline' && (
{sortedDates.length === 0 && (
@@ -398,8 +459,8 @@ export default function JourneyDetailPage() { /> )} - {/* Full Map View */} - {view === 'map' &&
-
+
+
+

{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}

@@ -2187,7 +2249,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
{pros.map((p, i) => ( -
+
{cons.map((c, i) => ( -
+
{locationLat && (
- +
)}
@@ -2332,8 +2394,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa const active = mood === key return ( @@ -2363,7 +2427,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
-
+
)} - {/* Timeline */} - {view === 'timeline' && perms.share_timeline && ( + {/* Mobile combined map+timeline (public, read-only) */} + {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( + ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))} + dark={document.documentElement.classList.contains('dark')} + readOnly + onEntryClick={() => {}} + publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} + /> + )} + + {/* Timeline (desktop, or mobile without map permission) */} + {(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
{sortedDates.map(date => { const dayEntries = groupedEntries.get(date)! diff --git a/server/src/systemNotices/registry.ts b/server/src/systemNotices/registry.ts index 4f6db7e8..ccf4f6c3 100644 --- a/server/src/systemNotices/registry.ts +++ b/server/src/systemNotices/registry.ts @@ -100,6 +100,20 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ priority: 70, }, + { + // Page 1 — personal thank-you from the creator (shown first) + id: 'v3-thankyou', + display: 'modal', + severity: 'info', + icon: 'Heart', + titleKey: 'system_notice.v3_thankyou.title', + bodyKey: 'system_notice.v3_thankyou.body', + dismissible: true, + conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }], + publishedAt: '2026-04-16T00:00:00Z', + priority: 95, + }, + // ── Onboarding ───────────────────────────────────────────────────────────── {