Files
Client_Fe/src/components/hexa/views/face-photo-dialog.tsx
T
Dhananjaya99 a1b870fa8a first commit
2026-09-28 11:48:14 +05:30

287 lines
8.4 KiB
TypeScript

"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<Mode>("choose")
const [photo, setPhoto] = React.useState<NormalizedFacePhoto | null>(null)
const [error, setError] = React.useState<string | null>(null)
const [busy, setBusy] = React.useState(false)
const [saving, setSaving] = React.useState(false)
const videoRef = React.useRef<HTMLVideoElement>(null)
const streamRef = React.useRef<MediaStream | null>(null)
const fileRef = React.useRef<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Face photo</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div
className="relative mx-auto overflow-hidden rounded-lg border bg-muted"
style={{ width: 240, height: 320 }}
>
{mode === "camera" && (
<video
ref={videoRef}
playsInline
muted
className="size-full object-cover"
/>
)}
{mode === "preview" && photo && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photo.previewUrl}
alt={`Face photo for ${memberName}`}
className="size-full object-cover"
/>
)}
{mode === "choose" && (
<div className="flex size-full flex-col items-center justify-center gap-2 p-4 text-center">
<CameraIcon className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Face the camera straight on, with even lighting and no hat or sunglasses.
</p>
</div>
)}
{busy && (
<div className="absolute inset-0 flex items-center justify-center bg-background/70">
<Spinner className="size-6" />
</div>
)}
</div>
{photo && (
<p className="text-center text-xs text-muted-foreground">
{FACE_WIDTH}&times;{FACE_HEIGHT} &middot; {Math.round(photo.bytes / 1024)} KB
</p>
)}
{error && (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFile}
/>
</div>
<DialogFooter className="gap-2 sm:justify-between">
{mode === "preview" ? (
<>
<Button variant="outline" onClick={retake} disabled={saving}>
<RefreshCwIcon />
Retake
</Button>
<Button onClick={save} disabled={saving}>
{saving && <Spinner className="size-4" />}
Send to terminal
</Button>
</>
) : mode === "camera" ? (
<>
<Button variant="outline" onClick={retake} disabled={busy}>
Cancel
</Button>
<Button onClick={capture} disabled={busy}>
<CameraIcon />
Capture
</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => fileRef.current?.click()} disabled={busy}>
<UploadIcon />
Upload photo
</Button>
<Button onClick={startCamera} disabled={busy}>
<CameraIcon />
Use camera
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export { FacePhotoDialog }