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';
|
||||
method: DoorPaymentMethod | null;
|
||||
amount: number;
|
||||
/** Epoch ms the action was fired; the undo window is measured from here. */
|
||||
startedAt: number;
|
||||
undone: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user