From a0161a67d2443840c55e29191c5b2c99848af80d Mon Sep 17 00:00:00 2001 From: Michilis Date: Sat, 22 Aug 2026 04:39:54 +0000 Subject: [PATCH 1/6] Add "paid at the door" ticket type to the Add Ticket modal. Door walk-ins were only expressible as an unpaid ticket, which left the cash out of revenue. The new type records the cash payment as paid and makes every field optional, since a walk-in often gives no details: a blank name is logged as "Walk-in", and a confirmation email only goes out when an email is entered. Door tickets reuse paymentStatus 'paid' (the column enum is capped at paid/unpaid/comp) so badges and revenue totals pick them up with no migration; the cash payment row is referenced "Paid at door" to keep them distinguishable from emailed manual tickets. Co-Authored-By: Claude Opus 5 --- backend/src/routes/tickets.ts | 36 +++++++++++++------ .../events/[id]/_modals/AddTicketModal.tsx | 25 ++++++++++--- .../admin/events/[id]/_modals/EventModals.tsx | 10 ++++++ .../admin/events/[id]/_tabs/AttendeesTab.tsx | 3 ++ frontend/src/app/admin/events/[id]/_types.ts | 3 +- frontend/src/app/admin/events/[id]/page.tsx | 4 +-- frontend/src/lib/api/tickets.ts | 7 ++-- 7 files changed, 68 insertions(+), 20 deletions(-) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 776dbfa..9cee47a 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1529,14 +1529,18 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) // Unified admin add-attendee endpoint backing the single Add Ticket modal. // type drives payment handling: // paid — email required; paid cash payment; confirmation email + QR sent +// door — paid in cash at the door; all fields optional; counts toward revenue; +// confirmation email only when an email is provided // unpaid — QR issued with balance due (collect at door); pending tpago payment; // pay-link (Bancard/TPago) email sent when an email is provided // guest — free comp ticket, not counted in revenue; confirmation email only // when an email is provided ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ eventId: z.string(), - type: z.enum(['paid', 'unpaid', 'guest']), - firstName: z.string().min(1), + type: z.enum(['paid', 'door', 'unpaid', 'guest']), + // Door walk-ins can be logged with nothing filled in, so firstName is only + // required for the other types + firstName: z.string().optional().or(z.literal('')), lastName: z.string().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal('')), phone: z.string().optional().or(z.literal('')), @@ -1546,6 +1550,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z }).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), { message: 'Email is required for paid tickets', path: ['email'], +}).refine((d) => d.type === 'door' || !!(d.firstName && d.firstName.trim()), { + message: 'First name is required', + path: ['firstName'], })), async (c) => { const data = c.req.valid('json'); @@ -1565,9 +1572,11 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z ? data.email!.trim() : `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`; + // Nameless door walk-ins still need a display name on the ticket + const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in'; const fullName = data.lastName && data.lastName.trim() - ? `${data.firstName} ${data.lastName}`.trim() - : data.firstName; + ? `${firstName} ${data.lastName.trim()}` + : firstName; // Find or create user let user = await dbGet( @@ -1613,13 +1622,13 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z const ticketId = generateId(); const qrCode = generateTicketCode(); - const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid'; + const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'unpaid' ? 'unpaid' : 'paid'; const newTicket = { id: ticketId, userId: user.id, eventId: data.eventId, - attendeeFirstName: data.firstName, + attendeeFirstName: firstName, attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, attendeeEmail: hasEmail ? data.email!.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, @@ -1636,7 +1645,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z await (db as any).insert(tickets).values(newTicket); - // Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid + // Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid const paymentId = generateId(); const newPayment = data.type === 'unpaid' ? { @@ -1659,7 +1668,11 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z amount: data.type === 'guest' ? 0 : event.price, currency: event.currency, status: 'paid', - reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket', + reference: data.type === 'guest' + ? 'Guest invite' + : data.type === 'door' + ? 'Paid at door' + : 'Manual ticket', paidAt: now, paidByAdminId: adminUser?.id || null, createdAt: now, @@ -1668,8 +1681,8 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z await (db as any).insert(payments).values(newPayment); - // Emails (asynchronous): paid always confirms; guest confirms when an email - // exists; unpaid sends the TPago (Bancard) pay-link instructions instead + // Emails (asynchronous): paid always confirms; door/guest confirm only when an + // email exists; unpaid sends the TPago (Bancard) pay-link instructions instead if (data.type === 'unpaid') { if (hasEmail) { emailService.sendPaymentInstructions(ticketId).then(result => { @@ -1692,6 +1705,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z const messages: Record = { paid: 'Ticket created — confirmation email sent', + door: hasEmail + ? 'Ticket created — paid at the door, confirmation email sent' + : 'Ticket created — paid at the door', unpaid: hasEmail ? 'Unpaid ticket created — payment link sent' : 'Unpaid ticket created — collect payment at the door', diff --git a/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx b/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx index d971606..e676d41 100644 --- a/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx +++ b/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx @@ -24,18 +24,21 @@ interface AddTicketModalProps { const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [ { value: 'paid', label: 'Paid' }, + { value: 'door', label: 'At Door' }, { value: 'unpaid', label: 'Unpaid' }, { value: 'guest', label: 'Guest' }, ]; const SUBMIT_LABELS: Record = { paid: 'Create & send ticket', + door: 'Record door payment', unpaid: 'Create & send pay link', guest: 'Invite guest', }; const SUBMIT_ICONS: Record = { paid: EnvelopeIcon, + door: BanknotesIcon, unpaid: LinkIcon, guest: StarIcon, }; @@ -47,6 +50,15 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string if (form.type === 'paid') { lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`); lines.push('Confirmation email with QR ticket sent'); + } else if (form.type === 'door') { + lines.push(`Cash payment of ${eventPriceLabel} recorded as paid at the door — counts toward revenue`); + lines.push('QR code issued'); + if (!form.firstName.trim()) { + lines.push('No name — the ticket is logged as a "Walk-in"'); + } + lines.push(hasEmail + ? 'Confirmation email with QR ticket sent' + : 'No email — nothing is sent, walk-in kept on the list only'); } else if (form.type === 'unpaid') { lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`); lines.push('QR code issued, flagged "unpaid" for door staff'); @@ -66,12 +78,14 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string const PREVIEW_STYLES: Record = { paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' }, + door: { box: 'bg-emerald-50 border-emerald-200', icon: 'text-emerald-500', text: 'text-emerald-800' }, unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' }, guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' }, }; const PREVIEW_ICONS: Record = { paid: CheckCircleIcon, + door: BanknotesIcon, unpaid: BanknotesIcon, guest: StarIcon, }; @@ -88,6 +102,8 @@ export function AddTicketModal({ if (!open) return null; const emailRequired = form.type === 'paid'; + // Door walk-ins can be logged with nothing filled in + const nameRequired = form.type !== 'door'; const style = PREVIEW_STYLES[form.type]; const PreviewIcon = PREVIEW_ICONS[form.type]; const SubmitIcon = SUBMIT_ICONS[form.type]; @@ -120,7 +136,7 @@ export function AddTicketModal({ type="button" onClick={() => setForm((f) => ({ ...f, type: option.value }))} className={clsx( - 'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors', + 'flex-1 px-2 py-2 text-xs sm:text-sm font-medium rounded-btn min-h-[36px] whitespace-nowrap transition-colors', form.type === option.value ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700' @@ -133,11 +149,11 @@ export function AddTicketModal({
- - First Name {nameRequired && '*'} + setForm((f) => ({ ...f, firstName: e.target.value }))} className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" - placeholder="First name" /> + placeholder={nameRequired ? 'First name' : 'First name (optional)'} />
@@ -155,6 +171,7 @@ export function AddTicketModal({ placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />

{form.type === 'paid' && 'Ticket will be sent to this email'} + {form.type === 'door' && 'Optional — if provided, the ticket confirmation is sent here'} {form.type === 'unpaid' && 'If provided, the payment link is sent here'} {form.type === 'guest' && 'If provided, a confirmation email will be sent'}

diff --git a/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx b/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx index d4acfe2..b00c995 100644 --- a/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx +++ b/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx @@ -133,6 +133,16 @@ export function EventModals(props: EventModalsProps) {

Send confirmation email with QR ticket

+
) : ( <> + {/* Door takings — reconciliation first, configuration below */} + {doorSummary && (doorSummary.door.count > 0 || doorSummary.presale.count > 0) && ( + + )} + {/* Header */}
diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 61c07e9..60554db 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -66,7 +66,7 @@ export default function AdminEventDetailPage() { const eventId = params.id as string; const { locale } = useLanguage(); - const { loading, event, tickets, templates, loadEventData } = useEventDetailData(eventId); + const { loading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId); const [activeTab, setActiveTab] = useState('overview'); // Email state @@ -401,7 +401,14 @@ export default function AdminEventDetailPage() { const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest); const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length; const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length; - const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price; + // Door sales can be taken at a custom amount (someone paying for their whole + // group), so once the door summary is loaded it is the authority on the total: + // pre-sale tickets at face value plus whatever was actually taken on the night. + const presaleRevenue = doorSummary + ? doorSummary.presale.total + : (paidConfirmedCount + paidCheckedInCount) * event.price; + const doorRevenue = doorSummary?.door.total ?? 0; + const revenue = presaleRevenue + doorRevenue; const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [ { key: 'overview', label: 'Overview', icon: CalendarIcon }, @@ -507,7 +514,15 @@ export default function AdminEventDetailPage() { { label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' }, { label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' }, { label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' }, - { label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600' }, + { + label: 'Revenue', + value: formatCurrency(revenue, event.currency), + icon: CurrencyDollarIcon, + color: 'bg-gray-50 text-gray-600', + detail: doorSummary + ? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}` + : undefined, + }, ].map((stat) => (
@@ -515,7 +530,7 @@ export default function AdminEventDetailPage() {

{stat.value}

-

{stat.label}

+

{('detail' in stat && stat.detail) || stat.label}

))} @@ -547,7 +562,15 @@ export default function AdminEventDetailPage() { { label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' }, { label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' }, { label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' }, - { label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50' }, + { + label: 'Revenue', + value: formatCurrency(revenue, event.currency), + icon: CurrencyDollarIcon, + color: 'text-gray-600 bg-gray-50', + detail: doorSummary + ? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}` + : undefined, + }, ].map((stat) => (
@@ -555,7 +578,7 @@ export default function AdminEventDetailPage() {

{stat.value}

-

{stat.label}

+

{('detail' in stat && stat.detail) || stat.label}

))} @@ -705,7 +728,7 @@ export default function AdminEventDetailPage() { )} {activeTab === 'payments' && ( - + )}
diff --git a/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx new file mode 100644 index 0000000..0381803 --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx @@ -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 = { + 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 ( +
+ + + {expanded && !attendee.checkedIn && ( +
+ {isCancelled && ( +

+ + Reactivate as a walk-in — pick how they are paying. +

+ )} + +
+ )} + + {expanded && attendee.checkedIn && ( +
+

+ Already checked in + {attendee.checkinAt ? ` at ${checkinTime(attendee.checkinAt)}` : ''} + {attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''}. +

+
+ )} +
+ ); +} diff --git a/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx new file mode 100644 index 0000000..7c849a2 --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx @@ -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(null); + const [customOpen, setCustomOpen] = useState(false); + const [customValue, setCustomValue] = useState(''); + const [pressTimer, setPressTimer] = useState | 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 ( +
+
+

{tender.label} — how many?

+ +
+
+ {[1, 2, 3].map((qty) => ( + + ))} + +
+ {customOpen && ( +
+ 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" + /> + +
+ )} +
+ ); + } + + return ( +
+ {TENDERS.map((tender) => ( + + ))} +
+ ); +} diff --git a/frontend/src/app/admin/scanner/_components/QRScannerOverlay.tsx b/frontend/src/app/admin/scanner/_components/QRScannerOverlay.tsx new file mode 100644 index 0000000..de71eb3 --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/QRScannerOverlay.tsx @@ -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