import { lazy, Suspense, useEffect, useState } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate, useParams } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { analyticsService, AnalyticsRouteTracker } from './services/analytics.service'; import { GalleryAuthProvider, MaintenanceProvider } from './contexts'; import { ThemeProvider } from './contexts/ThemeContext'; import { GalleryPage } from './pages/GalleryPage'; import { ClientAccessPage } from './pages/ClientAccessPage'; import { PreviewPage } from './pages/gallery/PreviewPage'; const SlideshowPage = lazy(() => import('./pages/gallery/SlideshowPage').then((m) => ({ default: m.SlideshowPage }))); import { LegalPage } from './pages/public/LegalPage'; import { AdminLoginPage, AdminDashboard, EventsListPage, CreateEventPage, EventDetailsPage, EventFeedbackPage, ArchivesPage, AnalyticsPage, SettingsPage, SystemHealthPage, UserManagementPage, CustomerManagementPage, CustomerDetailPage, WebhookDeliveriesPage, // CRM routes (#TBD) — feature-flagged at the route layer via RequireFeature. QuotesListPage, QuoteEditorPage, QuoteDetailPage, BillsListPage, BillEditorPage, BillDetailPage, } from './pages/admin'; import { CrmDevelopmentPage } from './pages/admin/clients/CrmDevelopmentPage'; import { TaxReportPage } from './pages/admin/clients/TaxReportPage'; import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage'; // E.6 — Calendar page lazy-loaded so the ~200 KB FullCalendar bundle // (carved into its own chunk in vite.config.ts) doesn't ship with the // main app. Only pages that visit /admin/clients/calendar fetch it. const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage }))); const MessagesPage = lazy(() => import('./pages/admin/messages/MessagesPage').then((m) => ({ default: m.MessagesPage }))); import { QuoteResponsePage } from './pages/public/QuoteResponsePage'; import { ContractResponsePage } from './pages/public/ContractResponsePage'; import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage'; import { ProjectCockpitPage } from './pages/admin/projects/ProjectCockpitPage'; import { WorkflowsListPage } from './pages/admin/workflows/WorkflowsListPage'; import { WorkflowApprovalsPage } from './pages/admin/workflows/WorkflowApprovalsPage'; import { WorkflowEditorPage } from './pages/admin/workflows/WorkflowEditorPage'; import { ContractsListPage } from './pages/admin/contracts/ContractsListPage'; import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage'; import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage'; import { BlockLibraryPage } from './pages/admin/contracts/BlockLibraryPage'; import { PaymentCheckPage } from './pages/public/PaymentCheckPage'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { TransfersPage } from './pages/admin/transfers/TransfersPage'; import { TransferDownloadPage } from './pages/public/TransferDownloadPage'; import { TransferUploadPage } from './pages/public/TransferUploadPage'; import { CustomerLoginPage, CustomerDashboardPage, CustomerAcceptInvitePage, CustomerLayout, CustomerProfilePage, CustomerCalendarPage, CustomerQuotesPage, CustomerBillsPage, CustomerContractsPage, CustomerResetPasswordPage, } from './pages/customer'; import { CustomerAuthProvider } from './contexts/CustomerAuthContext'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { ClientsLayout } from './components/admin/ClientsLayout'; import { AccountingLayout, AccountingIndex } from './components/admin/AccountingLayout'; import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage'; import { ExpensesLedgerPage } from './pages/admin/accounting/ExpensesLedgerPage'; import { RequireFeature } from './components/admin/RequireFeature'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { GlobalThemeProvider } from './components/GlobalThemeProvider'; import { ConfirmDialogProvider } from './components/common'; import { usePublicSettings } from './hooks/usePublicSettings'; import { SetupPage } from './pages/SetupPage'; import { AdminAuthProvider } from './contexts'; // Create a client const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1, }, }, }); // Bootstraps the analytics tracker from /public/settings. Lives inside // QueryClientProvider so it shares the public-settings cache with every // other consumer of usePublicSettings. Dispatches based on the // `analytics_tracker_provider` switch (#663 Phase 1) — Umami / Rybbit / // Custom / None. Back-compat: when the provider field is missing or unset, // falls through to the legacy `umami_enabled`-based behaviour so installs // that haven't picked yet keep working. function AnalyticsBootstrap() { const { data: settings, isError } = usePublicSettings(); useEffect(() => { if (!settings && !isError) return; const envUmamiUrl = import.meta.env.VITE_UMAMI_URL; const envUmamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID; const provider = settings?.analytics_tracker_provider; if (provider === 'rybbit' && settings?.rybbit_url && settings.rybbit_website_id) { analyticsService.initialize({ provider: 'rybbit', hostUrl: settings.rybbit_url, websiteId: settings.rybbit_website_id, doNotTrack: true, // Mask every /gallery/* path (they embed the share token) so Rybbit's // auto-tracked page views never carry the secret (GHSA-7m6c). maskPatterns: ['/gallery/**'], }); return; } if (provider === 'custom') { analyticsService.initialize({ provider: 'custom', customHeadHtml: settings?.analytics_custom_head_html || '', }); return; } // Umami: explicit provider OR legacy umami_enabled path. if ( (provider === 'umami' || settings?.umami_enabled) && settings?.umami_url && settings?.umami_website_id ) { analyticsService.initialize({ provider: 'umami', hostUrl: settings.umami_url, websiteId: settings.umami_website_id, // autoTrack omitted → data-auto-track="false": Umami must NOT read the // raw window.location (token leak). Page views come from the manual, // sanitized AnalyticsRouteTracker instead (GHSA-7m6c). doNotTrack: true, }); return; } // Env-var fallback (legacy deploys). Only when no DB config and // analytics aren't disabled at the public-site level. if (envUmamiUrl && envUmamiWebsiteId && (isError || settings?.enable_analytics !== false)) { analyticsService.initialize({ provider: 'umami', hostUrl: envUmamiUrl, websiteId: envUmamiWebsiteId, // autoTrack omitted → data-auto-track="false" (see above, GHSA-7m6c). doNotTrack: true, }); } }, [settings, isError]); return null; } /** * Backward-compat redirect for /admin/customers/:id → /admin/clients/accounts/:id. * Needed because can't interpolate route params and we * want stale bookmarks / email links to keep working after the Clients * section reorg. */ function RedirectCustomerDetail() { const { id } = useParams(); return ; } function App() { // Track dark mode for toast theming const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light'); useEffect(() => { const observer = new MutationObserver(() => { setToastTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light'); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); return () => observer.disconnect(); }, []); return ( {/* Public gallery routes */} } /> {/* Live Slideshow ("Diashow") — token-only fullscreen kiosk. Self-manages its session token; no GalleryAuthProvider. */} }> } /> } /> } /> {/* First-run setup — public, self-closes once an admin exists */} } /> {/* Admin routes - wrap with AdminAuthProvider */} }> } /> }> } /> } /> } /> } /> } /> } /> {/* PicTransfer (#997) — cross-event file transfers. Gated by the `transfers` flag (strictly opt-in). */} }> } /> {/* Feature-gated surfaces — redirect to /admin/dashboard when flag is off. */} }> } /> }> } /> }> }> } /> {/* Clients section (#354 follow-up). Parent route gated by the top-level `clients` flag — when off the sidebar entry is hidden and every /admin/clients/* URL redirects to /admin/dashboard. Inside, the ClientsLayout renders a Settings-style sub-nav and the active sub-feature's page through an Outlet. Each sub-route is feature-flagged independently. */} }> }> }> } /> } /> {/* Quotes (CRM) — gated by `quotes`. */} }> } /> } /> } /> } /> {/* Project Overview (CRM) — admin-only grouping layer above events, gated by `projects`. */} }> } /> } /> {/* Bills / invoices (CRM) — gated by `bills`. */} }> } /> } /> } /> } /> {/* Contracts (CRM) — gated by `contracts`. Independent of quotes/bills; a free-standing legal document type composed from a library of reusable blocks. */} }> } /> } /> } /> } /> } /> {/* Hour logging (standalone surface) — gated by `hoursLogging`. Independent of `bills` so admin can log hours before the full billing surface is enabled. */} }> } /> {/* Admin calendar (migration 137) — gated by `calendar`. Lazy-loaded so the FullCalendar bundle stays out of the main chunk. */} }> }> } /> {/* Tax export moved permanently to the Accounting section. Keep this path as a redirect so old bookmarks / links don't 404. */} } /> {/* Developer tools — gated by `crmDevelopment`. */} }> } /> {/* Default: send /admin/clients (no sub-path) to the first enabled sub-feature. accounts comes first because it predates the others. The empty state inside ClientsLayout handles "parent on, all children off". */} } /> {/* Accounting section (migration 122). Parent gated by the `accounting` flag. Hosts the Tax report — which relocates here from the CRM sub-nav when accounting is on — plus the future inbound-invoice / expenses pages. Each sub-route is independently flagged. */} }> }> }> } /> }> } /> }> } /> {/* Treuhänder export moved onto the Tax page; keep the old path working for bookmarks. */} } /> {/* Chart of accounts (Layer A) moved into Settings → Accounting; keep the old path working for bookmarks. */} } /> } /> {/* Old /admin/customers paths now live under /admin/clients/accounts. Kept indefinitely as redirects so existing bookmarks and email links don't 404. */} } /> } /> {/* Workflows (automation engine) — top-level area gated by the `workflows` flag. */} }> } /> } /> } /> } /> } /> } /> {/* Old top-level routes — these surfaces now live as Settings tabs (#feature-flags-settings-reorg). Kept indefinitely as redirects so existing bookmarks and external links don't 404. */} } /> } /> } /> } /> } /> } /> {/* Public invitation acceptance page */} } /> {/* Public quote accept/decline page (CRM). Token-only, no auth required. */} } /> } /> {/* Admin payment-check page (CRM) — token only, no auth. Reached from the "Paid in full / Partial / Not paid" buttons in the payment- check email. */} } /> {/* PicTransfer (#997) — recipient download + client upload, token-only, no auth. */} } /> } /> {/* Customer surface (#354). Strictly separate provider / cookie / API surface from /admin/*. The customerPortal feature flag hides the *admin-side* surfaces (sidebar entry, /admin/customers routes, CustomerAccountPicker) via RequireFeature. The customer-side /customer/* tree stays publicly reachable so existing customers can still log in even if the admin temporarily flips the flag off — and because RequireFeature reads from FeatureFlagsProvider (admin-only context), gating these routes here would crash unauthenticated visitors with an unmounted-provider error. */} {/* Public surfaces: login, accept-invite, reset — no CustomerLayout (their own branded shells). */} } /> } /> } /> {/* Authenticated surfaces share the sidebar layout (Outlet pattern, mirrors AdminLayout). The CustomerLayout itself enforces auth — bouncing unauthenticated visitors to /customer/login. */} }> } /> } /> } /> } /> } /> } /> } /> } /> {/* Public legal pages */} } /> } /> } /> {/* Default redirect */} } /> {/* Customisable 404 (#324) — caught here for any path that didn't match. Top-level `/:slug` is consumed above by LegalPage; this picks up deeper unknown paths. */} } /> {/* Offline indicator */} {/* Toast notifications */} ); } export default App;