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:
co-authored by
Claude Fable 5.1
parent
59acc32a46
commit
9cc330030b
@@ -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<DoorResultKind, number> = {
|
||||||
|
success: 4000,
|
||||||
|
already_in: 6000,
|
||||||
|
not_found: 6000,
|
||||||
|
unpaid: 6000,
|
||||||
|
failed: 6000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SURFACE: Record<DoorResultKind, { bg: string; text: string; icon: typeof CheckCircleIcon; title: string }> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-live="assertive"
|
||||||
|
onClick={onClose}
|
||||||
|
className={clsx(
|
||||||
|
'fixed inset-0 z-50 flex flex-col text-white select-none transition-opacity duration-150',
|
||||||
|
surface.bg,
|
||||||
|
shown ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
style={{ paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
|
||||||
|
<div className="w-24 h-24 rounded-full bg-white/20 flex items-center justify-center mb-6">
|
||||||
|
<Icon className="w-16 h-16" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm uppercase tracking-widest text-white/70 mb-2">{surface.title}</p>
|
||||||
|
<h2 className="text-3xl font-bold leading-tight break-words max-w-full">{result.name}</h2>
|
||||||
|
{result.detail && <p className="text-white/85 text-xl mt-3">{result.detail}</p>}
|
||||||
|
<p className="text-white/50 text-sm mt-8">Tap anywhere to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 pb-6 flex gap-3">
|
||||||
|
{result.kind === 'success' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { stop(e); onUndo(); }}
|
||||||
|
className="flex-1 min-h-[56px] rounded-2xl bg-white/20 text-white text-xl font-bold active:scale-[0.98] transition-transform"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { stop(e); onClose(); }}
|
||||||
|
className={clsx(
|
||||||
|
'flex-1 min-h-[56px] rounded-2xl bg-white text-xl font-bold active:scale-[0.98] transition-transform',
|
||||||
|
surface.text,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{result.kind === 'unpaid' ? 'Collect' : 'Close'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown to auto-close, so the screen vanishing never surprises anyone. */}
|
||||||
|
<div className="h-1 bg-white/20">
|
||||||
|
<div
|
||||||
|
key={identity}
|
||||||
|
className="h-full bg-white/70"
|
||||||
|
style={{
|
||||||
|
width: shown ? '0%' : '100%',
|
||||||
|
transition: shown ? `width ${duration}ms linear` : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export interface SessionEntry {
|
|||||||
entry: 'scan' | 'search' | 'walkin';
|
entry: 'scan' | 'search' | 'walkin';
|
||||||
method: DoorPaymentMethod | null;
|
method: DoorPaymentMethod | null;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
/** Epoch ms the action was fired; the undo window is measured from here. */
|
||||||
|
startedAt: number;
|
||||||
undone: boolean;
|
undone: boolean;
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { QRScannerOverlay } from './_components/QRScannerOverlay';
|
|||||||
import { AttendeeRow } from './_components/AttendeeRow';
|
import { AttendeeRow } from './_components/AttendeeRow';
|
||||||
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
|
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
|
||||||
import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
|
import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
|
||||||
|
import { ResultScreen, type DoorResult } from './_components/ResultScreen';
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Door check-in screen
|
// Door check-in screen
|
||||||
@@ -90,6 +91,14 @@ export default function AdminDoorPage() {
|
|||||||
const [walkInDraft, setWalkInDraft] = useState<WalkInDraft>(emptyWalkIn());
|
const [walkInDraft, setWalkInDraft] = useState<WalkInDraft>(emptyWalkIn());
|
||||||
const [scannerOpen, setScannerOpen] = useState(false);
|
const [scannerOpen, setScannerOpen] = useState(false);
|
||||||
const [sessionOpen, setSessionOpen] = 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 ──
|
// ── Session bookkeeping ──
|
||||||
const [sessionEntries, setSessionEntries] = useState<SessionEntry[]>([]);
|
const [sessionEntries, setSessionEntries] = useState<SessionEntry[]>([]);
|
||||||
@@ -256,8 +265,8 @@ export default function AdminDoorPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showUndoToast = useCallback(
|
const showUndoToast = useCallback(
|
||||||
(message: string, entry: SessionEntry) => {
|
(message: string, entry: SessionEntry, durationMs: number = UNDO_WINDOW_MS) => {
|
||||||
toast.custom(
|
const id = toast.custom(
|
||||||
(t) => (
|
(t) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -284,12 +293,45 @@ export default function AdminDoorPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
{ duration: UNDO_WINDOW_MS, position: 'bottom-center' },
|
{ duration: durationMs, position: 'bottom-center' },
|
||||||
);
|
);
|
||||||
|
undoToastIdsRef.current.set(entry.idempotencyKey, id);
|
||||||
},
|
},
|
||||||
[handleUndo],
|
[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 ──────────────────────────────────────
|
// ─── The one write path ──────────────────────────────────────
|
||||||
// Every completed action on this screen — scan, tap, collect, walk-in — flows
|
// 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
|
// 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,
|
entry: opts.entry,
|
||||||
method: opts.payment?.method ?? null,
|
method: opts.payment?.method ?? null,
|
||||||
amount: opts.payment?.amount ?? 0,
|
amount: opts.payment?.amount ?? 0,
|
||||||
|
startedAt: now.getTime(),
|
||||||
undone: false,
|
undone: false,
|
||||||
failed: false,
|
failed: false,
|
||||||
};
|
};
|
||||||
@@ -355,7 +398,12 @@ export default function AdminDoorPage() {
|
|||||||
playSuccessSound();
|
playSuccessSound();
|
||||||
|
|
||||||
const paidSuffix = opts.payment ? `, ${METHOD_PAST_TENSE[opts.payment.method]}` : '';
|
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('');
|
setQuery('');
|
||||||
setExpandedId(null);
|
setExpandedId(null);
|
||||||
@@ -395,6 +443,20 @@ export default function AdminDoorPage() {
|
|||||||
if (previousRow) upsertAttendee(previousRow);
|
if (previousRow) upsertAttendee(previousRow);
|
||||||
playErrorSound();
|
playErrorSound();
|
||||||
vibrate([100, 50, 100]);
|
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(), {
|
toast.error(`FAILED — ${opts.displayName} is NOT checked in. ${error?.message || ''}`.trim(), {
|
||||||
duration: 12000,
|
duration: 12000,
|
||||||
position: 'bottom-center',
|
position: 'bottom-center',
|
||||||
@@ -404,7 +466,7 @@ export default function AdminDoorPage() {
|
|||||||
setBusyId((current) => (current === opts.busyKey ? null : current));
|
setBusyId((current) => (current === opts.busyKey ? null : current));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[data?.attendees, patchAttendee, upsertAttendee, showUndoToast, focusInput, loadAttendees],
|
[data?.attendees, patchAttendee, upsertAttendee, showResult, focusInput, loadAttendees],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Row interactions ────────────────────────────────────────
|
// ─── Row interactions ────────────────────────────────────────
|
||||||
@@ -488,19 +550,17 @@ export default function AdminDoorPage() {
|
|||||||
if (!attendee) {
|
if (!attendee) {
|
||||||
playErrorSound();
|
playErrorSound();
|
||||||
vibrate([100, 50, 100]);
|
vibrate([100, 50, 100]);
|
||||||
toast.error('Ticket not found for this event', { position: 'bottom-center', duration: 6000 });
|
showResult({ kind: 'not_found', name: 'Ticket not found', detail: 'No ticket for this event' });
|
||||||
focusInput();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attendee.checkedIn) {
|
if (attendee.checkedIn) {
|
||||||
playErrorSound();
|
playErrorSound();
|
||||||
vibrate([100, 50, 100]);
|
vibrate([100, 50, 100]);
|
||||||
toast(`${attendee.fullName} already checked in${attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : ''}`, {
|
const at = attendee.checkinAt ? ` at ${clockTime(attendee.checkinAt)}` : '';
|
||||||
icon: 'ℹ️',
|
const by = attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : '';
|
||||||
position: 'bottom-center',
|
showResult({ kind: 'already_in', name: attendee.fullName, detail: `Already checked in${at}${by}` });
|
||||||
duration: 6000,
|
// Their row sits open underneath, so closing lands on the same person.
|
||||||
});
|
|
||||||
setQuery(attendee.fullName);
|
setQuery(attendee.fullName);
|
||||||
setExpandedId(attendee.ticketId);
|
setExpandedId(attendee.ticketId);
|
||||||
return;
|
return;
|
||||||
@@ -508,13 +568,14 @@ export default function AdminDoorPage() {
|
|||||||
|
|
||||||
if (attendee.paymentStatus === 'unpaid') {
|
if (attendee.paymentStatus === 'unpaid') {
|
||||||
// Checking an unpaid ticket in silently would walk the money out the
|
// 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();
|
playErrorSound();
|
||||||
vibrate(200);
|
vibrate(200);
|
||||||
toast(`Collect ${formatCurrency(attendee.amountDue || price, currency)} from ${attendee.fullName}`, {
|
showResult({
|
||||||
icon: '💰',
|
kind: 'unpaid',
|
||||||
position: 'bottom-center',
|
name: attendee.fullName,
|
||||||
duration: 8000,
|
detail: `Collect ${formatCurrency(attendee.amountDue || price, currency)}`,
|
||||||
});
|
});
|
||||||
setQuery(attendee.fullName);
|
setQuery(attendee.fullName);
|
||||||
setExpandedId(attendee.ticketId);
|
setExpandedId(attendee.ticketId);
|
||||||
@@ -529,7 +590,7 @@ export default function AdminDoorPage() {
|
|||||||
busyKey: attendee.ticketId,
|
busyKey: attendee.ticketId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[data?.attendees, runAction, focusInput, price, currency],
|
[data?.attendees, runAction, showResult, price, currency],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Session summary ─────────────────────────────────────────
|
// ─── Session summary ─────────────────────────────────────────
|
||||||
@@ -732,6 +793,8 @@ export default function AdminDoorPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{result && <ResultScreen result={result} onClose={closeResult} onUndo={undoFromResult} />}
|
||||||
|
|
||||||
{sessionOpen && (
|
{sessionOpen && (
|
||||||
<SessionSheet
|
<SessionSheet
|
||||||
entries={sessionEntries}
|
entries={sessionEntries}
|
||||||
|
|||||||
Reference in New Issue
Block a user