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