287 lines
8.4 KiB
TypeScript
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}×{FACE_HEIGHT} · {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 }
|