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>
186 lines
6.6 KiB
TypeScript
186 lines
6.6 KiB
TypeScript
'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>
|
|
);
|
|
}
|