import React, { useEffect, useState } from 'react' import { useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' import DemoBanner from '../components/Layout/DemoBanner' import TripFormModal from '../components/Trips/TripFormModal' import ConfirmDialog from '../components/shared/ConfirmDialog' import CopyTripDialog from '../components/shared/CopyTripDialog' import CustomSelect from '../components/shared/CustomSelect' import PlaceAvatar from '../components/shared/PlaceAvatar' import MobileTopBar from '../components/Layout/MobileTopBar' import { useDashboard } from './dashboard/useDashboard' import { type DashboardTrip, type HeroBundle, type TravelStats, type UpcomingReservation, MS_PER_DAY, daysUntil, getTripStatus, } from './dashboard/dashboardModel' import { Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin, Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar, LayoutGrid, List, Ticket, X, } from 'lucide-react' import '../styles/dashboard.css' const GRADIENTS = [ 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)', 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)', 'linear-gradient(135deg, #96fbc4 0%, #f9f586 100%)', ] function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.length] } // Day + short month for the boarding pass / cards, e.g. { d: '10', m: 'Sep' }. function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null { if (!dateStr) return null const date = new Date(dateStr + 'T00:00:00Z') return { d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }), m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }), } } function buddyColor(seed: number): string { const pairs = [ ['#6366f1', '#8b5cf6'], ['#10b981', '#059669'], ['#f59e0b', '#d97706'], ['#ec4899', '#be185d'], ['#0ea5e9', '#2563eb'], ['#14b8a6', '#0d9488'], ] const [a, b] = pairs[seed % pairs.length] return `linear-gradient(135deg, ${a}, ${b})` } function initials(name: string | null | undefined): string { if (!name) return '?' const parts = name.trim().split(/\s+/) if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase() return name.slice(0, 2).toUpperCase() } const RES_ICON: Record = { flight: , hotel: , restaurant: , } const RES_TYPE_CLASS: Record = { flight: 'flight', hotel: 'hotel', restaurant: 'food' } // Mobile gets a different boarding-pass treatment (separate card under the hero). function useIsMobile(): boolean { const [mobile, setMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 720px)').matches) useEffect(() => { const mq = window.matchMedia('(max-width: 720px)') const onChange = () => setMobile(mq.matches) mq.addEventListener('change', onChange) return () => mq.removeEventListener('change', onChange) }, []) return mobile } export default function DashboardPage(): React.ReactElement { // Page = wiring container: all state, data loading and mutations live in the // useDashboard data hook; this component only renders what it returns. const { demoMode, locale, t, navigate, spotlight, heroBundle, stats, upcoming, gridTrips, isLoading, tripFilter, setTripFilter, viewMode, toggleViewMode, showForm, setShowForm, editingTrip, setEditingTrip, deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips, handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy, } = useDashboard() return ( <> {/* Navbar lives outside .trek-dash so it keeps the app-wide font + button styling instead of inheriting the dashboard scope's font and the `.trek-dash button` reset (which shifted the bell icon + menu items). */}
{demoMode && }
{spotlight && ( navigate(`/trips/${spotlight.id}`)} onEdit={() => { setEditingTrip(spotlight); setShowForm(true) }} onCopy={() => setCopyTrip(spotlight)} onArchive={() => spotlight.is_archived ? handleUnarchive(spotlight.id) : handleArchive(spotlight.id)} onDelete={() => setDeleteTrip(spotlight)} /> )}

{t('dashboard.title')}

{gridTrips.map(trip => ( navigate(`/trips/${trip.id}`)} onEdit={() => { setEditingTrip(trip); setShowForm(true) }} onCopy={() => setCopyTrip(trip)} onArchive={() => trip.is_archived ? handleUnarchive(trip.id) : handleArchive(trip.id)} onDelete={() => setDeleteTrip(trip)} /> ))} {tripFilter === 'planned' && !isLoading && ( )}
{showForm && ( { setShowForm(false); setEditingTrip(null) }} onSave={editingTrip ? handleUpdate : handleCreate} onCoverUpdate={(tripId, coverUrl) => setTrips(prev => prev.map(t => t.id === tripId ? { ...t, cover_image: coverUrl } : t))} /> )} {deleteTrip && ( setDeleteTrip(null)} danger /> )} {copyTrip && ( setCopyTrip(null)} /> )}
) } // ── Boarding-pass hero ─────────────────────────────────────────────────────── function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArchive, onDelete }: { trip: DashboardTrip; bundle: HeroBundle | null; locale: string; onOpen: () => void onEdit: () => void; onCopy: () => void; onArchive: () => void; onDelete: () => void }): React.ReactElement { const { t } = useTranslation() const mobile = useIsMobile() const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() } const status = getTripStatus(trip) const start = splitDate(trip.start_date, locale) const end = splitDate(trip.end_date, locale) // Countdown cell — plain text in the same style as the trip-dates cell: // days remaining while the trip runs, days until departure before it starts. const until = daysUntil(trip.start_date) const ongoing = status === 'ongoing' let countdownTop = '' let countdownNumber = '' let countdownLabel = '' if (ongoing && trip.end_date) { const todayMid = new Date(); todayMid.setHours(0, 0, 0, 0) const endMid = new Date(trip.end_date + 'T00:00:00') const daysLeft = Math.max(0, Math.round((endMid.getTime() - todayMid.getTime()) / MS_PER_DAY)) countdownTop = t('dashboard.status.ongoing') countdownNumber = String(daysLeft) countdownLabel = daysLeft === 0 ? t('dashboard.hero.lastDay') : daysLeft === 1 ? t('dashboard.hero.dayLeft') : t('dashboard.hero.daysLeft') } else if (until !== null && until >= 0) { countdownTop = t('dashboard.hero.startsIn') countdownNumber = String(until) countdownLabel = until === 1 ? t('dashboard.hero.dayUnitOne') : t('dashboard.hero.dayUnitMany') } const members = bundle?.members || [] const places = bundle?.places || [] const buddyCount = trip.shared_count != null ? trip.shared_count + 1 : members.length const placeCount = trip.place_count || places.length const badge = status === 'ongoing' ? t('dashboard.hero.badgeLive') : status === 'today' ? t('dashboard.hero.badgeToday') : status === 'tomorrow' ? t('dashboard.hero.badgeTomorrow') : status === 'future' ? t('dashboard.hero.badgeNext') : t('dashboard.hero.badgeRecent') const passCells = ( <>
{t('dashboard.members')}
{members.slice(0, 4).map((m, i) => ( m.avatar_url ? {m.username} :
{initials(m.username)}
))} {members.length > 4 &&
+{members.length - 4}
} {members.length === 0 &&
{initials(trip.owner_username)}
}
{buddyCount === 1 ? t('dashboard.hero.travelerOne', { count: buddyCount }) : t('dashboard.hero.travelerMany', { count: buddyCount })}
{t('dashboard.hero.tripDates')}
{start ?
{start.d}
{start.m}
:
}
{end ?
{end.d}
{end.m}
:
}
{countdownNumber && ( <>
{countdownTop}
{countdownNumber}
{countdownLabel}
)}
{t('dashboard.places')}
{places.slice(0, 3).map(p => (
))} {places.length === 0 &&
} {places.length > 3 &&
+{places.length - 3}
}
{placeCount === 1 ? t('dashboard.hero.destinationOne', { count: placeCount }) : t('dashboard.hero.destinationMany', { count: placeCount })}
) return ( <>
{trip.cover_image ? {trip.title} :
}
{status === 'ongoing' && } {badge}

{trip.title}

{!mobile && (
{ e.stopPropagation(); onOpen() }}>{passCells}
)}
{mobile &&
{passCells}
} ) } // ── Atlas / stats row ──────────────────────────────────────────────────────── function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElement { const { t } = useTranslation() const countries = stats?.countries || [] const distanceKm = stats?.totalDistanceKm || 0 const distanceText = distanceKm >= 1000 ? `${(distanceKm / 1000).toFixed(1)}k` : String(distanceKm) const equatorTimes = (distanceKm / 40075).toFixed(2) return (
{t('dashboard.atlas.countriesVisited')}
{countries.length} {t('dashboard.atlas.ofTotal', { total: 195 })}
{countries.slice(0, 5).map((c, i) => ( {c} ))} {countries.length > 5 && +{countries.length - 5}}
{t('dashboard.atlas.tripsTotal')}
{stats?.totalTrips ?? 0}
{t('dashboard.atlas.placesMapped', { count: stats?.totalPlaces ?? 0 })}
{t('dashboard.atlas.daysTraveled')}
{stats?.totalDays ?? 0} {t('dashboard.atlas.daysUnit')}
{t('dashboard.atlas.acrossAllTrips')}
{t('dashboard.atlas.distanceFlown')}
{distanceText} {t('dashboard.atlas.kmUnit')}
{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}
) } // ── Trip card ──────────────────────────────────────────────────────────────── function TripCard({ trip, locale, onOpen, onEdit, onCopy, onArchive, onDelete }: { trip: DashboardTrip; locale: string; onOpen: () => void onEdit: () => void; onCopy: () => void; onArchive: () => void; onDelete: () => void }): React.ReactElement { const { t } = useTranslation() const status = getTripStatus(trip) const start = splitDate(trip.start_date, locale) const end = splitDate(trip.end_date, locale) const until = daysUntil(trip.start_date) const statusClass = status === 'ongoing' ? '' : status === 'past' ? 'completed' : status === 'future' || status === 'today' || status === 'tomorrow' ? 'upcoming' : 'idea' const statusLabel = status === 'ongoing' ? t('dashboard.mobile.liveNow') : status === 'today' ? t('dashboard.status.today') : status === 'tomorrow' ? t('dashboard.status.tomorrow') : status === 'future' && until !== null ? (until > 60 ? t('dashboard.mobile.inMonths', { count: Math.round(until / 30) }) : t('dashboard.mobile.inDays', { count: until })) : status === 'past' ? t('dashboard.mobile.completed') : t('dashboard.card.idea') const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() } return (
{trip.cover_image ? {trip.title} :
}
{statusLabel}

{trip.title}

{start && end ? ( <> {start.m} {start.d} {end.m} {end.d} ) : {t('dashboard.hero.noDates')}}
{trip.day_count ?? 0}{t('dashboard.days')}
{trip.place_count ?? 0}{t('dashboard.places')}
{trip.shared_count ?? 0}{trip.shared_count === 1 ? t('dashboard.card.buddyOne') : t('dashboard.members')}
) } // ── Currency tool (self-contained, mirrors the design's fx widget) ─────────── const FX_FALLBACK = ['EUR', 'USD', 'GBP', 'CHF', 'JPY', 'CAD', 'AUD', 'CNY', 'SEK', 'NOK', 'DKK', 'PLN', 'CZK', 'HUF', 'TRY', 'THB', 'INR', 'BRL', 'MXN', 'ZAR'] function CurrencyTool(): React.ReactElement { const { t } = useTranslation() const [from, setFrom] = useState(() => localStorage.getItem('trek_fx_from') || 'EUR') const [to, setTo] = useState(() => localStorage.getItem('trek_fx_to') || 'USD') const [amount, setAmount] = useState('100') const [rates, setRates] = useState | null>(null) const fetchRate = React.useCallback(() => { fetch(`https://api.exchangerate-api.com/v4/latest/${from}`) .then(r => r.json()) .then(d => setRates(d.rates ?? null)) .catch(() => setRates(null)) }, [from]) useEffect(() => { fetchRate() }, [fetchRate]) useEffect(() => { localStorage.setItem('trek_fx_from', from); localStorage.setItem('trek_fx_to', to) }, [from, to]) const currencies = rates ? Object.keys(rates).sort() : FX_FALLBACK const ccyOptions = currencies.map(c => ({ value: c, label: c })) const rate = rates?.[to] ?? null const converted = rate != null ? (parseFloat(amount.replace(',', '.')) || 0) * rate : null const swap = () => { setFrom(to); setTo(from) } return (
{t('dashboard.currency')}
{t('dashboard.fx.from')}
setAmount(e.target.value)} inputMode="decimal" /> setFrom(String(v))} options={ccyOptions} searchable size="sm" style={{ marginTop: 6 }} />
{t('dashboard.fx.to')}
setTo(String(v))} options={ccyOptions} searchable size="sm" style={{ marginTop: 6 }} />
{rate != null ? `1 ${from} = ${rate.toFixed(4)} ${to}` : t('dashboard.fx.unavailable')}
) } // ── Timezone tool ──────────────────────────────────────────────────────────── const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo'] // Fallback for the rare browser without Intl.supportedValuesOf. const FALLBACK_ZONES = [ 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Moscow', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Sao_Paulo', 'Asia/Dubai', 'Asia/Kolkata', 'Asia/Bangkok', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland', 'UTC', ] function shortZone(tz: string): string { const city = tz.split('/').pop() || tz return city.replace(/_/g, ' ') } function TimezoneTool({ locale }: { locale: string }): React.ReactElement { const { t } = useTranslation() const home = Intl.DateTimeFormat().resolvedOptions().timeZone const [now, setNow] = useState(() => new Date()) const [zones, setZones] = useState(() => { try { const raw = localStorage.getItem('trek_dashboard_tz') if (raw) return JSON.parse(raw) } catch { /* ignore malformed storage */ } return [home, ...DEFAULT_ZONES] }) const [adding, setAdding] = useState(false) // A minute's resolution is plenty for clocks and keeps re-renders cheap. useEffect(() => { const id = setInterval(() => setNow(new Date()), 30000) return () => clearInterval(id) }, []) useEffect(() => { localStorage.setItem('trek_dashboard_tz', JSON.stringify(zones)) }, [zones]) const allZones = React.useMemo(() => { const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf try { return supported ? supported('timeZone') : FALLBACK_ZONES } catch { return FALLBACK_ZONES } }, []) const tzOptions = allZones .filter(z => !zones.includes(z)) .map(z => ({ value: z, label: z.replace(/_/g, ' '), searchLabel: z })) const addZone = (tz: string) => { if (tz) setZones(prev => prev.includes(tz) ? prev : [...prev, tz]); setAdding(false) } const removeZone = (tz: string) => setZones(prev => prev.filter(z => z !== tz)) const timeIn = (tz: string) => now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: tz }) const offsetLabel = (tz: string) => { const part = new Intl.DateTimeFormat(locale, { timeZone: tz, timeZoneName: 'short' }).formatToParts(now).find(p => p.type === 'timeZoneName') return part?.value || '' } return (
{t('dashboard.timezone')}
{adding && (
)}
{zones.map(tz => (
{shortZone(tz)[0]?.toUpperCase()}
{shortZone(tz)}
{offsetLabel(tz)}
{timeIn(tz)}
))} {zones.length === 0 && (
{t('dashboard.tz.empty')}
)}
) } // ── Upcoming reservations tool ─────────────────────────────────────────────── function UpcomingTool({ items, locale, onOpen }: { items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void }): React.ReactElement { const { t } = useTranslation() return (
{t('dashboard.upcoming.title')}
{items.length === 0 ? (
{t('dashboard.upcoming.empty')}
) : (
{items.map(r => { const when = r.reservation_time || (r.day_date ? r.day_date + 'T00:00:00' : null) const d = when ? new Date(when) : null const dateStr = d ? splitDate(d.toISOString().slice(0, 10), locale) : null const timeStr = r.reservation_time ? new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : null const typeClass = RES_TYPE_CLASS[r.type] || 'other' return (
onOpen(r.trip_id)}>
{dateStr?.d ?? '–'}
{dateStr?.m ?? ''}
{r.title}
{timeStr && <> {timeStr} · } {r.location || r.place_name || r.trip_title}
{RES_ICON[r.type] || }
) })}
)}
) }