From 0002f66f0f3384f7e7e7cae9d8fa1720e3717877 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Thu, 21 May 2026 17:49:35 -0300 Subject: [PATCH 1/4] fix: improve barcode scanner compatibility --- app/(pages)/sales/pdv/pdv.view.tsx | 6 +- .../create/stock-movement-scanner.view.tsx | 13 +- .../[id]/validate/validate-transfer.view.tsx | 14 +- components/product/barcode-scanner-camera.ts | 147 ++++++++++++++++++ components/product/barcode-scanner-modal.tsx | 14 +- components/product/barcode-scanner.tsx | 123 +++++++++++++++ 6 files changed, 282 insertions(+), 35 deletions(-) create mode 100644 components/product/barcode-scanner-camera.ts create mode 100644 components/product/barcode-scanner.tsx 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 */}
- ; +type BarcodeScannerDetectedCodes = Parameters[0]; + +const BARCODE_SCANNER_CAMERA_RETRY_DELAY_MS = 5500; +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 hasDetectedSinceCameraChange = useRef(false); + + 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 handleScan = useCallback( + (detectedCodes: BarcodeScannerDetectedCodes) => { + if (detectedCodes.length > 0) hasDetectedSinceCameraChange.current = true; + + onScan(detectedCodes); + }, + [onScan], + ); + + const handleScannerError = useCallback( + (error: unknown) => { + if (!usesCompatibleCamera && shouldRetryBarcodeScannerCamera(error)) { + setUsesCompatibleCamera(true); + } + + void refreshCameraDevices(); + onError?.(error); + }, + [onError, refreshCameraDevices, usesCompatibleCamera], + ); + + 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]); + + useEffect(() => { + hasDetectedSinceCameraChange.current = false; + + const timeoutId = window.setTimeout(() => { + if (hasDetectedSinceCameraChange.current) return; + + selectNextCameraConfiguration(); + }, BARCODE_SCANNER_CAMERA_RETRY_DELAY_MS); + + return () => window.clearTimeout(timeoutId); + }, [ + scannerKey, + selectNextCameraConfiguration, + ]); + + return ( + + ); +}; From e7d766730d4e4f97087c135e0eeaccd58d9ae3d1 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Thu, 21 May 2026 18:02:04 -0300 Subject: [PATCH 2/4] fix: restore qr scanner format --- components/product/barcode-scanner-camera.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/components/product/barcode-scanner-camera.ts b/components/product/barcode-scanner-camera.ts index 198da1e..f475abf 100644 --- a/components/product/barcode-scanner-camera.ts +++ b/components/product/barcode-scanner-camera.ts @@ -35,6 +35,7 @@ export const barcodeScannerFormats: BarcodeScannerFormatList = [ "ean_13", "ean_8", "itf", + "qr_code", "upc_a", "upc_e", ]; From 53b74666784d146ad40a916d51527972ac01da71 Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Thu, 21 May 2026 18:02:23 -0300 Subject: [PATCH 3/4] fix: avoid timed scanner fallback --- components/product/barcode-scanner.tsx | 37 ++++---------------------- 1 file changed, 5 insertions(+), 32 deletions(-) diff --git a/components/product/barcode-scanner.tsx b/components/product/barcode-scanner.tsx index 009ade1..0e6b59d 100644 --- a/components/product/barcode-scanner.tsx +++ b/components/product/barcode-scanner.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Scanner, type IScannerProps, @@ -13,9 +13,7 @@ import { } from "@/components/product/barcode-scanner-camera"; type BarcodeScannerProps = Omit; -type BarcodeScannerDetectedCodes = Parameters[0]; -const BARCODE_SCANNER_CAMERA_RETRY_DELAY_MS = 5500; const BARCODE_SCANNER_DEVICE_REFRESH_DELAYS_MS = [750, 2500] as const; export const BarcodeScanner = ({ @@ -26,7 +24,6 @@ export const BarcodeScanner = ({ const [usesCompatibleCamera, setUsesCompatibleCamera] = useState(false); const [cameraDeviceIds, setCameraDeviceIds] = useState([]); const [cameraDeviceIndex, setCameraDeviceIndex] = useState(0); - const hasDetectedSinceCameraChange = useRef(false); const selectedCameraDeviceId = cameraDeviceIds[cameraDeviceIndex] ?? null; const cameraConstraints = useMemo( @@ -57,25 +54,16 @@ export const BarcodeScanner = ({ }); }, [cameraDeviceIds.length]); - const handleScan = useCallback( - (detectedCodes: BarcodeScannerDetectedCodes) => { - if (detectedCodes.length > 0) hasDetectedSinceCameraChange.current = true; - - onScan(detectedCodes); - }, - [onScan], - ); - const handleScannerError = useCallback( (error: unknown) => { - if (!usesCompatibleCamera && shouldRetryBarcodeScannerCamera(error)) { - setUsesCompatibleCamera(true); + if (shouldRetryBarcodeScannerCamera(error)) { + selectNextCameraConfiguration(); } void refreshCameraDevices(); onError?.(error); }, - [onError, refreshCameraDevices, usesCompatibleCamera], + [onError, refreshCameraDevices, selectNextCameraConfiguration], ); useEffect(() => { @@ -95,28 +83,13 @@ export const BarcodeScanner = ({ }); }, [cameraDeviceIds.length]); - useEffect(() => { - hasDetectedSinceCameraChange.current = false; - - const timeoutId = window.setTimeout(() => { - if (hasDetectedSinceCameraChange.current) return; - - selectNextCameraConfiguration(); - }, BARCODE_SCANNER_CAMERA_RETRY_DELAY_MS); - - return () => window.clearTimeout(timeoutId); - }, [ - scannerKey, - selectNextCameraConfiguration, - ]); - return ( ); From 9562523f20dfb7eea7a51e1dfb7aca704bb846fe Mon Sep 17 00:00:00 2001 From: lexmarcos Date: Thu, 21 May 2026 18:04:20 -0300 Subject: [PATCH 4/4] Revert "fix: restore qr scanner format" This reverts commit e7d766730d4e4f97087c135e0eeaccd58d9ae3d1. --- components/product/barcode-scanner-camera.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/components/product/barcode-scanner-camera.ts b/components/product/barcode-scanner-camera.ts index f475abf..198da1e 100644 --- a/components/product/barcode-scanner-camera.ts +++ b/components/product/barcode-scanner-camera.ts @@ -35,7 +35,6 @@ export const barcodeScannerFormats: BarcodeScannerFormatList = [ "ean_13", "ean_8", "itf", - "qr_code", "upc_a", "upc_e", ];