ec9ea9d1f9
- Added a new `barcode_products` collection to the Appwrite setup for managing barcode data. - Implemented barcode scanning feature with a dedicated modal for scanning and adding products. - Introduced new components for barcode product preview and management. - Updated the setup script to seed verified barcode products from a JSON file. - Enhanced the application state management to handle barcode-related actions and user interactions.
268 lines
8.2 KiB
TypeScript
268 lines
8.2 KiB
TypeScript
import {
|
|
BarcodeFormat,
|
|
BrowserCodeReader,
|
|
BrowserMultiFormatReader,
|
|
type IScannerControls,
|
|
} from "@zxing/browser";
|
|
import { normalizeBarcode } from "./barcodeLookup";
|
|
|
|
export type BarcodeScannerErrorCode =
|
|
| "camera-denied"
|
|
| "no-camera"
|
|
| "unsupported"
|
|
| "camera-in-use"
|
|
| "unknown";
|
|
|
|
export type BarcodeScannerError = {
|
|
code: BarcodeScannerErrorCode;
|
|
message: string;
|
|
};
|
|
|
|
export type BarcodeScanResult = {
|
|
value: string;
|
|
format: string;
|
|
};
|
|
|
|
export type BarcodeScannerController = {
|
|
mode: "native" | "zxing";
|
|
stop: () => void;
|
|
};
|
|
|
|
type NativeBarcode = {
|
|
rawValue?: string;
|
|
format?: string;
|
|
};
|
|
|
|
type NativeBarcodeDetector = {
|
|
detect: (source: HTMLVideoElement) => Promise<NativeBarcode[]>;
|
|
};
|
|
|
|
type NativeBarcodeDetectorConstructor = new (options?: {
|
|
formats?: string[];
|
|
}) => NativeBarcodeDetector;
|
|
|
|
type WindowWithBarcodeDetector = Window & {
|
|
BarcodeDetector?: NativeBarcodeDetectorConstructor & {
|
|
getSupportedFormats?: () => Promise<string[]>;
|
|
};
|
|
};
|
|
|
|
const NATIVE_FORMATS = ["ean_13", "ean_8", "upc_a", "upc_e"];
|
|
const ZXING_FORMATS = [
|
|
BarcodeFormat.EAN_13,
|
|
BarcodeFormat.EAN_8,
|
|
BarcodeFormat.UPC_A,
|
|
BarcodeFormat.UPC_E,
|
|
];
|
|
const SCAN_CONSTRAINTS: MediaStreamConstraints = {
|
|
video: {
|
|
facingMode: { ideal: "environment" },
|
|
width: { ideal: 1280 },
|
|
height: { ideal: 720 },
|
|
},
|
|
audio: false,
|
|
};
|
|
|
|
export async function startBarcodeScanner(
|
|
videoElement: HTMLVideoElement,
|
|
onResult: (result: BarcodeScanResult) => void,
|
|
onError: (error: BarcodeScannerError) => void,
|
|
): Promise<BarcodeScannerController> {
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
throw toScannerError(new Error("Camera access is not supported in this browser."));
|
|
}
|
|
|
|
if (await supportsNativeBarcodeDetector()) {
|
|
try {
|
|
return await startNativeBarcodeScanner(videoElement, onResult);
|
|
} catch (error) {
|
|
stopVideoStream(videoElement);
|
|
if (isCameraAccessError(error)) {
|
|
throw toScannerError(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
return startZxingBarcodeScanner(videoElement, onResult, onError);
|
|
}
|
|
|
|
export function stopVideoStream(videoElement: HTMLVideoElement | null) {
|
|
if (!videoElement) return;
|
|
const stream = videoElement.srcObject;
|
|
if (stream instanceof MediaStream) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
}
|
|
videoElement.pause();
|
|
videoElement.removeAttribute("src");
|
|
videoElement.srcObject = null;
|
|
videoElement.load();
|
|
}
|
|
|
|
export function scannerErrorMessage(code: BarcodeScannerErrorCode) {
|
|
switch (code) {
|
|
case "camera-denied":
|
|
return "Camera permission was denied. Allow camera access, then try scanning again.";
|
|
case "no-camera":
|
|
return "No camera was found on this device. You can type the barcode instead.";
|
|
case "camera-in-use":
|
|
return "The camera looks busy in another app or browser tab. Close it there, then try again.";
|
|
case "unsupported":
|
|
return "Barcode scanning is not supported in this browser. You can type the barcode instead.";
|
|
case "unknown":
|
|
default:
|
|
return "The scanner could not start. You can type the barcode instead.";
|
|
}
|
|
}
|
|
|
|
function startNativeBarcodeScanner(
|
|
videoElement: HTMLVideoElement,
|
|
onResult: (result: BarcodeScanResult) => void,
|
|
): Promise<BarcodeScannerController> {
|
|
return new Promise((resolve, reject) => {
|
|
let stopped = false;
|
|
let animationFrame = 0;
|
|
let stream: MediaStream | null = null;
|
|
|
|
async function start() {
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia(SCAN_CONSTRAINTS);
|
|
videoElement.srcObject = stream;
|
|
videoElement.setAttribute("playsinline", "true");
|
|
videoElement.muted = true;
|
|
await videoElement.play();
|
|
|
|
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
|
if (!Detector) {
|
|
throw new Error("Native barcode detector unavailable.");
|
|
}
|
|
const detector = new Detector({ formats: NATIVE_FORMATS });
|
|
|
|
const stop = () => {
|
|
stopped = true;
|
|
window.cancelAnimationFrame(animationFrame);
|
|
stopVideoStream(videoElement);
|
|
};
|
|
|
|
const scan = async () => {
|
|
if (stopped) return;
|
|
try {
|
|
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
const barcodes = await detector.detect(videoElement);
|
|
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
|
|
if (barcode?.rawValue) {
|
|
onResult({
|
|
value: normalizeBarcode(barcode.rawValue),
|
|
format: barcode.format ?? "unknown",
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
if (!stopped) animationFrame = window.requestAnimationFrame(() => void scan());
|
|
}
|
|
};
|
|
|
|
animationFrame = window.requestAnimationFrame(() => void scan());
|
|
resolve({ mode: "native", stop });
|
|
} catch (error) {
|
|
if (stream) stream.getTracks().forEach((track) => track.stop());
|
|
reject(error);
|
|
}
|
|
}
|
|
|
|
void start();
|
|
});
|
|
}
|
|
|
|
async function startZxingBarcodeScanner(
|
|
videoElement: HTMLVideoElement,
|
|
onResult: (result: BarcodeScanResult) => void,
|
|
onError: (error: BarcodeScannerError) => void,
|
|
): Promise<BarcodeScannerController> {
|
|
const reader = new BrowserMultiFormatReader();
|
|
reader.possibleFormats = ZXING_FORMATS;
|
|
|
|
try {
|
|
const controls = await reader.decodeFromConstraints(
|
|
SCAN_CONSTRAINTS,
|
|
videoElement,
|
|
(result, error) => {
|
|
if (result) {
|
|
onResult({
|
|
value: normalizeBarcode(result.getText()),
|
|
format: BarcodeFormat[result.getBarcodeFormat()] ?? "unknown",
|
|
});
|
|
return;
|
|
}
|
|
if (error && !/not.?found/i.test(error.name) && !/not.?found/i.test(error.message)) {
|
|
onError(toScannerError(error));
|
|
}
|
|
},
|
|
);
|
|
|
|
return {
|
|
mode: "zxing",
|
|
stop: () => stopZxingScanner(controls, videoElement),
|
|
};
|
|
} catch (error) {
|
|
stopVideoStream(videoElement);
|
|
BrowserCodeReader.releaseAllStreams();
|
|
throw toScannerError(error);
|
|
}
|
|
}
|
|
|
|
function stopZxingScanner(controls: IScannerControls, videoElement: HTMLVideoElement) {
|
|
controls.stop();
|
|
BrowserCodeReader.releaseAllStreams();
|
|
stopVideoStream(videoElement);
|
|
}
|
|
|
|
async function supportsNativeBarcodeDetector() {
|
|
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
|
|
if (!Detector) return false;
|
|
if (!Detector.getSupportedFormats) return true;
|
|
|
|
try {
|
|
const formats = await Detector.getSupportedFormats();
|
|
return NATIVE_FORMATS.some((format) => formats.includes(format));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isCameraAccessError(error: unknown) {
|
|
if (!(error instanceof DOMException)) return false;
|
|
return ["NotAllowedError", "NotFoundError", "NotReadableError", "OverconstrainedError"].includes(error.name);
|
|
}
|
|
|
|
function toScannerError(error: unknown): BarcodeScannerError {
|
|
if (error instanceof DOMException) {
|
|
if (error.name === "NotAllowedError" || error.name === "SecurityError") {
|
|
return { code: "camera-denied", message: scannerErrorMessage("camera-denied") };
|
|
}
|
|
if (error.name === "NotFoundError" || error.name === "OverconstrainedError") {
|
|
return { code: "no-camera", message: scannerErrorMessage("no-camera") };
|
|
}
|
|
if (error.name === "NotReadableError" || error.name === "TrackStartError") {
|
|
return { code: "camera-in-use", message: scannerErrorMessage("camera-in-use") };
|
|
}
|
|
}
|
|
|
|
if (error instanceof Error && /not.?found|video input|requested device/i.test(error.message)) {
|
|
return { code: "no-camera", message: scannerErrorMessage("no-camera") };
|
|
}
|
|
|
|
if (error instanceof Error && /not.?allowed|permission|denied/i.test(error.message)) {
|
|
return { code: "camera-denied", message: scannerErrorMessage("camera-denied") };
|
|
}
|
|
|
|
if (error instanceof Error && /in use|busy|could not start video source/i.test(error.message)) {
|
|
return { code: "camera-in-use", message: scannerErrorMessage("camera-in-use") };
|
|
}
|
|
|
|
if (error instanceof Error && /not supported|unsupported|barcode detector unavailable/i.test(error.message)) {
|
|
return { code: "unsupported", message: scannerErrorMessage("unsupported") };
|
|
}
|
|
|
|
return { code: "unknown", message: scannerErrorMessage("unknown") };
|
|
}
|