feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots
Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket id barcode; site_config.exit_voucher_default (migration 0002) drives the default. Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher. Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'), SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
|
||||
|
||||
// Entry/exit evidence images for a session. Lets the operator verify the car at the
|
||||
// booth against the ticket. Thumbnails load from /api/snapshots/:id (cookie-authed,
|
||||
// served with a long immutable cache); clicking one enlarges it. Read-only.
|
||||
|
||||
export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["snapshots", identity],
|
||||
queryFn: () => fetchSnapshots(identity),
|
||||
enabled: !!identity,
|
||||
});
|
||||
const [zoom, setZoom] = useState<string | null>(null);
|
||||
|
||||
const shots = data?.snapshots ?? [];
|
||||
|
||||
if (isLoading) return <div className="text-[11px] text-term-muted">loading snapshots…</div>;
|
||||
if (shots.length === 0) return <div className="text-[11px] text-term-muted">no snapshots</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{shots.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setZoom(s.id)}
|
||||
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
||||
title={`${s.direction ?? "snapshot"} · ${new Date(s.capturedAt).toLocaleString()}`}
|
||||
>
|
||||
<img
|
||||
src={snapshotImageUrl(s.id)}
|
||||
alt={s.direction ?? "snapshot"}
|
||||
className="h-20 w-28 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className={`text-[9px] uppercase tracking-wider ${
|
||||
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
|
||||
}`}
|
||||
>
|
||||
{s.direction ?? "—"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{zoom && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
|
||||
onClick={() => setZoom(null)}
|
||||
>
|
||||
<img src={snapshotImageUrl(zoom)} alt="snapshot" className="max-h-full max-w-full object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user