- Removed maxWidth constraint that caused scaling - Set explicit height/width to prevent shrinking
589 lines
30 KiB
TypeScript
589 lines
30 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { QRCodeSVG as QRCode } from 'qrcode.react';
|
|
import { User, Building, Clock, CheckCircle, XCircle, UserPlus, LogOut, Search, Shield, AlertTriangle, Camera, Trash2 } from 'lucide-react';
|
|
import { visitorsApi, Visitor, ActiveVisitor } from '../lib/visitorsApi';
|
|
|
|
type KioskMode = 'home' | 'new-visitor' | 'returning' | 'check-in' | 'check-out' | 'success';
|
|
|
|
export default function VisitorKioskPage() {
|
|
const [mode, setMode] = useState<KioskMode>('home');
|
|
const [activeVisitors, setActiveVisitors] = useState<ActiveVisitor[]>([]);
|
|
const [searchResults, setSearchResults] = useState<Visitor[]>([]);
|
|
const [selectedVisitor, setSelectedVisitor] = useState<Visitor | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<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(() => {
|
|
loadActiveVisitors();
|
|
}, []);
|
|
|
|
const loadActiveVisitors = async () => {
|
|
try {
|
|
const { visitors } = await visitorsApi.getActive();
|
|
setActiveVisitors(visitors);
|
|
} catch (err) {
|
|
console.error('Failed to load active visitors:', err);
|
|
}
|
|
};
|
|
|
|
const handleSearch = async () => {
|
|
if (!searchQuery.trim()) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const { visitors } = await visitorsApi.getAll({ search: searchQuery });
|
|
setSearchResults(visitors);
|
|
} catch (err) {
|
|
setError('Search failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await visitorsApi.checkIn(visitor.id, {});
|
|
setSuccessData({
|
|
badgeNumber: result.badgeNumber,
|
|
visitId: result.visitId,
|
|
message: `Welcome back, ${visitor.name}!`
|
|
});
|
|
setMode('success');
|
|
loadActiveVisitors();
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.error || 'Failed to check in');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCheckOut = async (visitor: ActiveVisitor) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await visitorsApi.checkOut(visitor.visitorId);
|
|
setSuccessData({
|
|
message: `Goodbye, ${visitor.name}! Visit duration: ${result.duration} minutes`
|
|
});
|
|
setMode('success');
|
|
loadActiveVisitors();
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.error || 'Failed to check out');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const resetToHome = () => {
|
|
setMode('home');
|
|
setSelectedVisitor(null);
|
|
setSearchQuery('');
|
|
setSearchResults([]);
|
|
setError(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)
|
|
useEffect(() => {
|
|
if (mode === 'success' && !successData?.badgeNumber) {
|
|
const timer = setTimeout(resetToHome, 5000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [mode, successData]);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex flex-col">
|
|
{/* Header */}
|
|
<header className="bg-slate-800/50 border-b border-white/10 p-4">
|
|
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
|
<div className="flex items-center gap-3">
|
|
<Shield className="text-emerald-400" size={28} />
|
|
<div>
|
|
<h1 className="text-xl font-bold text-white">Visitor Check-In</h1>
|
|
<p className="text-xs text-slate-400">777 Wolfpack Facility</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
|
<Clock size={16} />
|
|
<span>{new Date().toLocaleTimeString()}</span>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-1 flex items-center justify-center p-8">
|
|
<div className="w-full max-w-2xl">
|
|
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<div className="mb-6 bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-center gap-3">
|
|
<AlertTriangle className="text-red-400" size={20} />
|
|
<span className="text-red-400">{error}</span>
|
|
<button onClick={() => setError(null)} className="ml-auto text-red-400 hover:text-red-300">
|
|
<XCircle size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Home Screen */}
|
|
{mode === 'home' && (
|
|
<div className="text-center">
|
|
<h2 className="text-3xl font-bold text-white mb-2">Welcome</h2>
|
|
<p className="text-slate-400 mb-8">Please select an option to continue</p>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<button
|
|
onClick={() => setMode('new-visitor')}
|
|
className="bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 rounded-2xl p-8 text-left transition-all group"
|
|
>
|
|
<UserPlus className="text-emerald-400 mb-4" size={48} />
|
|
<h3 className="text-xl font-bold text-white mb-2">First Time Visitor</h3>
|
|
<p className="text-sm text-slate-400">Register and check in for your first visit</p>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setMode('returning')}
|
|
className="bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-2xl p-8 text-left transition-all group"
|
|
>
|
|
<User className="text-blue-400 mb-4" size={48} />
|
|
<h3 className="text-xl font-bold text-white mb-2">Returning Visitor</h3>
|
|
<p className="text-sm text-slate-400">Quick check-in for registered visitors</p>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Check Out Section */}
|
|
{activeVisitors.length > 0 && (
|
|
<div className="mt-8 pt-8 border-t border-white/10">
|
|
<h3 className="text-lg font-bold text-white mb-4 flex items-center justify-center gap-2">
|
|
<LogOut size={20} />
|
|
Check Out ({activeVisitors.length} visitors on-site)
|
|
</h3>
|
|
<div className="grid gap-3 max-h-48 overflow-y-auto">
|
|
{activeVisitors.map(visitor => (
|
|
<button
|
|
key={visitor.logId}
|
|
onClick={() => handleCheckOut(visitor)}
|
|
className="bg-slate-800 hover:bg-slate-700 rounded-xl p-4 flex items-center justify-between transition-colors"
|
|
>
|
|
<div className="text-left">
|
|
<span className="text-white font-medium">{visitor.name}</span>
|
|
{visitor.company && (
|
|
<span className="text-slate-400 ml-2 text-sm">({visitor.company})</span>
|
|
)}
|
|
</div>
|
|
<span className="text-xs bg-emerald-500/20 text-emerald-400 px-2 py-1 rounded">
|
|
Badge: {visitor.badgeNumber}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</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 */}
|
|
{mode === 'returning' && (
|
|
<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">Find Your Record</h2>
|
|
|
|
<div className="flex gap-3 mb-6">
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
className="w-full bg-slate-800 border border-slate-700 rounded-xl pl-12 pr-4 py-3 text-white text-lg focus:border-emerald-500 outline-none"
|
|
placeholder="Search by name, email, or company..."
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleSearch}
|
|
disabled={loading}
|
|
className="bg-blue-500 hover:bg-blue-600 px-6 py-3 rounded-xl text-white font-medium"
|
|
>
|
|
Search
|
|
</button>
|
|
</div>
|
|
|
|
{searchResults.length > 0 && (
|
|
<div className="space-y-3">
|
|
{searchResults.map(visitor => (
|
|
<button
|
|
key={visitor.id}
|
|
onClick={() => handleReturningVisitorCheckIn(visitor)}
|
|
className="w-full bg-slate-800 hover:bg-slate-700 rounded-xl p-4 flex items-center justify-between transition-colors text-left"
|
|
>
|
|
<div>
|
|
<div className="text-white font-medium">{visitor.name}</div>
|
|
<div className="text-sm text-slate-400">
|
|
{visitor.company && `${visitor.company} • `}
|
|
{visitor.type}
|
|
</div>
|
|
</div>
|
|
<CheckCircle className="text-emerald-400" size={24} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{searchQuery && searchResults.length === 0 && !loading && (
|
|
<div className="text-center py-8 text-slate-400">
|
|
No visitors found. Try a different search or register as a new visitor.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Success Screen */}
|
|
{mode === 'success' && successData && (
|
|
<div className="text-center py-12">
|
|
<div className="w-24 h-24 mx-auto mb-6 bg-emerald-500/20 rounded-full flex items-center justify-center">
|
|
<CheckCircle className="text-emerald-400" size={48} />
|
|
</div>
|
|
<h2 className="text-3xl font-bold text-white mb-2">{successData.message}</h2>
|
|
|
|
{successData.badgeNumber && ( // Only show QR for Check-IN, not Check-OUT
|
|
<div className="mt-8 space-y-6">
|
|
<div className="inline-block bg-white p-8 rounded-3xl shadow-2xl">
|
|
<QRCode
|
|
size={320}
|
|
style={{ height: 320, width: 320 }}
|
|
value={`${window.location.origin}/badges/${successData.visitId || 'demo-token'}`}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-white font-bold text-lg mb-1">Scan for Digital Badge</p>
|
|
<p className="text-slate-400 text-sm">Use your phone to carry your badge with you.</p>
|
|
</div>
|
|
|
|
<div className="bg-slate-800 rounded-2xl p-4 inline-block">
|
|
<p className="text-slate-400 text-xs mb-1">Badge Number</p>
|
|
<p className="text-2xl font-bold font-mono text-emerald-400">{successData.badgeNumber}</p>
|
|
</div>
|
|
|
|
<div className="pt-8">
|
|
<button
|
|
onClick={resetToHome}
|
|
className="bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-lg px-8 py-4 rounded-xl transition-colors shadow-lg hover:shadow-emerald-500/20"
|
|
>
|
|
I have my badge
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!successData.badgeNumber && ( // Auto-redirect for Check-OUT only
|
|
<p className="mt-6 text-slate-400">Returning to home in 5 seconds...</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</main>
|
|
|
|
{/* Footer */}
|
|
<footer className="bg-slate-800/50 border-t border-white/10 p-4 text-center text-slate-500 text-sm">
|
|
<p>By signing in, you agree to follow all facility rules and regulations.</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|