146 lines
5.6 KiB
TypeScript
146 lines
5.6 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { Html5Qrcode } from 'html5-qrcode';
|
|
import { X, Camera, AlertCircle, ZoomIn, ZoomOut } from 'lucide-react';
|
|
|
|
interface BarcodeScannerProps {
|
|
onScan: (value: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function BarcodeScanner({ onScan, onClose }: BarcodeScannerProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [error, setError] = useState('');
|
|
const [zoom, setZoom] = useState(1);
|
|
const [zoomMax, setZoomMax] = useState(3);
|
|
const [supportsZoom, setSupportsZoom] = useState(false);
|
|
const initializedRef = useRef(false);
|
|
const scannerRef = useRef<Html5Qrcode | null>(null);
|
|
const trackRef = useRef<MediaStreamTrack | null>(null);
|
|
|
|
const applyZoom = useCallback(async (level: number, video?: HTMLVideoElement | null) => {
|
|
const v = video || (document.querySelector('#sr-barcode-scanner video') as HTMLVideoElement | null);
|
|
if (v && v.srcObject) {
|
|
if (trackRef.current && supportsZoom) {
|
|
try { await trackRef.current.applyConstraints({ advanced: [{ zoom: level }] as any }); } catch { }
|
|
}
|
|
(v as HTMLElement).style.transform = `scale(${level})`;
|
|
}
|
|
}, [supportsZoom]);
|
|
|
|
useEffect(() => {
|
|
// StrictMode guard: skip second effect run
|
|
if (initializedRef.current) return;
|
|
initializedRef.current = true;
|
|
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
|
|
const id = 'sr-barcode-scanner';
|
|
el.id = id;
|
|
el.innerHTML = '';
|
|
|
|
let cancelled = false;
|
|
const s = new Html5Qrcode(id);
|
|
scannerRef.current = s;
|
|
|
|
const startWithFacing = async (facingMode: 'environment' | 'user') => {
|
|
await s.start(
|
|
{ facingMode },
|
|
{ fps: 30 },
|
|
(decodedText) => {
|
|
if (cancelled) return;
|
|
try { s.stop(); } catch { }
|
|
onScan(decodedText);
|
|
},
|
|
() => {}
|
|
);
|
|
// Set up zoom after camera starts
|
|
setTimeout(() => {
|
|
if (cancelled) return;
|
|
const video = el.querySelector('video');
|
|
if (video && video.srcObject) {
|
|
(video as HTMLElement).style.transition = 'transform 0.2s';
|
|
(video as HTMLElement).style.transformOrigin = 'center center';
|
|
const track = (video.srcObject as MediaStream).getVideoTracks()[0];
|
|
if (track) {
|
|
trackRef.current = track;
|
|
const caps = track.getCapabilities() as any;
|
|
if (caps?.zoom) { setSupportsZoom(true); setZoomMax(caps.zoom.max || 3); }
|
|
}
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
startWithFacing('environment').catch(() => {
|
|
return startWithFacing('user');
|
|
}).catch((err: unknown) => {
|
|
if (cancelled) return;
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
const isIOS = navigator.userAgent.includes('iPhone') || navigator.userAgent.includes('iPad');
|
|
if (msg.includes('NotAllowed') || msg.includes('Permission')) {
|
|
setError(isIOS ? 'Camera denied. Settings → Safari → Camera → Allow.' : 'Camera permission denied.');
|
|
} else if (msg.includes('NotFound')) {
|
|
setError('No camera found on this device.');
|
|
} else {
|
|
setError(msg);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
try { s.stop(); } catch { }
|
|
};
|
|
}, []);
|
|
|
|
const handleZoomIn = () => {
|
|
const newZoom = Math.min(zoom + 0.5, zoomMax);
|
|
setZoom(newZoom);
|
|
applyZoom(newZoom);
|
|
};
|
|
const handleZoomOut = () => {
|
|
const newZoom = Math.max(zoom - 0.5, 0.5);
|
|
setZoom(newZoom);
|
|
applyZoom(newZoom);
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
|
<div className="bg-white rounded-2xl shadow-xl overflow-hidden w-full max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200">
|
|
<div className="flex items-center gap-2">
|
|
<Camera className="w-4 h-5 text-primary" />
|
|
<span className="text-sm font-bold text-slate-900">Scan Barcode</span>
|
|
</div>
|
|
<button onClick={onClose} className="p-1 hover:bg-slate-100 rounded-lg transition-colors">
|
|
<X className="w-5 h-5 text-slate-400" />
|
|
</button>
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="p-6 text-center space-y-4">
|
|
<div className="w-12 h-12 mx-auto rounded-full bg-red-50 flex items-center justify-center">
|
|
<AlertCircle className="w-6 h-6 text-red-500" />
|
|
</div>
|
|
<p className="text-sm text-slate-600">{error}</p>
|
|
<button onClick={onClose} className="px-6 py-2.5 bg-primary text-white rounded-xl text-sm font-medium">Close</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="relative w-full aspect-[4/3] overflow-hidden">
|
|
<div ref={containerRef} className="absolute inset-0 bg-black" />
|
|
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 rounded-full px-2 py-1.5 z-10">
|
|
<button onClick={handleZoomOut} className="p-1 text-white/70 hover:text-white" type="button"><ZoomOut className="w-4 h-4" /></button>
|
|
<span className="text-[10px] text-white font-mono min-w-[24px] text-center">{zoom.toFixed(1)}x</span>
|
|
<button onClick={handleZoomIn} className="p-1 text-white/70 hover:text-white" type="button"><ZoomIn className="w-4 h-4" /></button>
|
|
</div>
|
|
</div>
|
|
<p className="text-[11px] text-slate-500 text-center py-3 px-4">Position the barcode or QR code within the scanning area</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|