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>
243 lines
8.7 KiB
TypeScript
243 lines
8.7 KiB
TypeScript
'use client';
|
|
|
|
import clsx from 'clsx';
|
|
import {
|
|
XMarkIcon,
|
|
ClockIcon,
|
|
QrCodeIcon,
|
|
MagnifyingGlassIcon,
|
|
UserPlusIcon,
|
|
ArrowPathIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import type { DoorPaymentMethod, DoorSummary } from '@/lib/api';
|
|
import { DOOR_PAYMENT_METHODS } from '@/lib/api';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
|
|
export interface SessionEntry {
|
|
idempotencyKey: string;
|
|
ticketId: string;
|
|
name: string;
|
|
at: string;
|
|
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;
|
|
}
|
|
|
|
const ENTRY_ICONS = {
|
|
scan: QrCodeIcon,
|
|
search: MagnifyingGlassIcon,
|
|
walkin: UserPlusIcon,
|
|
};
|
|
|
|
const ENTRY_LABELS = {
|
|
scan: 'Scanned',
|
|
search: 'Search',
|
|
walkin: 'Walk-in',
|
|
};
|
|
|
|
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
|
|
cash: 'Cash',
|
|
bitcoin: 'Bitcoin',
|
|
transfer: 'Transfer',
|
|
guest: 'Guest',
|
|
};
|
|
|
|
/** Totals for the current shift, computed from this session's own entries. */
|
|
function sessionTotals(entries: SessionEntry[]) {
|
|
const totals: Record<DoorPaymentMethod, { count: number; total: number }> = {
|
|
cash: { count: 0, total: 0 },
|
|
bitcoin: { count: 0, total: 0 },
|
|
transfer: { count: 0, total: 0 },
|
|
guest: { count: 0, total: 0 },
|
|
};
|
|
let grand = 0;
|
|
for (const entry of entries) {
|
|
if (entry.undone || entry.failed || !entry.method) continue;
|
|
totals[entry.method].count += 1;
|
|
totals[entry.method].total += entry.amount;
|
|
grand += entry.amount;
|
|
}
|
|
return { totals, grand };
|
|
}
|
|
|
|
function CashUpGrid({
|
|
totals,
|
|
currency,
|
|
}: {
|
|
totals: Record<DoorPaymentMethod, { count: number; total: number }>;
|
|
currency: string;
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{DOOR_PAYMENT_METHODS.map((method) => (
|
|
<div key={method} className="bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
|
|
<p className="text-[11px] uppercase tracking-wide text-gray-500">{METHOD_LABELS[method]}</p>
|
|
<p className="font-bold text-white text-base leading-tight">
|
|
{method === 'guest' ? `${totals[method].count} free` : formatCurrency(totals[method].total, currency)}
|
|
</p>
|
|
{method !== 'guest' && (
|
|
<p className="text-[11px] text-gray-500">
|
|
{totals[method].count} {totals[method].count === 1 ? 'payment' : 'payments'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The end-of-night view: what this shift took, what the whole event day took,
|
|
* and the feed of who came in and how.
|
|
*/
|
|
export function SessionSheet({
|
|
entries,
|
|
summary,
|
|
summaryLoading,
|
|
showEventTotals,
|
|
currency,
|
|
onRefresh,
|
|
onClose,
|
|
}: {
|
|
entries: SessionEntry[];
|
|
summary: DoorSummary | null;
|
|
summaryLoading: boolean;
|
|
/** Whole-event takings are admin/organizer only; door staff see their own shift. */
|
|
showEventTotals: boolean;
|
|
currency: string;
|
|
onRefresh: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const { totals, grand } = sessionTotals(entries);
|
|
const liveEntries = entries.filter((e) => !e.undone);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 bg-gray-950 flex flex-col" style={{ height: '100dvh' }}>
|
|
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top flex items-center justify-between">
|
|
<div>
|
|
<p className="font-bold text-white text-lg">Session</p>
|
|
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{showEventTotals && (
|
|
<button
|
|
onClick={onRefresh}
|
|
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
|
|
aria-label="Refresh totals"
|
|
>
|
|
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
|
|
aria-label="Close session view"
|
|
>
|
|
<XMarkIcon className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5 pb-safe">
|
|
{/* This shift */}
|
|
<section className="space-y-2">
|
|
<div className="flex items-baseline justify-between">
|
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">This session</h2>
|
|
<p className="text-primary-yellow font-bold">{formatCurrency(grand, currency)}</p>
|
|
</div>
|
|
<CashUpGrid totals={totals} currency={currency} />
|
|
</section>
|
|
|
|
{/* Whole event, from the server — the number to reconcile the cash box
|
|
against. Admin/organizer only; the API enforces the same split. */}
|
|
{showEventTotals && (
|
|
<section className="space-y-2">
|
|
<div className="flex items-baseline justify-between">
|
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
|
|
<p className="text-primary-yellow font-bold">
|
|
{summary ? formatCurrency(summary.door.total, summary.currency) : '—'}
|
|
</p>
|
|
</div>
|
|
{summary ? (
|
|
<>
|
|
<CashUpGrid totals={summary.door.byMethod} currency={summary.currency} />
|
|
<div className="flex items-center justify-between bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
|
|
<div>
|
|
<p className="text-[11px] uppercase tracking-wide text-gray-500">Pre-sale</p>
|
|
<p className="font-bold text-white">
|
|
{formatCurrency(summary.presale.total, summary.currency)}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-[11px] uppercase tracking-wide text-gray-500">Event total</p>
|
|
<p className="font-bold text-white">{formatCurrency(summary.total, summary.currency)}</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-gray-500">
|
|
{summaryLoading ? 'Loading totals…' : 'Totals unavailable — pull to refresh.'}
|
|
</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* Feed */}
|
|
<section className="space-y-2">
|
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Recent check-ins</h2>
|
|
{entries.length === 0 ? (
|
|
<div className="text-center text-gray-500 py-10">
|
|
<ClockIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
|
<p className="text-sm">No check-ins yet</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{entries.map((entry) => {
|
|
const Icon = ENTRY_ICONS[entry.entry];
|
|
return (
|
|
<div
|
|
key={entry.idempotencyKey}
|
|
className={clsx(
|
|
'rounded-xl border px-3 py-2.5 flex items-center gap-3',
|
|
entry.failed
|
|
? 'bg-red-950/40 border-red-900'
|
|
: entry.undone
|
|
? 'bg-gray-900 border-gray-800 opacity-50'
|
|
: 'bg-gray-800 border-gray-700',
|
|
)}
|
|
>
|
|
<Icon className="w-5 h-5 text-gray-500 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p
|
|
className={clsx(
|
|
'font-medium truncate',
|
|
entry.undone ? 'text-gray-500 line-through' : 'text-white',
|
|
)}
|
|
>
|
|
{entry.name}
|
|
</p>
|
|
<p className="text-xs text-gray-500 truncate">
|
|
{ENTRY_LABELS[entry.entry]}
|
|
{entry.method ? ` · ${METHOD_LABELS[entry.method]}` : ''}
|
|
{entry.method && entry.method !== 'guest'
|
|
? ` ${formatCurrency(entry.amount, currency)}`
|
|
: ''}
|
|
{entry.failed ? ' · failed' : entry.undone ? ' · undone' : ''}
|
|
</p>
|
|
</div>
|
|
<p className="text-sm text-gray-400 flex-shrink-0">{entry.at}</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|