Also includes admin, dashboard, and API updates; PWA icon assets; and assorted layout and utility changes on dev.
973 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|