first commit
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
"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 }
|
||||
Reference in New Issue
Block a user