"use client" import * as React from "react" import { CameraIcon, RefreshCwIcon, UploadIcon } from "lucide-react" import { toast } from "sonner" import { setCustomerFacePhoto } from "@/lib/api/access-devices" import { FACE_HEIGHT, FACE_WIDTH, FacePhotoError, normalizeFacePhoto, type NormalizedFacePhoto, } from "@/lib/face-photo" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Spinner } from "@/components/ui/spinner" interface FacePhotoDialogProps { open: boolean onOpenChange: (open: boolean) => void customerId: string memberName: string onSaved?: () => void } type Mode = "choose" | "camera" | "preview" function FacePhotoDialog({ open, onOpenChange, customerId, memberName, onSaved, }: FacePhotoDialogProps) { const [mode, setMode] = React.useState("choose") const [photo, setPhoto] = React.useState(null) const [error, setError] = React.useState(null) const [busy, setBusy] = React.useState(false) const [saving, setSaving] = React.useState(false) const videoRef = React.useRef(null) const streamRef = React.useRef(null) const fileRef = React.useRef(null) const stopCamera = React.useCallback(() => { streamRef.current?.getTracks().forEach((track) => track.stop()) streamRef.current = null }, []) // Unmount safety net: the camera must be released even if the tree goes away without // the close handler running. React.useEffect(() => stopCamera, [stopCamera]) // Reset on the close event rather than in an effect, so state changes stay in the // event handler and don't cascade an extra render. function handleOpenChange(next: boolean) { if (!next) { stopCamera() setMode("choose") setPhoto(null) setError(null) setBusy(false) } onOpenChange(next) } async function startCamera() { setError(null) try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 }, }, audio: false, }) streamRef.current = stream setMode("camera") // The element only exists after the mode switch renders. requestAnimationFrame(() => { if (videoRef.current) { videoRef.current.srcObject = stream void videoRef.current.play() } }) } catch (err) { const name = err instanceof Error ? err.name : "" setError( name === "NotAllowedError" ? "Camera access was blocked. Allow the camera for this site, or upload a photo instead." : name === "NotFoundError" ? "No camera was found on this device. Upload a photo instead." : "Could not start the camera. Upload a photo instead." ) } } async function capture() { if (!videoRef.current) return setBusy(true) setError(null) try { const normalized = await normalizeFacePhoto(videoRef.current) stopCamera() setPhoto(normalized) setMode("preview") } catch (err) { setError(err instanceof FacePhotoError ? err.message : "Could not capture that frame.") } finally { setBusy(false) } } async function handleFile(event: React.ChangeEvent) { const file = event.target.files?.[0] // Allow picking the same file again after a rejection. event.target.value = "" if (!file) return setBusy(true) setError(null) try { const normalized = await normalizeFacePhoto(file) stopCamera() setPhoto(normalized) setMode("preview") } catch (err) { setError( err instanceof FacePhotoError ? err.message : "Could not read that image file." ) } finally { setBusy(false) } } async function save() { if (!photo) return setSaving(true) setError(null) try { const result = await setCustomerFacePhoto(customerId, photo.base64) if (result.queuedToDevices === 0) { toast.warning("Photo saved, but no terminal is online", { description: "It will not enrol until an access terminal reconnects.", }) } else { toast.success("Face photo sent to the access terminal", { description: `Enrolling ${memberName}. The status updates here when the terminal confirms.`, }) } onSaved?.() handleOpenChange(false) } catch (err) { setError(err instanceof Error ? err.message : "Could not save the face photo.") } finally { setSaving(false) } } function retake() { setPhoto(null) setError(null) setMode("choose") } return ( Face photo Take or upload a clear, front-facing photo of {memberName} for the access terminal. The photo is sent straight to the terminal and is not stored here.
{mode === "camera" && (
{photo && (

{FACE_WIDTH}×{FACE_HEIGHT} · {Math.round(photo.bytes / 1024)} KB

)} {error && (

{error}

)}
{mode === "preview" ? ( <> ) : mode === "camera" ? ( <> ) : ( <> )}
) } export { FacePhotoDialog }