"use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; import { useDismissingState } from "@/hooks/useDismissingState"; import { useRouter } from "next/navigation"; export default function TicketScanningPage() { const router = useRouter(); const scannerRef = useRef(null); const [lastResult, setLastResult] = useState(null); const [scanInfo, setScanInfo] = useDismissingState(null); const [error, setError] = useDismissingState(null); const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null); const [errorModal, setErrorModal] = useState(null); const { token, user } = useAuth(); const [recentScans, setRecentScans] = useState([]); const [stats, setStats] = useState(null); const [loadingStats, setLoadingStats] = useState(false); const [successModal, setSuccessModal] = useState<{ optionName: string; ticketId: string; eventTitle: string; qtyRedeemed: number; remaining: number; total: number; } | null>(null); // Confirm step state const [confirmModal, setConfirmModal] = useState<{ qrCode: string; ticket: any; remaining: number; } | null>(null); const [confirmQty, setConfirmQty] = useState(1); const [confirmQtyRaw, setConfirmQtyRaw] = useState("1"); // Event selection state const [allEvents, setAllEvents] = useState([]); const [includePastEvents, setIncludePastEvents] = useState(false); const [selectedEventId, setSelectedEventId] = useState("all"); const [loadingEvents, setLoadingEvents] = useState(false); const [sections, setSections] = useState([]); const [selectedSectionId, setSelectedSectionId] = useState("all"); const [loadingSections, setLoadingSections] = useState(false); // All events from API (including past); frontend filters to 12h window or all past useEffect(() => { (async () => { try { if (!token) return; setLoadingEvents(true); const evs = await apiFetch("/api/events/all?includePast=true", { authToken: token }); const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime()); setAllEvents(sorted); } catch (e: any) { // non-fatal } finally { setLoadingEvents(false); } })(); }, [token]); const events = useMemo(() => { if (includePastEvents) return allEvents; const cutoff = Date.now() - 12 * 60 * 60 * 1000; return allEvents.filter(ev => { const t = new Date(ev.endDate).getTime(); return !isNaN(t) && t >= cutoff; }); }, [allEvents, includePastEvents]); useEffect(() => { (async () => { if (!token || !selectedEventId || selectedEventId === "all") { setSections([]); setSelectedSectionId("all"); return; } try { setLoadingSections(true); const list = await apiFetch(`/api/sections?eventId=${selectedEventId}`, { authToken: token }); setSections(Array.isArray(list) ? list : []); setSelectedSectionId("all"); } catch { setSections([]); } finally { setLoadingSections(false); } })(); }, [selectedEventId, token]); // Bug fix: stop the scanner when the event or section filter changes so stale scan // context is never used. The user must press "Scan Ticket" again to resume. const isFirstFilterRender = useRef(true); useEffect(() => { if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; } scannerRef.current?.stop(); }, [selectedEventId, selectedSectionId]); async function handleResult(text: string) { setLastResult(text); setError(null); setScanInfo(null); try { if (!token) { setErrorModal("Please login as staff to scan tickets."); return; } // Always preview first to validate the ticket before showing confirm step // Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal) let preview: any; try { preview = await apiFetch(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token }); } catch (pe: any) { setErrorModal(pe?.message || 'Failed to validate ticket'); return; } // Validate event if (selectedEventId && selectedEventId !== "all") { const ticketEventId = preview?.event?.id || preview?.eventId; if (ticketEventId && ticketEventId !== selectedEventId) { const eventTitle = preview?.event?.title || 'Event'; setErrorModal(`This ticket is for a different event: ${eventTitle}`); return; } // Validate section if (selectedSectionId !== "all") { const ticketOptionId = preview?.registrationOption?.eventOption?.id || preview?.eventOptionId; if (!ticketOptionId) { setErrorModal("Unable to determine ticket type"); return; } const section = sections.find(s => s.id === selectedSectionId); const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId); if (!allowedOptionIds.includes(ticketOptionId)) { setErrorModal("This ticket is not valid for this section"); return; } } } // Compute remaining qty const ticketQty = preview?.quantity || 1; const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0); const remaining = ticketQty - totalRedeemed; if (remaining <= 0) { setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview }); return; } // Show confirm step setConfirmQty(remaining); setConfirmQtyRaw(String(remaining)); setConfirmModal({ qrCode: text, ticket: preview, remaining }); } catch (e: any) { setErrorModal(e?.message || "Failed to scan ticket"); } } async function commitScan() { if (!confirmModal || !token) return; // Capture everything before clearing modal state const { qrCode, ticket, remaining } = confirmModal; const qty = confirmQty; const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket"; const variantName = ticket?.registrationOption?.variant?.name; const optionName = variantName ? `${baseName} — ${variantName}` : baseName; const eventTitle = ticket?.event?.title || 'Event'; const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : ''; const newRemaining = remaining - qty; // Show success immediately — we already have all the data from the preview step. // The actual write happens in the background so the user sees an instant response. setConfirmModal(null); setScanInfo(`Ticket #${idPart} — ${optionName} — ${eventTitle}`); setSuccessModal({ optionName, ticketId: idPart, eventTitle, qtyRedeemed: qty, remaining: newRemaining, total: ticket?.quantity || 1, }); // Background write — reverse the optimistic update on failure try { const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${ selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : '' }`; await apiFetch(url, { method: "POST", authToken: token, body: { qty }, }); refreshStatsAndRecent(); } catch (e: any) { setSuccessModal(null); setScanInfo(null); const raw = e?.message || "Failed to scan ticket"; try { const parsed = JSON.parse(raw); const msg: string | undefined = parsed?.message; if (msg && /already\s*(been)?\s*used/i.test(msg)) { setAlreadyUsedModal({ message: msg, ticket: parsed?.ticket }); return; } setErrorModal(msg || raw); } catch { setErrorModal(raw); } } } const loadRecent = async () => { if (!token) return; try { const list = await apiFetch(`/api/tickets/scans/recent?limit=10`, { authToken: token }); setRecentScans(list); } catch {} }; const loadStats = async () => { if (!token) return; try { setLoadingStats(true); const s = await apiFetch(`/api/tickets/scans/stats`, { authToken: token }); setStats(s); } catch {} finally { setLoadingStats(false); } }; const refreshStatsAndRecent = () => { loadRecent(); loadStats(); }; useEffect(() => { refreshStatsAndRecent(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [token]); return (

Ticket Scanning

Use the button to start/stop scanning. The back camera will be used when available.

{/* Event filter */}
{loadingEvents && Loading events…}
{sections.length > 0 && (
{loadingSections && Loading sections…}
)} {scanInfo && (
{scanInfo}
)} {error && (
{error}
)}

Scanner Stats

{loadingStats &&
Loading stats…
} {stats && (
Today
{stats.totalToday}
My scans
{stats.myToday}
Last hour
{stats.lastHour}
)} {stats?.byStaff?.length > 0 && (
Today by staff
    {stats.byStaff.map((s: any) => (
  • {s.name || 'Staff'} {s.count}
  • ))}
)}

Last 10 scans

    {recentScans.map((u: any) => (
  • {u.ticket?.event?.title || 'Event'}
    {new Date(u.scannedAt).toLocaleString()}
    {u.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{u.ticket?.registrationOption?.variant?.name ? ` — ${u.ticket.registrationOption.variant.name}` : ''} {u.quantityRedeemed && u.quantityRedeemed > 1 ? ` ×${u.quantityRedeemed}` : ''} {' — '}#{String(u.ticket?.id || '').slice(0,8)}
    Scanned by: {u.scannedBy?.name || u.scannedById}
  • ))} {recentScans.length === 0 &&
  • No scans yet.
  • }
{/* ── Confirm scan modal ───────────────────────────────────────────── */} {confirmModal && (

Confirm Scan

Review the ticket details before confirming.

Ticket type: {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}
Event: {confirmModal.ticket?.event?.title || '—'}
Holder: {confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}
Qty on ticket: {confirmModal.ticket?.quantity || 1}
Remaining: {confirmModal.remaining}
{(confirmModal.ticket?.quantity || 1) > 1 && (
setConfirmQtyRaw(e.target.value)} onBlur={() => { const parsed = parseInt(confirmQtyRaw, 10); const clamped = isNaN(parsed) ? 1 : Math.min(confirmModal.remaining, Math.max(1, parsed)); setConfirmQty(clamped); setConfirmQtyRaw(String(clamped)); }} />
)}
)} {/* ── Already used modal ───────────────────────────────────────────── */} {alreadyUsedModal && (

Ticket Already Scanned

{alreadyUsedModal.message}

{alreadyUsedModal.ticket && (
Ticket ID: {alreadyUsedModal.ticket.id}
{alreadyUsedModal.ticket.event?.title && (
Event: {alreadyUsedModal.ticket.event.title}
)} {Array.isArray(alreadyUsedModal.ticket.usages) && alreadyUsedModal.ticket.usages.length > 0 && (
Usages:
    {alreadyUsedModal.ticket.usages.map((u: any) => (
  • {new Date(u.scannedAt).toLocaleString()}{u.quantityRedeemed > 1 ? ` (×${u.quantityRedeemed})` : ''}
  • ))}
)}
)}
)} {/* ── Error modal ──────────────────────────────────────────────────── */} {errorModal && (

Scan Error

{errorModal}

)} {/* ── Success modal ────────────────────────────────────────────────── */} {successModal && (

Scan Successful

{successModal.optionName}
#{successModal.ticketId} — {successModal.eventTitle}
{successModal.total > 1 && (
0 ? 'text-amber-600' : 'text-green-700'}`}> {successModal.qtyRedeemed} redeemed · {successModal.remaining} remaining of {successModal.total}
)}
)}
); }