BillTracker/client/App.tsx

425 lines
13 KiB
TypeScript

import { lazy, Suspense, useId, type ReactNode } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import Layout from '@/components/layout/Layout';
import AppNavigation from '@/components/layout/Sidebar';
import PageTransition from '@/components/PageTransition';
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
import CommandPalette from '@/components/CommandPalette';
import LoginPage from '@/pages/LoginPage';
import NotFoundPage from '@/pages/NotFoundPage';
import ErrorBoundary from '@/components/ErrorBoundary';
import PageLoader from '@/components/PageLoader';
// TanStack Query
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { toast } from 'sonner';
const queryClient = new QueryClient({
// Global error handling: pages render an inline error on the *initial* load
// (no data yet), so only surface a toast when a *background refetch* fails
// while stale data is still shown — otherwise the failure would be silent.
queryCache: new QueryCache({
onError: (error, query) => {
if (query.state.data !== undefined) {
toast.error(error?.message || 'Could not refresh — showing the last data.');
}
},
}),
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
});
// Lazy-loaded components
const AdminPage = lazy(() => import('@/pages/AdminPage'));
const TrackerPage = lazy(() => import('@/pages/TrackerPage'));
const CalendarPage = lazy(() => import('@/pages/CalendarPage'));
const SummaryPage = lazy(() => import('@/pages/SummaryPage'));
const BillsPage = lazy(() => import('@/pages/BillsPage'));
const SubscriptionsPage = lazy(() => import('@/pages/SubscriptionsPage'));
const SubscriptionCatalogPage = lazy(() => import('@/pages/SubscriptionCatalogPage'));
const CategoriesPage = lazy(() => import('@/pages/CategoriesPage'));
const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
const StatusPage = lazy(() => import('@/pages/StatusPage'));
const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage'));
const ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage'));
const AboutPage = lazy(() => import('@/pages/AboutPage'));
const PrivacyPage = lazy(() => import('@/pages/PrivacyPage'));
const RoadmapPage = lazy(() => import('@/pages/RoadmapPage'));
const DataPage = lazy(() => import('@/pages/DataPage'));
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
const HealthPage = lazy(() => import('@/pages/HealthPage'));
const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage'));
function RequireAuth({ children, role }: { children: ReactNode; role?: string }): ReactNode {
const { user, singleUserMode } = useAuth();
const location = useLocation();
// Loading state
if (user === undefined) {
return (
<div className="flex items-center justify-center min-h-screen bg-background text-muted-foreground text-sm">
Loading...
</div>
);
}
// Single-user mode bypass
if (singleUserMode && role === 'user') return children;
// Not authenticated
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
const roleAllowed = !role || user.role === role || (role === 'user' && user.role === 'admin');
if (role === 'user' && user.is_default_admin) {
return <Navigate to="/admin" replace />;
}
// Role mismatch
if (!roleAllowed) {
return <Navigate to={user.role === 'admin' ? '/admin' : '/'} replace />;
}
return children;
}
function AdminShell({ children }: { children: ReactNode }) {
const location = useLocation();
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_32rem),radial-gradient(circle_at_top_right,oklch(var(--accent)/0.08),transparent_24rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.18))] text-foreground">
<AppNavigation adminMode />
<main className="mx-auto w-full max-w-[1500px] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
<PageTransition routeKey={location.pathname}>{children}</PageTransition>
</main>
</div>
);
}
export default function App() {
const { user } = useAuth();
const mainContentId = useId();
return (
<QueryClientProvider client={queryClient}>
{/* Release notes (only for user role) */}
{user?.role === 'user' && <ReleaseNotesDialog />}
{user && !user.is_default_admin && <CommandPalette />}
{/* Skip link for keyboard users */}
<a
href={`#${mainContentId}`}
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:bg-background focus:text-foreground focus:px-4 focus:py-2 focus:rounded-md focus:shadow-lg focus:outline-none"
>
Skip to main content
</a>
<main id={mainContentId}>
<Routes>
<Route
path="/login"
element={
<ErrorBoundary>
<LoginPage />
</ErrorBoundary>
}
/>
<Route
path="/about"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<AboutPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="/privacy"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<PrivacyPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="/release-notes"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<ReleaseNotesPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="/admin"
element={
<RequireAuth role="admin">
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<AdminPage />
</Suspense>
</ErrorBoundary>
</RequireAuth>
}
/>
<Route
path="/admin/about"
element={
<RequireAuth role="admin">
<ErrorBoundary>
<AdminShell>
<Suspense fallback={<PageLoader />}>
<AboutPage />
</Suspense>
</AdminShell>
</ErrorBoundary>
</RequireAuth>
}
/>
<Route
path="/roadmap"
element={
<RequireAuth role="admin">
<ErrorBoundary>
<AdminShell>
<Suspense fallback={<PageLoader />}>
<RoadmapPage />
</Suspense>
</AdminShell>
</ErrorBoundary>
</RequireAuth>
}
/>
<Route
path="/admin/roadmap"
element={
<RequireAuth role="admin">
<ErrorBoundary>
<AdminShell>
<Suspense fallback={<PageLoader />}>
<RoadmapPage />
</Suspense>
</AdminShell>
</ErrorBoundary>
</RequireAuth>
}
/>
<Route
path="/admin/status"
element={
<RequireAuth role="admin">
<ErrorBoundary>
<AdminShell>
<Suspense fallback={<PageLoader />}>
<StatusPage />
</Suspense>
</AdminShell>
</ErrorBoundary>
</RequireAuth>
}
/>
<Route
path="/status"
element={
<RequireAuth role="admin">
<Navigate to="/admin/status" replace />
</RequireAuth>
}
/>
<Route
element={
<RequireAuth role="user">
<Layout mainContentId={mainContentId} />
</RequireAuth>
}
>
<Route
index
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<TrackerPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="calendar"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<CalendarPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="summary"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<SummaryPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="bills"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<BillsPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="subscriptions"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<SubscriptionsPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="subscriptions/catalog"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<SubscriptionCatalogPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="categories"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<CategoriesPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="health"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<HealthPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="analytics"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<AnalyticsPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="settings"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<SettingsPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="snowball"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<SnowballPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="payoff"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<PayoffPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="spending"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<SpendingPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="bank-transactions"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<BankTransactionsPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="data"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<DataPage />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="profile"
element={
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<ProfilePage />
</Suspense>
</ErrorBoundary>
}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
{/* Top-level catch-all — covers public paths that don't exist */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}