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>
This commit is contained in:
Michilis
2026-09-14 21:01:22 +00:00
co-authored by Claude Fable 5.1
parent 59acc32a46
commit 9cc330030b
3 changed files with 217 additions and 18 deletions
+81 -18
View File
@@ -31,6 +31,7 @@ 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
@@ -90,6 +91,14 @@ export default function AdminDoorPage() {
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[]>([]);
@@ -256,8 +265,8 @@ export default function AdminDoorPage() {
);
const showUndoToast = useCallback(
(message: string, entry: SessionEntry) => {
toast.custom(
(message: string, entry: SessionEntry, durationMs: number = UNDO_WINDOW_MS) => {
const id = toast.custom(
(t) => (
<div
className={clsx(
@@ -284,12 +293,45 @@ export default function AdminDoorPage() {
</button>
</div>
),
{ duration: UNDO_WINDOW_MS, position: 'bottom-center' },
{ 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
@@ -321,6 +363,7 @@ export default function AdminDoorPage() {
entry: opts.entry,
method: opts.payment?.method ?? null,
amount: opts.payment?.amount ?? 0,
startedAt: now.getTime(),
undone: false,
failed: false,
};
@@ -355,7 +398,12 @@ export default function AdminDoorPage() {
playSuccessSound();
const paidSuffix = opts.payment ? `, ${METHOD_PAST_TENSE[opts.payment.method]}` : '';
showUndoToast(`${opts.displayName} checked in${paidSuffix}`, sessionEntry);
showResult({
kind: 'success',
name: opts.displayName,
detail: `Checked in ${clockTime(now)}${paidSuffix}`,
entry: sessionEntry,
});
setQuery('');
setExpandedId(null);
@@ -395,6 +443,20 @@ export default function AdminDoorPage() {
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',
@@ -404,7 +466,7 @@ export default function AdminDoorPage() {
setBusyId((current) => (current === opts.busyKey ? null : current));
}
},
[data?.attendees, patchAttendee, upsertAttendee, showUndoToast, focusInput, loadAttendees],
[data?.attendees, patchAttendee, upsertAttendee, showResult, focusInput, loadAttendees],
);
// ─── Row interactions ────────────────────────────────────────
@@ -488,19 +550,17 @@ export default function AdminDoorPage() {
if (!attendee) {
playErrorSound();
vibrate([100, 50, 100]);
toast.error('Ticket not found for this event', { position: 'bottom-center', duration: 6000 });
focusInput();
showResult({ kind: 'not_found', name: 'Ticket not found', detail: 'No ticket for this event' });
return;
}
if (attendee.checkedIn) {
playErrorSound();
vibrate([100, 50, 100]);
toast(`${attendee.fullName} already checked in${attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : ''}`, {
icon: '',
position: 'bottom-center',
duration: 6000,
});
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;
@@ -508,13 +568,14 @@ export default function AdminDoorPage() {
if (attendee.paymentStatus === 'unpaid') {
// Checking an unpaid ticket in silently would walk the money out the
// door. Surface them with the tenders open instead.
// door. Surface them with the tenders open instead: "Collect" closes
// the screen straight onto the payment buttons.
playErrorSound();
vibrate(200);
toast(`Collect ${formatCurrency(attendee.amountDue || price, currency)} from ${attendee.fullName}`, {
icon: '💰',
position: 'bottom-center',
duration: 8000,
showResult({
kind: 'unpaid',
name: attendee.fullName,
detail: `Collect ${formatCurrency(attendee.amountDue || price, currency)}`,
});
setQuery(attendee.fullName);
setExpandedId(attendee.ticketId);
@@ -529,7 +590,7 @@ export default function AdminDoorPage() {
busyKey: attendee.ticketId,
});
},
[data?.attendees, runAction, focusInput, price, currency],
[data?.attendees, runAction, showResult, price, currency],
);
// ─── Session summary ─────────────────────────────────────────
@@ -732,6 +793,8 @@ export default function AdminDoorPage() {
/>
)}
{result && <ResultScreen result={result} onClose={closeResult} onUndo={undoFromResult} />}
{sessionOpen && (
<SessionSheet
entries={sessionEntries}