diff --git a/app/(pages)/sales/pdv/pdv.view.tsx b/app/(pages)/sales/pdv/pdv.view.tsx index 0147466..164cadb 100644 --- a/app/(pages)/sales/pdv/pdv.view.tsx +++ b/app/(pages)/sales/pdv/pdv.view.tsx @@ -9,7 +9,7 @@ import { } from "react"; import type { PointerEvent } from "react"; import type { UseFormReturn } from "react-hook-form"; -import { Scanner, IDetectedBarcode } from "@yudiel/react-qr-scanner"; +import { type IDetectedBarcode } from "@yudiel/react-qr-scanner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { NumberInput } from "@/components/ui/number-input"; @@ -63,6 +63,7 @@ import { METHODS_WITH_INSTALLMENTS, paymentMethods } from "./pdv.schema"; import type { PdvSchema } from "./pdv.schema"; import type { PaymentMethod } from "./pdv.schema"; import { FixedBottomBar } from "@/components/ui/fixed-bottom-bar"; +import { BarcodeScanner } from "@/components/product/barcode-scanner"; const PAYMENT_METHOD_ICONS: Record = { CASH: Banknote, @@ -687,10 +688,9 @@ function BarcodeDrawer({ open, onClose, onScan }: BarcodeDrawerProps) {
- console.error("Camera error:", err)} - formats={["qr_code", "ean_13", "ean_8", "code_128", "code_39", "upc_a", "upc_e"]} styles={{ container: { width: "100%", height: "280px" }, video: { objectFit: "cover" } }} components={{ onOff: false, torch: false, zoom: false, finder: true }} /> diff --git a/app/(pages)/stock-movements/create/stock-movement-scanner.view.tsx b/app/(pages)/stock-movements/create/stock-movement-scanner.view.tsx index 4a33049..aa37ac9 100644 --- a/app/(pages)/stock-movements/create/stock-movement-scanner.view.tsx +++ b/app/(pages)/stock-movements/create/stock-movement-scanner.view.tsx @@ -1,9 +1,10 @@ "use client"; -import { Scanner, IDetectedBarcode } from "@yudiel/react-qr-scanner"; +import { type IDetectedBarcode } from "@yudiel/react-qr-scanner"; import { ScanLine, X } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { BarcodeScanner } from "@/components/product/barcode-scanner"; import { Drawer, DrawerContent, @@ -63,17 +64,9 @@ export function StockMovementScanner({
-
- console.error("Camera error:", error)} - formats={[ - "qr_code", - "ean_13", - "ean_8", - "code_128", - "code_39", - "upc_a", - "upc_e", - ]} styles={{ container: { width: "100%", height: "280px" }, video: { objectFit: "cover" }, diff --git a/components/product/barcode-scanner-camera.ts b/components/product/barcode-scanner-camera.ts new file mode 100644 index 0000000..198da1e --- /dev/null +++ b/components/product/barcode-scanner-camera.ts @@ -0,0 +1,147 @@ +import type { IScannerProps } from "@yudiel/react-qr-scanner"; + +type BarcodeScannerFormatList = NonNullable; + +type BarcodeScannerCameraOptions = { + deviceId: string | null; + usesCompatibleCamera: boolean; +}; + +type BarcodeScannerFocusPoint = { + x: number; + y: number; +}; + +type BarcodeScannerConstraintSet = MediaTrackConstraintSet & { + focusMode?: string; + pointsOfInterest?: BarcodeScannerFocusPoint[]; + zoom?: number; +}; + +const BARCODE_SCANNER_FOCUS_CONSTRAINTS: BarcodeScannerConstraintSet = { + focusMode: "continuous", + pointsOfInterest: [{ x: 0.5, y: 0.5 }], + zoom: 2, +}; + +export const barcodeScannerFormats: BarcodeScannerFormatList = [ + "codabar", + "code_128", + "code_39", + "code_93", + "databar", + "databar_expanded", + "databar_limited", + "ean_13", + "ean_8", + "itf", + "upc_a", + "upc_e", +]; + +export const barcodeScannerCameraConstraints: MediaTrackConstraints = { + facingMode: { ideal: "environment" }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + advanced: [BARCODE_SCANNER_FOCUS_CONSTRAINTS], +}; + +export const barcodeScannerCompatibleCameraConstraints: MediaTrackConstraints = { + facingMode: { ideal: "environment" }, +}; + +const BARCODE_SCANNER_FRONT_CAMERA_PATTERN = + /front|frontal|selfie|user|frente/i; + +const BARCODE_SCANNER_BACK_CAMERA_PATTERN = + /back|rear|environment|traseira|trasera|facing back/i; + +const BARCODE_SCANNER_LOW_FOCUS_CAMERA_PATTERN = + /depth|macro|tele|ultra|wide|0\.5/i; + +const BARCODE_SCANNER_CAMERA_RETRY_ERROR_NAMES = new Set([ + "AbortError", + "ConstraintNotSatisfiedError", + "NotFoundError", + "NotReadableError", + "OverconstrainedError", +]); + +type BarcodeScannerNamedError = { + message?: string; + name: string; +}; + +const removeFacingModeFromConstraints = ( + constraints: MediaTrackConstraints, +): MediaTrackConstraints => { + const nextConstraints = { ...constraints }; + delete nextConstraints.facingMode; + return nextConstraints; +}; + +const isBarcodeScannerNamedError = ( + error: unknown, +): error is BarcodeScannerNamedError => { + if (typeof error !== "object" || error === null) return false; + + const errorCandidate = error as { message?: unknown; name?: unknown }; + return typeof errorCandidate.name === "string"; +}; + +const getBarcodeScannerDeviceScore = ( + device: MediaDeviceInfo, + index: number, +): number => { + const label = device.label.toLowerCase(); + let score = 100 - index; + + if (BARCODE_SCANNER_BACK_CAMERA_PATTERN.test(label)) score += 50; + if (BARCODE_SCANNER_FRONT_CAMERA_PATTERN.test(label)) score -= 100; + if (BARCODE_SCANNER_LOW_FOCUS_CAMERA_PATTERN.test(label)) score -= 25; + + return score; +}; + +export const createBarcodeScannerCameraConstraints = ({ + deviceId, + usesCompatibleCamera, +}: BarcodeScannerCameraOptions): MediaTrackConstraints => { + const baseConstraints = usesCompatibleCamera + ? barcodeScannerCompatibleCameraConstraints + : barcodeScannerCameraConstraints; + + if (!deviceId) return baseConstraints; + + return { + ...removeFacingModeFromConstraints(baseConstraints), + deviceId: { exact: deviceId }, + }; +}; + +export const getBarcodeScannerDeviceIds = ( + devices: MediaDeviceInfo[], +): string[] => { + const videoDevices = devices.filter((device) => device.kind === "videoinput"); + const labeledDevices = videoDevices.filter((device) => device.label.length > 0); + const candidateDevices = labeledDevices.filter( + (device) => !BARCODE_SCANNER_FRONT_CAMERA_PATTERN.test(device.label), + ); + + return candidateDevices + .sort( + (firstDevice, secondDevice) => + getBarcodeScannerDeviceScore(secondDevice, videoDevices.indexOf(secondDevice)) - + getBarcodeScannerDeviceScore(firstDevice, videoDevices.indexOf(firstDevice)), + ) + .map((device) => device.deviceId) + .filter((deviceId) => deviceId.length > 0); +}; + +export const shouldRetryBarcodeScannerCamera = (error: unknown): boolean => { + if (!isBarcodeScannerNamedError(error)) return false; + + if (BARCODE_SCANNER_CAMERA_RETRY_ERROR_NAMES.has(error.name)) return true; + + return error.message?.toLowerCase().includes("timed out") ?? false; +}; diff --git a/components/product/barcode-scanner-modal.tsx b/components/product/barcode-scanner-modal.tsx index a3bcf73..de59ed2 100644 --- a/components/product/barcode-scanner-modal.tsx +++ b/components/product/barcode-scanner-modal.tsx @@ -1,9 +1,10 @@ "use client"; -import { Scanner, IDetectedBarcode } from "@yudiel/react-qr-scanner"; +import { type IDetectedBarcode } from "@yudiel/react-qr-scanner"; import { AlertCircle } from "lucide-react"; import { ResponsiveModal } from "@/components/ui/responsive-modal"; import { Button } from "@/components/ui/button"; +import { BarcodeScanner } from "@/components/product/barcode-scanner"; interface BarcodeScannerModalProps { open: boolean; @@ -48,18 +49,9 @@ export const BarcodeScannerModal = ({
{/* Scanner Area */}
- ; + +const BARCODE_SCANNER_DEVICE_REFRESH_DELAYS_MS = [750, 2500] as const; + +export const BarcodeScanner = ({ + onError, + onScan, + ...scannerProps +}: BarcodeScannerProps) => { + const [usesCompatibleCamera, setUsesCompatibleCamera] = useState(false); + const [cameraDeviceIds, setCameraDeviceIds] = useState([]); + const [cameraDeviceIndex, setCameraDeviceIndex] = useState(0); + + const selectedCameraDeviceId = cameraDeviceIds[cameraDeviceIndex] ?? null; + const cameraConstraints = useMemo( + () => + createBarcodeScannerCameraConstraints({ + deviceId: selectedCameraDeviceId, + usesCompatibleCamera, + }), + [selectedCameraDeviceId, usesCompatibleCamera], + ); + + const scannerKey = `${selectedCameraDeviceId ?? "environment"}:${ + usesCompatibleCamera ? "compatible" : "preferred" + }`; + + const refreshCameraDevices = useCallback(async (): Promise => { + if (!navigator.mediaDevices?.enumerateDevices) return; + + const devices = await navigator.mediaDevices.enumerateDevices(); + setCameraDeviceIds(getBarcodeScannerDeviceIds(devices)); + }, []); + + const selectNextCameraConfiguration = useCallback(() => { + setUsesCompatibleCamera(true); + setCameraDeviceIndex((currentIndex) => { + if (cameraDeviceIds.length < 2) return currentIndex; + return (currentIndex + 1) % cameraDeviceIds.length; + }); + }, [cameraDeviceIds.length]); + + const handleScannerError = useCallback( + (error: unknown) => { + if (shouldRetryBarcodeScannerCamera(error)) { + selectNextCameraConfiguration(); + } + + void refreshCameraDevices(); + onError?.(error); + }, + [onError, refreshCameraDevices, selectNextCameraConfiguration], + ); + + useEffect(() => { + void refreshCameraDevices(); + + const timeoutIds = BARCODE_SCANNER_DEVICE_REFRESH_DELAYS_MS.map((delay) => + window.setTimeout(() => void refreshCameraDevices(), delay), + ); + + return () => timeoutIds.forEach((timeoutId) => window.clearTimeout(timeoutId)); + }, [refreshCameraDevices]); + + useEffect(() => { + setCameraDeviceIndex((currentIndex) => { + if (currentIndex < cameraDeviceIds.length) return currentIndex; + return 0; + }); + }, [cameraDeviceIds.length]); + + return ( + + ); +};