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>
815 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|