Files
Spanglish/frontend/src/app/admin/scanner/page.tsx
T
MichilisandClaude Fable 5 9b2668f498 Unify admin ticket creation into one modal with first-class payment status.
- Replace the three Attendees-tab modals (Manual Ticket / Add at Door /
  Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented
  control, shared fields, "Check in now" for all types, and a live
  "what happens" preview, backed by one POST /api/tickets/admin/add.
- Add tickets.payment_status (paid | unpaid | comp) with a backfill
  migration; keep it in sync on every payment-settlement path (mark-paid,
  admin approval, Lightning, free bookings, hold recovery).
- Show Paid/Unpaid/Comp badges in the attendee list, count only paid
  tickets toward revenue, let unpaid tickets be resolved via Mark Paid,
  and flag unpaid tickets with their balance due in the door scanner.
- Replace the per-page useStatsPrivacy hook with an admin-wide
  PrivacyContext + SensitiveValue mask, toggled from the admin layout.
- Add server-side pagination with page-size options to the users page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 05:25:00 +00:00

984 lines
36 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, formatCurrency, 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>
)}
{/* Unpaid tickets are valid for entry but the balance is collected at the door */}
{validation.ticket?.paymentStatus === 'unpaid' && (
<div className="bg-orange-500 rounded-2xl px-6 py-3 w-full max-w-sm text-center mb-3">
<p className="font-bold text-lg">UNPAID collect payment</p>
{!!validation.ticket.amountDue && (
<p className="text-orange-100 text-sm">
Balance due: {formatCurrency(validation.ticket.amountDue)}
</p>
)}
</div>
)}
<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>
);
}