- Apply Climate Monitoring design system to all 81 files - Replace 931 hardcoded color references with CSS variables - Consistent theming: --color-primary, --color-text-*, --color-bg-* - Status colors: --color-error, --color-warning, --color-accent
301 lines
15 KiB
TypeScript
301 lines
15 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { QRCodeSVG as QRCode } from 'qrcode.react';
|
|
import { User, CheckCircle, XCircle, UserPlus, LogOut, Search, AlertTriangle } from 'lucide-react';
|
|
import { visitorsApi, Visitor, ActiveVisitor } from '../lib/visitorsApi';
|
|
import VisitorCheckIn, { VisitorKioskShell } from '../components/aura/VisitorCheckIn';
|
|
|
|
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 [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);
|
|
|
|
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 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');
|
|
setSearchQuery('');
|
|
setSearchResults([]);
|
|
setError(null);
|
|
setSuccessData(null);
|
|
};
|
|
|
|
// 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]);
|
|
|
|
if (mode === 'new-visitor') {
|
|
return (
|
|
<VisitorCheckIn
|
|
onBack={resetToHome}
|
|
onSuccess={(data) => {
|
|
setSuccessData(data);
|
|
setMode('success');
|
|
loadActiveVisitors();
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Dynamic Title and Subtitle based on mode
|
|
let shellTitle = "Welcome";
|
|
let shellSubtitle = "Please select an option to continue";
|
|
|
|
if (mode === 'returning') {
|
|
shellTitle = "Returning Visitor";
|
|
shellSubtitle = "Quick check-in for registered visitors";
|
|
} else if (mode === 'success') {
|
|
shellTitle = "Success";
|
|
shellSubtitle = "Action completed successfully";
|
|
}
|
|
|
|
return (
|
|
<VisitorKioskShell
|
|
onBack={mode !== 'home' ? resetToHome : undefined}
|
|
title={mode !== 'success' ? shellTitle : undefined}
|
|
subtitle={mode !== 'success' ? shellSubtitle : undefined}
|
|
>
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<div className="mb-6 bg-red-50 text-red-600 border border-red-100 rounded-xl p-4 flex items-center gap-3">
|
|
<AlertTriangle className="text-red-500" size={20} />
|
|
<span>{error}</span>
|
|
<button onClick={() => setError(null)} className="ml-auto text-red-400 hover:text-red-500">
|
|
<XCircle size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Home Screen */}
|
|
{mode === 'home' && (
|
|
<div className="space-y-4">
|
|
<button
|
|
onClick={() => setMode('new-visitor')}
|
|
className="w-full bg-white hover:bg-slate-50 border border-slate-200 hover:border-cyan-400 rounded-2xl p-6 text-left transition-all group shadow-sm hover:shadow-md flex items-center gap-5"
|
|
>
|
|
<div className="w-14 h-14 rounded-full bg-cyan-50 text-cyan-500 flex items-center justify-center group-hover:scale-110 transition-transform">
|
|
<UserPlus size={28} />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-bold text-slate-800 group-hover:text-cyan-600 transition-colors">First Time Visitor</h3>
|
|
<p className="text-sm text-[var(--color-text-tertiary)]">Register and check in for your first visit</p>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setMode('returning')}
|
|
className="w-full bg-white hover:bg-slate-50 border border-slate-200 hover:border-blue-400 rounded-2xl p-6 text-left transition-all group shadow-sm hover:shadow-md flex items-center gap-5"
|
|
>
|
|
<div className="w-14 h-14 rounded-full bg-blue-50 text-[var(--color-accent)] flex items-center justify-center group-hover:scale-110 transition-transform">
|
|
<User size={28} />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-bold text-slate-800 group-hover:text-blue-600 transition-colors">Returning Visitor</h3>
|
|
<p className="text-sm text-[var(--color-text-tertiary)]">Quick check-in for registered visitors</p>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Check Out Section */}
|
|
{activeVisitors.length > 0 && (
|
|
<div className="mt-8 pt-8 border-t border-slate-100">
|
|
<h3 className="text-sm font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-4 flex items-center justify-center gap-2">
|
|
<LogOut size={16} />
|
|
Check Out ({activeVisitors.length} on-site)
|
|
</h3>
|
|
<div className="grid gap-2 max-h-48 overflow-y-auto pr-1">
|
|
{activeVisitors.map(visitor => (
|
|
<button
|
|
key={visitor.logId}
|
|
onClick={() => handleCheckOut(visitor)}
|
|
className="w-full bg-slate-50 hover:bg-red-50 hover:border-red-200 border border-transparent rounded-xl p-3 flex items-center justify-between transition-colors group"
|
|
>
|
|
<div className="text-left">
|
|
<span className="text-slate-700 font-medium group-hover:text-red-700">{visitor.name}</span>
|
|
{visitor.company && (
|
|
<span className="text-[var(--color-text-tertiary)] ml-2 text-xs">({visitor.company})</span>
|
|
)}
|
|
</div>
|
|
<span className="text-xs bg-white text-[var(--color-text-tertiary)] px-2 py-1 rounded border border-slate-100 group-hover:border-red-100">
|
|
#{visitor.badgeNumber}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Returning Visitor Search */}
|
|
{mode === 'returning' && (
|
|
<div>
|
|
<div className="flex gap-2 mb-6">
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]" size={20} />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
className="w-full bg-slate-50 border border-slate-200 rounded-xl pl-12 pr-4 py-3 text-slate-900 text-lg focus:border-cyan-500 outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all"
|
|
placeholder="Search by name..."
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleSearch}
|
|
disabled={loading}
|
|
className="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-xl text-white font-medium shadow-md shadow-blue-500/20 transition-all"
|
|
>
|
|
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-white hover:bg-cyan-50 border border-slate-100 hover:border-cyan-200 rounded-xl p-4 flex items-center justify-between transition-all group shadow-sm"
|
|
>
|
|
<div>
|
|
<div className="text-slate-800 font-bold group-hover:text-cyan-700">{visitor.name}</div>
|
|
<div className="text-sm text-[var(--color-text-tertiary)] group-hover:text-cyan-600/80">
|
|
{visitor.company && `${visitor.company} • `}
|
|
{visitor.type}
|
|
</div>
|
|
</div>
|
|
<CheckCircle className="text-gray-200 group-hover:text-cyan-500 transition-colors" size={24} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{searchQuery && searchResults.length === 0 && !loading && (
|
|
<div className="text-center py-12 text-[var(--color-text-tertiary)] bg-slate-50 rounded-xl border border-dashed border-slate-200">
|
|
No visitors found. <br />Try a different search or register as new.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Success Screen */}
|
|
{mode === 'success' && successData && (
|
|
<div className="text-center py-6">
|
|
<div className="w-20 h-20 mx-auto mb-6 bg-emerald-100 text-[var(--color-primary)] rounded-full flex items-center justify-center animate-bounce-slow">
|
|
<CheckCircle size={40} />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-slate-900 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-4 rounded-3xl shadow-lg border border-slate-100">
|
|
<QRCode
|
|
size={240}
|
|
style={{ height: 240, width: 240 }}
|
|
value={`${window.location.origin}/badges/${successData.visitId || 'demo-token'}`}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-slate-800 font-bold text-lg mb-1">Scan for Digital Badge</p>
|
|
<p className="text-[var(--color-text-tertiary)] text-sm">Use your phone to carry your badge with you.</p>
|
|
</div>
|
|
|
|
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-4 inline-block">
|
|
<p className="text-[var(--color-text-tertiary)] text-xs mb-1 uppercase tracking-wider font-semibold">Badge Number</p>
|
|
<p className="text-3xl font-mono font-bold text-slate-800">{successData.badgeNumber}</p>
|
|
</div>
|
|
|
|
<div className="pt-4">
|
|
<button
|
|
onClick={resetToHome}
|
|
className="w-full bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] 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-[var(--color-text-tertiary)]">Returning to home in 5 seconds...</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</VisitorKioskShell>
|
|
);
|
|
}
|