mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 23:31:47 +00:00
c0b5d941dd
When the backend or identity provider was unreachable, a returning user with a persisted session landed on the dashboard with an empty trip grid and no error. That looks identical to a logged-in user who simply has no trips, so people assumed their data had been lost. Three client-side layers were quietly swallowing the failure: the auth check only cleared state on a 401, so a 5xx or a network error left the stale session in place and kept rendering the protected route; the offline-first trip repo turned a failed fetch into the empty cache without throwing; and the dashboard had neither an error nor an empty state, so a blank grid meant both "outage" and "no trips". The auth check now tells genuine offline (keep serving the cache silently, the PWA happy path) apart from a server outage while online (keep the session but flag it). The dashboard shows a reassuring "couldn't reach the server, your trips are safe" banner with a retry, and a real zero-trip account finally gets a proper empty state so the two cases never look alike. New strings added across all locales.
199 lines
7.3 KiB
TypeScript
199 lines
7.3 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { tripsApi, authApi, reservationsApi } from '../../api/client'
|
|
import { tripRepo } from '../../repo/tripRepo'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import { useToast } from '../../components/shared/Toast'
|
|
import { getApiErrorMessage } from '../../types'
|
|
import type { TripCreateRequest } from '@trek/shared'
|
|
import {
|
|
type DashboardTrip,
|
|
type TravelStats,
|
|
type UpcomingReservation,
|
|
type HeroBundle,
|
|
getTripStatus,
|
|
sortTrips,
|
|
} from './dashboardModel'
|
|
|
|
/**
|
|
* Dashboard data hook — owns every bit of the page's state, data loading and
|
|
* mutations (trip CRUD, archive/copy, travel stats, upcoming reservations,
|
|
* the spotlight hero bundle) and exposes derived values + handlers. The
|
|
* DashboardPage component is a pure wiring container that renders what this
|
|
* returns. Behaviour is identical to the previous in-component logic.
|
|
*/
|
|
export function useDashboard() {
|
|
const [trips, setTrips] = useState<DashboardTrip[]>([])
|
|
const [archivedTrips, setArchivedTrips] = useState<DashboardTrip[]>([])
|
|
const [isLoading, setIsLoading] = useState<boolean>(true)
|
|
const [showForm, setShowForm] = useState<boolean>(false)
|
|
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
|
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
|
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
|
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
|
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
|
|
const [loadError, setLoadError] = useState<boolean>(false)
|
|
|
|
const [stats, setStats] = useState<TravelStats | null>(null)
|
|
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
|
|
const [heroBundle, setHeroBundle] = useState<HeroBundle | null>(null)
|
|
|
|
const navigate = useNavigate()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const toast = useToast()
|
|
const { t, locale } = useTranslation()
|
|
const { demoMode, authCheckFailed, loadUser } = useAuthStore()
|
|
|
|
const toggleViewMode = () => {
|
|
setViewMode(prev => {
|
|
const next = prev === 'grid' ? 'list' : 'grid'
|
|
localStorage.setItem('trek_dashboard_view', next)
|
|
return next
|
|
})
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (searchParams.get('create') === '1') {
|
|
setShowForm(true)
|
|
setSearchParams({}, { replace: true })
|
|
}
|
|
}, [searchParams])
|
|
|
|
useEffect(() => { loadTrips() }, [])
|
|
|
|
// Travel stats + upcoming reservations power the atlas row and the sidebar.
|
|
// Both are best-effort: a failure just leaves that section empty.
|
|
useEffect(() => {
|
|
authApi.travelStats().then(setStats).catch(() => {})
|
|
reservationsApi.upcoming().then((r: { reservations: UpcomingReservation[] }) => setUpcoming(r.reservations || [])).catch(() => {})
|
|
}, [])
|
|
|
|
const loadTrips = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const { trips, archivedTrips } = await tripRepo.list()
|
|
setTrips(sortTrips(trips))
|
|
setArchivedTrips(sortTrips(archivedTrips))
|
|
setLoadError(false)
|
|
} catch {
|
|
setLoadError(true)
|
|
toast.error(t('dashboard.toast.loadError'))
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
// Re-run both the trip fetch and the auth check so a recovered backend clears
|
|
// the error banner (loadUser resets authCheckFailed on success). #1283
|
|
const retryLoad = () => {
|
|
loadUser({ silent: true })
|
|
loadTrips()
|
|
}
|
|
|
|
const today = new Date().toISOString().split('T')[0]
|
|
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|
|
|| trips.find(t => t.start_date && t.start_date >= today)
|
|
|| trips[0]
|
|
|| null
|
|
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
|
|
|
|
// Pull the spotlight trip's members + places so the boarding pass can show
|
|
// real buddies and place thumbnails instead of placeholders.
|
|
useEffect(() => {
|
|
if (!spotlight) { setHeroBundle(null); return }
|
|
let cancelled = false
|
|
tripsApi.bundle(spotlight.id)
|
|
.then((b: HeroBundle) => { if (!cancelled) setHeroBundle({ members: b.members || [], places: b.places || [] }) })
|
|
.catch(() => { if (!cancelled) setHeroBundle(null) })
|
|
return () => { cancelled = true }
|
|
}, [spotlight?.id])
|
|
|
|
const handleCreate = async (tripData: TripCreateRequest) => {
|
|
try {
|
|
const data = await tripsApi.create(tripData)
|
|
setTrips(prev => sortTrips([data.trip, ...prev]))
|
|
toast.success(t('dashboard.toast.created'))
|
|
return data
|
|
} catch (err: unknown) {
|
|
throw new Error(getApiErrorMessage(err, t('dashboard.toast.createError')))
|
|
}
|
|
}
|
|
|
|
const handleUpdate = async (tripData: TripCreateRequest) => {
|
|
if (!editingTrip) return
|
|
try {
|
|
const data = await tripsApi.update(editingTrip.id, tripData)
|
|
setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t)))
|
|
toast.success(t('dashboard.toast.updated'))
|
|
} catch (err: unknown) {
|
|
throw new Error(getApiErrorMessage(err, t('dashboard.toast.updateError')))
|
|
}
|
|
}
|
|
|
|
const confirmDelete = async () => {
|
|
if (!deleteTrip) return
|
|
try {
|
|
await tripsApi.delete(deleteTrip.id)
|
|
setTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
|
|
setArchivedTrips(prev => prev.filter(t => t.id !== deleteTrip.id))
|
|
toast.success(t('dashboard.toast.deleted'))
|
|
} catch {
|
|
toast.error(t('dashboard.toast.deleteError'))
|
|
}
|
|
setDeleteTrip(null)
|
|
}
|
|
|
|
const handleArchive = async (id: number) => {
|
|
try {
|
|
const data = await tripsApi.archive(id)
|
|
setTrips(prev => prev.filter(t => t.id !== id))
|
|
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
|
|
toast.success(t('dashboard.toast.archived'))
|
|
} catch {
|
|
toast.error(t('dashboard.toast.archiveError'))
|
|
}
|
|
}
|
|
|
|
const handleUnarchive = async (id: number) => {
|
|
try {
|
|
const data = await tripsApi.unarchive(id)
|
|
setArchivedTrips(prev => prev.filter(t => t.id !== id))
|
|
setTrips(prev => sortTrips([data.trip, ...prev]))
|
|
toast.success(t('dashboard.toast.restored'))
|
|
} catch {
|
|
toast.error(t('dashboard.toast.restoreError'))
|
|
}
|
|
}
|
|
|
|
const confirmCopy = async () => {
|
|
if (!copyTrip) return
|
|
try {
|
|
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
|
|
setTrips(prev => sortTrips([data.trip, ...prev]))
|
|
toast.success(t('dashboard.toast.copied'))
|
|
} catch {
|
|
toast.error(t('dashboard.toast.copyError'))
|
|
}
|
|
setCopyTrip(null)
|
|
}
|
|
|
|
const gridTrips = tripFilter === 'archive' ? archivedTrips
|
|
: tripFilter === 'completed' ? rest.filter(t => getTripStatus(t) === 'past')
|
|
: rest.filter(t => getTripStatus(t) !== 'past')
|
|
|
|
return {
|
|
// cross-cutting
|
|
demoMode, locale, t, navigate,
|
|
// data + derived
|
|
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
|
loadError: loadError || authCheckFailed, retryLoad,
|
|
// ui state
|
|
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
|
showForm, setShowForm, editingTrip, setEditingTrip,
|
|
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
|
// actions
|
|
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
|
|
}
|
|
}
|