From 2a607b23b57be590d18904976fdca57fb6b3bc8f Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:37:43 -0800 Subject: [PATCH] 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 --- .../src/components/aura/VisitorCheckIn.tsx | 450 ++++++++++++++++++ frontend/src/pages/VisitorKioskPage.tsx | 303 +----------- 2 files changed, 466 insertions(+), 287 deletions(-) create mode 100644 frontend/src/components/aura/VisitorCheckIn.tsx diff --git a/frontend/src/components/aura/VisitorCheckIn.tsx b/frontend/src/components/aura/VisitorCheckIn.tsx new file mode 100644 index 0000000..997a5ce --- /dev/null +++ b/frontend/src/components/aura/VisitorCheckIn.tsx @@ -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(null); + const [formData, setFormData] = useState({ + name: '', + company: '', + email: '', + phone: '', + purpose: '', + type: 'VISITOR' as const, + ndaAccepted: false + }); + + // Camera State + const [capturedPhoto, setCapturedPhoto] = useState(null); + const [showCamera, setShowCamera] = useState(false); + const videoRef = useRef(null); + const streamRef = useRef(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 ( +
+
+ {/* Left Hero Section - Fixed/Sticky on Desktop */} +
+ Modern Facility Hallway +
+ + {/* Animated Particles/Orbs could go here for more 'Aura' */} + +
+ + +
+ SECURE FACILITY +
+

+ Welcome to
+ + 777 Wolfpack + +

+

+ Please complete the check-in process to receive your digital visitor badge and safety clearance. +

+
    + {[ + "Digital NDA Signature", + "Safety Briefing", + "Instant Badge Issuance", + "Escort Assignment", + ].map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+ + {/* Right Form Section - Scrollable */} +
+ + +
+

+ Visitor Check-In +

+

+ Create your visitor profile for today. +

+ + {error && ( +
+ + {error} +
+ )} + + {/* Form */} +
+ {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + required + disabled={loading} + /> + +
+ + {/* Company */} +
+ + setFormData({ ...formData, company: e.target.value })} + disabled={loading} + /> + +
+ +
+ {/* Email */} +
+ + setFormData({ ...formData, email: e.target.value })} + disabled={loading} + /> + +
+ {/* Phone */} +
+ + setFormData({ ...formData, phone: e.target.value })} + disabled={loading} + /> + +
+
+ + {/* Purpose */} +
+ + setFormData({ ...formData, purpose: e.target.value })} + required + disabled={loading} + /> + +
+ + {/* Visitor Type Selector */} +
+ + + +
+ + {/* Photo Capture */} +
+ {!showCamera && !capturedPhoto && ( + + )} + + {showCamera && ( +
+
+ )} + + {capturedPhoto && ( +
+ Captured +
+ +
+
+ + Photo Ready + +
+
+ )} +
+ + {/* NDA Checkbox */} + + + +
+
+
+
+
+ ); +}; + +export default VisitorCheckIn; diff --git a/frontend/src/pages/VisitorKioskPage.tsx b/frontend/src/pages/VisitorKioskPage.tsx index 8723c8e..8e1c944 100644 --- a/frontend/src/pages/VisitorKioskPage.tsx +++ b/frontend/src/pages/VisitorKioskPage.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; 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 VisitorCheckIn from '../components/aura/VisitorCheckIn'; type KioskMode = 'home' | 'new-visitor' | 'returning' | 'check-in' | 'check-out' | 'success'; @@ -16,67 +17,6 @@ export default function VisitorKioskPage() { const [error, setError] = useState(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(null); - const [showCamera, setShowCamera] = useState(false); - const videoRef = useRef(null); - const streamRef = useRef(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(); }, []); @@ -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 = { - 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); @@ -189,17 +89,6 @@ export default function VisitorKioskPage() { 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) @@ -210,6 +99,19 @@ export default function VisitorKioskPage() { } }, [mode, successData]); + if (mode === 'new-visitor') { + return ( + { + setSuccessData(data); + setMode('success'); + loadActiveVisitors(); + }} + /> + ); + } + return (
{/* Header */} @@ -310,179 +212,6 @@ export default function VisitorKioskPage() {
)} - {/* New Visitor Form */} - {mode === 'new-visitor' && ( -
- - -

New Visitor Registration

- -
-
- - 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" - /> -
- -
-
- - 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" - /> -
-
- - -
-
- -
- - 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..." - /> -
- - {/* Photo Capture Section */} -
- - - {!capturedPhoto && !showCamera && ( - - )} - - {showCamera && ( -
-
-
-
- - -
-
- )} - - {capturedPhoto && ( -
-
- Captured -
- - - Photo Captured - -
-
- -
- )} -
- -
-
- - 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" - /> -
-
- - 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" - /> -
-
- - - - -
-
- )} - {/* Returning Visitor Search */} {mode === 'returning' && (