Rebuild the Scanner page into a unified door check-in screen.
Most attendees arrive without their QR open, and taking money at the door meant leaving the scanner for the event dashboard, where the Add Ticket modal demanded an email and recorded no payment method. The screen now leads with manual name search, keeps the camera one tap away behind a fullscreen overlay, and creates and charges walk-ins inline. Check-in and payment are one action: anything done here is born confirmed, paid (or comp) and checked in through a single endpoint, POST /api/events/:eventId/door-checkin. There are no confirm dialogs anywhere, because they stall the queue; a ten-second Undo replaces them, reversing exactly what the action changed via the undo state recorded alongside its idempotency key. Writes fire in the background with retries, so venue wifi never blocks the person at the door, and a capacity limit only warns, since staff at the door are the authority. Every write carries a client-generated idempotency key, inserted in the same transaction as the writes it guards, so a double tap or a retry after a timeout cannot produce a second ticket, payment or check-in. Search runs entirely in memory over one preloaded list: names are matched accent- and case-insensitively in both directions, per word, prefix before substring, with a mostly-numeric query searching phone digits so two people with the same name can be told apart. Door money is recorded as payments.source 'door' plus payments.method (cash, bitcoin, transfer or guest) while provider keeps its existing value, so capacity counting, the stale-booking sweeps and the admin payment lists are unaffected and revenue can still be split pre-sale versus door. Bitcoin records the payment as made, on the same trust model as cash, with no invoice generated; lib/doorPayments.ts is where a real Lightning flow slots in later. Also fixes the SQLite tickets DDL, which still created the pre-split attendee_name column with NOT NULL email and phone. Only fresh databases were affected -- existing ones were relaxed by later ALTERs -- but on those, door walk-ins (and any other ticket) could not be inserted at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a0161a67d2
commit
e296e80e48
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
|
||||
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import { PaymentButtons } from './PaymentButtons';
|
||||
|
||||
function checkinTime(checkinAt: string | null): string {
|
||||
if (!checkinAt) return '';
|
||||
return parseDate(checkinAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: EVENT_TIMEZONE,
|
||||
});
|
||||
}
|
||||
|
||||
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
|
||||
cash: 'cash',
|
||||
bitcoin: 'bitcoin',
|
||||
transfer: 'transfer',
|
||||
guest: 'guest',
|
||||
};
|
||||
|
||||
/** The second line of a row: everything staff needs to decide in one glance. */
|
||||
function statusLine(attendee: DoorAttendee, currency: string): string {
|
||||
if (attendee.status === 'cancelled') return 'Cancelled';
|
||||
if (attendee.checkedIn) {
|
||||
const time = checkinTime(attendee.checkinAt);
|
||||
const how = attendee.doorMethod ? ` · paid ${METHOD_LABELS[attendee.doorMethod]}` : '';
|
||||
return time ? `Checked in ${time}${how}` : `Checked in${how}`;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (attendee.paymentStatus === 'comp') parts.push('Guest');
|
||||
else if (attendee.paymentStatus === 'paid') parts.push('Paid');
|
||||
else parts.push(`Unpaid · ${formatCurrency(attendee.amountDue, currency)} due`);
|
||||
|
||||
if (attendee.isGroupBooking) parts.push('group booking');
|
||||
if (attendee.status === 'pending') parts.push('pending');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function AttendeeRow({
|
||||
attendee,
|
||||
currency,
|
||||
price,
|
||||
expanded,
|
||||
flashing,
|
||||
busy,
|
||||
onTap,
|
||||
onPay,
|
||||
}: {
|
||||
attendee: DoorAttendee;
|
||||
currency: string;
|
||||
price: number;
|
||||
expanded: boolean;
|
||||
flashing: boolean;
|
||||
busy: boolean;
|
||||
onTap: () => void;
|
||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||
}) {
|
||||
const isCancelled = attendee.status === 'cancelled';
|
||||
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
|
||||
// A settled, not-yet-arrived attendee is the one-tap case: the whole row checks
|
||||
// them in. Everyone else opens the tenders inline instead.
|
||||
const isOneTap = !isCancelled && !attendee.checkedIn && settled;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-2xl border transition-colors',
|
||||
flashing
|
||||
? 'bg-emerald-600 border-emerald-400'
|
||||
: attendee.checkedIn || isCancelled
|
||||
? 'bg-gray-900 border-gray-800'
|
||||
: 'bg-gray-800 border-gray-700',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={onTap}
|
||||
disabled={busy}
|
||||
className="w-full text-left px-4 py-3 min-h-[64px] flex items-center gap-3 active:scale-[0.99] transition-transform disabled:opacity-60"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={clsx(
|
||||
'font-bold text-lg truncate',
|
||||
flashing ? 'text-white' : attendee.checkedIn || isCancelled ? 'text-gray-400' : 'text-white',
|
||||
)}
|
||||
>
|
||||
{attendee.fullName}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
'text-sm truncate flex items-center gap-1.5',
|
||||
flashing
|
||||
? 'text-emerald-50'
|
||||
: isCancelled
|
||||
? 'text-red-400'
|
||||
: attendee.checkedIn
|
||||
? 'text-gray-500'
|
||||
: attendee.paymentStatus === 'unpaid'
|
||||
? 'text-amber-400'
|
||||
: 'text-gray-400',
|
||||
)}
|
||||
>
|
||||
{attendee.isGroupBooking && !attendee.checkedIn && <UserGroupIcon className="w-4 h-4 flex-shrink-0" />}
|
||||
{statusLine(attendee, currency)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{flashing ? (
|
||||
<CheckCircleIcon className="w-8 h-8 text-white flex-shrink-0" />
|
||||
) : attendee.checkedIn ? (
|
||||
<CheckCircleIcon className="w-7 h-7 text-emerald-500/60 flex-shrink-0" />
|
||||
) : isCancelled ? (
|
||||
<span className="flex-shrink-0 text-[10px] font-bold uppercase tracking-wide px-2 py-1 rounded-full bg-red-950 text-red-400">
|
||||
Cancelled
|
||||
</span>
|
||||
) : isOneTap ? (
|
||||
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-primary-yellow">
|
||||
Check in
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-amber-400">
|
||||
Collect
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && !attendee.checkedIn && (
|
||||
<div className="px-3 pb-3 pt-1 space-y-2">
|
||||
{isCancelled && (
|
||||
<p className="text-xs text-gray-400 px-1 flex items-center gap-1.5">
|
||||
<ArrowUturnLeftIcon className="w-4 h-4" />
|
||||
Reactivate as a walk-in — pick how they are paying.
|
||||
</p>
|
||||
)}
|
||||
<PaymentButtons price={price} currency={currency} onPay={onPay} disabled={busy} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && attendee.checkedIn && (
|
||||
<div className="px-4 pb-3 -mt-1">
|
||||
<p className="text-sm text-gray-400">
|
||||
Already checked in
|
||||
{attendee.checkinAt ? ` at ${checkinTime(attendee.checkinAt)}` : ''}
|
||||
{attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
BanknotesIcon,
|
||||
BoltIcon,
|
||||
BuildingLibraryIcon,
|
||||
GiftIcon,
|
||||
ChevronDownIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { DoorPaymentMethod } from '@/lib/api';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
// The four tenders staff can take at the door. One tap settles and checks in;
|
||||
// long-press (or the chevron) opens multiples for someone paying for their group.
|
||||
|
||||
const TENDERS: {
|
||||
method: DoorPaymentMethod;
|
||||
label: string;
|
||||
icon: typeof BanknotesIcon;
|
||||
className: string;
|
||||
}[] = [
|
||||
{ method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' },
|
||||
{ method: 'bitcoin', label: 'Bitcoin', icon: BoltIcon, className: 'bg-orange-500 active:bg-orange-600' },
|
||||
{ method: 'transfer', label: 'Transfer', icon: BuildingLibraryIcon, className: 'bg-blue-600 active:bg-blue-700' },
|
||||
{ method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' },
|
||||
];
|
||||
|
||||
const LONG_PRESS_MS = 450;
|
||||
|
||||
export function PaymentButtons({
|
||||
price,
|
||||
currency,
|
||||
onPay,
|
||||
disabled,
|
||||
}: {
|
||||
price: number;
|
||||
currency: string;
|
||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
// Which tender has its quick-amounts open. Guest is always free, so it never opens one.
|
||||
const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [customValue, setCustomValue] = useState('');
|
||||
const [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [longPressed, setLongPressed] = useState(false);
|
||||
|
||||
const openAmounts = (method: DoorPaymentMethod) => {
|
||||
if (method === 'guest') return;
|
||||
setAmountsFor(method);
|
||||
setCustomOpen(false);
|
||||
setCustomValue('');
|
||||
};
|
||||
|
||||
const startPress = (method: DoorPaymentMethod) => {
|
||||
setLongPressed(false);
|
||||
const timer = setTimeout(() => {
|
||||
setLongPressed(true);
|
||||
openAmounts(method);
|
||||
}, LONG_PRESS_MS);
|
||||
setPressTimer(timer);
|
||||
};
|
||||
|
||||
const endPress = (method: DoorPaymentMethod) => {
|
||||
if (pressTimer) clearTimeout(pressTimer);
|
||||
setPressTimer(null);
|
||||
// A long press already opened the multiples; don't also charge 1x on release.
|
||||
if (longPressed) {
|
||||
setLongPressed(false);
|
||||
return;
|
||||
}
|
||||
if (disabled) return;
|
||||
onPay(method, method === 'guest' ? 0 : price);
|
||||
};
|
||||
|
||||
const cancelPress = () => {
|
||||
if (pressTimer) clearTimeout(pressTimer);
|
||||
setPressTimer(null);
|
||||
setLongPressed(false);
|
||||
};
|
||||
|
||||
if (amountsFor) {
|
||||
const tender = TENDERS.find((t) => t.method === amountsFor)!;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="text-sm font-semibold text-white">{tender.label} — how many?</p>
|
||||
<button
|
||||
onClick={() => { setAmountsFor(null); setCustomOpen(false); }}
|
||||
className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[1, 2, 3].map((qty) => (
|
||||
<button
|
||||
key={qty}
|
||||
disabled={disabled}
|
||||
onClick={() => onPay(tender.method, price * qty)}
|
||||
className={clsx(
|
||||
'min-h-[56px] rounded-2xl font-bold text-white text-lg flex flex-col items-center justify-center leading-tight disabled:opacity-50 active:scale-[0.97] transition-transform',
|
||||
tender.className,
|
||||
)}
|
||||
>
|
||||
{qty}x
|
||||
<span className="text-[10px] font-medium opacity-80">
|
||||
{formatCurrency(price * qty, currency)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={() => setCustomOpen((open) => !open)}
|
||||
className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{customOpen && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
placeholder={`Amount in ${currency}`}
|
||||
className="flex-1 min-h-[48px] px-4 bg-gray-800 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
<button
|
||||
disabled={disabled || !customValue || Number(customValue) < 0}
|
||||
onClick={() => onPay(tender.method, Number(customValue))}
|
||||
className={clsx(
|
||||
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
|
||||
tender.className,
|
||||
)}
|
||||
>
|
||||
Take
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{TENDERS.map((tender) => (
|
||||
<button
|
||||
key={tender.method}
|
||||
disabled={disabled}
|
||||
onPointerDown={() => startPress(tender.method)}
|
||||
onPointerUp={() => endPress(tender.method)}
|
||||
onPointerLeave={cancelPress}
|
||||
onPointerCancel={cancelPress}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className={clsx(
|
||||
'relative min-h-[64px] rounded-2xl text-white font-bold flex flex-col items-center justify-center gap-1 select-none disabled:opacity-50 active:scale-[0.97] transition-transform',
|
||||
tender.className,
|
||||
)}
|
||||
>
|
||||
<tender.icon className="w-6 h-6" />
|
||||
<span className="text-xs">{tender.label}</span>
|
||||
{tender.method !== 'guest' && (
|
||||
// Visible affordance for the same thing long-press does: staff who
|
||||
// never discover the hold still find the multiples.
|
||||
<span
|
||||
role="button"
|
||||
aria-label={`${tender.label} quick amounts`}
|
||||
onPointerDown={(e) => { e.stopPropagation(); cancelPress(); }}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); openAmounts(tender.method); }}
|
||||
className="absolute top-0.5 right-0.5 w-7 h-7 flex items-center justify-center rounded-full text-white/70"
|
||||
>
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { QrCodeIcon, XMarkIcon, VideoCameraIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// The camera is a fullscreen overlay opened from the search row, not a tab. It
|
||||
// only exists while a scan is happening, so it never holds the camera (or the
|
||||
// screen) while staff are typing a name.
|
||||
|
||||
/** Release any camera stream html5-qrcode left attached to a <video> element. */
|
||||
function stopAllTracks() {
|
||||
try {
|
||||
document.querySelectorAll('video').forEach((video) => {
|
||||
const stream = video.srcObject as MediaStream | null;
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
video.srcObject = null;
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function QRScannerOverlay({
|
||||
onScan,
|
||||
onClose,
|
||||
}: {
|
||||
onScan: (code: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scannerRef = useRef<any>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const elementId = useRef(`qr-scanner-${Date.now()}`);
|
||||
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const destroyScanner = useCallback(async () => {
|
||||
if (scannerRef.current) {
|
||||
try { await scannerRef.current.stop(); } catch {}
|
||||
try { scannerRef.current.clear(); } catch {}
|
||||
scannerRef.current = null;
|
||||
}
|
||||
stopAllTracks();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
let cancelled = false;
|
||||
|
||||
const init = async () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const id = elementId.current;
|
||||
container.innerHTML = '';
|
||||
const div = document.createElement('div');
|
||||
div.id = id;
|
||||
div.style.width = '100%';
|
||||
div.style.height = '100%';
|
||||
container.appendChild(div);
|
||||
|
||||
try {
|
||||
const { Html5Qrcode } = await import('html5-qrcode');
|
||||
if (cancelled) return;
|
||||
|
||||
const scanner = new Html5Qrcode(id);
|
||||
scannerRef.current = scanner;
|
||||
|
||||
await scanner.start(
|
||||
{ facingMode },
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 },
|
||||
(decodedText: string) => {
|
||||
if (mountedRef.current) onScan(decodedText);
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
await destroyScanner();
|
||||
return;
|
||||
}
|
||||
|
||||
// Force a layout pass: some browsers leave the video mis-sized until reflow.
|
||||
requestAnimationFrame(() => {
|
||||
if (container) {
|
||||
container.style.display = 'none';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
container.offsetHeight;
|
||||
container.style.display = '';
|
||||
}
|
||||
if (mountedRef.current) setReady(true);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Scanner error:', error);
|
||||
if (!cancelled && mountedRef.current) {
|
||||
toast.error('Failed to start camera. Check permissions.');
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
destroyScanner();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [facingMode]);
|
||||
|
||||
// Backgrounding the browser suspends the camera; drop it and rebuild on return.
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
destroyScanner();
|
||||
} else if (document.visibilityState === 'visible' && mountedRef.current) {
|
||||
setFacingMode((prev) => {
|
||||
const temp = prev === 'environment' ? 'user' : 'environment';
|
||||
setTimeout(() => {
|
||||
if (mountedRef.current) setFacingMode(prev);
|
||||
}, 100);
|
||||
return temp;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||
}, [destroyScanner]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 safe-area-top">
|
||||
<p className="text-white font-semibold">Scan ticket</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{ready && (
|
||||
<button
|
||||
onClick={() => setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'))}
|
||||
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
|
||||
aria-label="Switch camera"
|
||||
>
|
||||
<VideoCameraIcon className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
|
||||
aria-label="Close scanner"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full [&_video]:!object-cover [&_video]:!h-full [&_video]:!w-full"
|
||||
/>
|
||||
{!ready && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm opacity-60">Starting camera...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 px-6 py-5 pb-safe">
|
||||
<p className="text-center text-gray-400 text-sm">
|
||||
Point at the ticket QR — it checks in and closes automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'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;
|
||||
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,
|
||||
currency,
|
||||
onRefresh,
|
||||
onClose,
|
||||
}: {
|
||||
entries: SessionEntry[];
|
||||
summary: DoorSummary | null;
|
||||
summaryLoading: 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">
|
||||
<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 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import type { DoorPaymentMethod } from '@/lib/api';
|
||||
import { PaymentButtons } from './PaymentButtons';
|
||||
|
||||
export interface WalkInDraft {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
ruc: string;
|
||||
}
|
||||
|
||||
export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
|
||||
firstName,
|
||||
lastName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
ruc: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* The pinned bottom row. Collapsed it is a single tap; expanded it is a first
|
||||
* name and four tenders. Email, phone and RUC live behind "Add details" so the
|
||||
* rare person who wants a receipt never slows down the queue behind them.
|
||||
*/
|
||||
export function WalkInRow({
|
||||
typedText,
|
||||
expanded,
|
||||
draft,
|
||||
price,
|
||||
currency,
|
||||
busy,
|
||||
onExpand,
|
||||
onChange,
|
||||
onPay,
|
||||
onCancel,
|
||||
}: {
|
||||
typedText: string;
|
||||
expanded: boolean;
|
||||
draft: WalkInDraft;
|
||||
price: number;
|
||||
currency: string;
|
||||
busy: boolean;
|
||||
onExpand: () => void;
|
||||
onChange: (draft: WalkInDraft) => void;
|
||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const firstNameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) firstNameRef.current?.focus();
|
||||
}, [expanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) setDetailsOpen(false);
|
||||
}, [expanded]);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={onExpand}
|
||||
className="w-full min-h-[64px] px-4 py-3 rounded-2xl border-2 border-dashed border-primary-yellow/50 bg-primary-yellow/5 text-left flex items-center gap-3 active:scale-[0.99] transition-transform"
|
||||
>
|
||||
<UserPlusIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<span className="font-bold text-primary-yellow truncate">
|
||||
{typedText ? `Add "${typedText}" as walk-in` : 'Add a walk-in'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const field = (key: keyof WalkInDraft, value: string) => onChange({ ...draft, [key]: value });
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border-2 border-primary-yellow/50 bg-gray-800 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="font-bold text-primary-yellow">New walk-in</p>
|
||||
<button onClick={onCancel} className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
ref={firstNameRef}
|
||||
value={draft.firstName}
|
||||
onChange={(e) => field('firstName', e.target.value)}
|
||||
placeholder="First name"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
<input
|
||||
value={draft.lastName}
|
||||
onChange={(e) => field('lastName', e.target.value)}
|
||||
placeholder="Last name (optional)"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={draft.phone}
|
||||
onChange={(e) => field('phone', e.target.value)}
|
||||
placeholder="Phone (optional)"
|
||||
inputMode="tel"
|
||||
autoComplete="off"
|
||||
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setDetailsOpen((open) => !open)}
|
||||
className="w-full min-h-[48px] flex items-center justify-between px-2 text-sm text-gray-400 active:text-white"
|
||||
>
|
||||
Add details (email, RUC)
|
||||
<ChevronDownIcon className={clsx('w-4 h-4 transition-transform', detailsOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{detailsOpen && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={draft.email}
|
||||
onChange={(e) => field('email', e.target.value)}
|
||||
placeholder="Email — sends the usual confirmation"
|
||||
inputMode="email"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
<input
|
||||
value={draft.ruc}
|
||||
onChange={(e) => field('ruc', e.target.value)}
|
||||
placeholder="RUC (for factura)"
|
||||
autoComplete="off"
|
||||
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaymentButtons
|
||||
price={price}
|
||||
currency={currency}
|
||||
onPay={onPay}
|
||||
disabled={busy || !draft.firstName.trim()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user