diff --git a/frontend/src/app/admin/scanner/_components/ResultScreen.tsx b/frontend/src/app/admin/scanner/_components/ResultScreen.tsx new file mode 100644 index 0000000..f0c5f8b --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/ResultScreen.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useEffect, useState, type SyntheticEvent } from 'react'; +import clsx from 'clsx'; +import { CheckCircleIcon, XCircleIcon, BanknotesIcon } from '@heroicons/react/24/outline'; +import type { SessionEntry } from './SessionSheet'; + +// Full-screen outcome after a door action. At a loud, dark door a row flash and +// a small toast are too easy to miss; a screen that is entirely green or +// entirely red answers "is this person in or not?" from across the table. +// +// It never traps the queue: tapping anywhere closes it, and it closes itself +// after a few seconds. The undo affordance survives the close — the page shows +// the bottom Undo toast for whatever is left of the window. + +export type DoorResultKind = 'success' | 'already_in' | 'not_found' | 'unpaid' | 'failed'; + +export interface DoorResult { + kind: DoorResultKind; + /** Headline: the attendee's name, or a short verdict when there is no one. */ + name: string; + /** Second line: "Checked in 19:42 · paid cash", "Already in at 19:42", "Collect ₲60.000". */ + detail?: string; + /** Success only — drives Undo and the post-close toast. */ + entry?: SessionEntry; +} + +// Success needs no reading; the red ones carry a time or an amount the staff +// member has to relay to the person, so they get a little longer. +export const RESULT_AUTO_CLOSE_MS: Record = { + success: 4000, + already_in: 6000, + not_found: 6000, + unpaid: 6000, + failed: 6000, +}; + +const SURFACE: Record = { + success: { bg: 'bg-emerald-600', text: 'text-emerald-700', icon: CheckCircleIcon, title: 'Checked in' }, + already_in: { bg: 'bg-red-600', text: 'text-red-700', icon: XCircleIcon, title: 'Already checked in' }, + not_found: { bg: 'bg-red-600', text: 'text-red-700', icon: XCircleIcon, title: 'Not found' }, + unpaid: { bg: 'bg-amber-600', text: 'text-amber-700', icon: BanknotesIcon, title: 'Payment due' }, + failed: { bg: 'bg-red-700', text: 'text-red-800', icon: XCircleIcon, title: 'NOT checked in' }, +}; + +export function ResultScreen({ + result, + onClose, + onUndo, +}: { + result: DoorResult; + onClose: () => void; + onUndo: () => void; +}) { + const surface = SURFACE[result.kind]; + const duration = RESULT_AUTO_CLOSE_MS[result.kind]; + // Toggled after mount so the fade-in and the countdown bar both animate from + // their starting state instead of appearing already finished. + const [shown, setShown] = useState(false); + + // Keyed on the result identity: a success that flips to failed restarts both + // the fade and the countdown for the new state. + const identity = `${result.kind}:${result.entry?.idempotencyKey ?? result.name}`; + + useEffect(() => { + setShown(false); + const raf = requestAnimationFrame(() => setShown(true)); + const timer = setTimeout(onClose, duration); + return () => { + cancelAnimationFrame(raf); + clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [identity]); + + const stop = (e: SyntheticEvent) => e.stopPropagation(); + const Icon = surface.icon; + + return ( +
+
+
+ +
+

{surface.title}

+

{result.name}

+ {result.detail &&

{result.detail}

} +

Tap anywhere to continue

+
+ +
+ {result.kind === 'success' && ( + + )} + +
+ + {/* Countdown to auto-close, so the screen vanishing never surprises anyone. */} +
+
+
+
+ ); +} diff --git a/frontend/src/app/admin/scanner/_components/SessionSheet.tsx b/frontend/src/app/admin/scanner/_components/SessionSheet.tsx index 8ccb2a0..0916074 100644 --- a/frontend/src/app/admin/scanner/_components/SessionSheet.tsx +++ b/frontend/src/app/admin/scanner/_components/SessionSheet.tsx @@ -21,6 +21,8 @@ export interface SessionEntry { entry: 'scan' | 'search' | 'walkin'; method: DoorPaymentMethod | null; amount: number; + /** Epoch ms the action was fired; the undo window is measured from here. */ + startedAt: number; undone: boolean; failed: boolean; } diff --git a/frontend/src/app/admin/scanner/page.tsx b/frontend/src/app/admin/scanner/page.tsx index 30e90c1..f4e8c37 100644 --- a/frontend/src/app/admin/scanner/page.tsx +++ b/frontend/src/app/admin/scanner/page.tsx @@ -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(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([]); @@ -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) => (
), - { 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 && } + {sessionOpen && (