Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/(pages)/sales/pdv/pdv.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<PaymentMethod, LucideIcon> = {
CASH: Banknote,
Expand Down Expand Up @@ -687,10 +688,9 @@ function BarcodeDrawer({ open, onClose, onScan }: BarcodeDrawerProps) {
</DrawerHeader>
<div className="px-4 pb-6 pt-4">
<div className="relative overflow-hidden rounded-[4px] border border-neutral-800 bg-[#0A0A0A]">
<Scanner
<BarcodeScanner
onScan={onScan}
onError={(err: unknown) => 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 }}
/>
Expand Down
13 changes: 3 additions & 10 deletions app/(pages)/stock-movements/create/stock-movement-scanner.view.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -63,17 +64,9 @@ export function StockMovementScanner({

<div className="p-4">
<div className="relative overflow-hidden rounded-[4px] border border-neutral-800 bg-[#0A0A0A]">
<Scanner
<BarcodeScanner
onScan={handleScan}
onError={handleError}
formats={[
"ean_13",
"ean_8",
"code_128",
"code_39",
"upc_a",
"upc_e",
]}
components={{
finder: true,
onOff: false,
Expand Down
14 changes: 3 additions & 11 deletions app/(pages)/transfers/[id]/validate/validate-transfer.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type ReactNode,
type SetStateAction,
} from "react";
import { Scanner, type IDetectedBarcode } from "@yudiel/react-qr-scanner";
import { type IDetectedBarcode } from "@yudiel/react-qr-scanner";
import {
AlertCircle,
AlertTriangle,
Expand All @@ -27,6 +27,7 @@ import { FixedBottomBar } from "@/components/ui/fixed-bottom-bar";
import { Input } from "@/components/ui/input";
import { PageContainer } from "@/components/ui/page-container";
import { PageHeader } from "@/components/ui/page-header";
import { BarcodeScanner } from "@/components/product/barcode-scanner";
import { ResponsiveModal } from "@/components/ui/responsive-modal";
import { SectionLabel } from "@/components/ui/section-label";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -534,18 +535,9 @@ function CameraScannerModal({
}
>
<div className="relative overflow-hidden rounded-[4px] border border-neutral-800 bg-[#0A0A0A]">
<Scanner
<BarcodeScanner
onScan={viewState.handleCameraScan}
onError={(error) => 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" },
Expand Down
147 changes: 147 additions & 0 deletions components/product/barcode-scanner-camera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { IScannerProps } from "@yudiel/react-qr-scanner";

type BarcodeScannerFormatList = NonNullable<IScannerProps["formats"]>;

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",
];
Comment on lines +27 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore QR support in shared scanner formats

The new shared barcodeScannerFormats list no longer includes "qr_code", but every replaced call site previously passed "qr_code" explicitly. This changes runtime behavior across PDV, transfer validation, and modal scanners: QR labels that worked before this commit now never trigger onScan. If operators use QR-encoded product/transfer labels, scanning is a hard regression rather than a compatibility improvement.

Useful? React with 👍 / 👎.


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;
};
14 changes: 3 additions & 11 deletions components/product/barcode-scanner-modal.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -48,18 +49,9 @@ export const BarcodeScannerModal = ({
<div className="flex flex-col gap-4">
{/* Scanner Area */}
<div className="relative overflow-hidden rounded-[4px] border border-neutral-800 bg-[#0A0A0A]">
<Scanner
<BarcodeScanner
onScan={handleScan}
onError={handleError}
formats={[
"qr_code",
"ean_13",
"ean_8",
"code_128",
"code_39",
"upc_a",
"upc_e",
]}
styles={{
container: {
width: "100%",
Expand Down
96 changes: 96 additions & 0 deletions components/product/barcode-scanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import {
Scanner,
type IScannerProps,
} from "@yudiel/react-qr-scanner";
import {
barcodeScannerFormats,
createBarcodeScannerCameraConstraints,
getBarcodeScannerDeviceIds,
shouldRetryBarcodeScannerCamera,
} from "@/components/product/barcode-scanner-camera";

type BarcodeScannerProps = Omit<IScannerProps, "constraints" | "formats">;

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<string[]>([]);
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<void> => {
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 (
<Scanner
key={scannerKey}
{...scannerProps}
constraints={cameraConstraints}
formats={barcodeScannerFormats}
onScan={onScan}
onError={handleScannerError}
/>
);
};
Loading