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:
@@ -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
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user