Add scanner utility, modal, and stock scan page implementation
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user