feat(ui): Integrate AuraUI Visitor Check-In
- Implemented AuraUI 'Sign Up 1' as new Visitor Check-In flow - Integrated high-tech split-screen layout with 'Secure Facility' branding - Moved camera and form logic to self-contained VisitorCheckIn component - Updated VisitorKioskPage to route to new experience
This commit is contained in:
parent
d44238417b
commit
2a607b23b5
2 changed files with 466 additions and 287 deletions
450
frontend/src/components/aura/VisitorCheckIn.tsx
Normal file
450
frontend/src/components/aura/VisitorCheckIn.tsx
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
User,
|
||||||
|
Mail,
|
||||||
|
FileText,
|
||||||
|
Camera,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
Building,
|
||||||
|
Phone,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle
|
||||||
|
} from "lucide-react";
|
||||||
|
import { visitorsApi } from "@/lib/visitorsApi";
|
||||||
|
|
||||||
|
const commonStyles = {
|
||||||
|
inputWrapper:
|
||||||
|
"relative group flex items-center w-full rounded-full overflow-hidden shadow-sm transition-all duration-300",
|
||||||
|
input:
|
||||||
|
"w-full py-3.5 pl-12 pr-4 text-sm text-slate-900 placeholder-transparent bg-white/70 backdrop-blur-md border border-slate-200 rounded-full focus:outline-none focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500 peer transition-all duration-300 disabled:opacity-50",
|
||||||
|
inputIcon:
|
||||||
|
"absolute left-4 text-slate-500 peer-focus:text-cyan-500 transition-colors w-5 h-5",
|
||||||
|
label:
|
||||||
|
"absolute left-12 text-slate-500 text-sm transition-all duration-200 peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-slate-400 peer-placeholder-shown:text-base peer-focus:top-1.5 peer-focus:text-xs peer-focus:text-cyan-600",
|
||||||
|
button:
|
||||||
|
"w-full py-3.5 px-6 font-semibold rounded-full bg-gradient-to-r from-cyan-600 to-blue-600 text-white shadow-lg hover:opacity-90 hover:shadow-cyan-500/30 transition-all duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
|
link: "font-medium text-cyan-600 hover:text-cyan-700 transition-colors hover:underline",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface VisitorCheckInProps {
|
||||||
|
onBack: () => void;
|
||||||
|
onSuccess: (data: { badgeNumber: string; visitId: string; message: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
company: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
purpose: '',
|
||||||
|
type: 'VISITOR' as const,
|
||||||
|
ndaAccepted: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Camera State
|
||||||
|
const [capturedPhoto, setCapturedPhoto] = useState<string | null>(null);
|
||||||
|
const [showCamera, setShowCamera] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'user', width: 640, height: 480 }
|
||||||
|
});
|
||||||
|
streamRef.current = stream;
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
}
|
||||||
|
setShowCamera(true);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Camera access denied:', err);
|
||||||
|
setError('Camera access denied. Please allow camera permissions.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const capturePhoto = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = videoRef.current.videoWidth;
|
||||||
|
canvas.height = videoRef.current.videoHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(videoRef.current, 0, 0);
|
||||||
|
const photoDataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||||||
|
setCapturedPhoto(photoDataUrl);
|
||||||
|
stopCamera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopCamera = () => {
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
setShowCamera(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const retakePhoto = () => {
|
||||||
|
setCapturedPhoto(null);
|
||||||
|
startCamera();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!formData.name || !formData.purpose) {
|
||||||
|
throw new Error("Name and Purpose are required.");
|
||||||
|
}
|
||||||
|
if (!formData.ndaAccepted) {
|
||||||
|
throw new Error("You must accept the NDA to check in.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create Visitor
|
||||||
|
const visitorData: any = {
|
||||||
|
name: formData.name,
|
||||||
|
purpose: formData.purpose,
|
||||||
|
type: formData.type,
|
||||||
|
company: formData.company || undefined,
|
||||||
|
email: formData.email || undefined,
|
||||||
|
phone: formData.phone || undefined,
|
||||||
|
photoUrl: capturedPhoto || undefined,
|
||||||
|
ndaAccepted: formData.ndaAccepted
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove empty strings
|
||||||
|
Object.keys(visitorData).forEach(key =>
|
||||||
|
(visitorData[key] === undefined || visitorData[key] === '') && delete visitorData[key]
|
||||||
|
);
|
||||||
|
|
||||||
|
const visitor = await visitorsApi.create(visitorData);
|
||||||
|
|
||||||
|
// 2. Check In
|
||||||
|
const result = await visitorsApi.checkIn(visitor.id, {
|
||||||
|
ndaAccepted: formData.ndaAccepted
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess({
|
||||||
|
badgeNumber: result.badgeNumber,
|
||||||
|
visitId: result.visitId,
|
||||||
|
message: `Welcome, ${visitor.name}!`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err.response?.data?.error || err.message || 'Failed to check in');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative bg-white min-h-screen">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen">
|
||||||
|
{/* Left Hero Section - Fixed/Sticky on Desktop */}
|
||||||
|
<div className="relative flex items-end px-6 pb-16 pt-20 lg:pt-60 lg:pb-24 bg-slate-900 lg:sticky lg:top-0 lg:h-screen overflow-hidden">
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1565553642973-6afe791aee33?q=80&w=2623&auto=format&fit=crop"
|
||||||
|
alt="Modern Facility Hallway"
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-40 mix-blend-overlay"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-900/80 to-transparent" />
|
||||||
|
|
||||||
|
{/* Animated Particles/Orbs could go here for more 'Aura' */}
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-lg">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="absolute -top-40 left-0 lg:hidden text-white/50 hover:text-white flex items-center gap-2 mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} /> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<span className="badge badge-accent bg-cyan-500/10 text-cyan-400 border-cyan-500/20 shadow-[0_0_15px_rgba(6,182,212,0.3)]">SECURE FACILITY</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-4xl sm:text-5xl font-bold text-white drop-shadow-2xl leading-tight">
|
||||||
|
Welcome to <br />
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500 animate-pulse-slow">
|
||||||
|
777 Wolfpack
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-4 text-lg text-slate-300 leading-relaxed">
|
||||||
|
Please complete the check-in process to receive your digital visitor badge and safety clearance.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-10 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-5">
|
||||||
|
{[
|
||||||
|
"Digital NDA Signature",
|
||||||
|
"Safety Briefing",
|
||||||
|
"Instant Badge Issuance",
|
||||||
|
"Escort Assignment",
|
||||||
|
].map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 text-slate-200 text-lg font-medium"
|
||||||
|
>
|
||||||
|
<CheckCircle className="text-cyan-500 w-5 h-5 flex-shrink-0" />
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Form Section - Scrollable */}
|
||||||
|
<div className="flex items-center justify-center px-6 py-16 lg:py-24 bg-gradient-to-br from-white via-slate-50 to-white relative">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="absolute top-8 left-8 hidden lg:flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} /> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-full max-w-md bg-white/80 backdrop-blur-xl shadow-2xl rounded-3xl p-8 lg:p-10 border border-white/50 ring-1 ring-slate-100">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 sm:text-4xl tracking-tight">
|
||||||
|
Visitor <span className="text-cyan-600">Check-In</span>
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Create your visitor profile for today.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-6 bg-red-50 text-red-600 px-4 py-3 rounded-xl text-sm flex items-start gap-3 border border-red-100">
|
||||||
|
<AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form className="mt-8 space-y-5" onSubmit={handleSubmit}>
|
||||||
|
{/* Name */}
|
||||||
|
<div className={commonStyles.inputWrapper}>
|
||||||
|
<User className={commonStyles.inputIcon} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Full Name *"
|
||||||
|
className={`${commonStyles.input} peer`}
|
||||||
|
id="fullname"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<label htmlFor="fullname" className={commonStyles.label}>
|
||||||
|
Full Name
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company */}
|
||||||
|
<div className={commonStyles.inputWrapper}>
|
||||||
|
<Building className={commonStyles.inputIcon} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Company / Organization"
|
||||||
|
className={`${commonStyles.input} peer`}
|
||||||
|
id="company"
|
||||||
|
value={formData.company}
|
||||||
|
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<label htmlFor="company" className={commonStyles.label}>
|
||||||
|
Company
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Email */}
|
||||||
|
<div className={commonStyles.inputWrapper}>
|
||||||
|
<Mail className={commonStyles.inputIcon} />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
className={`${commonStyles.input} peer`}
|
||||||
|
id="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<label htmlFor="email" className={commonStyles.label}>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/* Phone */}
|
||||||
|
<div className={commonStyles.inputWrapper}>
|
||||||
|
<Phone className={commonStyles.inputIcon} />
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
placeholder="Phone"
|
||||||
|
className={`${commonStyles.input} peer`}
|
||||||
|
id="phone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<label htmlFor="phone" className={commonStyles.label}>
|
||||||
|
Phone
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Purpose */}
|
||||||
|
<div className={commonStyles.inputWrapper}>
|
||||||
|
<FileText className={commonStyles.inputIcon} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Purpose of Visit *"
|
||||||
|
className={`${commonStyles.input} peer`}
|
||||||
|
id="purpose"
|
||||||
|
value={formData.purpose}
|
||||||
|
onChange={(e) => setFormData({ ...formData, purpose: e.target.value })}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<label htmlFor="purpose" className={commonStyles.label}>
|
||||||
|
Purpose of Visit
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visitor Type Selector */}
|
||||||
|
<div className={commonStyles.inputWrapper}>
|
||||||
|
<Shield className={commonStyles.inputIcon} />
|
||||||
|
<select
|
||||||
|
className={`${commonStyles.input} peer appearance-none`}
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<option value="VISITOR">Standard Visitor</option>
|
||||||
|
<option value="CONTRACTOR">Contractor</option>
|
||||||
|
<option value="INSPECTOR">Inspector</option>
|
||||||
|
<option value="VENDOR">Vendor</option>
|
||||||
|
<option value="DELIVERY">Delivery</option>
|
||||||
|
</select>
|
||||||
|
<label className={commonStyles.label}>
|
||||||
|
Visit Type
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Capture */}
|
||||||
|
<div className="relative group rounded-3xl overflow-hidden shadow-inner bg-slate-50 border border-slate-200 hover:border-cyan-400/50 transition-colors">
|
||||||
|
{!showCamera && !capturedPhoto && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startCamera}
|
||||||
|
className="w-full h-32 flex flex-col items-center justify-center gap-2 text-slate-400 hover:text-cyan-600 transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Camera className="w-8 h-8 opacity-50 group-hover:opacity-100" />
|
||||||
|
<span className="text-sm font-medium">Take Security Photo (Optional)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCamera && (
|
||||||
|
<div className="relative aspect-[4/3] bg-black">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-4 left-0 right-0 flex justify-center gap-4 px-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stopCamera}
|
||||||
|
className="px-4 py-2 bg-slate-800/80 text-white rounded-full text-sm hover:bg-slate-700 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={capturePhoto}
|
||||||
|
className="px-6 py-2 bg-cyan-500 text-white rounded-full text-sm font-bold shadow-lg shadow-cyan-500/30 hover:bg-cyan-400"
|
||||||
|
>
|
||||||
|
Capture
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capturedPhoto && (
|
||||||
|
<div className="relative aspect-[4/3] bg-black group">
|
||||||
|
<img
|
||||||
|
src={capturedPhoto}
|
||||||
|
alt="Captured"
|
||||||
|
className="w-full h-full object-cover opacity-90"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retakePhoto}
|
||||||
|
className="px-4 py-2 bg-red-500/90 text-white rounded-full text-sm font-medium flex items-center gap-2 hover:bg-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} /> Retake
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<span className="bg-emerald-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1 shadow-sm">
|
||||||
|
<CheckCircle size={10} /> Photo Ready
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NDA Checkbox */}
|
||||||
|
<label className="flex items-start gap-3 p-4 bg-slate-50 rounded-2xl cursor-pointer hover:bg-slate-100 transition-colors border border-transparent hover:border-slate-200">
|
||||||
|
<div className={`mt-0.5 w-5 h-5 rounded-full border flex items-center justify-center transition-all ${formData.ndaAccepted ? 'bg-cyan-500 border-cyan-500 text-white' : 'bg-white border-slate-300'}`}>
|
||||||
|
{formData.ndaAccepted && <CheckCircle size={14} />}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.ndaAccepted}
|
||||||
|
onChange={e => setFormData({ ...formData, ndaAccepted: e.target.checked })}
|
||||||
|
className="sr-only"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-slate-700 font-medium">Agreement Required</span>
|
||||||
|
<p className="text-slate-500 text-xs mt-0.5">I agree to the 777 Wolfpack Non-Disclosure Agreement and safety policies.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={commonStyles.button}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" size={20} /> Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Start Check-In <ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VisitorCheckIn;
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { QRCodeSVG as QRCode } from 'qrcode.react';
|
import { QRCodeSVG as QRCode } from 'qrcode.react';
|
||||||
import { User, Building, Clock, CheckCircle, XCircle, UserPlus, LogOut, Search, Shield, AlertTriangle, Camera, Trash2, Download, LayoutDashboard } from 'lucide-react';
|
import { User, Clock, CheckCircle, XCircle, UserPlus, LogOut, Search, Shield, AlertTriangle, Download, LayoutDashboard } from 'lucide-react';
|
||||||
import { visitorsApi, Visitor, ActiveVisitor } from '../lib/visitorsApi';
|
import { visitorsApi, Visitor, ActiveVisitor } from '../lib/visitorsApi';
|
||||||
|
import VisitorCheckIn from '../components/aura/VisitorCheckIn';
|
||||||
|
|
||||||
type KioskMode = 'home' | 'new-visitor' | 'returning' | 'check-in' | 'check-out' | 'success';
|
type KioskMode = 'home' | 'new-visitor' | 'returning' | 'check-in' | 'check-out' | 'success';
|
||||||
|
|
||||||
|
|
@ -16,67 +17,6 @@ export default function VisitorKioskPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [successData, setSuccessData] = useState<{ badgeNumber?: string; message: string; visitId?: string } | null>(null);
|
const [successData, setSuccessData] = useState<{ badgeNumber?: string; message: string; visitId?: string } | null>(null);
|
||||||
|
|
||||||
// Form state for new visitor
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
company: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
type: 'VISITOR' as const,
|
|
||||||
purpose: '',
|
|
||||||
ndaAccepted: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Photo capture state
|
|
||||||
const [capturedPhoto, setCapturedPhoto] = useState<string | null>(null);
|
|
||||||
const [showCamera, setShowCamera] = useState(false);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
|
||||||
|
|
||||||
const startCamera = async () => {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: { facingMode: 'user', width: 640, height: 480 }
|
|
||||||
});
|
|
||||||
streamRef.current = stream;
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.srcObject = stream;
|
|
||||||
}
|
|
||||||
setShowCamera(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Camera access denied:', err);
|
|
||||||
setError('Camera access denied. Please allow camera permissions.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const capturePhoto = () => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = videoRef.current.videoWidth;
|
|
||||||
canvas.height = videoRef.current.videoHeight;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (ctx) {
|
|
||||||
ctx.drawImage(videoRef.current, 0, 0);
|
|
||||||
const photoDataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
|
||||||
setCapturedPhoto(photoDataUrl);
|
|
||||||
stopCamera();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopCamera = () => {
|
|
||||||
if (streamRef.current) {
|
|
||||||
streamRef.current.getTracks().forEach(track => track.stop());
|
|
||||||
streamRef.current = null;
|
|
||||||
}
|
|
||||||
setShowCamera(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const retakePhoto = () => {
|
|
||||||
setCapturedPhoto(null);
|
|
||||||
startCamera();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadActiveVisitors();
|
loadActiveVisitors();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -104,46 +44,6 @@ export default function VisitorKioskPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNewVisitorSubmit = async () => {
|
|
||||||
if (!formData.name || !formData.purpose) {
|
|
||||||
setError('Name and purpose are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Only include non-empty fields to avoid validation errors on optional fields
|
|
||||||
const visitorData: Record<string, any> = {
|
|
||||||
name: formData.name,
|
|
||||||
purpose: formData.purpose,
|
|
||||||
type: formData.type
|
|
||||||
};
|
|
||||||
if (formData.company) visitorData.company = formData.company;
|
|
||||||
if (formData.email) visitorData.email = formData.email;
|
|
||||||
if (formData.phone) visitorData.phone = formData.phone;
|
|
||||||
if (capturedPhoto) visitorData.photoUrl = capturedPhoto;
|
|
||||||
|
|
||||||
const visitor = await visitorsApi.create(visitorData);
|
|
||||||
const result = await visitorsApi.checkIn(visitor.id, {
|
|
||||||
ndaAccepted: formData.ndaAccepted
|
|
||||||
});
|
|
||||||
|
|
||||||
setSuccessData({
|
|
||||||
badgeNumber: result.badgeNumber,
|
|
||||||
visitId: result.visitId,
|
|
||||||
message: `Welcome, ${visitor.name}!`
|
|
||||||
});
|
|
||||||
setMode('success');
|
|
||||||
loadActiveVisitors();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.error || 'Failed to check in');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReturningVisitorCheckIn = async (visitor: Visitor) => {
|
const handleReturningVisitorCheckIn = async (visitor: Visitor) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -189,17 +89,6 @@ export default function VisitorKioskPage() {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccessData(null);
|
setSuccessData(null);
|
||||||
setFormData({
|
|
||||||
name: '',
|
|
||||||
company: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
type: 'VISITOR',
|
|
||||||
purpose: '',
|
|
||||||
ndaAccepted: false
|
|
||||||
});
|
|
||||||
setCapturedPhoto(null);
|
|
||||||
stopCamera();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-reset success screen after 5 seconds ONLY for check-out (no badge number)
|
// Auto-reset success screen after 5 seconds ONLY for check-out (no badge number)
|
||||||
|
|
@ -210,6 +99,19 @@ export default function VisitorKioskPage() {
|
||||||
}
|
}
|
||||||
}, [mode, successData]);
|
}, [mode, successData]);
|
||||||
|
|
||||||
|
if (mode === 'new-visitor') {
|
||||||
|
return (
|
||||||
|
<VisitorCheckIn
|
||||||
|
onBack={resetToHome}
|
||||||
|
onSuccess={(data) => {
|
||||||
|
setSuccessData(data);
|
||||||
|
setMode('success');
|
||||||
|
loadActiveVisitors();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex flex-col">
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -310,179 +212,6 @@ export default function VisitorKioskPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New Visitor Form */}
|
|
||||||
{mode === 'new-visitor' && (
|
|
||||||
<div>
|
|
||||||
<button onClick={resetToHome} className="text-slate-400 hover:text-white mb-6 flex items-center gap-2">
|
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-6">New Visitor Registration</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-slate-400 mb-1">Full Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white text-lg focus:border-emerald-500 outline-none"
|
|
||||||
placeholder="John Smith"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-slate-400 mb-1">Company</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.company}
|
|
||||||
onChange={e => setFormData({ ...formData, company: e.target.value })}
|
|
||||||
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white focus:border-emerald-500 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-slate-400 mb-1">Visit Type</label>
|
|
||||||
<select
|
|
||||||
value={formData.type}
|
|
||||||
onChange={e => setFormData({ ...formData, type: e.target.value as any })}
|
|
||||||
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white focus:border-emerald-500 outline-none"
|
|
||||||
>
|
|
||||||
<option value="VISITOR">Visitor</option>
|
|
||||||
<option value="CONTRACTOR">Contractor</option>
|
|
||||||
<option value="INSPECTOR">Inspector</option>
|
|
||||||
<option value="VENDOR">Vendor</option>
|
|
||||||
<option value="DELIVERY">Delivery</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-slate-400 mb-1">Purpose of Visit *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.purpose}
|
|
||||||
onChange={e => setFormData({ ...formData, purpose: e.target.value })}
|
|
||||||
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white focus:border-emerald-500 outline-none"
|
|
||||||
placeholder="Meeting with..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Photo Capture Section */}
|
|
||||||
<div className="border border-dashed border-slate-600 rounded-xl p-4">
|
|
||||||
<label className="block text-sm text-slate-400 mb-3">Photo (Optional)</label>
|
|
||||||
|
|
||||||
{!capturedPhoto && !showCamera && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={startCamera}
|
|
||||||
className="w-full bg-slate-700 hover:bg-slate-600 text-white py-4 rounded-xl flex items-center justify-center gap-3 transition-colors"
|
|
||||||
>
|
|
||||||
<Camera size={24} />
|
|
||||||
<span>Take Photo</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCamera && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="relative rounded-xl overflow-hidden bg-black aspect-[4/3]">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={stopCamera}
|
|
||||||
className="flex-1 bg-slate-700 hover:bg-slate-600 text-white py-3 rounded-xl transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={capturePhoto}
|
|
||||||
className="flex-1 bg-emerald-500 hover:bg-emerald-600 text-white py-3 rounded-xl flex items-center justify-center gap-2 transition-colors"
|
|
||||||
>
|
|
||||||
<Camera size={20} />
|
|
||||||
Capture
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{capturedPhoto && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="relative rounded-xl overflow-hidden bg-black aspect-[4/3]">
|
|
||||||
<img
|
|
||||||
src={capturedPhoto}
|
|
||||||
alt="Captured"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute top-2 right-2">
|
|
||||||
<span className="bg-emerald-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
|
||||||
<CheckCircle size={12} />
|
|
||||||
Photo Captured
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={retakePhoto}
|
|
||||||
className="w-full bg-slate-700 hover:bg-slate-600 text-white py-3 rounded-xl flex items-center justify-center gap-2 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 size={18} />
|
|
||||||
Retake Photo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-slate-400 mb-1">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
|
||||||
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white focus:border-emerald-500 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-slate-400 mb-1">Phone</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
|
||||||
className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white focus:border-emerald-500 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 p-4 bg-slate-800/50 rounded-xl cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.ndaAccepted}
|
|
||||||
onChange={e => setFormData({ ...formData, ndaAccepted: e.target.checked })}
|
|
||||||
className="w-5 h-5 rounded"
|
|
||||||
/>
|
|
||||||
<span className="text-white">I agree to the facility Non-Disclosure Agreement</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleNewVisitorSubmit}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white font-bold py-4 rounded-xl transition-colors text-lg"
|
|
||||||
>
|
|
||||||
{loading ? 'Processing...' : 'Check In'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Returning Visitor Search */}
|
{/* Returning Visitor Search */}
|
||||||
{mode === 'returning' && (
|
{mode === 'returning' && (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue