ca-grow-ops-manager/frontend/src/pages/VisitorKioskPage.tsx
fullsizemalt b35c32279c
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s
fix(kiosk): Increase QR code size to 320px with fixed dimensions
- Removed maxWidth constraint that caused scaling
- Set explicit height/width to prevent shrinking
2025-12-11 15:59:49 -08:00

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>
);
}