mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-24 07:41:47 +00:00
fix(dashboard): show an error instead of a blank trip list when the server is unreachable (#1283)
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.
This commit is contained in:
@@ -84,6 +84,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const {
|
||||
demoMode, locale, t, navigate,
|
||||
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
||||
loadError, retryLoad,
|
||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
||||
@@ -102,6 +103,15 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<MobileTopBar />
|
||||
<main className="page">
|
||||
<div className="page-main">
|
||||
{loadError && (
|
||||
<div className="dash-error" role="alert">
|
||||
<span className="dash-error-txt">{t('dashboard.loadErrorBanner')}</span>
|
||||
<button className="dash-error-retry" onClick={retryLoad}>
|
||||
<RefreshCw size={15} />
|
||||
{t('dashboard.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{spotlight && (
|
||||
<BoardingPassHero
|
||||
trip={spotlight}
|
||||
@@ -132,6 +142,13 @@ export default function DashboardPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
|
||||
<div className="trips-empty">
|
||||
<h4>{t('dashboard.emptyTitle')}</h4>
|
||||
<p>{t('dashboard.emptyText')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
|
||||
{gridTrips.map(trip => (
|
||||
<TripCard
|
||||
|
||||
@@ -33,6 +33,7 @@ export function useDashboard() {
|
||||
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[]>([])
|
||||
@@ -42,7 +43,7 @@ export function useDashboard() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const { demoMode } = useAuthStore()
|
||||
const { demoMode, authCheckFailed, loadUser } = useAuthStore()
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setViewMode(prev => {
|
||||
@@ -74,13 +75,22 @@ export function useDashboard() {
|
||||
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)
|
||||
@@ -177,6 +187,7 @@ export function useDashboard() {
|
||||
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,
|
||||
|
||||
@@ -25,6 +25,11 @@ interface AuthState {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
/** The auth check (loadUser) failed for a non-401 reason while we were online —
|
||||
* the server was unreachable or erroring. Surfaced by the UI so a backend/IdP
|
||||
* outage doesn't render as a blank, error-free page that looks like lost data.
|
||||
* Transient, never persisted. #1283 */
|
||||
authCheckFailed: boolean
|
||||
error: string | null
|
||||
demoMode: boolean
|
||||
devMode: boolean
|
||||
@@ -86,6 +91,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
authCheckFailed: false,
|
||||
error: null,
|
||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||
devMode: false,
|
||||
@@ -200,6 +206,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
authCheckFailed: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
@@ -215,22 +222,33 @@ export const useAuthStore = create<AuthState>()(
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
authCheckFailed: false,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
} catch (err: unknown) {
|
||||
if (seq !== authSequence) return // stale response — ignore
|
||||
// Only clear auth state on 401 (invalid/expired token), not on network errors
|
||||
const isAuthError = err && typeof err === 'object' && 'response' in err &&
|
||||
(err as { response?: { status?: number } }).response?.status === 401
|
||||
if (isAuthError) {
|
||||
const status = err && typeof err === 'object' && 'response' in err
|
||||
? (err as { response?: { status?: number } }).response?.status
|
||||
: undefined
|
||||
if (status === 401) {
|
||||
// Invalid/expired token — clear auth so the guard redirects to login.
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
authCheckFailed: false,
|
||||
})
|
||||
} else {
|
||||
} else if (status === undefined && typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
// Genuinely offline — keep the persisted session so the PWA serves cached
|
||||
// data without a scary error. This is the offline-first happy path.
|
||||
set({ isLoading: false })
|
||||
} else {
|
||||
// Server erroring (5xx) or unreachable while we're online: keep the session
|
||||
// (don't eject the user over a transient outage), but flag it so the UI can
|
||||
// say "couldn't reach the server" instead of showing a blank, error-free
|
||||
// page that looks like the user's trips were lost. #1283
|
||||
set({ isLoading: false, authCheckFailed: true })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -456,6 +456,33 @@
|
||||
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
|
||||
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
|
||||
|
||||
/* Error banner — shown when the trip list or the auth check couldn't reach the
|
||||
server, so a backend/IdP outage no longer looks like an empty (lost-data)
|
||||
dashboard. Amber rather than red: it reassures (data is safe) more than it alarms. */
|
||||
.trek-dash .dash-error {
|
||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||
padding: 14px 18px; margin-bottom: 22px;
|
||||
background: oklch(0.74 0.14 75 / 0.13);
|
||||
border: 1px solid oklch(0.74 0.14 75 / 0.45);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .dash-error-txt { flex: 1; min-width: 200px; font-size: 14px; color: var(--ink); }
|
||||
.trek-dash .dash-error-retry {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 8px 14px; border: none; border-radius: var(--r-xs);
|
||||
background: var(--ink); color: var(--surface);
|
||||
font-size: 13px; font-weight: 500; cursor: pointer;
|
||||
transition: opacity .15s ease;
|
||||
}
|
||||
.trek-dash .dash-error-retry:hover { opacity: .88; }
|
||||
|
||||
/* Empty state — a genuine "you have no trips yet" message, visually distinct
|
||||
from the error banner above so an outage and a real empty list never look alike. */
|
||||
.trek-dash .trips-empty { margin-bottom: 18px; }
|
||||
.trek-dash .trips-empty h4 { font-size: 18px; font-weight: 600; color: var(--ink); margin: 0 0 6px; }
|
||||
.trek-dash .trips-empty p { font-size: 14px; color: var(--ink-3); margin: 0; }
|
||||
|
||||
/* ----------------- tools sidebar ----------------- */
|
||||
.trek-dash .tool {
|
||||
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
|
||||
|
||||
Reference in New Issue
Block a user