Files
parking_solution/apps/web/src/lib/use-scanner.ts
T
julian 3ed785c33e 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
2026-06-21 15:03:01 +02:00

79 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}, []);
}