import { useCallback, useEffect, useRef, useState } from 'react';
import GlassSurface from '../ui/GlassSurface.js';
import ScannerToast from './ScannerToast.js';
import ScannerCountPill from './ScannerCountPill.js';
import ScannerScanPeek from './ScannerScanPeek.js';
import ScannerDisambiguation from './ScannerDisambiguation.js';
import { useScannerSound } from '../../lib/use-scanner-sound.js';
import { useScannerFlash } from '../../lib/use-scanner-flash.js';
const TOAST_DURATION_MS = 2500;
function DetectionFrame({ card, videoMetrics }) {
return (
);
}
function ChromeIconButton({
onClick,
label,
pressed,
busy,
children,
className = '',
}) {
return (
);
}
export default function ScannerCamera({
queue,
camera,
identification,
onBack = () => {},
onOpenCheckout = () => {},
onGalleryIdentify,
latestPeekCard = null,
cartCount,
isCheckoutOpen = false,
verificationPausedRef,
}) {
const [toast, setToast] = useState({ message: '', visible: false, type: 'success' });
const [galleryBusy, setGalleryBusy] = useState(false);
const toastTimerRef = useRef(null);
const prevCountRef = useRef(queue.scannedCards?.length ?? 0);
const galleryInputRef = useRef(null);
const {
videoRef,
canvasRef,
detectionCanvasRef,
isStreaming,
trackedCards,
videoMetrics,
startCamera,
streamRef,
facingMode,
switchFacingMode,
} = camera;
const sound = useScannerSound();
const flash = useScannerFlash(streamRef);
const resolvedCartCount = cartCount ?? queue.scannedCards?.length ?? 0;
useEffect(() => {
startCamera();
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount
}, []);
const currentCount = queue.scannedCards?.length ?? 0;
useEffect(() => {
if (currentCount > prevCountRef.current) {
const newest = queue.scannedCards[0];
const cardName = newest?.name || newest?.card?.name || 'Card';
navigator.vibrate?.(50);
sound.playSuccess();
clearTimeout(toastTimerRef.current);
setToast({ message: `${cardName} added`, visible: true, type: 'success' });
toastTimerRef.current = setTimeout(() => {
setToast((t) => ({ ...t, visible: false }));
}, TOAST_DURATION_MS);
}
prevCountRef.current = currentCount;
}, [currentCount]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only reacts to count
useEffect(() => {
return () => clearTimeout(toastTimerRef.current);
}, []);
useEffect(() => {
if (facingMode === 'user' && flash.flashOn) {
flash.toggleFlash();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to facing mode changes
}, [facingMode]);
const handleGalleryClick = useCallback(() => {
galleryInputRef.current?.click();
}, []);
const handleGalleryChange = useCallback(
async (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
const identify =
onGalleryIdentify ||
identification.identifyFromGalleryFile?.bind(identification);
if (!identify) return;
setGalleryBusy(true);
try {
await identify(file);
} finally {
setGalleryBusy(false);
}
},
[identification, onGalleryIdentify]
);
const foundCards = trackedCards.filter(
(c) => c.status === 'confirmed' || c.status === 'scanned'
);
const hasMetrics = videoMetrics.width > 0 && videoMetrics.height > 0;
const showFlash = flash.flashSupported && facingMode === 'environment';
const scanStatus = isCheckoutOpen
? 'Checkout open'
: isStreaming
? 'Scanning…'
: 'Starting camera…';
const switchCameraLabel =
facingMode === 'environment' ? 'Switch to front camera' : 'Switch to rear camera';
return (
{isStreaming &&
hasMetrics &&
foundCards.map((card) => (
))}
{isStreaming && (
LIVE
)}
{!isStreaming && (
)}
Scan Cards
{galleryBusy ? (
) : (
)}
{isStreaming && (
{showFlash ? (
) : (
)}
{showFlash && (
)}
{scanStatus}
)}
{identification.disambiguation && (
)}
);
}