'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { ticketsApi, eventsApi, Event, TicketValidationResult, LiveSearchResult } from '@/lib/api'; import { QrCodeIcon, CheckCircleIcon, XCircleIcon, XMarkIcon, MagnifyingGlassIcon, ArrowPathIcon, ClockIcon, UserIcon, ArrowLeftIcon, VideoCameraIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; import clsx from 'clsx'; // ─── Types ─────────────────────────────────────────────────── type ActiveTab = 'scan' | 'search' | 'recent'; type ScanState = 'idle' | 'scanning' | 'valid' | 'invalid'; type InvalidReason = 'already_checked_in' | 'cancelled' | 'not_found' | 'pending' | 'wrong_event' | 'unknown'; interface ScanResultData { state: ScanState; validation?: TicketValidationResult; invalidReason?: InvalidReason; error?: string; } interface RecentCheckin { name: string; time: string; ticketId: string; } // ─── Haptic + Sound Feedback ───────────────────────────────── function playSuccessSound() { try { const ctx = new AudioContext(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = 880; osc.type = 'sine'; gain.gain.value = 0.3; osc.start(); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15); osc.stop(ctx.currentTime + 0.15); } catch {} } function playErrorSound() { try { const ctx = new AudioContext(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = 300; osc.type = 'square'; gain.gain.value = 0.2; osc.start(); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3); osc.stop(ctx.currentTime + 0.3); } catch {} } function vibrate(pattern: number | number[]) { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch {} } // ─── Stop all tracks on a media stream ─────────────────────── function stopAllTracks() { try { // Find all video elements and stop their streams document.querySelectorAll('video').forEach((video) => { const stream = video.srcObject as MediaStream | null; if (stream) { stream.getTracks().forEach((track) => track.stop()); video.srcObject = null; } }); } catch {} } // ─── QR Scanner Component ──────────────────────────────────── // This component fully mounts/unmounts — use a key prop externally // to force a fresh instance when the scan tab becomes active. function QRScanner({ onScan, onError, }: { onScan: (code: string) => void; onError: () => void; }) { const containerRef = useRef(null); const scannerRef = useRef(null); const mountedRef = useRef(true); const elementId = useRef(`qr-scanner-${Date.now()}`); const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment'); const [ready, setReady] = useState(false); // Full cleanup helper const destroyScanner = useCallback(async () => { if (scannerRef.current) { try { await scannerRef.current.stop(); } catch {} try { scannerRef.current.clear(); } catch {} scannerRef.current = null; } stopAllTracks(); }, []); // Start scanner on mount, destroy on unmount useEffect(() => { mountedRef.current = true; let cancelled = false; const init = async () => { const container = containerRef.current; if (!container) return; // Create a fresh div for the scanner const id = elementId.current; container.innerHTML = ''; const div = document.createElement('div'); div.id = id; div.style.width = '100%'; div.style.height = '100%'; container.appendChild(div); try { const { Html5Qrcode } = await import('html5-qrcode'); if (cancelled) return; const scanner = new Html5Qrcode(id); scannerRef.current = scanner; await scanner.start( { facingMode }, { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 }, (decodedText: string) => { if (mountedRef.current) onScan(decodedText); }, () => {} ); if (cancelled) { await destroyScanner(); return; } // Force layout recalculation after camera starts requestAnimationFrame(() => { if (container) { container.style.display = 'none'; // eslint-disable-next-line @typescript-eslint/no-unused-expressions container.offsetHeight; // force reflow container.style.display = ''; } if (mountedRef.current) setReady(true); }); } catch (error: any) { console.error('Scanner error:', error); if (!cancelled && mountedRef.current) { toast.error('Failed to start camera. Check permissions.'); onError(); } } }; init(); return () => { cancelled = true; mountedRef.current = false; destroyScanner(); }; }, [facingMode]); // restart when camera flips // Handle browser visibility change (suspend/resume) useEffect(() => { const handleVisibility = () => { if (document.visibilityState === 'hidden') { destroyScanner(); } else if (document.visibilityState === 'visible' && mountedRef.current) { // Re-trigger by flipping facingMode back-and-forth (forces useEffect re-run) setFacingMode((prev) => { // Toggle and toggle back to trigger the effect const temp = prev === 'environment' ? 'user' : 'environment'; setTimeout(() => { if (mountedRef.current) setFacingMode(prev); }, 100); return temp; }); } }; document.addEventListener('visibilitychange', handleVisibility); return () => document.removeEventListener('visibilitychange', handleVisibility); }, [destroyScanner]); const switchCamera = () => { setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment')); }; return (
{ready && ( )} {!ready && (

Starting camera...

)}
); } // ─── Fullscreen Valid Ticket State ─────────────────────────── function ValidTicketScreen({ validation, onConfirmCheckin, onClose, checkingIn, }: { validation: TicketValidationResult; onConfirmCheckin: () => void; onClose: () => void; checkingIn: boolean; }) { return (
{/* Close button (dismiss without check-in) */}

{validation.ticket?.attendeeName || 'Guest'}

{validation.ticket?.attendeeEmail && (

{validation.ticket.attendeeEmail}

)}
{validation.event && (

{validation.event.title}

)}

Ticket: {validation.ticket?.id.slice(0, 12)}...

); } // ─── Fullscreen Invalid Ticket State ───────────────────────── function InvalidTicketScreen({ reason, validation, error, onScanNext, }: { reason: InvalidReason; validation?: TicketValidationResult; error?: string; onScanNext: () => void; }) { const reasonText: Record = { already_checked_in: 'Already Checked In', cancelled: 'Ticket Cancelled', not_found: 'Ticket Not Found', pending: 'Payment Pending', wrong_event: 'Wrong Event', unknown: 'Invalid Ticket', }; const reasonDetail: Record = { already_checked_in: validation?.ticket?.checkinAt ? `Checked in at ${parseDate(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}` : 'This ticket was already used', cancelled: 'This ticket has been cancelled and is no longer valid.', not_found: error || 'No ticket matching this code was found.', pending: 'This ticket has not been paid for yet.', wrong_event: 'This ticket belongs to a different event.', unknown: error || 'This ticket could not be validated.', }; return (

{reasonText[reason]}

{reasonDetail[reason]}

{validation?.ticket && (

{validation.ticket.attendeeName}

{validation.ticket.attendeeEmail && (

{validation.ticket.attendeeEmail}

)}
)}
); } // ─── Search Tab Content ────────────────────────────────────── function SearchTab({ eventId, onSelectTicket, }: { eventId: string; onSelectTicket: (ticket: LiveSearchResult) => void; }) { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [hasSearched, setHasSearched] = useState(false); const inputRef = useRef(null); const debounceRef = useRef>(); // Auto-focus search field useEffect(() => { setTimeout(() => inputRef.current?.focus(), 100); }, []); // Debounced live search useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); if (query.trim().length < 2) { setResults([]); setHasSearched(false); return; } debounceRef.current = setTimeout(async () => { setLoading(true); setHasSearched(true); try { const { tickets } = await ticketsApi.searchLive(query.trim(), eventId || undefined); setResults(tickets); } catch (err: any) { console.error('Search error:', err); setResults([]); } finally { setLoading(false); } }, 300); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [query, eventId]); const statusBadge = (status: string) => { const config: Record = { confirmed: { bg: 'bg-emerald-100', text: 'text-emerald-700', label: 'Confirmed' }, checked_in: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Checked In' }, pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Pending' }, cancelled: { bg: 'bg-red-100', text: 'text-red-700', label: 'Cancelled' }, }; const c = config[status] || { bg: 'bg-gray-100', text: 'text-gray-700', label: status }; return ( {c.label} ); }; return (
{/* Search input */}
setQuery(e.target.value)} placeholder="Search by name, email, or ticket ID..." className="w-full pl-12 pr-4 py-4 bg-gray-800 border border-gray-700 rounded-2xl text-white placeholder:text-gray-500 text-lg focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent" autoComplete="off" autoCorrect="off" spellCheck={false} /> {loading && (
)}
{/* Results */}
{!hasSearched && query.length < 2 && (

Type at least 2 characters to search

)} {hasSearched && !loading && results.length === 0 && (

No tickets found

)} {results.map((ticket) => ( ))}
); } // ─── Ticket Detail View (from search) ──────────────────────── function TicketDetailView({ validation, onCheckin, onBack, checkingIn, }: { validation: TicketValidationResult; onCheckin: () => void; onBack: () => void; checkingIn: boolean; }) { const isValid = validation.status === 'valid' && validation.canCheckIn; const isCheckedIn = validation.status === 'already_checked_in'; const isPending = validation.status === 'pending_payment'; const isCancelled = validation.status === 'cancelled'; const bgColor = isValid ? 'bg-emerald-600' : isCheckedIn ? 'bg-blue-600' : isPending ? 'bg-amber-600' : 'bg-red-600'; return (
{/* Back button */}
{isValid ? ( ) : isCheckedIn ? ( ) : ( )}

{validation.ticket?.attendeeName || 'Unknown'}

{validation.ticket?.attendeeEmail && (

{validation.ticket.attendeeEmail}

)}

Status

{isValid ? 'Ready for Check-in' : isCheckedIn ? 'Already Checked In' : isPending ? 'Payment Pending' : isCancelled ? 'Cancelled' : 'Invalid'}

{validation.event && (

{validation.event.title}

)}

ID: {validation.ticket?.id.slice(0, 12)}...

{isValid ? ( ) : ( )}
); } // ─── Recent Tab Content ────────────────────────────────────── function RecentTab({ recentCheckins, sessionCount }: { recentCheckins: RecentCheckin[]; sessionCount: number }) { return (
{/* Session counter */}

{sessionCount}

Checked in this session

{/* Recent list */}
{recentCheckins.length === 0 ? (

No check-ins yet

) : (
{recentCheckins.map((checkin, i) => (

{checkin.name}

{checkin.ticketId.slice(0, 12)}...

{checkin.time}

))}
)}
); } // ═══════════════════════════════════════════════════════════════ // ─── Main Scanner Page ─────────────────────────────────────── // ═══════════════════════════════════════════════════════════════ export default function AdminScannerPage() { const { locale } = useLanguage(); const router = useRouter(); const { user } = useAuth(); // Determine back destination based on role (staff can only access scanner/events) const backHref = user?.role === 'staff' ? '/admin/events' : '/admin'; // Events const [events, setEvents] = useState([]); const [selectedEventId, setSelectedEventId] = useState(''); const [loading, setLoading] = useState(true); // Tabs const [activeTab, setActiveTab] = useState('scan'); // Scanner state const [scannerKey, setScannerKey] = useState(0); // increment to force remount const [scanResult, setScanResult] = useState({ state: 'idle' }); const [lastScannedCode, setLastScannedCode] = useState(''); const [checkingIn, setCheckingIn] = useState(false); // Search detail view const [searchDetailValidation, setSearchDetailValidation] = useState(null); // Stats const [checkinCount, setCheckinCount] = useState(0); const [recentCheckins, setRecentCheckins] = useState([]); // Event stats const [eventCheckedIn, setEventCheckedIn] = useState(0); const [eventCapacity, setEventCapacity] = useState(0); // Refs const selectedEventIdRef = useRef(''); const lastScannedCodeRef = useRef(''); useEffect(() => { selectedEventIdRef.current = selectedEventId; }, [selectedEventId]); useEffect(() => { lastScannedCodeRef.current = lastScannedCode; }, [lastScannedCode]); // Load events useEffect(() => { eventsApi.getAll() .then((res) => { const bookable = res.events.filter((e) => e.status === 'published' || e.status === 'unlisted'); setEvents(bookable); const upcoming = bookable.filter((e) => new Date(e.startDatetime) >= new Date()); if (upcoming.length === 1) { setSelectedEventId(upcoming[0].id); } }) .catch(console.error) .finally(() => setLoading(false)); }, []); // Load event check-in stats when event changes useEffect(() => { if (!selectedEventId) { setEventCheckedIn(0); setEventCapacity(0); return; } const loadStats = async () => { try { const stats = await ticketsApi.getCheckinStats(selectedEventId); setEventCapacity(stats.capacity); setEventCheckedIn(stats.checkedIn); } catch (err) { console.error('Failed to load event stats:', err); } }; loadStats(); }, [selectedEventId]); // When scan tab becomes active again or scan result is dismissed, bump key to remount scanner const scannerActive = activeTab === 'scan' && scanResult.state === 'idle' && !loading; // Validate ticket const validateTicket = useCallback(async (code: string) => { try { const result = await ticketsApi.validate(code, selectedEventIdRef.current || undefined); if (result.status === 'valid') { vibrate(100); playSuccessSound(); setScanResult({ state: 'valid', validation: result }); } else { vibrate([100, 50, 100]); playErrorSound(); let reason: InvalidReason = 'unknown'; if (result.status === 'already_checked_in') reason = 'already_checked_in'; else if (result.status === 'cancelled') reason = 'cancelled'; else if (result.status === 'pending_payment') reason = 'pending'; else if (result.status === 'wrong_event') reason = 'wrong_event'; else if (result.status === 'invalid') reason = 'not_found'; setScanResult({ state: 'invalid', validation: result, invalidReason: reason }); } } catch (error: any) { vibrate([100, 50, 100]); playErrorSound(); setScanResult({ state: 'invalid', invalidReason: 'not_found', error: error.message || 'Failed to validate ticket', }); } }, []); // Handle QR scan const handleScan = useCallback((decodedText: string) => { if (decodedText === lastScannedCodeRef.current) return; lastScannedCodeRef.current = decodedText; setLastScannedCode(decodedText); let code = decodedText; const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/); if (urlMatch) code = urlMatch[1]; validateTicket(code); }, [validateTicket]); // Check in ticket (from scan) const handleCheckin = async () => { if (!scanResult.validation?.ticket?.id) return; setCheckingIn(true); try { const result = await ticketsApi.checkin(scanResult.validation.ticket.id); vibrate(200); toast.success(`${result.ticket.attendeeName || 'Guest'} checked in!`); setCheckinCount((p) => p + 1); setEventCheckedIn((p) => p + 1); setRecentCheckins((prev) => [ { name: result.ticket.attendeeName || 'Guest', time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }), ticketId: scanResult.validation!.ticket!.id, }, ...prev.slice(0, 19), ]); // Auto-return to camera after 1.5s setTimeout(() => { resetScan(); }, 1500); } catch (error: any) { toast.error(error.message || 'Check-in failed'); } finally { setCheckingIn(false); } }; // Check in from search detail const handleSearchCheckin = async () => { if (!searchDetailValidation?.ticket?.id) return; setCheckingIn(true); try { const result = await ticketsApi.checkin(searchDetailValidation.ticket.id); vibrate(200); toast.success(`${result.ticket.attendeeName || 'Guest'} checked in!`); setCheckinCount((p) => p + 1); setEventCheckedIn((p) => p + 1); setRecentCheckins((prev) => [ { name: result.ticket.attendeeName || 'Guest', time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }), ticketId: searchDetailValidation!.ticket!.id, }, ...prev.slice(0, 19), ]); setSearchDetailValidation(null); } catch (error: any) { toast.error(error.message || 'Check-in failed'); } finally { setCheckingIn(false); } }; // Select search result → validate and show detail const handleSelectSearchResult = async (ticket: LiveSearchResult) => { try { const result = await ticketsApi.validate(ticket.qrCode || ticket.ticket_id, selectedEventIdRef.current || undefined); setSearchDetailValidation(result); } catch (err: any) { toast.error(err.message || 'Failed to load ticket details'); } }; // Reset scan state — bump key so scanner fully remounts const resetScan = () => { setScanResult({ state: 'idle' }); setLastScannedCode(''); lastScannedCodeRef.current = ''; setScannerKey((k) => k + 1); }; // Get selected event name const selectedEvent = events.find((e) => e.id === selectedEventId); if (loading) { return (
); } return (
{/* ── Sticky Header ── */}
{/* Back to dashboard */} {/* Event selector */}
{/* Live counter */} {selectedEventId && eventCapacity > 0 && (

{eventCheckedIn} / {eventCapacity}

Checked in

)}
{/* ── Tab Bar ── */}
{( [ { key: 'scan' as ActiveTab, label: 'Scan', icon: QrCodeIcon }, { key: 'search' as ActiveTab, label: 'Search', icon: MagnifyingGlassIcon }, { key: 'recent' as ActiveTab, label: 'Recent', icon: ClockIcon }, ] as const ).map((tab) => ( ))}
{/* ── Tab Content ── */}
{/* SCAN TAB — scanner fully unmounts when not active */} {scannerActive && ( {}} /> )} {activeTab === 'scan' && !scannerActive && scanResult.state !== 'idle' && (
)} {/* SEARCH TAB */} {activeTab === 'search' && ( )} {/* RECENT TAB */} {activeTab === 'recent' && ( )}
{/* ── Fullscreen overlays ── */} {scanResult.state === 'valid' && scanResult.validation && ( )} {scanResult.state === 'invalid' && ( )} {searchDetailValidation && ( setSearchDetailValidation(null)} checkingIn={checkingIn} /> )}
); }