feat(booth): open the pay/exit modal on a hardware scan regardless of focus

A barcode/QR scanner is an HID "keyboard wedge" — it types the id + Enter into
whatever holds focus. Previously that only worked while the ticket <input> was
focused; a scan with focus elsewhere (or nowhere) went nowhere.

New useScanner hook (apps/web/src/lib/use-scanner.ts): a document-level keydown
listener that detects the scanner's FAST keystroke burst ended by Enter and opens
the pay/exit modal via setActiveTicket — regardless of focus. A gap > 50ms resets
the buffer, so human-paced typing with nothing focused never registers as a scan
(min length 3 guards stray Enters). Keystrokes into an input/textarea/select/
contenteditable are ignored, so the manual ticket field still works by hand. The
hook is paused while a modal is already open — a scan must not abandon an
in-progress payment; the operator finishes/closes, then scans the next car.

Verified at runtime (Playwright): a fast burst with focus on BODY opens the modal;
a second scan while the modal is open is ignored; slow (120ms) human typing does
NOT open it; the manual input submit still opens it. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-21 15:03:01 +02:00
parent 35c10a7310
commit 3ed785c33e
4 changed files with 102 additions and 0 deletions
+6
View File
@@ -5,6 +5,7 @@ import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from ".
import { qk } from "./lib/query.js";
import { useLiveStore } from "./lib/live-store.js";
import { useShift } from "./lib/use-shift.js";
import { useScanner } from "./lib/use-scanner.js";
import { Panel } from "./ui/Panel.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothPayModal } from "./BoothPayModal.js";
@@ -132,6 +133,11 @@ export function BoothScreen() {
// The ledger event open in the read-only detail modal (null = closed).
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
// A hardware scan opens the pay/exit modal regardless of focus (the operator needn't
// click the ticket field first). Paused while a modal is already up — a scan must not
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
// Live-feed filters: free-text search, event category, and direction/source.
const [feedSearch, setFeedSearch] = useState("");
const [feedType, setFeedType] = useState<FeedCat | "">("");