feat(web): frontend foundation — Tailwind terminal theme, Query, Router, Zustand + live booth screen
Add tailwindcss (Bloomberg-terminal theme in index.css), @tanstack/react-query + react-router, zustand, and Radix primitives. Router with role-guarded routes; QueryClient wrapping the existing apiFetch; a small Zustand live store fed by a /api/ws client that invalidates Query caches. Booth screen: live occupancy gauge + streaming entry/exit/payment feed. Vite proxies the WS upgrade. Note: BoothScreen references the pay/exit modal + active-sessions panel added in following commits; final HEAD builds.
This commit is contained in:
+10
-1
@@ -12,13 +12,22 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.17",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.15",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
|
"@tanstack/react-router": "^1.170.16",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.16"
|
"vite": "8.0.16"
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-40
@@ -1,15 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchMe, logout, type SessionUser } from "./api.js";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { fetchMe, type SessionUser } from "./api.js";
|
||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
import { PermitManager } from "./PermitManager.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { router } from "./router.js";
|
||||||
import { ShiftControl } from "./ShiftControl.js";
|
|
||||||
import { SiteSettings } from "./SiteSettings.js";
|
|
||||||
import { TariffComposer } from "./TariffComposer.js";
|
|
||||||
|
|
||||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
// simple enough that a framework's abstractions cost more than they save.
|
// off to TanStack Router inside the QueryClient provider. The router renders the
|
||||||
// Auth is cookie-based; the SPA bootstraps the session from /api/auth/me.
|
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
||||||
|
// the signed-in user flows into the router context for role-based route guards.
|
||||||
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -22,37 +22,20 @@ export function App() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <p style={{ fontFamily: "system-ui", padding: "2rem" }}>Loading…</p>;
|
if (loading) {
|
||||||
if (!user) return <Login onLoggedIn={setUser} />;
|
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<main style={{ fontFamily: "system-ui", padding: "2rem", maxWidth: 720 }}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
<Login onLoggedIn={setUser} />
|
||||||
<h1 style={{ margin: 0 }}>Parking System</h1>
|
</QueryClientProvider>
|
||||||
<span style={{ color: "#555" }}>
|
);
|
||||||
{user.username} ({user.role}){" "}
|
}
|
||||||
<button
|
|
||||||
type="button"
|
return (
|
||||||
onClick={async () => {
|
<QueryClientProvider client={queryClient}>
|
||||||
await logout();
|
<RouterProvider router={router} context={{ user, setUser }} />
|
||||||
setUser(null);
|
</QueryClientProvider>
|
||||||
}}
|
|
||||||
>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<SiteSettings canEdit={user.role === "admin"} />
|
|
||||||
{user.role !== "readonly" && <ShiftControl />}
|
|
||||||
{user.role === "admin" ? (
|
|
||||||
<>
|
|
||||||
<SetupWizard />
|
|
||||||
<TariffComposer />
|
|
||||||
<PermitManager />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||||
|
import { qk } from "./lib/query.js";
|
||||||
|
import { useLiveStore } from "./lib/live-store.js";
|
||||||
|
import { Panel } from "./ui/Panel.js";
|
||||||
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
|
import { BoothPayModal } from "./BoothPayModal.js";
|
||||||
|
import { ActiveSessions } from "./ActiveSessions.js";
|
||||||
|
|
||||||
|
// The live operator booth view — the real-time heart of the console. Occupancy
|
||||||
|
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
||||||
|
// the authoritative numbers; the WS-fed live store overlays real-time updates so
|
||||||
|
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
|
||||||
|
|
||||||
|
/** Per-event-type display: label + accent colour for the ticker. */
|
||||||
|
const EVENT_STYLE: Record<string, { label: string; color: string }> = {
|
||||||
|
vehicle_entry: { label: "ENTRY", color: "text-term-green" },
|
||||||
|
vehicle_exit: { label: "EXIT", color: "text-term-red" },
|
||||||
|
payment: { label: "PAY", color: "text-term-cyan" },
|
||||||
|
void: { label: "VOID", color: "text-term-amber" },
|
||||||
|
barrier_open_command: { label: "OPEN→", color: "text-term-muted" },
|
||||||
|
barrier_open_observed: { label: "OPEN✓", color: "text-term-muted" },
|
||||||
|
shift_open: { label: "SHIFT+", color: "text-term-amber" },
|
||||||
|
shift_z_report: { label: "SHIFT Z", color: "text-term-amber" },
|
||||||
|
anomaly: { label: "ANOMALY", color: "text-term-red" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function hhmmss(iso: string): string {
|
||||||
|
// Local time-of-day, terminal style. Defensive against a bad timestamp.
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||||
|
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
|
||||||
|
const barColor = occ.full ? "bg-term-red" : pct != null && pct >= 85 ? "bg-term-amber" : "bg-term-green";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
||||||
|
<div className="pb-1 text-term-muted">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider">inside</div>
|
||||||
|
<div className="text-sm tabular-nums">
|
||||||
|
{occ.capacity == null ? "uncapped" : `of ${occ.capacity}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto text-right">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-term-muted">free</div>
|
||||||
|
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
||||||
|
{occ.free == null ? "∞" : occ.free}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pct != null && (
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-term bg-term-panel-2">
|
||||||
|
<div className={`h-full ${barColor} transition-[width] duration-300`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{occ.full && (
|
||||||
|
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
|
||||||
|
● lot full
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventRow({ e }: { e: LedgerEvent }) {
|
||||||
|
const style = EVENT_STYLE[e.type] ?? { label: e.type.toUpperCase(), color: "text-term-text" };
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 border-b border-term-border/50 py-1 text-[12px] tabular-nums">
|
||||||
|
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
|
<span className={`w-20 shrink-0 font-semibold ${style.color}`}>{style.label}</span>
|
||||||
|
<span className="truncate text-term-text">{e.identity ?? "—"}</span>
|
||||||
|
<span className="ml-auto text-term-muted">#{e.index}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
|
||||||
|
* operator types it. Either way, submit opens the pay/exit modal for that id. The
|
||||||
|
* input auto-focuses and re-focuses after a scan so the scanner always lands here. */
|
||||||
|
function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = value.trim();
|
||||||
|
if (id) {
|
||||||
|
onSubmit(id);
|
||||||
|
setValue("");
|
||||||
|
ref.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="Scan or type ticket number…"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="flex-1 rounded-term border border-term-border bg-term-bg px-3 py-2 text-lg tabular-nums text-term-text placeholder:text-term-muted focus:border-term-amber"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-term border border-term-amber bg-term-amber/10 px-4 py-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoothScreen() {
|
||||||
|
// Initial load via Query (also the fallback if the WS is briefly down).
|
||||||
|
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
||||||
|
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) });
|
||||||
|
|
||||||
|
// The ticket currently open in the pay/exit modal (null = no modal).
|
||||||
|
const [activeTicket, setActiveTicket] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Live overlays from the WS store.
|
||||||
|
const liveOcc = useLiveStore((s) => s.occupancy);
|
||||||
|
const liveFeed = useLiveStore((s) => s.feed);
|
||||||
|
|
||||||
|
// Prefer the live-pushed occupancy; fall back to the query.
|
||||||
|
const occ = liveOcc ?? occQuery.data ?? null;
|
||||||
|
|
||||||
|
// Merge: live events first (newest), then the queried history, de-duped by id.
|
||||||
|
const seen = new Set(liveFeed.map((e) => e.id));
|
||||||
|
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||||
|
const events = [...liveFeed, ...history].slice(0, 200);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||||
|
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Panel title="Process ticket">
|
||||||
|
<TicketInput onSubmit={setActiveTicket} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left column: occupancy gauge above the active-sessions list. */}
|
||||||
|
<div className="flex min-h-0 flex-col gap-3">
|
||||||
|
<Panel title="Occupancy" right={<StatusDot />}>
|
||||||
|
{occ ? (
|
||||||
|
<OccupancyGauge occ={occ} />
|
||||||
|
) : (
|
||||||
|
<div className="text-term-muted">{occQuery.isError ? "occupancy unavailable" : "loading…"}</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<ActiveSessions onPick={setActiveTicket} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title="Live feed"
|
||||||
|
right={<span className="text-[10px] uppercase tracking-wider text-term-muted">{events.length} events</span>}
|
||||||
|
className="min-h-0"
|
||||||
|
>
|
||||||
|
<div className="h-full overflow-y-auto pr-1">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="text-term-muted">
|
||||||
|
{eventsQuery.isLoading ? "loading…" : "no events yet — entries and exits will stream here."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
events.map((e) => <EventRow key={e.id} e={e} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Bloomberg-terminal aesthetic: dense, dark, monospace, keyboard-first.
|
||||||
|
Tailwind v4 — design tokens live here in @theme (no tailwind.config.js).
|
||||||
|
The booth runs on a fixed appliance display; we optimise for a dark room,
|
||||||
|
glanceable status colour, and high information density over whitespace. */
|
||||||
|
@theme {
|
||||||
|
/* Surfaces — near-black, layered greys for panels/borders. */
|
||||||
|
--color-term-bg: #0a0e12;
|
||||||
|
--color-term-panel: #11161c;
|
||||||
|
--color-term-panel-2: #161d25;
|
||||||
|
--color-term-border: #232c37;
|
||||||
|
--color-term-muted: #6b7785;
|
||||||
|
--color-term-text: #c9d3de;
|
||||||
|
|
||||||
|
/* Status accents — the terminal's signal colours. */
|
||||||
|
--color-term-amber: #f5a623; /* primary accent / headings / focus */
|
||||||
|
--color-term-green: #2ecc71; /* entry / ok / free */
|
||||||
|
--color-term-red: #ff4d4f; /* exit / fault / full */
|
||||||
|
--color-term-cyan: #38bdf8; /* payment / info */
|
||||||
|
|
||||||
|
/* Monospace stack — IBM Plex Mono / JetBrains first, system mono fallback. */
|
||||||
|
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
|
||||||
|
"Menlo", "Consolas", monospace;
|
||||||
|
|
||||||
|
/* Tight radius — terminals are square. */
|
||||||
|
--radius-term: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-term-bg);
|
||||||
|
color: var(--color-term-text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
/* Crisp text and no rubber-banding on the fixed appliance display. */
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal scrollbars — thin, dark, unobtrusive. */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-term-border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A visible keyboard-focus ring in the amber accent (keyboard-first UI). */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 1px solid var(--color-term-amber);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { LedgerEvent, Occupancy } from "../api.js";
|
||||||
|
|
||||||
|
// CLIENT state for the live booth feed — deliberately small. Server data (the
|
||||||
|
// authoritative event list, occupancy totals) is owned by TanStack Query; this
|
||||||
|
// store holds only what Query shouldn't: the WS connection status, the latest
|
||||||
|
// pushed occupancy snapshot, and a rolling in-memory tail of recent events for the
|
||||||
|
// live ticker. Anything durable is re-fetched via Query. See lib/query.ts.
|
||||||
|
|
||||||
|
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
|
||||||
|
export type WsStatus = "connecting" | "open" | "closed";
|
||||||
|
|
||||||
|
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
||||||
|
* unbounded — the full history is always available via the /api/events query. */
|
||||||
|
const MAX_FEED = 200;
|
||||||
|
|
||||||
|
interface LiveState {
|
||||||
|
status: WsStatus;
|
||||||
|
/** Most recent occupancy pushed by the server (rides on every ledger event). */
|
||||||
|
occupancy: Occupancy | null;
|
||||||
|
/** Newest-first tail of recently pushed ledger events (for the live ticker). */
|
||||||
|
feed: LedgerEvent[];
|
||||||
|
setStatus: (s: WsStatus) => void;
|
||||||
|
setOccupancy: (o: Occupancy) => void;
|
||||||
|
pushEvent: (e: LedgerEvent) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLiveStore = create<LiveState>((set) => ({
|
||||||
|
status: "connecting",
|
||||||
|
occupancy: null,
|
||||||
|
feed: [],
|
||||||
|
setStatus: (status) => set({ status }),
|
||||||
|
setOccupancy: (occupancy) => set({ occupancy }),
|
||||||
|
pushEvent: (e) =>
|
||||||
|
set((s) => ({
|
||||||
|
// Newest first; de-dupe by id (a reconnect can replay) and cap the length.
|
||||||
|
feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED),
|
||||||
|
})),
|
||||||
|
reset: () => set({ status: "connecting", occupancy: null, feed: [] }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Single QueryClient for the app. TanStack Query owns SERVER state (fetch, cache,
|
||||||
|
// refetch, loading/error) — wrapping the existing thin api.ts fetchers. Client/UI
|
||||||
|
// state (live feed, WS status) lives in Zustand, not here. The WS layer invalidates
|
||||||
|
// these caches on live events so Query stays the source of truth for server data.
|
||||||
|
//
|
||||||
|
// Defaults tuned for a single-appliance booth: no window-focus refetch (it's a
|
||||||
|
// kiosk, not a tab someone switches to), and a short staleTime since the WS is the
|
||||||
|
// real freshness mechanism — queries are the fallback/initial load.
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 5_000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Stable query keys — referenced by both the screens and the WS invalidator. */
|
||||||
|
export const qk = {
|
||||||
|
me: ["me"] as const,
|
||||||
|
occupancy: ["occupancy"] as const,
|
||||||
|
events: ["events"] as const,
|
||||||
|
activeSessions: ["active-sessions"] as const,
|
||||||
|
siteConfig: ["site-config"] as const,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { LedgerEvent, Occupancy } from "../api.js";
|
||||||
|
import { qk } from "./query.js";
|
||||||
|
import { useLiveStore } from "./live-store.js";
|
||||||
|
|
||||||
|
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||||
|
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
||||||
|
// so TanStack Query remains the source of truth for durable server data. The browser
|
||||||
|
// attaches the auth cookie automatically; the backend gates by cookie + Origin
|
||||||
|
// (see routes/ws.ts). Auto-reconnects with capped backoff so a booth left running
|
||||||
|
// recovers from a server restart without a manual refresh.
|
||||||
|
|
||||||
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
|
type WsMessage =
|
||||||
|
| { kind: "hello"; occupancy: Occupancy }
|
||||||
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||||
|
| { kind: "printer-status"; event: unknown };
|
||||||
|
|
||||||
|
/** Build the ws:// or wss:// URL for the same origin the SPA is served from. */
|
||||||
|
function wsUrl(): string {
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${proto}//${window.location.host}/api/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLiveFeed(): void {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { setStatus, setOccupancy, pushEvent } = useLiveStore();
|
||||||
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
|
// double-invoke and unmount.
|
||||||
|
const sockRef = useRef<WebSocket | null>(null);
|
||||||
|
const retryRef = useRef(0);
|
||||||
|
const closedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
closedRef.current = false;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closedRef.current) return;
|
||||||
|
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
||||||
|
const sock = new WebSocket(wsUrl());
|
||||||
|
sockRef.current = sock;
|
||||||
|
|
||||||
|
sock.onopen = () => {
|
||||||
|
retryRef.current = 0;
|
||||||
|
setStatus("open");
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onmessage = (ev) => {
|
||||||
|
let msg: WsMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(ev.data as string) as WsMessage;
|
||||||
|
} catch {
|
||||||
|
return; // ignore malformed frames
|
||||||
|
}
|
||||||
|
if (msg.kind === "hello") {
|
||||||
|
setOccupancy(msg.occupancy);
|
||||||
|
} else if (msg.kind === "ledger") {
|
||||||
|
setOccupancy(msg.occupancy);
|
||||||
|
pushEvent(msg.event);
|
||||||
|
// Keep Query authoritative: the durable event list, occupancy totals,
|
||||||
|
// and active-sessions list refetch on the next read instead of trusting
|
||||||
|
// the pushed copy alone.
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
|
} else if (msg.kind === "printer-status") {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["printers"] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (closedRef.current) return;
|
||||||
|
setStatus("closed");
|
||||||
|
// Capped exponential backoff: 0.5s, 1s, 2s, … up to 10s.
|
||||||
|
const delay = Math.min(500 * 2 ** retryRef.current, 10_000);
|
||||||
|
retryRef.current += 1;
|
||||||
|
window.setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
sock.onclose = scheduleReconnect;
|
||||||
|
// onerror fires before onclose; let onclose own the reconnect to avoid double.
|
||||||
|
sock.onerror = () => sock.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closedRef.current = true;
|
||||||
|
sockRef.current?.close();
|
||||||
|
sockRef.current = null;
|
||||||
|
};
|
||||||
|
// qc / store setters are stable; run once on mount.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
|
|
||||||
const rootEl = document.getElementById("root");
|
const rootEl = document.getElementById("root");
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
createRootRouteWithContext,
|
||||||
|
createRoute,
|
||||||
|
createRouter,
|
||||||
|
Link,
|
||||||
|
Outlet,
|
||||||
|
redirect,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
import type { SessionUser } from "./api.js";
|
||||||
|
import { logout } from "./api.js";
|
||||||
|
import { queryClient } from "./lib/query.js";
|
||||||
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
|
import { BoothScreen } from "./BoothScreen.js";
|
||||||
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
|
import { TariffComposer } from "./TariffComposer.js";
|
||||||
|
import { PermitManager } from "./PermitManager.js";
|
||||||
|
import { ShiftControl } from "./ShiftControl.js";
|
||||||
|
import { SiteSettings } from "./SiteSettings.js";
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||||
|
component: RootLayout,
|
||||||
|
});
|
||||||
|
|
||||||
|
function NavLink({ to, label }: { to: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="px-2 py-1 text-[11px] uppercase tracking-wider text-term-muted rounded-term hover:text-term-text [&.active]:text-term-amber [&.active]:bg-term-panel-2"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootLayout() {
|
||||||
|
const { user, setUser } = rootRoute.useRouteContext();
|
||||||
|
// One app-wide WebSocket for the live feed (booth + any live widget).
|
||||||
|
useLiveFeed();
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||||||
|
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||||
|
<nav className="flex items-center gap-1">
|
||||||
|
<NavLink to="/booth" label="Booth" />
|
||||||
|
<NavLink to="/shift" label="Shift" />
|
||||||
|
{isAdmin && <NavLink to="/setup" label="Setup" />}
|
||||||
|
{isAdmin && <NavLink to="/tariff" label="Tariff" />}
|
||||||
|
{isAdmin && <NavLink to="/permits" label="Permits" />}
|
||||||
|
{isAdmin && <NavLink to="/site" label="Site" />}
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<StatusDot />
|
||||||
|
<span className="text-[11px] text-term-muted">
|
||||||
|
{user?.username} · {user?.role}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-term border border-term-border px-2 py-0.5 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text"
|
||||||
|
onClick={async () => {
|
||||||
|
await logout();
|
||||||
|
setUser(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/",
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: "/booth" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const boothRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/booth",
|
||||||
|
component: BoothScreen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shiftRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/shift",
|
||||||
|
component: function ShiftRoute() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
return <ShiftControl isAdmin={user?.role === "admin"} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Guard: admin-only routes redirect non-admins back to the booth. */
|
||||||
|
function adminOnly(ctx: RouterContext) {
|
||||||
|
if (ctx.user?.role !== "admin") throw redirect({ to: "/booth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/setup",
|
||||||
|
beforeLoad: ({ context }) => adminOnly(context),
|
||||||
|
component: () => <SetupWizard />,
|
||||||
|
});
|
||||||
|
const tariffRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/tariff",
|
||||||
|
beforeLoad: ({ context }) => adminOnly(context),
|
||||||
|
component: () => <TariffComposer />,
|
||||||
|
});
|
||||||
|
const permitsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/permits",
|
||||||
|
beforeLoad: ({ context }) => adminOnly(context),
|
||||||
|
component: () => <PermitManager />,
|
||||||
|
});
|
||||||
|
const siteRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/site",
|
||||||
|
beforeLoad: ({ context }) => adminOnly(context),
|
||||||
|
component: () => <SiteSettings canEdit={true} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeTree = rootRoute.addChildren([
|
||||||
|
indexRoute,
|
||||||
|
boothRoute,
|
||||||
|
shiftRoute,
|
||||||
|
setupRoute,
|
||||||
|
tariffRoute,
|
||||||
|
permitsRoute,
|
||||||
|
siteRoute,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { user: null, setUser: () => {} },
|
||||||
|
defaultPreload: "intent",
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Terminal panel: a bordered, titled box — the basic building block of the dense
|
||||||
|
// booth layout. Title bar in amber, square corners, subtle layered surfaces.
|
||||||
|
|
||||||
|
export function Panel({
|
||||||
|
title,
|
||||||
|
right,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
/** Optional right-aligned content in the title bar (e.g. a status dot). */
|
||||||
|
right?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`flex flex-col border border-term-border bg-term-panel rounded-term overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
|
||||||
|
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{right}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-h-0 p-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useLiveStore, type WsStatus } from "../lib/live-store.js";
|
||||||
|
|
||||||
|
// Small live-connection indicator for the booth chrome: a coloured dot + label
|
||||||
|
// reflecting the WebSocket status. Green = live, amber = connecting, red = down.
|
||||||
|
|
||||||
|
const COLOR: Record<WsStatus, string> = {
|
||||||
|
open: "bg-term-green",
|
||||||
|
connecting: "bg-term-amber",
|
||||||
|
closed: "bg-term-red",
|
||||||
|
};
|
||||||
|
const LABEL: Record<WsStatus, string> = {
|
||||||
|
open: "LIVE",
|
||||||
|
connecting: "CONNECTING",
|
||||||
|
closed: "OFFLINE",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusDot() {
|
||||||
|
const status = useLiveStore((s) => s.status);
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
|
||||||
|
/>
|
||||||
|
{LABEL[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
// Operator SPA. Built by Vite and served by Fastify in production
|
// Operator SPA. Built by Vite and served by Fastify in production
|
||||||
// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the
|
// (see wiki/entities/react-vite-spa.md). The dev proxy points the API at the
|
||||||
// local Fastify server.
|
// local Fastify server.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
|
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
|
||||||
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
|
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
|
||||||
// notably under WSL2 mirrored networking.
|
// notably under WSL2 mirrored networking.
|
||||||
"/api": "http://127.0.0.1:3000",
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:3000",
|
||||||
|
// The live booth feed (/api/ws) is a WebSocket — without `ws: true` the
|
||||||
|
// proxy would not forward the upgrade. The backend's Origin allowlist must
|
||||||
|
// include the dev origin (http://localhost:5173) via WS_ALLOWED_ORIGINS.
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
"/health": "http://127.0.0.1:3000",
|
"/health": "http://127.0.0.1:3000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1203
-7
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user