/** * App — BrowserRouter shell with react-router declarative routing. * * Routes: * / → redirect to /calendar * /calendar → CalendarShell * /lists → ListsIndex * /lists/:listId → ListDetail * /setup → SetupPage (standalone wizard — no AppNav/BottomTabBar) * * Setup gate (Phase 12): * On app load, GET /api/setup/status is fetched with staleTime 0 (always fresh). * While loading: render nothing (prevent flash — mirrors the isAdmin loading gate). * When setupComplete === false: redirect all non-/setup routes to /setup via Navigate. * When setupComplete === true: normal app boot proceeds. * The /setup route renders standalone — AppNav/BottomTabBar are NOT rendered on wizard. * * Layout: * AppNav is rendered as a PERSISTENT sibling of (outside any Route), * so it survives route transitions (FIX 3). AppNav provides: * - Phone (≤767px): 48px top bar (the header above the main content area) * - Desktop (≥768px): 240px left sidebar with nav links + colour legend * * BottomTabBar is also a sibling of so the tab state remains consistent. * On desktop (≥768px) BottomTabBar is hidden via CSS (FIX 4). * * Auth flow: * /api/me is fetched once at the App level. While loading or on error the * full-screen AuthSplash overlay is shown (CalendarShell's AuthSplash is * positioned fixed with z-index so it covers AppNav too). AppNav renders * with empty / partial data until auth resolves — this is fine because the * AuthSplash overlay hides the AppNav during that window. * * navigateFallback ('/index.html') in vite.config.ts covers SPA deep-links to * /lists/* — the SW denylist only excludes /callback, /api/, and /health, so * /lists/* is served from cache correctly. * * Phase 5 additions: * - PermissionDeniedBanner: shown below AppNav when OS permission revoked (D-10) * - SettingsSheet: avatar-triggered bottom sheet with master notifications toggle (D-09) * * Phase 6 fix (FIX 3): * - AppNav lifted from CalendarShell to this level so /lists keeps the nav chrome. * - /api/me query shared so AppNav has user data on all routes. */ import { useState, useMemo } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { CalendarShell } from './components/CalendarShell.js'; import { ListsIndex } from './routes/ListsIndex.js'; import { ListDetail } from './routes/ListDetail.js'; import { AdminPage } from './routes/AdminPage.js'; import { SetupPage } from './routes/SetupPage.js'; import { BottomTabBar } from './components/BottomTabBar.js'; import { AppNav } from './components/AppNav.js'; import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'; import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'; import { SetupBanner } from './components/SetupBanner.js'; import { SettingsSheet } from './components/SettingsSheet.js'; import { fetchMe, fetchSetupStatus } from './api/client.js'; function isPhone(): boolean { return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; } export default function App() { const [settingsOpen, setSettingsOpen] = useState(false); const phone = isPhone(); // Setup status query — staleTime 0 so the wizard gate is always fresh (D-10 spirit). // Must be fetched before rendering any authenticated route to gate the app on /setup. // Uses the pre-auth /api/setup/status endpoint — no OIDC session required. const setupQuery = useQuery({ queryKey: ['setupStatus'], queryFn: fetchSetupStatus, retry: false, staleTime: 0, }); // Fetch current user once at the app shell level so AppNav has member data on // ALL routes. This is the same query key (['me']) used by CalendarShell, so // TanStack Query deduplicates the request — no double fetch. const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, retry: false, staleTime: 5 * 60 * 1000, }); // isAdmin from /api/me — used for UX gating only (D-03). Server enforces 403. // While meQuery is loading, isAdmin is false/undefined → admin route redirects // (loading gate: no flash of admin content for non-admins). const isAdmin = meQuery.data?.user.isAdmin ?? false; // Derive members for AppNav from the shared /api/me response const members = useMemo(() => { if (!meQuery.data?.user) return []; return [ { id: String(meQuery.data.user.id), name: meQuery.data.user.displayName ?? 'Member', color: meQuery.data.user.color, }, ]; }, [meQuery.data]); // Outer layout: phone = column, desktop = row (AppNav sidebar + content) const outerStyle: React.CSSProperties = { height: '100dvh', display: 'flex', flexDirection: phone ? 'column' : 'row', background: 'var(--color-surface)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-base)', overflow: 'hidden', }; // Content area: fills remaining space next to / below AppNav const contentStyle: React.CSSProperties = { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative', }; // Setup gate: while setup status is loading, render nothing (prevent flash). // Mirrors the isAdmin loading-gate pattern for the admin route. const setupComplete = setupQuery.data?.setupComplete; const setupLoading = setupQuery.isLoading; return ( {/* /setup route — standalone wizard, no AppNav/BottomTabBar shell (UI-SPEC §Routing) */} } /> {/* All other routes are gated on setup completion */} ); }