Files
Spanglish/frontend/src/app/admin/scanner/page.tsx
T
MichilisandClaude Fable 5.1 9cc330030b Show a full-screen result after every door action.
A 900ms row flash and a small bottom toast were too easy to miss at a
loud, dark door: staff could not tell at a glance whether the person in
front of them was in or not. Every action now ends on a screen that is
entirely green (checked in), red (already in, not found, write failed)
or amber (payment due), with the name and the detail staff need to
relay -- the check-in time, who did it, or the amount to collect.

It never holds the queue. Tapping anywhere closes it, it closes itself
after four seconds (six for the red ones, which carry a time or an
amount to read out), and a thin bar counts that down so the screen
vanishing never surprises anyone. Undo and Close are also explicit
buttons for staff who want them.

The undo affordance survives the close: the bottom Undo toast now
appears *after* the screen, for whatever remains of the ten-second
window, rather than alongside it -- react-hot-toast renders above the
overlay and would have floated a second Undo over the first. A write
that fails while the green screen is still up turns it red in place;
one that fails after the toast is up dismisses that toast, so staff are
never offered to undo something that never happened.

The old scanner's safe-area-top, pb-safe and animate-in classes were
undefined in this Tailwind config and silently ignored; the new screen
uses env(safe-area-inset-*) and a real transition instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 21:01:22 +00:00

815 lines
31 KiB
TypeScript

'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<DoorPaymentMethod, string> = {
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<Event[]>([]);
const [selectedEventId, setSelectedEventId] = useState('');
const [loadingEvents, setLoadingEvents] = useState(true);
// ── Attendee list (preloaded once, searched in memory) ──
const [data, setData] = useState<DoorAttendeesResponse | null>(null);
const [listError, setListError] = useState<string | null>(null);
const [loadingList, setLoadingList] = useState(false);
// ── Screen state ──
const [query, setQuery] = useState('');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [flashId, setFlashId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [walkInOpen, setWalkInOpen] = useState(false);
const [walkInDraft, setWalkInDraft] = useState<WalkInDraft>(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<DoorResult | null>(null);
const resultRef = useRef<DoorResult | null>(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<string, string>());
// ── Session bookkeeping ──
const [sessionEntries, setSessionEntries] = useState<SessionEntry[]>([]);
const [doorCount, setDoorCount] = useState(0);
const [checkedInCount, setCheckedInCount] = useState(0);
const [summary, setSummary] = useState<DoorSummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(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<DoorAttendee>) => {
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) => (
<div
className={clsx(
'flex items-center gap-3 bg-emerald-600 text-white rounded-2xl px-4 py-3 shadow-lg max-w-[92vw]',
t.visible ? 'animate-in fade-in' : 'opacity-0',
)}
>
<span className="font-semibold truncate">{message}</span>
<button
onClick={() => {
toast.dismiss(t.id);
handleUndo(entry);
}}
className="flex-shrink-0 min-h-[40px] px-3 font-bold underline underline-offset-2 active:scale-95 transition-transform"
>
Undo
</button>
<button
onClick={() => toast.dismiss(t.id)}
className="flex-shrink-0 min-h-[40px] w-8 flex items-center justify-center opacity-70"
aria-label="Dismiss"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
),
{ 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 (
<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' }}>
{/* ── Top bar ── */}
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-3 py-2.5 safe-area-top">
<div className="flex items-center gap-2">
<button
onClick={() => router.push(backHref)}
className="flex-shrink-0 min-w-[48px] min-h-[48px] flex items-center justify-center rounded-xl text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Back to dashboard"
>
<ArrowLeftIcon className="w-5 h-5" />
</button>
<select
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
className="flex-1 min-w-0 min-h-[48px] bg-gray-800 border border-gray-700 text-white rounded-xl px-3 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-primary-yellow truncate"
>
<option value="">Select an event</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{event.title}
</option>
))}
</select>
<div className="flex-shrink-0 flex items-center gap-1.5">
<div className="bg-gray-800 border border-gray-700 rounded-xl px-2.5 py-1.5 text-center">
<p className="text-primary-yellow font-bold text-sm leading-tight whitespace-nowrap">
{checkedInCount}
{capacity > 0 ? `/${capacity}` : ''} in
</p>
<p className="text-gray-500 text-[10px] leading-tight whitespace-nowrap">door {doorCount}</p>
</div>
<button
onClick={() => setSessionOpen(true)}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-xl bg-gray-800 border border-gray-700 text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Session summary and recent check-ins"
>
<ClockIcon className="w-5 h-5" />
</button>
</div>
</div>
</header>
{/* ── Search row ── */}
<div className="flex-shrink-0 px-3 pt-3 pb-2 flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<MagnifyingGlassIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500 pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
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 && (
<button
onClick={() => {
setQuery('');
focusInput();
}}
className="absolute right-1 top-1/2 -translate-y-1/2 w-10 h-10 flex items-center justify-center text-gray-500 active:text-white"
aria-label="Clear search"
>
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
<button
onClick={() => setScannerOpen(true)}
disabled={!selectedEventId}
className="flex-shrink-0 w-[56px] h-[56px] flex items-center justify-center rounded-2xl bg-primary-yellow text-gray-900 active:scale-95 transition-transform disabled:opacity-40"
aria-label="Scan a QR ticket"
>
<QrCodeIcon className="w-7 h-7" />
</button>
</div>
{/* ── Results ── */}
<div className="flex-1 min-h-0 overflow-y-auto px-3 pb-4 space-y-2">
{!selectedEventId && (
<p className="text-center text-gray-500 pt-12 text-sm">Pick an event to start checking people in.</p>
)}
{selectedEventId && loadingList && (
<div className="flex justify-center pt-12">
<div className="animate-spin w-7 h-7 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
)}
{selectedEventId && !loadingList && listError && (
<div className="text-center pt-12 space-y-3">
<p className="text-red-400 text-sm">{listError}</p>
<button
onClick={() => loadAttendees(selectedEventId)}
className="min-h-[48px] px-5 rounded-xl bg-gray-800 border border-gray-700 text-white font-semibold active:scale-95 transition-transform"
>
Retry
</button>
</div>
)}
{selectedEventId && !loadingList && !listError && (
<>
{results.length === 0 && !showWalkInRow && (
<p className="text-center text-gray-500 pt-12 text-sm">
{trimmedQuery ? 'No one matches that.' : 'Everyone on the list is already checked in.'}
</p>
)}
{results.map((attendee) => (
<AttendeeRow
key={attendee.ticketId}
attendee={attendee}
currency={currency}
price={price}
expanded={expandedId === attendee.ticketId}
flashing={flashId === attendee.ticketId}
busy={busyId === attendee.ticketId}
onTap={() => handleRowTap(attendee)}
onPay={(method, amount) => handleRowPay(attendee, method, amount)}
/>
))}
{/* Pinned to the bottom of the results, never above a real person. */}
{showWalkInRow && (
<WalkInRow
typedText={trimmedQuery}
expanded={walkInOpen}
draft={walkInDraft}
price={price}
currency={currency}
busy={busyId === 'walk-in'}
onExpand={() => {
setWalkInDraft(emptyWalkIn(trimmedQuery));
setWalkInOpen(true);
setExpandedId(null);
}}
onChange={setWalkInDraft}
onPay={handleWalkInPay}
onCancel={() => {
setWalkInOpen(false);
setWalkInDraft(emptyWalkIn());
focusInput();
}}
/>
)}
</>
)}
</div>
{/* ── Overlays ── */}
{scannerOpen && (
<QRScannerOverlay
onScan={handleScan}
onClose={() => {
setScannerOpen(false);
focusInput();
}}
/>
)}
{result && <ResultScreen result={result} onClose={closeResult} onUndo={undoFromResult} />}
{sessionOpen && (
<SessionSheet
entries={sessionEntries}
summary={summary}
summaryLoading={summaryLoading}
showEventTotals={canSeeEventTotals}
currency={currency}
onRefresh={loadSummary}
onClose={() => {
setSessionOpen(false);
focusInput();
}}
/>
)}
</div>
);
}