import { createRootRouteWithContext, createRoute, createRouter, Link, Outlet, redirect, } from "@tanstack/react-router"; import { lazy, Suspense, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import type { Lang, Permission, SessionUser, Theme } from "./api.js"; import { can, fetchVersion, logout, setLanguagePref, setThemePref, setFontScalePref, FONT_SCALE_MIN, FONT_SCALE_MAX, FONT_SCALE_STEP, } from "./api.js"; import { queryClient } from "./lib/query.js"; import { Modal } from "./ui/Modal.js"; import { Spinner } from "./ui/Spinner.js"; import { setLanguage } from "./lib/i18n/index.js"; import { applyTheme, applyFontScale } from "./lib/theme.js"; import { useLiveFeed } from "./lib/use-live-feed.js"; import { inTauri } from "./lib/origin.js"; import { DeviceFooter } from "./ui/DeviceFooter.js"; import { ShiftButton } from "./ShiftControl.js"; import { StatusDot } from "./ui/StatusDot.js"; import { BoothScreen } from "./BoothScreen.js"; import { SetupWizard } from "./SetupWizard.js"; import { TariffComposer } from "./TariffComposer.js"; import { TariffLab } from "./TariffLab.js"; import { SubscriptionManager } from "./SubscriptionManager.js"; import { SubscriptionPlansManager } from "./SubscriptionPlansManager.js"; import { SiteSettings } from "./SiteSettings.js"; import { UsersManager } from "./UsersManager.js"; import { RolesManager } from "./RolesManager.js"; import { ShiftsHistory } from "./ShiftsHistory.js"; import { DrawerManager } from "./DrawerManager.js"; import { LogsViewer } from "./LogsViewer.js"; import { BackupSettings } from "./BackupSettings.js"; import { WEB_MODULES } from "./modules/index.js"; import { canWatchFeed, moduleOn } from "./lib/modules.js"; import { TILL_IDS, tillGuards } from "@parking/shared"; import { RecycleBin } from "./RecycleBin.js"; import { Profile } from "./Profile.js"; // Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's // initial bundle and only downloads when an admin opens /setup/reports. const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports }))); // Code-based TanStack Router (no file-based codegen — the app is small enough that // an explicit tree is clearer). The router context carries the signed-in user and // a setter so route guards can redirect by role. The root renders the terminal // chrome (nav + user + live status) and opens the booth WebSocket once, app-wide. export interface RouterContext { user: SessionUser | null; setUser: (u: SessionUser | null) => void; } // Exported so a deep component (e.g. the booth pay modal) can read the signed-in user // from route context without prop-threading through every layer. export const rootRoute = createRootRouteWithContext()({ component: RootLayout, }); function NavLink({ to, label }: { to: string; label: string }) { return ( {label} ); } /** A tab inside the Setup layout. `exact` (activeOptions) so the Devices tab at * `/setup` doesn't stay highlighted on the child tabs. */ function SetupTab({ to, label, exact = false }: { to: string; label: string; exact?: boolean }) { return ( {label} ); } /** The running deploy's "-" (matches the Komodo Stack's TAG in * komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the * same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a * local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */ function VersionBadge() { const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity }); const version = q.data?.buildVersion; if (!version) return null; return {version}; } /** The installed Tauri app's own "vX.Y.Z" (from tauri.conf.json, synced to the git tag by * release.yml — see wiki/decisions/desktop-shell-tauri.md) — the client's version, distinct * from VersionBadge's SERVER build. No-op / renders nothing in a browser (there's no Tauri * API to call). Was invisible before this: an operator had no way to tell which desktop * build was actually installed short of reading the update-available prompt. */ function DesktopVersionBadge() { const [version, setVersion] = useState(null); useEffect(() => { if (!inTauri()) return; let cancelled = false; void import("@tauri-apps/api/app").then(({ getVersion }) => getVersion().then((v) => { if (!cancelled) setVersion(v); }), ); return () => { cancelled = true; }; }, []); if (!version) return null; return app v{version}; } /** Desktop-only "change which server this install talks to" control. No-op / * renders nothing in a browser (the concept doesn't apply — same-origin). * Simplest correct action: clear the saved backend URL and reload, which * drops the app back to ConnectScreen (see App.tsx) to re-enter it — this * mirrors clearing the session (logout → back to Login), not an inline * editor, since repointing the app is a rare, deliberate admin action. */ function DesktopServerButton() { const { t } = useTranslation(); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); if (!inTauri()) return null; return ( <> {confirming && ( setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">

{t("connect.changeServerConfirm")}

)} ); } /** Setup layout — the config hub. Renders a permission-gated tab bar and the active * tab's screen via . Each tab is a child route (its own URL + guard), so * deep links and the back button work and a denied tab redirects to the booth. */ function SetupLayout() { const { user } = rootRoute.useRouteContext(); const { t } = useTranslation(); const show = (perm: Permission) => can(user, perm); return (
); } /** Subscriptions layout — a standalone top-level section (its own header nav entry), * with tabs for the subscriber catalog and the plan catalog. Each tab is a gated * child route; an operator with only subscription:read sees just the first tab. * (The tariff lab moved to /setup/tariff/lab, 2026-07-05 — it tests the tariff, so * it lives with the tariff.) */ function SubscriptionsLayout() { const { user } = rootRoute.useRouteContext(); const { t } = useTranslation(); const show = (perm: Permission) => can(user, perm); return (
); } /** Tariff layout — the rate-card hub under Setup: the composer (index) and the * pricing LAB as sub-tabs. One tariff:read gate on the parent covers both. */ function TariffLayout() { const { t } = useTranslation(); return (
); } /** SQ/EN toggle. Persists the choice to the user's profile (restored on next login) * and applies it immediately. Updates the router-context user so App re-syncs. */ function LanguageToggle({ user, setUser, }: { user: SessionUser; setUser: (u: SessionUser | null) => void; }) { // The ACTIVE language is i18n's own state, not the router-context `user` — the // latter is captured at route-resolution time and does NOT re-render when we call // setUser, so reading `user.language` here goes stale after the first switch (the // highlight froze and the equality guard blocked switching back until a refresh). // useTranslation() subscribes to i18n's languageChanged, so this stays live. const { i18n } = useTranslation(); const active = i18n.language as Lang; async function pick(lang: Lang) { if (lang === active) return; setLanguage(lang); // instant UI (fires i18n languageChanged → re-render) setUser({ ...user, language: lang }); // keep context eventually-consistent + persisted state try { await setLanguagePref(lang); // persist } catch { /* non-fatal — the choice still applies this session */ } } return (
{(["sq", "en"] as const).map((l) => ( ))}
); } /** Dark/light theme toggle. Same shape as the language toggle: applies instantly, * persists to the user's profile, and updates the router-context user so App * re-syncs. Restored on the next login from any booth. */ function ThemeToggle({ user, setUser, }: { user: SessionUser; setUser: (u: SessionUser | null) => void; }) { const { t } = useTranslation(); // Local state for the ACTIVE theme — same reason as LanguageToggle: the router // context `user` doesn't re-render on setUser, so reading `user.theme` here froze // the highlight after one switch and blocked toggling back until a refresh. Seed // from the prop; update optimistically on pick. App's effect keeps the DOM in sync // with the persisted user on (re)login. const [active, setActive] = useState(user.theme); async function pick(theme: Theme) { if (theme === active) return; setActive(theme); applyTheme(theme); // instant UI setUser({ ...user, theme }); // keep context eventually-consistent + persisted state try { await setThemePref(theme); // persist } catch { /* non-fatal — the choice still applies this session */ } } return (
{(["dark", "light"] as const).map((th) => ( ))}
); } /** Header font-size control: A−/value/A+ scaling the whole UI (root font-size). Persisted * to the user profile like the theme, restored on next login. Local `active` state seeded * from the prop (the router context doesn't re-render on setUser); App's effect keeps the * DOM in sync with the persisted user on (re)login. */ function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: SessionUser | null) => void }) { const { t } = useTranslation(); const [active, setActive] = useState(user.fontScale); function step(delta: number) { const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, active + delta)); if (next === active) return; setActive(next); applyFontScale(next); // instant UI setUser({ ...user, fontScale: next }); void setFontScalePref(next).catch(() => { /* non-fatal — the choice still applies this session */ }); } const btn = "rounded-term px-1.5 py-0.5 text-term-muted hover:text-term-text disabled:opacity-40"; return (
{active}%
); } // Header shift control lives in ShiftControl.tsx (shared with the wash desk, per till). function RootLayout() { const { user, setUser } = rootRoute.useRouteContext(); const { t } = useTranslation(); // Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants // the permission its screen needs (the route guards enforce the same server-side). const show = (perm: Permission) => can(user, perm); // One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY // for roles the server would accept (routes/ws.ts admits any WATCH permission: // event/session/device read, or an effective module's own feed permission — and // then filters what it pushes per role). A merchant validator holds none and must // not even attempt it: the 403'd upgrade would reconnect on backoff forever and // spam the server log. Same rule for the widgets that feed off it (StatusDot) or // make their own gated calls (ShiftButton → shift:read, DeviceFooter → device:read). const canWatch = canWatchFeed(user); useLiveFeed(canWatch); return (
▮ Parking
{/* The header button is the BOOTH till's, guarded by the booth's own shift:read (a wash role holds no shift:* at all and has its own control on the wash desk). The server resolves the same guard from the till. */} {user && show("shift:read") && } {user && } {user && } {user && } {canWatch && } {user && ( {user.username} · {user.roleName} )}
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST seed needs device:read (and its live updates ride the report:read WS), so it's hidden for roles without device visibility (e.g. merchant validators). */} {user && show("device:read") && }
); } const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", beforeLoad: ({ context }) => { // Landing = the first screen this role can actually use. The booth for anyone // with the booth's permission; otherwise the first venue-module landing the role // holds (wash desk for a wash operator, scan screen for a merchant); otherwise // the shift hub; otherwise the profile. Every guard that bounces sends people // HERE (never straight to the booth) so a booth-less role never dead-ends. throw redirect({ to: landingFor(context.user) }); }, }); function landingFor(user: SessionUser | null): string { if (can(user, "session:read")) return "/booth"; for (const m of WEB_MODULES) { if (m.landing && moduleOn(user, m.id) && can(user, m.landing.perm)) return m.landing.to; } if (can(user, "shift:read")) return "/shifts"; return "/profile"; } const boothRoute = createRoute({ getParentRoute: () => rootRoute, path: "/booth", // The booth is the parking operator's screen; a role without session:read (a wash // operator, a merchant) goes to its own landing instead of a screen that 403s. beforeLoad: ({ context }) => { if (!can(context.user, "session:read")) throw redirect({ to: "/" }); }, component: BoothScreen, }); // Back-compat redirects for paths that moved. Most config screens live under /setup; // Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone // /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing // bookmarks/links don't 404. (No "/subscriptions" entry: that's now a REAL route.) const legacyRedirects = ( [ ["/tariff", "/setup/tariff"], ["/site", "/setup/site"], ["/users", "/setup/users"], ["/roles", "/setup/roles"], ["/shift", "/shifts"], ["/setup/subscriptions", "/subscriptions"], ["/setup/plans", "/subscriptions/plans"], // The tariff lab bounced twice: /setup/tariff-lab → /subscriptions/tariff-lab // (2026-06-21) → /setup/tariff/lab (2026-07-05, back with the tariff it tests). ["/setup/tariff-lab", "/setup/tariff/lab"], ["/subscriptions/tariff-lab", "/setup/tariff/lab"], ["/setup/shifts", "/shifts"], ["/setup/reports", "/reports"], ] as const ).map(([from, to]) => createRoute({ getParentRoute: () => rootRoute, path: from, beforeLoad: () => { throw redirect({ to }); }, }), ); // Admin reports/charts — a top-level section (own header nav entry), NOT a Setup tab. // Gated by report:read. Lazy component (Recharts) in a Suspense so it stays out of the // booth's initial bundle. const reportsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/reports", beforeLoad: ({ context }) => requirePerm("report:read")(context), component: function ReportsRoute() { return ( …}> ); }, }); const shiftRoute = createRoute({ getParentRoute: () => rootRoute, path: "/shifts", component: function ShiftRoute() { const { user } = rootRoute.useRouteContext(); // The shift hub: list (current/open shift on top + history) + per-shift activity log. // The CURRENT shift's pane carries the actions (open/close, takings), each opening a // modal. `canManage` = shift:create (start/end). Drawer cash movements moved to /drawer // (2026-07-01). return ; }, }); const drawerRoute = createRoute({ getParentRoute: () => rootRoute, path: "/drawer", // Reachable by anyone who can record OR review; the component shows the right view per // permission. Guard on the broader of the two (create) so a review-only admin still gets // in — the redirect only fires if the user has NEITHER, which the nav already hides. beforeLoad: ({ context }) => { // Anyone who may read a till's drawer, record on one, or review — the component // shows the right view per till. (canWatchFeed-style: any of the till guards.) const u = context.user; const anyTill = TILL_IDS.some((t) => can(u, tillGuards(t).read) || can(u, tillGuards(t).cash)); if (!anyTill && !can(u, "drawer:review")) throw redirect({ to: "/" }); }, component: function DrawerRoute() { const { user } = rootRoute.useRouteContext(); return ( ); }, }); /** Guard factory: a route requiring `perm` redirects a user who lacks it back to * the booth. Same permission the server enforces — defence in depth, not the only * gate. */ function requirePerm(perm: Permission) { return (ctx: RouterContext) => { if (!can(ctx.user, perm)) throw redirect({ to: "/booth" }); }; } // The Setup tabs in display order, each with the permission its screen needs. Used // to land a user on the FIRST tab they may see when they open /setup without // `site:update` (e.g. an operator who only has shift:read → goes to the standalone // /shifts hub, which is no longer a Setup tab). const SETUP_TABS: { to: string; perm: Permission }[] = [ { to: "/setup", perm: "site:update" }, { to: "/setup/tariff", perm: "tariff:read" }, { to: "/setup/site", perm: "site:read" }, { to: "/setup/users", perm: "user:read" }, { to: "/setup/roles", perm: "role:read" }, { to: "/setup/recycle-bin", perm: "recyclebin:read" }, { to: "/shifts", perm: "shift:read" }, { to: "/setup/logs", perm: "log:read" }, ]; // /setup is a LAYOUT route (tab bar + ); the config screens are its // children. The layout itself has no permission gate — each child enforces its own // (so a user who can reach ANY tab gets the hub, but only the tabs they're allowed). const setupRoute = createRoute({ getParentRoute: () => rootRoute, path: "/setup", component: SetupLayout, }); // Index tab = Devices (the former SetupWizard). Lives at /setup exactly. A user who // lacks site:update (e.g. an operator) is redirected to the FIRST tab they CAN see // rather than bounced to the booth — so "Setup" always lands somewhere useful. const setupDevicesRoute = createRoute({ getParentRoute: () => setupRoute, path: "/", beforeLoad: ({ context }) => { if (can(context.user, "site:update")) return; const firstOther = SETUP_TABS.find((tab) => tab.to !== "/setup" && can(context.user, tab.perm)); throw redirect({ to: firstOther?.to ?? "/booth" }); }, component: () => , }); const tariffRoute = createRoute({ getParentRoute: () => setupRoute, path: "tariff", beforeLoad: ({ context }) => requirePerm("tariff:read")(context), component: TariffLayout, }); const tariffComposerRoute = createRoute({ getParentRoute: () => tariffRoute, path: "/", component: () => , }); // The tariff LAB — lives with the tariff it tests (moved from /subscriptions, // 2026-07-05). The parent's tariff:read gate covers it. const tariffLabRoute = createRoute({ getParentRoute: () => tariffRoute, path: "lab", component: () => , }); // --- /subscriptions — a standalone top-level section with its own tabs. The catalog // (index), the plan catalog, and the tariff lab live here, not under /setup. --- const subscriptionsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/subscriptions", component: SubscriptionsLayout, }); // Index tab = the subscriber catalog at /subscriptions exactly. A user lacking // subscription:read is redirected to the first sub-tab they CAN see (or the booth). const subscriptionsIndexRoute = createRoute({ getParentRoute: () => subscriptionsRoute, path: "/", beforeLoad: ({ context }) => { if (can(context.user, "subscription:read")) return; if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" }); throw redirect({ to: "/booth" }); }, component: function SubscriptionsRoute() { const { user } = rootRoute.useRouteContext(); return ; }, }); const subscriptionPlansRoute = createRoute({ getParentRoute: () => subscriptionsRoute, path: "plans", beforeLoad: ({ context }) => requirePerm("subscription:plan")(context), component: () => , }); const siteRoute = createRoute({ getParentRoute: () => setupRoute, path: "site", beforeLoad: ({ context }) => requirePerm("site:read")(context), component: function SiteRoute() { const { user } = rootRoute.useRouteContext(); return ; }, }); const usersRoute = createRoute({ getParentRoute: () => setupRoute, path: "users", beforeLoad: ({ context }) => requirePerm("user:read")(context), component: function UsersRoute() { const { user } = rootRoute.useRouteContext(); return ; }, }); const rolesRoute = createRoute({ getParentRoute: () => setupRoute, path: "roles", beforeLoad: ({ context }) => requirePerm("role:read")(context), component: function RolesRoute() { const { user } = rootRoute.useRouteContext(); return ; }, }); // (Shift history lives at the standalone /shifts route — see shiftRoute. It was // removed as a Setup tab; /setup/shifts and the old /shift both redirect there.) // Recycle bin — restore/purge soft-deleted master data. Gated by recyclebin:read. const recycleBinRoute = createRoute({ getParentRoute: () => setupRoute, path: "recycle-bin", beforeLoad: ({ context }) => requirePerm("recyclebin:read")(context), component: function RecycleBinRoute() { const { user } = rootRoute.useRouteContext(); return ; }, }); // Diagnostic logs. Gated by log:read (an admin/diagnostic permission). const logsRoute = createRoute({ getParentRoute: () => setupRoute, path: "logs", beforeLoad: ({ context }) => requirePerm("log:read")(context), component: LogsViewer, }); // Encrypted DB backup — status + manual run. Gated by backup:read (run by backup:create). const backupRoute = createRoute({ getParentRoute: () => setupRoute, path: "backup", beforeLoad: ({ context }) => requirePerm("backup:read")(context), component: BackupSettings, }); // My profile — self-service for ANY signed-in user (no permission gate). Edits only // the caller's own name/email/password. See Profile.tsx and routes/auth.ts. const profileRoute = createRoute({ getParentRoute: () => rootRoute, path: "profile", component: function ProfileRoute() { const { user, setUser } = rootRoute.useRouteContext(); if (!user) return null; return ; }, }); const routeTree = rootRoute.addChildren([ indexRoute, boothRoute, // Venue-module routes (e.g. /validate) — each module gates its own routes on // moduleOn + permission. See modules/index.ts. ...WEB_MODULES.flatMap((m) => m.routes(rootRoute)), ...legacyRedirects, profileRoute, shiftRoute, drawerRoute, reportsRoute, subscriptionsRoute.addChildren([ subscriptionsIndexRoute, subscriptionPlansRoute, ]), setupRoute.addChildren([ setupDevicesRoute, tariffRoute.addChildren([tariffComposerRoute, tariffLabRoute]), siteRoute, usersRoute, rolesRoute, recycleBinRoute, logsRoute, backupRoute, ...WEB_MODULES.flatMap((m) => m.setupRoutes?.(setupRoute) ?? []), ]), ]); export const router = createRouter({ routeTree, context: { user: null, setUser: () => {} }, defaultPreload: "intent", }); declare module "@tanstack/react-router" { interface Register { router: typeof router; } }