import { useCallback, useEffect, useId, useRef, useState } from 'react'; import type { WidgetTranslations } from '../i18n'; type CameraCopy = Pick; interface CameraCaptureProps { t: CameraCopy; onCapture: (file: File) => void; onUseUpload: () => void; } type CameraPhase = 'starting' | 'live' | 'error'; export type CameraSupportIssue = 'insecure' | 'unavailable' | null; export function cameraSupportIssue(): CameraSupportIssue { if (typeof window === 'undefined' || window.isSecureContext !== true) return 'insecure'; if (typeof navigator === 'undefined') return 'unavailable'; if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') return 'unavailable'; return null; } function cameraErrorMessage(error: unknown, t: CameraCopy): string { const name = error instanceof DOMException || error instanceof Error ? error.name : ''; if (name === 'NotAllowedError' || name === 'SecurityError') return t.cameraPermissionDenied; if (name === 'NotFoundError' || name === 'OverconstrainedError') return t.cameraNoDevice; if (name === 'NotReadableError' || name === 'AbortError') return t.cameraBusy; return t.cameraFailed; } export function CameraCapture({ t, onCapture, onUseUpload }: CameraCaptureProps) { const titleId = useId(); const videoRef = useRef(null); const captureRef = useRef(null); const streamRef = useRef(null); const requestEpochRef = useRef(0); const mountedRef = useRef(true); const [phase, setPhase] = useState('starting'); const [message, setMessage] = useState(t.cameraStarting); const stopCamera = useCallback(() => { const stream = streamRef.current; streamRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; stream?.getTracks().forEach((track) => track.stop()); }, []); const startCamera = useCallback(async () => { const supportIssue = cameraSupportIssue(); if (supportIssue) { setPhase('error'); setMessage(supportIssue === 'insecure' ? t.cameraInsecure : t.cameraUnavailable); return; } const requestEpoch = ++requestEpochRef.current; stopCamera(); setPhase('starting'); setMessage(t.cameraStarting); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: 'user' }, }); if (!mountedRef.current || requestEpoch !== requestEpochRef.current) { stream.getTracks().forEach((track) => track.stop()); return; } streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; void videoRef.current.play().catch(() => undefined); } setPhase('live'); setMessage(t.cameraPreview); } catch (error) { if (!mountedRef.current || requestEpoch !== requestEpochRef.current) return; stopCamera(); setPhase('error'); setMessage(cameraErrorMessage(error, t)); } }, [stopCamera, t]); useEffect(() => { mountedRef.current = true; void startCamera(); return () => { mountedRef.current = false; requestEpochRef.current += 1; stopCamera(); }; }, [startCamera, stopCamera]); useEffect(() => { if (phase === 'live') captureRef.current?.focus(); }, [phase]); const capture = () => { const video = videoRef.current; if (!video || video.videoWidth <= 0 || video.videoHeight <= 0) { stopCamera(); setPhase('error'); setMessage(t.cameraFailed); return; } const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const context = canvas.getContext('2d'); if (!context) { stopCamera(); setPhase('error'); setMessage(t.cameraFailed); return; } // The live preview is mirrored like a familiar selfie camera. Mirror the // canvas too so the reviewed JPEG is the frame the shopper actually saw. context.save(); context.translate(canvas.width, 0); context.scale(-1, 1); context.drawImage(video, 0, 0, canvas.width, canvas.height); context.restore(); stopCamera(); canvas.toBlob((blob) => { if (!mountedRef.current) return; if (!blob) { setPhase('error'); setMessage(t.cameraFailed); return; } onCapture(new File([blob], 'camera-capture.jpg', { type: blob.type || 'image/jpeg' })); }, 'image/jpeg', 0.92); }; const useUpload = () => { requestEpochRef.current += 1; stopCamera(); onUseUpload(); }; return (

{t.cameraTitle}

); }