'use client'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, doorApi, type Event, type DoorAttendee, type DoorAttendeesResponse, type DoorEntryMethod, type DoorPaymentMethod, type DoorSummary, } from '@/lib/api'; import { QrCodeIcon, MagnifyingGlassIcon, ClockIcon, ArrowLeftIcon, XMarkIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils'; import { buildIndex, searchAttendees, hasExactMatch } from './_lib/search'; import { newIdempotencyKey, submitDoorAction, undoDoorAction } from './_lib/doorActions'; import { playSuccessSound, playErrorSound, vibrate } from './_lib/feedback'; import { QRScannerOverlay } from './_components/QRScannerOverlay'; import { AttendeeRow } from './_components/AttendeeRow'; import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow'; import { SessionSheet, type SessionEntry } from './_components/SessionSheet'; import { ResultScreen, type DoorResult } from './_components/ResultScreen'; // ═══════════════════════════════════════════════════════════════ // Door check-in screen // // One screen, no tabs. Manual name search is the primary interaction because // most people arrive without their QR open; the camera is one tap away, and a // walk-in can be created and charged without leaving the screen. // // Everything created here is born confirmed, paid (or comp) and checked in, in // a single atomic call. No confirm dialogs anywhere — a 10-second Undo replaces // them, which is what keeps the queue moving. // ═══════════════════════════════════════════════════════════════ const REFRESH_INTERVAL_MS = 30_000; const FLASH_MS = 900; const UNDO_WINDOW_MS = 10_000; const clockTime = (value: Date | string) => (typeof value === 'string' ? parseDate(value) : value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE, }); const METHOD_PAST_TENSE: Record = { cash: 'paid cash', bitcoin: 'paid bitcoin', transfer: 'paid by transfer', guest: 'in as guest', }; export default function AdminDoorPage() { const router = useRouter(); const { user } = useAuth(); const backHref = user?.role === 'staff' ? '/admin/events' : '/admin'; // Whole-event takings are management information. Door staff reconcile their // own shift from the session feed below, which is local to this device; the // API enforces the same split (see REVENUE_ROLES in routes/door.ts). const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer'; // ── Events ── const [events, setEvents] = useState([]); const [selectedEventId, setSelectedEventId] = useState(''); const [loadingEvents, setLoadingEvents] = useState(true); // ── Attendee list (preloaded once, searched in memory) ── const [data, setData] = useState(null); const [listError, setListError] = useState(null); const [loadingList, setLoadingList] = useState(false); // ── Screen state ── const [query, setQuery] = useState(''); const [expandedId, setExpandedId] = useState(null); const [flashId, setFlashId] = useState(null); const [busyId, setBusyId] = useState(null); const [walkInOpen, setWalkInOpen] = useState(false); const [walkInDraft, setWalkInDraft] = useState(emptyWalkIn()); const [scannerOpen, setScannerOpen] = useState(false); const [sessionOpen, setSessionOpen] = useState(false); // Full-screen outcome of the last action. Mirrored in a ref because the // background write in runAction must read the *current* screen when it fails, // not the one captured when it started. const [result, setResult] = useState(null); const resultRef = useRef(null); // Undo toast per action, so a write that fails after the toast is up can // pull it down instead of offering to undo something that never happened. const undoToastIdsRef = useRef(new Map()); // ── Session bookkeeping ── const [sessionEntries, setSessionEntries] = useState([]); const [doorCount, setDoorCount] = useState(0); const [checkedInCount, setCheckedInCount] = useState(0); const [summary, setSummary] = useState(null); const [summaryLoading, setSummaryLoading] = useState(false); const inputRef = useRef(null); const capacityWarnedRef = useRef(false); const lastScannedRef = useRef(''); const selectedEventIdRef = useRef(''); useEffect(() => { selectedEventIdRef.current = selectedEventId; }, [selectedEventId]); const eventMeta = data?.event; const price = eventMeta?.price ?? 0; const currency = eventMeta?.currency ?? 'PYG'; const capacity = eventMeta?.capacity ?? 0; // ─── Load events ───────────────────────────────────────────── useEffect(() => { eventsApi .getAll() .then((res) => { const bookable = res.events.filter((e) => e.status === 'published' || e.status === 'unlisted'); setEvents(bookable); // The door is always working one event: default to the one about to // start, so staff can pick up the phone and start scanning. const now = Date.now(); const upcoming = bookable .filter((e) => new Date(e.startDatetime).getTime() >= now) .sort((a, b) => +new Date(a.startDatetime) - +new Date(b.startDatetime)); const fallback = bookable .slice() .sort((a, b) => +new Date(b.startDatetime) - +new Date(a.startDatetime)); const chosen = upcoming[0] || fallback[0]; if (chosen) setSelectedEventId(chosen.id); }) .catch(console.error) .finally(() => setLoadingEvents(false)); }, []); // ─── Preload / refresh the attendee list ───────────────────── const loadAttendees = useCallback( async (eventId: string, opts?: { silent?: boolean }) => { if (!eventId) return; if (!opts?.silent) setLoadingList(true); try { const res = await doorApi.attendees(eventId); setData(res); setCheckedInCount(res.stats.checkedIn); setListError(null); } catch (error: any) { // A failed background refresh must never wipe a list staff are working from. if (!opts?.silent) setListError(error?.message || 'Failed to load attendees'); } finally { if (!opts?.silent) setLoadingList(false); } }, [], ); useEffect(() => { if (!selectedEventId) { setData(null); return; } setQuery(''); setExpandedId(null); setWalkInOpen(false); capacityWarnedRef.current = false; loadAttendees(selectedEventId); }, [selectedEventId, loadAttendees]); // Keep the list fresh without ever costing a keystroke. useEffect(() => { if (!selectedEventId) return; const interval = setInterval(() => { loadAttendees(selectedEventIdRef.current, { silent: true }); }, REFRESH_INTERVAL_MS); const onVisible = () => { if (document.visibilityState === 'visible') { loadAttendees(selectedEventIdRef.current, { silent: true }); } }; document.addEventListener('visibilitychange', onVisible); return () => { clearInterval(interval); document.removeEventListener('visibilitychange', onVisible); }; }, [selectedEventId, loadAttendees]); // ─── Search (in memory, no network per keystroke) ──────────── const indexed = useMemo( () => (data?.attendees || []).map((attendee) => ({ attendee, index: buildIndex(attendee) })), [data?.attendees], ); const results = useMemo(() => searchAttendees(indexed, query), [indexed, query]); const trimmedQuery = query.trim(); const showWalkInRow = !!selectedEventId && (walkInOpen || (trimmedQuery.length >= 2 && !hasExactMatch(results, trimmedQuery))); const focusInput = useCallback(() => { // A 0ms defer lets the row unmount before focus moves, which stops mobile // browsers from scrolling the list back to the top. setTimeout(() => inputRef.current?.focus(), 0); }, []); // ─── Local list mutation ───────────────────────────────────── const upsertAttendee = useCallback((attendee: DoorAttendee) => { setData((prev) => { if (!prev) return prev; const existing = prev.attendees.findIndex((a) => a.ticketId === attendee.ticketId); const attendees = existing >= 0 ? prev.attendees.map((a, i) => (i === existing ? attendee : a)) : [...prev.attendees, attendee].sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }), ); return { ...prev, attendees }; }); }, []); const patchAttendee = useCallback((ticketId: string, patch: Partial) => { setData((prev) => prev ? { ...prev, attendees: prev.attendees.map((a) => (a.ticketId === ticketId ? { ...a, ...patch } : a)), } : prev, ); }, []); // ─── Undo ──────────────────────────────────────────────────── const handleUndo = useCallback( async (entry: SessionEntry) => { const eventId = selectedEventIdRef.current; setSessionEntries((prev) => prev.map((e) => (e.idempotencyKey === entry.idempotencyKey ? { ...e, undone: true } : e)), ); setCheckedInCount((c) => Math.max(0, c - 1)); if (entry.entry === 'walkin') setDoorCount((c) => Math.max(0, c - 1)); try { await undoDoorAction(eventId, entry.idempotencyKey); toast.success(`Undone — ${entry.name}`, { position: 'bottom-center' }); } catch (error: any) { toast.error(`Could not undo ${entry.name}: ${error?.message || 'request failed'}`, { position: 'bottom-center', duration: 8000, }); } finally { // Whatever happened, the server is the truth about who is inside. loadAttendees(eventId, { silent: true }); } }, [loadAttendees], ); const showUndoToast = useCallback( (message: string, entry: SessionEntry, durationMs: number = UNDO_WINDOW_MS) => { const id = toast.custom( (t) => (
{message}
), { duration: durationMs, position: 'bottom-center' }, ); undoToastIdsRef.current.set(entry.idempotencyKey, id); }, [handleUndo], ); // ─── Full-screen result ────────────────────────────────────── const showResult = useCallback((next: DoorResult) => { resultRef.current = next; setResult(next); }, []); // Closing a success hands the undo affordance to the bottom toast for // whatever remains of the window — never both at once (the toast layer sits // above the overlay and would show a second Undo on top of the first). const closeResult = useCallback( (opts?: { skipUndoToast?: boolean }) => { const closing = resultRef.current; resultRef.current = null; setResult(null); focusInput(); if (opts?.skipUndoToast || closing?.kind !== 'success' || !closing.entry) return; const remaining = UNDO_WINDOW_MS - (Date.now() - closing.entry.startedAt); if (remaining < 500) return; const suffix = closing.entry.method ? `, ${METHOD_PAST_TENSE[closing.entry.method]}` : ''; showUndoToast(`${closing.entry.name} checked in${suffix}`, closing.entry, remaining); }, [focusInput, showUndoToast], ); const undoFromResult = useCallback(() => { const entry = resultRef.current?.entry; // handleUndo announces itself with its own toast; don't also queue the undo one. closeResult({ skipUndoToast: true }); if (entry) handleUndo(entry); }, [closeResult, handleUndo]); // ─── The one write path ────────────────────────────────────── // Every completed action on this screen — scan, tap, collect, walk-in — flows // through here so the flash, the counter, the session feed and the undo all // behave identically. const runAction = useCallback( async (opts: { ticketId?: string; attendee?: { firstName: string; lastName?: string; phone?: string; email?: string; ruc?: string }; payment?: { method: DoorPaymentMethod; amount: number }; entry: DoorEntryMethod; displayName: string; /** Ticket row to flash and optimistically mark as in. */ optimisticTicketId?: string; /** Row (or 'walk-in') to keep dimmed until the write settles. */ busyKey: string; }) => { const eventId = selectedEventIdRef.current; if (!eventId) return; setBusyId(opts.busyKey); const idempotencyKey = newIdempotencyKey(); const now = new Date(); const sessionEntry: SessionEntry = { idempotencyKey, ticketId: opts.ticketId || '', name: opts.displayName, at: clockTime(now), entry: opts.entry, method: opts.payment?.method ?? null, amount: opts.payment?.amount ?? 0, startedAt: now.getTime(), undone: false, failed: false, }; // ── Optimistic: the person walks in now, not when the request lands ── setSessionEntries((prev) => [sessionEntry, ...prev].slice(0, 200)); setCheckedInCount((c) => c + 1); if (opts.entry === 'walkin') setDoorCount((c) => c + 1); const previousRow = opts.optimisticTicketId ? data?.attendees.find((a) => a.ticketId === opts.optimisticTicketId) : undefined; if (opts.optimisticTicketId) { patchAttendee(opts.optimisticTicketId, { status: 'checked_in', checkedIn: true, checkinAt: now.toISOString(), ...(opts.payment ? { paymentStatus: opts.payment.method === 'guest' ? 'comp' : 'paid', amountDue: 0, doorMethod: opts.payment.method, } : {}), }); setFlashId(opts.optimisticTicketId); setTimeout(() => setFlashId((id) => (id === opts.optimisticTicketId ? null : id)), FLASH_MS); } vibrate(120); playSuccessSound(); const paidSuffix = opts.payment ? `, ${METHOD_PAST_TENSE[opts.payment.method]}` : ''; showResult({ kind: 'success', name: opts.displayName, detail: `Checked in ${clockTime(now)}${paidSuffix}`, entry: sessionEntry, }); setQuery(''); setExpandedId(null); setWalkInOpen(false); setWalkInDraft(emptyWalkIn()); focusInput(); // ── The write itself: background, retried, never blocking the queue ── try { const res = await submitDoorAction(eventId, { ticketId: opts.ticketId, attendee: opts.attendee, payment: opts.payment, entryMethod: opts.entry, idempotencyKey, }); upsertAttendee(res.attendee); setSessionEntries((prev) => prev.map((e) => e.idempotencyKey === idempotencyKey ? { ...e, ticketId: res.attendee.ticketId, name: res.attendee.fullName } : e, ), ); if (res.warnings?.includes('at_capacity') && !capacityWarnedRef.current) { capacityWarnedRef.current = true; toast('Event at capacity, added anyway', { icon: '⚠️', position: 'bottom-center', duration: 6000 }); } } catch (error: any) { // Retries are exhausted: make the failure loud and name the person, so // staff know exactly who to redo. setSessionEntries((prev) => prev.map((e) => (e.idempotencyKey === idempotencyKey ? { ...e, failed: true } : e)), ); setCheckedInCount((c) => Math.max(0, c - 1)); if (opts.entry === 'walkin') setDoorCount((c) => Math.max(0, c - 1)); if (previousRow) upsertAttendee(previousRow); playErrorSound(); vibrate([100, 50, 100]); // If the green screen for this very action is still up, turn it red in // place; the toast below covers the case where it has already closed. const staleUndo = undoToastIdsRef.current.get(idempotencyKey); if (staleUndo) { toast.dismiss(staleUndo); undoToastIdsRef.current.delete(idempotencyKey); } if (resultRef.current?.entry?.idempotencyKey === idempotencyKey) { showResult({ kind: 'failed', name: opts.displayName, detail: error?.message || 'The check-in did not reach the server. Try again.', }); } toast.error(`FAILED — ${opts.displayName} is NOT checked in. ${error?.message || ''}`.trim(), { duration: 12000, position: 'bottom-center', }); loadAttendees(eventId, { silent: true }); } finally { setBusyId((current) => (current === opts.busyKey ? null : current)); } }, [data?.attendees, patchAttendee, upsertAttendee, showResult, focusInput, loadAttendees], ); // ─── Row interactions ──────────────────────────────────────── const handleRowTap = useCallback( (attendee: DoorAttendee) => { // Already inside, or needs money first: expand rather than act. Only a // settled, not-yet-arrived attendee is a one-tap check-in. const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp'; if (attendee.checkedIn || attendee.status === 'cancelled' || !settled) { setExpandedId((id) => (id === attendee.ticketId ? null : attendee.ticketId)); return; } runAction({ ticketId: attendee.ticketId, entry: 'search', displayName: attendee.fullName, optimisticTicketId: attendee.ticketId, busyKey: attendee.ticketId, }); }, [runAction], ); const handleRowPay = useCallback( (attendee: DoorAttendee, method: DoorPaymentMethod, amount: number) => { runAction({ ticketId: attendee.ticketId, payment: { method, amount }, entry: 'search', displayName: attendee.fullName, optimisticTicketId: attendee.ticketId, busyKey: attendee.ticketId, }); }, [runAction], ); const handleWalkInPay = useCallback( (method: DoorPaymentMethod, amount: number) => { const firstName = walkInDraft.firstName.trim(); if (!firstName) return; const displayName = walkInDraft.lastName.trim() ? `${firstName} ${walkInDraft.lastName.trim()}` : firstName; runAction({ attendee: { firstName, lastName: walkInDraft.lastName.trim() || undefined, phone: walkInDraft.phone.trim() || undefined, email: walkInDraft.email.trim() || undefined, ruc: walkInDraft.ruc.trim() || undefined, }, payment: { method, amount }, entry: 'walkin', displayName, busyKey: 'walk-in', }); }, [walkInDraft, runAction], ); // ─── Scanning ──────────────────────────────────────────────── const handleScan = useCallback( (decodedText: string) => { if (decodedText === lastScannedRef.current) return; lastScannedRef.current = decodedText; setTimeout(() => { lastScannedRef.current = ''; }, 2000); // The QR may hold a ticket URL or the bare code. const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/); const code = urlMatch ? urlMatch[1] : decodedText; const attendee = (data?.attendees || []).find( (a) => a.qrCode === code || a.ticketId === code, ); setScannerOpen(false); if (!attendee) { playErrorSound(); vibrate([100, 50, 100]); showResult({ kind: 'not_found', name: 'Ticket not found', detail: 'No ticket for this event' }); return; } if (attendee.checkedIn) { playErrorSound(); vibrate([100, 50, 100]); const at = attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : ''; const by = attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''; showResult({ kind: 'already_in', name: attendee.fullName, detail: `Already checked in${at}${by}` }); // Their row sits open underneath, so closing lands on the same person. setQuery(attendee.fullName); setExpandedId(attendee.ticketId); return; } if (attendee.paymentStatus === 'unpaid') { // Checking an unpaid ticket in silently would walk the money out the // door. Surface them with the tenders open instead: "Collect" closes // the screen straight onto the payment buttons. playErrorSound(); vibrate(200); showResult({ kind: 'unpaid', name: attendee.fullName, detail: `Collect ${formatCurrency(attendee.amountDue || price, currency)}`, }); setQuery(attendee.fullName); setExpandedId(attendee.ticketId); return; } runAction({ ticketId: attendee.ticketId, entry: 'scan', displayName: attendee.fullName, optimisticTicketId: attendee.ticketId, busyKey: attendee.ticketId, }); }, [data?.attendees, runAction, showResult, price, currency], ); // ─── Session summary ───────────────────────────────────────── const loadSummary = useCallback(async () => { const eventId = selectedEventIdRef.current; if (!eventId || !canSeeEventTotals) return; setSummaryLoading(true); try { setSummary(await doorApi.summary(eventId)); } catch (error) { console.error('Failed to load door summary:', error); } finally { setSummaryLoading(false); } }, [canSeeEventTotals]); useEffect(() => { if (sessionOpen) loadSummary(); }, [sessionOpen, loadSummary]); // ─── Render ────────────────────────────────────────────────── if (loadingEvents) { return (
); } return (
{/* ── Top bar ── */}

{checkedInCount} {capacity > 0 ? `/${capacity}` : ''} in

door {doorCount}

{/* ── Search row ── */}
{ setQuery(e.target.value); setExpandedId(null); if (walkInOpen) setWalkInOpen(false); }} autoFocus placeholder="Search name or phone…" className="w-full min-h-[56px] pl-12 pr-11 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" autoCapitalize="none" spellCheck={false} /> {query && ( )}
{/* ── Results ── */}
{!selectedEventId && (

Pick an event to start checking people in.

)} {selectedEventId && loadingList && (
)} {selectedEventId && !loadingList && listError && (

{listError}

)} {selectedEventId && !loadingList && !listError && ( <> {results.length === 0 && !showWalkInRow && (

{trimmedQuery ? 'No one matches that.' : 'Everyone on the list is already checked in.'}

)} {results.map((attendee) => ( handleRowTap(attendee)} onPay={(method, amount) => handleRowPay(attendee, method, amount)} /> ))} {/* Pinned to the bottom of the results, never above a real person. */} {showWalkInRow && ( { setWalkInDraft(emptyWalkIn(trimmedQuery)); setWalkInOpen(true); setExpandedId(null); }} onChange={setWalkInDraft} onPay={handleWalkInPay} onCancel={() => { setWalkInOpen(false); setWalkInDraft(emptyWalkIn()); focusInput(); }} /> )} )}
{/* ── Overlays ── */} {scannerOpen && ( { setScannerOpen(false); focusInput(); }} /> )} {result && } {sessionOpen && ( { setSessionOpen(false); focusInput(); }} /> )}
); }