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 | "">("");
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useRef } from "react";
// Global hardware-scanner capture (HID "keyboard wedge"). A barcode/QR scanner types
// the code as a fast keystroke burst followed by Enter — like a keyboard, but far
// faster than a human. This hook listens at the DOCUMENT level so a scan fires the
// callback no matter what's focused (or if nothing is), unlike a single <input> that
// only catches scans while it holds focus. See wiki/concepts/booth-console.md.
//
// It does NOT hijack manual typing: keystrokes into an <input>/<textarea>/editable
// element are left to that field (the booth's ticket input still works by hand). The
// burst heuristic — chars arriving faster than a human could type, ended by Enter —
// is what distinguishes a scan from a person pressing keys with nothing focused.
/** Tuning. A scanner emits keystrokes ~1–20ms apart; a human is ≥80–100ms. */
const MAX_INTERKEY_MS = 50; // a gap longer than this resets the buffer (not one scan)
const MIN_LENGTH = 3; // ignore stray single Enter presses / very short bursts
interface ScannerOptions {
/** Called with the scanned code (trimmed) when a burst completes with Enter. */
onScan: (code: string) => void;
/** When true, scans are ignored (e.g. a modal is already open — don't interrupt an
* in-progress payment). The listener stays attached; it just no-ops. */
paused?: boolean;
}
/** Capture hardware-scanner input globally. The callback fires on the Enter that ends a
* fast keystroke burst, regardless of focus. Editable-field keystrokes are ignored so
* manual typing is unaffected. */
export function useScanner({ onScan, paused = false }: ScannerOptions): void {
// Keep the latest callback + paused flag in refs so the effect's listener never goes
// stale and we don't re-attach on every render.
const onScanRef = useRef(onScan);
const pausedRef = useRef(paused);
onScanRef.current = onScan;
pausedRef.current = paused;
useEffect(() => {
let buffer = "";
let lastTime = 0;
function isEditableTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
}
function onKeyDown(e: KeyboardEvent) {
// Let the focused field (and its form) handle its own keystrokes — the manual
// ticket input submits via its form's onSubmit; we only cover the un-focused case.
if (isEditableTarget(e.target)) return;
const now = e.timeStamp || performance.now();
const gap = now - lastTime;
lastTime = now;
if (e.key === "Enter") {
const code = buffer.trim();
buffer = "";
// Only a fast-burst code of reasonable length counts as a scan; a lone Enter or
// a slowly-assembled string (a person mashing keys) is ignored.
if (code.length >= MIN_LENGTH && !pausedRef.current) {
e.preventDefault();
onScanRef.current(code);
}
return;
}
// A gap too long means a new (human-paced) sequence — start the buffer over.
if (gap > MAX_INTERKEY_MS) buffer = "";
// Accumulate printable single characters (scanner codes: digits + SUB-/SUBSESS-…).
if (e.key.length === 1) buffer += e.key;
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
}