ca-grow-ops-manager/frontend/src/pages/VisitorKioskPage.tsx
fullsizemalt 38f9ef5f0b
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run
style: Complete visual refactor with CSS variable tokens
- 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
2025-12-27 11:55:09 -08:00

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