import { BarcodeFormat, BrowserMultiFormatReader, } from '@zxing/browser'; import { DecodeHintType } from '@zxing/library'; const KITCHEN_ITEM_PREFIX = 'kitchen:item::'; const UUID_B64_PATTERN = /^[A-Za-z0-9_-]{22}$/; const UUID_B64_WITH_PADDING_PATTERN = /^[A-Za-z0-9_-]{22}={0,2}$/; const SCANNER_FORMATS = [ BarcodeFormat.DATA_MATRIX, BarcodeFormat.EAN_13, BarcodeFormat.EAN_8, BarcodeFormat.UPC_A, BarcodeFormat.UPC_E, BarcodeFormat.CODE_128, BarcodeFormat.CODE_39, BarcodeFormat.QR_CODE, ]; export function normalizeIdentifierCode(value) { return String(value || '').replace(/\s+/g, '').trim(); } export function parseKitchenScanPayload(rawValue) { const value = normalizeIdentifierCode(rawValue); if (!value) { return { type: 'empty', raw: '' }; } if (value.toLowerCase().startsWith(KITCHEN_ITEM_PREFIX)) { const uuidB64 = value.slice(KITCHEN_ITEM_PREFIX.length).trim(); return uuidB64 ? { type: 'item', uuidB64, raw: value } : { type: 'unknown', raw: value }; } // Backward compatibility: some labels/scanners provide the raw base64 UUID only. if (UUID_B64_PATTERN.test(value) || UUID_B64_WITH_PADDING_PATTERN.test(value)) { return { type: 'item', uuidB64: value.replace(/=+$/g, ''), raw: value, }; } return { type: 'identifier', identifierCode: value, raw: value }; } export function canUseCameraScanner() { return Boolean( typeof navigator !== 'undefined' && navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function', ); } export function createScannerReader() { const hints = new Map(); hints.set(DecodeHintType.POSSIBLE_FORMATS, SCANNER_FORMATS); return new BrowserMultiFormatReader(hints); } export function normalizeScannerError(error) { const message = String(error?.message || ''); const normalized = message.toLowerCase(); if (error?.name === 'NotAllowedError' || normalized.includes('permission')) { return 'Camera access was denied. Allow access to scan, or enter the code manually.'; } if (error?.name === 'NotFoundError' || normalized.includes('requested device not found')) { return 'No camera was found on this device. Enter the code manually.'; } if (error?.name === 'NotReadableError' || normalized.includes('could not start video source')) { return 'Camera is busy in another app. Close it there and try scanning again.'; } return 'Could not start scanning. Enter the code manually.'; } export async function startCameraScanner({ reader, videoElement, onDetected, onDecodeError, }) { const activeReader = reader || createScannerReader(); const shouldLogDecodeErrors = import.meta.env.DEV; let lastDecodeErrorName = ''; let lastDecodeErrorAt = 0; const controls = await activeReader.decodeFromConstraints( { audio: false, video: { facingMode: { ideal: 'environment' }, }, }, videoElement, (result, error) => { if (result) { onDetected?.(result.getText?.() || ''); return; } if (!error) { return; } onDecodeError?.(error); if (!shouldLogDecodeErrors) { return; } const errorName = String(error?.name || 'UnknownError'); const now = Date.now(); if (errorName !== lastDecodeErrorName || now - lastDecodeErrorAt > 2000) { console.debug('[scanner] Ignoring frame decode error while scanning:', errorName, error?.message || ''); lastDecodeErrorName = errorName; lastDecodeErrorAt = now; } }, ); return { reader: activeReader, controls }; } export function stopCameraScanner({ reader, controls, videoElement }) { try { controls?.stop?.(); } catch { // Ignore cleanup errors when scanner is already stopped. } try { reader?.reset?.(); } catch { // Ignore cleanup errors from stale reader state. } const stream = videoElement?.srcObject; if (stream && typeof stream.getTracks === 'function') { stream.getTracks().forEach((track) => track.stop()); } if (videoElement) { videoElement.srcObject = null; } }