Files
Spanglish/frontend/src/app/admin/scanner/page.tsx
Michilis 3dfb1689ad Booking flow: required terms and privacy checkbox with i18n
Also includes admin, dashboard, and API updates; PWA icon assets; and
assorted layout and utility changes on dev.
2026-04-27 03:21:15 +00:00

973 lines
35 KiB
TypeScript

'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<HTMLDivElement>(null);
const scannerRef = useRef<any>(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 (
<div className="relative w-full bg-black flex-1 min-h-0 overflow-hidden">
<div
ref={containerRef}
className="w-full h-full [&_video]:!object-cover [&_video]:!h-full [&_video]:!w-full"
/>
{ready && (
<button
onClick={switchCamera}
className="absolute top-3 right-3 z-10 bg-black/50 backdrop-blur-sm text-white p-2.5 rounded-full active:scale-95 transition-transform"
aria-label="Switch camera"
>
<VideoCameraIcon className="w-5 h-5" />
</button>
)}
{!ready && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<div className="text-center">
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
<p className="text-sm opacity-60">Starting camera...</p>
</div>
</div>
)}
</div>
);
}
// ─── Fullscreen Valid Ticket State ───────────────────────────
function ValidTicketScreen({
validation,
onConfirmCheckin,
onClose,
checkingIn,
}: {
validation: TicketValidationResult;
onConfirmCheckin: () => void;
onClose: () => void;
checkingIn: boolean;
}) {
return (
<div className="fixed inset-0 z-50 bg-emerald-600 flex flex-col animate-in fade-in duration-200">
{/* Close button (dismiss without check-in) */}
<div className="flex justify-end p-4">
<button
onClick={onClose}
className="p-2 rounded-full bg-white/20 text-white active:scale-95 transition-transform"
aria-label="Close"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center px-6 -mt-14 text-white">
<div className="w-24 h-24 rounded-full bg-white/20 flex items-center justify-center mb-6">
<CheckCircleIcon className="w-16 h-16 text-white" />
</div>
<h2 className="text-3xl font-bold mb-1 text-center">
{validation.ticket?.attendeeName || 'Guest'}
</h2>
{validation.ticket?.attendeeEmail && (
<p className="text-emerald-100 text-lg mb-4">{validation.ticket.attendeeEmail}</p>
)}
<div className="bg-white/15 rounded-2xl px-6 py-4 w-full max-w-sm space-y-2 text-center">
{validation.event && (
<p className="font-semibold text-lg">{validation.event.title}</p>
)}
<p className="text-emerald-100 font-mono text-sm">
Ticket: {validation.ticket?.id.slice(0, 12)}...
</p>
</div>
</div>
<div className="p-6 pb-safe">
<button
onClick={onConfirmCheckin}
disabled={checkingIn}
className="w-full py-5 bg-white text-emerald-700 font-bold text-xl rounded-2xl active:scale-[0.98] transition-transform disabled:opacity-50 flex items-center justify-center gap-3"
>
{checkingIn ? (
<div className="w-6 h-6 border-3 border-emerald-700 border-t-transparent rounded-full animate-spin" />
) : (
<>
<CheckCircleIcon className="w-7 h-7" />
Confirm Check-in
</>
)}
</button>
</div>
</div>
);
}
// ─── Fullscreen Invalid Ticket State ─────────────────────────
function InvalidTicketScreen({
reason,
validation,
error,
onScanNext,
}: {
reason: InvalidReason;
validation?: TicketValidationResult;
error?: string;
onScanNext: () => void;
}) {
const reasonText: Record<InvalidReason, string> = {
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<InvalidReason, string> = {
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 (
<div className="fixed inset-0 z-50 bg-red-600 flex flex-col animate-in fade-in duration-200">
<div className="flex-1 flex flex-col items-center justify-center px-6 text-white">
<div className="w-24 h-24 rounded-full bg-white/20 flex items-center justify-center mb-6">
<XCircleIcon className="w-16 h-16 text-white" />
</div>
<h2 className="text-3xl font-bold mb-2 text-center">{reasonText[reason]}</h2>
<p className="text-red-100 text-center text-lg max-w-sm">{reasonDetail[reason]}</p>
{validation?.ticket && (
<div className="bg-white/15 rounded-2xl px-6 py-4 mt-6 w-full max-w-sm space-y-1 text-center">
<p className="font-semibold text-lg">{validation.ticket.attendeeName}</p>
{validation.ticket.attendeeEmail && (
<p className="text-red-100 text-sm">{validation.ticket.attendeeEmail}</p>
)}
</div>
)}
</div>
<div className="p-6 pb-safe">
<button
onClick={onScanNext}
className="w-full py-5 bg-white text-red-700 font-bold text-xl rounded-2xl active:scale-[0.98] transition-transform flex items-center justify-center gap-3"
>
<ArrowPathIcon className="w-7 h-7" />
Scan Next
</button>
</div>
</div>
);
}
// ─── Search Tab Content ──────────────────────────────────────
function SearchTab({
eventId,
onSelectTicket,
}: {
eventId: string;
onSelectTicket: (ticket: LiveSearchResult) => void;
}) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<LiveSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// 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<string, { bg: string; text: string; label: string }> = {
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 (
<span className={clsx('inline-block px-2.5 py-1 rounded-full text-xs font-semibold', c.bg, c.text)}>
{c.label}
</span>
);
};
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Search input */}
<div className="px-4 pt-4 pb-3">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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 && (
<div className="absolute right-4 top-1/2 -translate-y-1/2">
<div className="w-5 h-5 border-2 border-gray-500 border-t-primary-yellow rounded-full animate-spin" />
</div>
)}
</div>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto px-4 pb-4 space-y-2">
{!hasSearched && query.length < 2 && (
<div className="text-center text-gray-500 pt-12">
<MagnifyingGlassIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">Type at least 2 characters to search</p>
</div>
)}
{hasSearched && !loading && results.length === 0 && (
<div className="text-center text-gray-500 pt-12">
<p className="text-sm">No tickets found</p>
</div>
)}
{results.map((ticket) => (
<button
key={ticket.ticket_id}
onClick={() => onSelectTicket(ticket)}
className="w-full text-left bg-gray-800 border border-gray-700 rounded-2xl p-4 active:scale-[0.98] transition-all hover:border-gray-600"
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-gray-700 flex items-center justify-center flex-shrink-0">
<UserIcon className="w-6 h-6 text-gray-400" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-white text-lg truncate">{ticket.name}</p>
{ticket.email && (
<p className="text-gray-400 text-sm truncate">{ticket.email}</p>
)}
</div>
<div className="flex-shrink-0">{statusBadge(ticket.status)}</div>
</div>
</button>
))}
</div>
</div>
);
}
// ─── 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 (
<div className={clsx('fixed inset-0 z-50 flex flex-col animate-in fade-in duration-200', bgColor)}>
{/* Back button */}
<div className="p-4">
<button
onClick={onBack}
className="flex items-center gap-2 text-white/80 hover:text-white active:scale-95 transition-all"
>
<ArrowLeftIcon className="w-5 h-5" />
<span className="font-medium">Back</span>
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center px-6 text-white">
<div className="w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mb-5">
{isValid ? (
<CheckCircleIcon className="w-12 h-12" />
) : isCheckedIn ? (
<CheckCircleIcon className="w-12 h-12" />
) : (
<XCircleIcon className="w-12 h-12" />
)}
</div>
<h2 className="text-3xl font-bold mb-1 text-center">
{validation.ticket?.attendeeName || 'Unknown'}
</h2>
{validation.ticket?.attendeeEmail && (
<p className="text-white/70 text-lg mb-4">{validation.ticket.attendeeEmail}</p>
)}
<div className="bg-white/15 rounded-2xl px-6 py-4 w-full max-w-sm space-y-2 text-center">
<p className="text-sm uppercase tracking-wide text-white/60">Status</p>
<p className="font-bold text-xl">
{isValid ? 'Ready for Check-in' : isCheckedIn ? 'Already Checked In' : isPending ? 'Payment Pending' : isCancelled ? 'Cancelled' : 'Invalid'}
</p>
{validation.event && (
<p className="text-white/70 text-sm mt-2">{validation.event.title}</p>
)}
<p className="text-white/50 font-mono text-xs mt-1">
ID: {validation.ticket?.id.slice(0, 12)}...
</p>
</div>
</div>
<div className="px-6 pb-safe mb-12">
{isValid ? (
<button
onClick={onCheckin}
disabled={checkingIn}
className="w-full py-5 bg-white text-emerald-700 font-bold text-xl rounded-2xl active:scale-[0.98] transition-transform disabled:opacity-50 flex items-center justify-center gap-3"
>
{checkingIn ? (
<div className="w-6 h-6 border-3 border-emerald-700 border-t-transparent rounded-full animate-spin" />
) : (
<>
<CheckCircleIcon className="w-7 h-7" />
Check In
</>
)}
</button>
) : (
<button
onClick={onBack}
className="w-full py-5 bg-white/20 text-white font-bold text-xl rounded-2xl active:scale-[0.98] transition-transform"
>
Back to Search
</button>
)}
</div>
</div>
);
}
// ─── Recent Tab Content ──────────────────────────────────────
function RecentTab({ recentCheckins, sessionCount }: { recentCheckins: RecentCheckin[]; sessionCount: number }) {
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Session counter */}
<div className="px-4 pt-4 pb-3">
<div className="bg-gray-800 border border-gray-700 rounded-2xl p-4 text-center">
<p className="text-4xl font-bold text-primary-yellow">{sessionCount}</p>
<p className="text-gray-400 text-sm mt-1">Checked in this session</p>
</div>
</div>
{/* Recent list */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
{recentCheckins.length === 0 ? (
<div className="text-center text-gray-500 pt-12">
<ClockIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No check-ins yet</p>
</div>
) : (
<div className="space-y-2">
{recentCheckins.map((checkin, i) => (
<div
key={`${checkin.ticketId}-${i}`}
className="bg-gray-800 border border-gray-700 rounded-2xl p-4 flex items-center gap-3"
>
<div className="w-10 h-10 rounded-full bg-emerald-900/50 flex items-center justify-center flex-shrink-0">
<CheckCircleIcon className="w-5 h-5 text-emerald-400" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{checkin.name}</p>
<p className="text-gray-500 text-xs font-mono truncate">{checkin.ticketId.slice(0, 12)}...</p>
</div>
<p className="text-gray-400 text-sm flex-shrink-0">{checkin.time}</p>
</div>
))}
</div>
)}
</div>
</div>
);
}
// ═══════════════════════════════════════════════════════════════
// ─── 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<Event[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>('');
const [loading, setLoading] = useState(true);
// Tabs
const [activeTab, setActiveTab] = useState<ActiveTab>('scan');
// Scanner state
const [scannerKey, setScannerKey] = useState(0); // increment to force remount
const [scanResult, setScanResult] = useState<ScanResultData>({ state: 'idle' });
const [lastScannedCode, setLastScannedCode] = useState('');
const [checkingIn, setCheckingIn] = useState(false);
// Search detail view
const [searchDetailValidation, setSearchDetailValidation] = useState<TicketValidationResult | null>(null);
// Stats
const [checkinCount, setCheckinCount] = useState(0);
const [recentCheckins, setRecentCheckins] = useState<RecentCheckin[]>([]);
// 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 (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="bg-gray-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
{/* ── Sticky Header ── */}
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top">
<div className="flex items-center justify-between gap-3">
{/* Back to dashboard */}
<button
onClick={() => router.push(backHref)}
className="flex-shrink-0 p-2 -ml-1 rounded-xl text-gray-400 hover:text-white active:scale-95 transition-all"
aria-label="Back to dashboard"
>
<ArrowLeftIcon className="w-5 h-5" />
</button>
{/* Event selector */}
<div className="flex-1 min-w-0">
<select
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 text-white rounded-xl px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-primary-yellow truncate"
>
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{event.title}
</option>
))}
</select>
</div>
{/* Live counter */}
{selectedEventId && eventCapacity > 0 && (
<div className="flex-shrink-0 bg-gray-800 border border-gray-700 rounded-xl px-3 py-2">
<p className="text-primary-yellow font-bold text-sm whitespace-nowrap">
{eventCheckedIn} / {eventCapacity}
</p>
<p className="text-gray-400 text-[10px] text-center">Checked in</p>
</div>
)}
</div>
</header>
{/* ── Tab Bar ── */}
<div className="flex-shrink-0 bg-gray-900 border-b border-gray-800">
<div className="flex">
{(
[
{ 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) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={clsx(
'flex-1 flex items-center justify-center gap-2 py-4 text-sm font-semibold transition-colors relative',
activeTab === tab.key
? 'text-primary-yellow'
: 'text-gray-500 active:text-gray-300'
)}
>
<tab.icon className="w-5 h-5" />
{tab.label}
{tab.key === 'recent' && checkinCount > 0 && (
<span className="bg-primary-yellow text-gray-900 text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[20px] text-center">
{checkinCount}
</span>
)}
{activeTab === tab.key && (
<span className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary-yellow rounded-full" />
)}
</button>
))}
</div>
</div>
{/* ── Tab Content ── */}
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* SCAN TAB — scanner fully unmounts when not active */}
{scannerActive && (
<QRScanner
key={scannerKey}
onScan={handleScan}
onError={() => {}}
/>
)}
{activeTab === 'scan' && !scannerActive && scanResult.state !== 'idle' && (
<div className="flex-1 bg-black" />
)}
{/* SEARCH TAB */}
{activeTab === 'search' && (
<SearchTab eventId={selectedEventId} onSelectTicket={handleSelectSearchResult} />
)}
{/* RECENT TAB */}
{activeTab === 'recent' && (
<RecentTab recentCheckins={recentCheckins} sessionCount={checkinCount} />
)}
</div>
{/* ── Fullscreen overlays ── */}
{scanResult.state === 'valid' && scanResult.validation && (
<ValidTicketScreen
validation={scanResult.validation}
onConfirmCheckin={handleCheckin}
onClose={resetScan}
checkingIn={checkingIn}
/>
)}
{scanResult.state === 'invalid' && (
<InvalidTicketScreen
reason={scanResult.invalidReason || 'unknown'}
validation={scanResult.validation}
error={scanResult.error}
onScanNext={resetScan}
/>
)}
{searchDetailValidation && (
<TicketDetailView
validation={searchDetailValidation}
onCheckin={handleSearchCheckin}
onBack={() => setSearchDetailValidation(null)}
checkingIn={checkingIn}
/>
)}
</div>
);
}