"use client"; // BarcodeDetector is not yet in TypeScript's lib — declare it inline declare global { interface Window { BarcodeDetector?: new (options: { formats: string[] }) => { detect(source: HTMLVideoElement | HTMLCanvasElement | ImageBitmap): Promise>; }; } } import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import GlassModal from "@/components/admin/GlassModal"; // One-color outline icons const Icons = { camera: (className: string) => ( ), keyboard: (className: string) => ( ), check: (className: string) => ( ), search: (className: string) => ( ), cameraOff: (className: string) => ( ), }; type ScanMode = "camera" | "manual"; interface QRScanModalProps { onClose: () => void; onScanResult?: (lotNumber: string) => void; } export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps) { const router = useRouter(); const [mode, setMode] = useState("camera"); const [manualInput, setManualInput] = useState(""); const [cameraError, setCameraError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [cameraStarting, setCameraStarting] = useState(true); const [detected, setDetected] = useState(false); const [scanSuccess, setScanSuccess] = useState(false); const videoRef = useRef(null); const streamRef = useRef(null); const detectorRef = useRef> | null>(null); const scanRef = useRef(0); // Start camera on mount for camera mode useEffect(() => { if (mode !== "camera") return; let mounted = true; async function startCamera() { setCameraStarting(true); setCameraError(null); // Check for BarcodeDetector support if (!window.BarcodeDetector) { try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } }, }); if (!mounted) { stream.getTracks().forEach(t => t.stop()); return; } streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play(); } setCameraStarting(false); } catch (err) { if (!mounted) return; const msg = err instanceof DOMException && err.name === "NotAllowedError" ? "Camera access denied. Please allow camera permission." : err instanceof DOMException && err.name === "NotFoundError" ? "No camera found. Use manual entry." : "Camera not available. Use manual entry."; setCameraError(msg); setCameraStarting(false); setTimeout(() => { if (mounted) setMode("manual"); }, 800); } return; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } }, }); if (!mounted) { stream.getTracks().forEach(t => t.stop()); return; } streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play(); } setCameraStarting(false); } catch (err) { if (!mounted) return; const msg = err instanceof DOMException && err.name === "NotAllowedError" ? "Camera access denied. Please allow camera permission." : err instanceof DOMException && err.name === "NotFoundError" ? "No camera found. Use manual entry." : "Camera not available. Use manual entry."; setCameraError(msg); setCameraStarting(false); setTimeout(() => { if (mounted) setMode("manual"); }, 800); return; } // Initialize BarcodeDetector try { detectorRef.current = new window.BarcodeDetector({ formats: ["qr_code"] }); } catch { detectorRef.current = null; } // Scan loop const scan = async () => { if (!mounted || detected || !detectorRef.current || !videoRef.current) return; if (videoRef.current.readyState < 2) { scanRef.current = requestAnimationFrame(scan); return; } try { const barcodes = await detectorRef.current.detect(videoRef.current); if (barcodes.length > 0 && !detected) { setDetected(true); setScanSuccess(true); const raw = barcodes[0].rawValue; if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop()); setTimeout(() => { onClose(); if (onScanResult) { onScanResult(raw); } else { router.push(`/trace/${encodeURIComponent(raw)}`); } }, 800); return; } } catch { // Ignore detection errors } scanRef.current = requestAnimationFrame(scan); }; scanRef.current = requestAnimationFrame(scan); } startCamera(); return () => { mounted = false; if (scanRef.current) cancelAnimationFrame(scanRef.current); if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop()); }; }, [mode, detected, onClose, router]); function handleManualSubmit(e: React.FormEvent) { e.preventDefault(); const trimmed = manualInput.trim(); if (!trimmed) return; setIsLoading(true); onClose(); router.push(`/trace/${encodeURIComponent(trimmed)}`); } function handleClose() { if (scanRef.current) cancelAnimationFrame(scanRef.current); if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop()); onClose(); } return ( {/* Mode toggle */}
{mode === "camera" ? (
{cameraStarting ? (

Starting camera...

) : cameraError ? (
{Icons.cameraOff("h-12 w-12")}

{cameraError}

) : ( <>
) : (
setManualInput(e.target.value.toUpperCase())} placeholder="e.g. TC-20260520-001" autoFocus className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400" />

Enter the lot number from any Route Trace sticker

)} ); }