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:
2026-06-18 11:00:42 +02:00
parent c2f06a5d2a
commit 49df2015c8
13 changed files with 1870 additions and 49 deletions
+41
View File
@@ -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: [] }),
}));