Add a per-event walk-in price and a POS tender to the door screen.
Events can now set an optional walk-in price (events.walk_in_price): null falls back to the ticket price, 0 is a free walk-in. It is set in the Create/Edit Event modal (EN/ES) and carried through create, update and duplicate, but it is internal: public event responses and the attendee-facing ticket/dashboard endpoints drop it, and only admin, organizer and staff callers receive it. The door screen no longer trusts the amount the client sends. It sends a quantity and the server prices the charge from the event record: walk-ins pay the walk-in price, existing tickets the ticket price. A typed amount is only honoured with amountOverride from admin/organizer and is written to audit_logs in the same transaction. The charged amount stays snapshotted on the payment row. POS is a new door tender for the physical card terminal. Staff pick POS, see the amount to key in, charge the card, then confirm with "Mark as paid"; it is then recorded like any other door payment (provider and method 'pos'). It can be switched off globally or per event via payment options, and shows up in the payments filters, bookings, revenue summaries and door takings. PosChargePanel is where an automatic terminal push would go later; nothing calls a terminal today. tickets.booking_source (online | walk_in | admin) records how a booking was made. The migration backfills walk-ins from the door screen's idempotency records; everything else stays online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
515707c9e4
commit
b51c1360e2
@@ -44,6 +44,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
|
||||
lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' },
|
||||
cash: { en: 'Cash', es: 'Efectivo' },
|
||||
bancard: { en: 'Card', es: 'Tarjeta' },
|
||||
pos: { en: 'POS', es: 'POS' },
|
||||
};
|
||||
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
|
||||
};
|
||||
|
||||
@@ -177,6 +177,7 @@ export default function AdminBookingsPage() {
|
||||
lightning: 'Lightning',
|
||||
tpago: 'TPago',
|
||||
bancard: 'Bancard',
|
||||
pos: 'POS',
|
||||
};
|
||||
return labels[provider] || provider;
|
||||
};
|
||||
@@ -443,7 +444,10 @@ export default function AdminBookingsPage() {
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||
{ticket.payment?.status || 'pending'}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(getDisplayProvider(ticket))}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{getPaymentMethodLabel(getDisplayProvider(ticket))}
|
||||
{ticket.bookingSource === 'walk_in' && (locale === 'es' ? ' · En puerta' : ' · Walk-in')}
|
||||
</p>
|
||||
{ticket.payment && (
|
||||
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,7 @@ const DOOR_METHOD_LABELS: Record<DoorPaymentMethod, { en: string; es: string }>
|
||||
cash: { en: 'Cash', es: 'Efectivo' },
|
||||
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
|
||||
transfer: { en: 'Transfer', es: 'Transferencia' },
|
||||
pos: { en: 'POS', es: 'POS' },
|
||||
guest: { en: 'Guests', es: 'Invitados' },
|
||||
};
|
||||
|
||||
@@ -103,6 +104,10 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
|
||||
handleSavePaymentOptions,
|
||||
} = payments;
|
||||
|
||||
// Stored as 0/1 on Postgres; POS is on unless explicitly switched off.
|
||||
const posEnabled = !!(getEffectivePaymentOption('posEnabled') ?? true);
|
||||
const posDisabledGlobally = !!globalPaymentOptions && !(globalPaymentOptions.posEnabled ?? true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loadingPayments ? (
|
||||
@@ -441,6 +446,42 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* POS terminal at the door */}
|
||||
<Card>
|
||||
<div className="p-4 md:p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-violet-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<CreditCardIcon className="w-4 h-4 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">POS</h4>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
{locale === 'es' ? 'Solo en el Escáner, confirmación manual' : 'Scanner only, confirmed by staff'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{posDisabledGlobally && (
|
||||
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
||||
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => updatePaymentOverride('posEnabled', !posEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
posEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<div className="p-4 md:p-5">
|
||||
@@ -453,6 +494,7 @@ export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps)
|
||||
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
|
||||
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
|
||||
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
|
||||
{ label: 'POS', enabled: posEnabled },
|
||||
].map((method) => (
|
||||
<div key={method.label} className="flex items-center gap-1.5">
|
||||
{method.enabled ? (
|
||||
|
||||
@@ -10,7 +10,7 @@ import DurationInput from '@/components/admin/DurationInput';
|
||||
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { parseDate, EVENT_TIMEZONE, formatDurationWords } from '@/lib/utils';
|
||||
import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils';
|
||||
|
||||
interface EventFormData {
|
||||
title: string;
|
||||
@@ -25,6 +25,9 @@ interface EventFormData {
|
||||
location: string;
|
||||
locationUrl: string;
|
||||
price: number;
|
||||
// Kept as the raw input so "empty" (not set -> falls back to price) stays
|
||||
// distinguishable from 0 (a free walk-in).
|
||||
walkInPrice: string;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||
@@ -47,12 +50,20 @@ const EMPTY_FORM: EventFormData = {
|
||||
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
||||
shortDescription: '', shortDescriptionEs: '',
|
||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft',
|
||||
price: 0, walkInPrice: '', currency: 'PYG', capacity: 50, status: 'draft',
|
||||
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
|
||||
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
|
||||
};
|
||||
|
||||
/** Empty is valid (not set); otherwise the same rule as Price: a whole number >= 0. */
|
||||
function isValidWalkInPrice(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return true;
|
||||
const n = Number(trimmed);
|
||||
return Number.isInteger(n) && n >= 0;
|
||||
}
|
||||
|
||||
function isoToLocalDatetime(isoString: string): string {
|
||||
const date = parseDate(isoString);
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
@@ -138,6 +149,7 @@ export default function EventFormModal({
|
||||
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||
location: event.location, locationUrl: event.locationUrl || '',
|
||||
price: event.price, currency: event.currency, capacity: event.capacity,
|
||||
walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice),
|
||||
status: event.status, bannerUrl: event.bannerUrl || '',
|
||||
externalBookingEnabled: event.externalBookingEnabled || false,
|
||||
externalBookingUrl: event.externalBookingUrl || '',
|
||||
@@ -208,6 +220,11 @@ export default function EventFormModal({
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
if (!isValidWalkInPrice(formData.walkInPrice)) {
|
||||
toast.error(t('admin.events.form.walkInPriceInvalid'));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const eventData: Partial<Event> = {
|
||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||
@@ -216,6 +233,8 @@ export default function EventFormModal({
|
||||
endDatetime: formData.endDatetime || undefined,
|
||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||
// Empty means "not set" and must be saved as null, never 0
|
||||
walkInPrice: formData.walkInPrice.trim() === '' ? null : Number(formData.walkInPrice),
|
||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||
externalBookingEnabled: formData.externalBookingEnabled,
|
||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||
@@ -342,17 +361,30 @@ export default function EventFormModal({
|
||||
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
|
||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input label="Price" type="number" min="0" value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="PYG">PYG</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input label="Price" type="number" min="0" value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||
<Input label={t('admin.events.form.walkInPrice')} type="number" min="0" inputMode="numeric"
|
||||
value={formData.walkInPrice}
|
||||
onChange={(e) => setFormData({ ...formData, walkInPrice: e.target.value })}
|
||||
placeholder={t('admin.events.form.walkInPricePlaceholder', {
|
||||
price: formatPrice(formData.price || 0, formData.currency),
|
||||
})}
|
||||
error={isValidWalkInPrice(formData.walkInPrice) ? undefined : t('admin.events.form.walkInPriceInvalid')} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="PYG">PYG</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('admin.events.form.walkInPriceHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input label="Capacity" type="number" min="1" value={formData.capacity}
|
||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function PaymentOptionsPage() {
|
||||
bankNotesEs: null,
|
||||
lightningEnabled: true,
|
||||
cashEnabled: true,
|
||||
posEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
allowDuplicateBookings: false,
|
||||
@@ -83,6 +84,9 @@ export default function PaymentOptionsPage() {
|
||||
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Stored as 0/1 on Postgres; on unless explicitly switched off.
|
||||
const posEnabled = !!(options.posEnabled ?? true);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -424,6 +428,39 @@ export default function PaymentOptionsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* POS terminal at the door */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-violet-100 rounded-full flex items-center justify-center">
|
||||
<CreditCardIcon className="w-5 h-5 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">POS</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Terminal de tarjetas en la puerta. Solo en el Escáner; el personal confirma el pago manualmente.'
|
||||
: 'Card terminal at the door. Scanner only; staff confirm the payment by hand.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateOption('posEnabled', !posEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
|
||||
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
posEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Booking Settings */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
@@ -531,6 +568,16 @@ export default function PaymentOptionsPage() {
|
||||
{locale === 'es' ? 'Efectivo' : 'Cash'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{posEnabled ? (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||
)}
|
||||
<span className={posEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||
POS
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -313,6 +313,7 @@ export default function AdminPaymentsPage() {
|
||||
bank_transfer: BuildingLibraryIcon,
|
||||
tpago: CreditCardIcon,
|
||||
bancard: CreditCardIcon,
|
||||
pos: CreditCardIcon,
|
||||
};
|
||||
const Icon = icons[provider] || CreditCardIcon;
|
||||
return <Icon className="w-4 h-4" />;
|
||||
@@ -325,6 +326,7 @@ export default function AdminPaymentsPage() {
|
||||
lightning: 'Lightning',
|
||||
tpago: 'TPago',
|
||||
bancard: 'Bancard',
|
||||
pos: 'POS',
|
||||
};
|
||||
return labels[provider] || provider;
|
||||
};
|
||||
@@ -736,7 +738,7 @@ export default function AdminPaymentsPage() {
|
||||
{/* By Provider */}
|
||||
<div className="bg-secondary-gray rounded-btn p-4">
|
||||
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
|
||||
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p>
|
||||
@@ -757,6 +759,10 @@ export default function AdminPaymentsPage() {
|
||||
<p className="text-gray-500">Bancard</p>
|
||||
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">POS</p>
|
||||
<p className="text-lg font-bold">{exportData.summary.byProvider.pos?.toLocaleString() || 0} PYG</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1054,6 +1060,7 @@ export default function AdminPaymentsPage() {
|
||||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||
<option value="tpago">TPago</option>
|
||||
<option value="pos">POS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
@@ -1292,6 +1299,7 @@ export default function AdminPaymentsPage() {
|
||||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||
<option value="tpago">TPago</option>
|
||||
<option value="pos">POS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
|
||||
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import { PaymentButtons } from './PaymentButtons';
|
||||
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
|
||||
|
||||
function checkinTime(checkinAt: string | null): string {
|
||||
if (!checkinAt) return '';
|
||||
@@ -23,6 +23,7 @@ const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
|
||||
cash: 'cash',
|
||||
bitcoin: 'bitcoin',
|
||||
transfer: 'transfer',
|
||||
pos: 'POS',
|
||||
guest: 'guest',
|
||||
};
|
||||
|
||||
@@ -49,6 +50,8 @@ export function AttendeeRow({
|
||||
attendee,
|
||||
currency,
|
||||
price,
|
||||
methods,
|
||||
canOverrideAmount,
|
||||
expanded,
|
||||
flashing,
|
||||
busy,
|
||||
@@ -58,11 +61,13 @@ export function AttendeeRow({
|
||||
attendee: DoorAttendee;
|
||||
currency: string;
|
||||
price: number;
|
||||
methods: readonly DoorPaymentMethod[];
|
||||
canOverrideAmount: boolean;
|
||||
expanded: boolean;
|
||||
flashing: boolean;
|
||||
busy: boolean;
|
||||
onTap: () => void;
|
||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
|
||||
}) {
|
||||
const isCancelled = attendee.status === 'cancelled';
|
||||
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
|
||||
@@ -141,7 +146,14 @@ export function AttendeeRow({
|
||||
Reactivate as a walk-in — pick how they are paying.
|
||||
</p>
|
||||
)}
|
||||
<PaymentButtons price={price} currency={currency} onPay={onPay} disabled={busy} />
|
||||
<PaymentButtons
|
||||
price={price}
|
||||
currency={currency}
|
||||
methods={methods}
|
||||
canOverrideAmount={canOverrideAmount}
|
||||
onPay={onPay}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,14 +6,29 @@ import {
|
||||
BanknotesIcon,
|
||||
BoltIcon,
|
||||
BuildingLibraryIcon,
|
||||
CreditCardIcon,
|
||||
GiftIcon,
|
||||
ChevronDownIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { DoorPaymentMethod } from '@/lib/api';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { PosChargePanel } from './PosChargePanel';
|
||||
|
||||
// The four tenders staff can take at the door. One tap settles and checks in;
|
||||
// The 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.
|
||||
// POS is the exception: it opens a confirm step, because the card is charged on
|
||||
// the terminal first (see PosChargePanel).
|
||||
|
||||
/**
|
||||
* What staff chose to charge. The server prices it from quantity; `amount` is
|
||||
* what the screen expects that to come to, and is only sent as a charge when
|
||||
* `override` is set (admin/organizer "Custom").
|
||||
*/
|
||||
export interface DoorCharge {
|
||||
quantity: number;
|
||||
amount: number;
|
||||
override?: boolean;
|
||||
}
|
||||
|
||||
const TENDERS: {
|
||||
method: DoorPaymentMethod;
|
||||
@@ -24,6 +39,7 @@ const TENDERS: {
|
||||
{ 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: 'pos', label: 'POS', icon: CreditCardIcon, className: 'bg-violet-600 active:bg-violet-700' },
|
||||
{ method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' },
|
||||
];
|
||||
|
||||
@@ -32,21 +48,32 @@ const LONG_PRESS_MS = 450;
|
||||
export function PaymentButtons({
|
||||
price,
|
||||
currency,
|
||||
methods,
|
||||
canOverrideAmount,
|
||||
onPay,
|
||||
disabled,
|
||||
}: {
|
||||
/** Unit price for one ticket, as the server will charge it. */
|
||||
price: number;
|
||||
currency: string;
|
||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||
/** Tenders enabled for this event. */
|
||||
methods: readonly DoorPaymentMethod[];
|
||||
/** Admin/organizer may type a custom amount; the server enforces the same rule. */
|
||||
canOverrideAmount: boolean;
|
||||
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => 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('');
|
||||
// POS charge waiting for staff to confirm the terminal approved it.
|
||||
const [posCharge, setPosCharge] = useState<DoorCharge | null>(null);
|
||||
const [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [longPressed, setLongPressed] = useState(false);
|
||||
|
||||
const tenders = TENDERS.filter((t) => methods.includes(t.method));
|
||||
|
||||
const openAmounts = (method: DoorPaymentMethod) => {
|
||||
if (method === 'guest') return;
|
||||
setAmountsFor(method);
|
||||
@@ -54,6 +81,15 @@ export function PaymentButtons({
|
||||
setCustomValue('');
|
||||
};
|
||||
|
||||
// Every tender settles immediately except POS, which stops at its confirm step.
|
||||
const charge = (method: DoorPaymentMethod, next: DoorCharge) => {
|
||||
if (method === 'pos') {
|
||||
setPosCharge(next);
|
||||
return;
|
||||
}
|
||||
onPay(method, next);
|
||||
};
|
||||
|
||||
const startPress = (method: DoorPaymentMethod) => {
|
||||
setLongPressed(false);
|
||||
const timer = setTimeout(() => {
|
||||
@@ -72,7 +108,7 @@ export function PaymentButtons({
|
||||
return;
|
||||
}
|
||||
if (disabled) return;
|
||||
onPay(method, method === 'guest' ? 0 : price);
|
||||
charge(method, { quantity: 1, amount: method === 'guest' ? 0 : price });
|
||||
};
|
||||
|
||||
const cancelPress = () => {
|
||||
@@ -81,6 +117,19 @@ export function PaymentButtons({
|
||||
setLongPressed(false);
|
||||
};
|
||||
|
||||
if (posCharge) {
|
||||
return (
|
||||
<PosChargePanel
|
||||
charge={posCharge}
|
||||
unitPrice={price}
|
||||
currency={currency}
|
||||
disabled={disabled}
|
||||
onConfirm={() => onPay('pos', posCharge)}
|
||||
onBack={() => setPosCharge(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (amountsFor) {
|
||||
const tender = TENDERS.find((t) => t.method === amountsFor)!;
|
||||
return (
|
||||
@@ -94,12 +143,12 @@ export function PaymentButtons({
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className={clsx('grid gap-2', canOverrideAmount ? 'grid-cols-4' : 'grid-cols-3')}>
|
||||
{[1, 2, 3].map((qty) => (
|
||||
<button
|
||||
key={qty}
|
||||
disabled={disabled}
|
||||
onClick={() => onPay(tender.method, price * qty)}
|
||||
onClick={() => charge(tender.method, { quantity: qty, amount: 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,
|
||||
@@ -111,15 +160,17 @@ export function PaymentButtons({
|
||||
</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>
|
||||
{canOverrideAmount && (
|
||||
<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 && (
|
||||
{canOverrideAmount && customOpen && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -132,7 +183,7 @@ export function PaymentButtons({
|
||||
/>
|
||||
<button
|
||||
disabled={disabled || !customValue || Number(customValue) < 0}
|
||||
onClick={() => onPay(tender.method, Number(customValue))}
|
||||
onClick={() => charge(tender.method, { quantity: 1, amount: Number(customValue), override: true })}
|
||||
className={clsx(
|
||||
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
|
||||
tender.className,
|
||||
@@ -147,8 +198,11 @@ export function PaymentButtons({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{TENDERS.map((tender) => (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: `repeat(${tenders.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{tenders.map((tender) => (
|
||||
<button
|
||||
key={tender.method}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { CreditCardIcon } from '@heroicons/react/24/outline';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import type { DoorCharge } from './PaymentButtons';
|
||||
|
||||
// The POS step of the door screen.
|
||||
//
|
||||
// Unlike cash, the money is not in staff's hand when they pick POS: they first
|
||||
// key the amount into the physical card terminal, wait for approval, and only
|
||||
// then confirm here. So POS is two taps: pick POS (this panel opens with the
|
||||
// amount to type), then "Mark as paid", which records it through the same
|
||||
// door-checkin call as every other tender.
|
||||
//
|
||||
// Nothing talks to the terminal today. If an automatic amount push is added
|
||||
// later, it starts when this panel opens and its approval replaces (or pre-fills)
|
||||
// the manual confirmation below; the rest of the door flow stays unchanged.
|
||||
|
||||
export function PosChargePanel({
|
||||
charge,
|
||||
unitPrice,
|
||||
currency,
|
||||
disabled,
|
||||
onConfirm,
|
||||
onBack,
|
||||
}: {
|
||||
charge: DoorCharge;
|
||||
unitPrice: number;
|
||||
currency: string;
|
||||
disabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="text-sm font-semibold text-white flex items-center gap-1.5">
|
||||
<CreditCardIcon className="w-5 h-5" />
|
||||
POS — charge on the terminal
|
||||
</p>
|
||||
<button onClick={onBack} className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white">
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-gray-900 border border-violet-500/40 px-4 py-3 text-center">
|
||||
<p className="text-[11px] uppercase tracking-wide text-gray-400">Amount to charge</p>
|
||||
<p className="text-3xl font-bold text-white tabular-nums">{formatCurrency(charge.amount, currency)}</p>
|
||||
{charge.override ? (
|
||||
<p className="text-xs text-amber-400 mt-0.5">Custom amount</p>
|
||||
) : charge.quantity > 1 ? (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{charge.quantity} × {formatCurrency(unitPrice, currency)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={onConfirm}
|
||||
className="w-full min-h-[56px] rounded-2xl bg-violet-600 active:bg-violet-700 text-white text-lg font-bold disabled:opacity-50 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
Mark as paid
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 text-center px-2">
|
||||
Only tap once the terminal has approved the card.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
|
||||
cash: 'Cash',
|
||||
bitcoin: 'Bitcoin',
|
||||
transfer: 'Transfer',
|
||||
pos: 'POS',
|
||||
guest: 'Guest',
|
||||
};
|
||||
|
||||
@@ -52,6 +53,7 @@ function sessionTotals(entries: SessionEntry[]) {
|
||||
cash: { count: 0, total: 0 },
|
||||
bitcoin: { count: 0, total: 0 },
|
||||
transfer: { count: 0, total: 0 },
|
||||
pos: { count: 0, total: 0 },
|
||||
guest: { count: 0, total: 0 },
|
||||
};
|
||||
let grand = 0;
|
||||
|
||||
@@ -4,7 +4,8 @@ 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';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { PaymentButtons, type DoorCharge } from './PaymentButtons';
|
||||
|
||||
export interface WalkInDraft {
|
||||
firstName: string;
|
||||
@@ -22,9 +23,14 @@ export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
|
||||
ruc: '',
|
||||
});
|
||||
|
||||
const PRICE_SOURCE_LABELS: Record<'walk_in' | 'ticket', string> = {
|
||||
walk_in: 'Walk-in price',
|
||||
ticket: 'Ticket price (no walk-in price set)',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* name, the walk-in price and the 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({
|
||||
@@ -32,7 +38,10 @@ export function WalkInRow({
|
||||
expanded,
|
||||
draft,
|
||||
price,
|
||||
priceSource,
|
||||
currency,
|
||||
methods,
|
||||
canOverrideAmount,
|
||||
busy,
|
||||
onExpand,
|
||||
onChange,
|
||||
@@ -42,12 +51,16 @@ export function WalkInRow({
|
||||
typedText: string;
|
||||
expanded: boolean;
|
||||
draft: WalkInDraft;
|
||||
/** Walk-in unit price as resolved by the server. */
|
||||
price: number;
|
||||
priceSource: 'walk_in' | 'ticket';
|
||||
currency: string;
|
||||
methods: readonly DoorPaymentMethod[];
|
||||
canOverrideAmount: boolean;
|
||||
busy: boolean;
|
||||
onExpand: () => void;
|
||||
onChange: (draft: WalkInDraft) => void;
|
||||
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||
onPay: (method: DoorPaymentMethod, charge: DoorCharge) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
@@ -146,9 +159,16 @@ export function WalkInRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-1 pt-1">
|
||||
<p className="text-2xl font-bold text-white tabular-nums">{formatCurrency(price, currency)}</p>
|
||||
<p className="text-xs text-gray-400">{PRICE_SOURCE_LABELS[priceSource]}</p>
|
||||
</div>
|
||||
|
||||
<PaymentButtons
|
||||
price={price}
|
||||
currency={currency}
|
||||
methods={methods}
|
||||
canOverrideAmount={canOverrideAmount}
|
||||
onPay={onPay}
|
||||
disabled={busy || !draft.firstName.trim()}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import {
|
||||
eventsApi,
|
||||
doorApi,
|
||||
DOOR_PAYMENT_METHODS,
|
||||
type Event,
|
||||
type DoorAttendee,
|
||||
type DoorAttendeesResponse,
|
||||
@@ -30,6 +31,7 @@ import { playSuccessSound, playErrorSound, vibrate, dismissAfter } from './_lib/
|
||||
import { QRScannerOverlay } from './_components/QRScannerOverlay';
|
||||
import { AttendeeRow } from './_components/AttendeeRow';
|
||||
import { WalkInRow, emptyWalkIn, type WalkInDraft } from './_components/WalkInRow';
|
||||
import type { DoorCharge } from './_components/PaymentButtons';
|
||||
import { SessionSheet, type SessionEntry } from './_components/SessionSheet';
|
||||
import { ResultScreen, type DoorResult } from './_components/ResultScreen';
|
||||
|
||||
@@ -64,6 +66,7 @@ const METHOD_PAST_TENSE: Record<DoorPaymentMethod, string> = {
|
||||
cash: 'paid cash',
|
||||
bitcoin: 'paid bitcoin',
|
||||
transfer: 'paid by transfer',
|
||||
pos: 'paid by POS',
|
||||
guest: 'in as guest',
|
||||
};
|
||||
|
||||
@@ -75,6 +78,9 @@ export default function AdminDoorPage() {
|
||||
// own shift from the session feed below, which is local to this device; the
|
||||
// API enforces the same split (see REVENUE_ROLES in routes/door.ts).
|
||||
const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer';
|
||||
// Typing a custom door amount is an admin decision; the API rejects it for
|
||||
// anyone else (AMOUNT_OVERRIDE_ROLES in routes/door.ts).
|
||||
const canOverrideAmount = user?.role === 'admin' || user?.role === 'organizer';
|
||||
|
||||
// ── Events ──
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
@@ -122,6 +128,10 @@ export default function AdminDoorPage() {
|
||||
|
||||
const eventMeta = data?.event;
|
||||
const price = eventMeta?.price ?? 0;
|
||||
// Walk-ins pay the walk-in price when the event sets one; the server resolves it.
|
||||
const walkInPrice = eventMeta?.walkInUnitPrice ?? price;
|
||||
const walkInPriceSource = eventMeta?.walkInPriceSource ?? 'ticket';
|
||||
const doorMethods = data?.doorMethods ?? DOOR_PAYMENT_METHODS;
|
||||
const currency = eventMeta?.currency ?? 'PYG';
|
||||
const capacity = eventMeta?.capacity ?? 0;
|
||||
|
||||
@@ -351,7 +361,7 @@ export default function AdminDoorPage() {
|
||||
async (opts: {
|
||||
ticketId?: string;
|
||||
attendee?: { firstName: string; lastName?: string; phone?: string; email?: string; ruc?: string };
|
||||
payment?: { method: DoorPaymentMethod; amount: number };
|
||||
payment?: { method: DoorPaymentMethod; charge: DoorCharge };
|
||||
entry: DoorEntryMethod;
|
||||
displayName: string;
|
||||
/** Ticket row to flash and optimistically mark as in. */
|
||||
@@ -373,7 +383,7 @@ export default function AdminDoorPage() {
|
||||
at: clockTime(now),
|
||||
entry: opts.entry,
|
||||
method: opts.payment?.method ?? null,
|
||||
amount: opts.payment?.amount ?? 0,
|
||||
amount: opts.payment?.charge.amount ?? 0,
|
||||
startedAt: now.getTime(),
|
||||
undone: false,
|
||||
failed: false,
|
||||
@@ -424,10 +434,19 @@ export default function AdminDoorPage() {
|
||||
|
||||
// ── The write itself: background, retried, never blocking the queue ──
|
||||
try {
|
||||
const { method, charge } = opts.payment ?? {};
|
||||
const res = await submitDoorAction(eventId, {
|
||||
ticketId: opts.ticketId,
|
||||
attendee: opts.attendee,
|
||||
payment: opts.payment,
|
||||
// The server prices the charge from quantity; a typed amount only
|
||||
// travels as an explicit admin override.
|
||||
payment: method && charge
|
||||
? {
|
||||
method,
|
||||
quantity: charge.quantity,
|
||||
...(charge.override ? { amount: charge.amount, amountOverride: true } : {}),
|
||||
}
|
||||
: undefined,
|
||||
entryMethod: opts.entry,
|
||||
idempotencyKey,
|
||||
});
|
||||
@@ -435,7 +454,13 @@ export default function AdminDoorPage() {
|
||||
setSessionEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.idempotencyKey === idempotencyKey
|
||||
? { ...e, ticketId: res.attendee.ticketId, name: res.attendee.fullName }
|
||||
? {
|
||||
...e,
|
||||
ticketId: res.attendee.ticketId,
|
||||
name: res.attendee.fullName,
|
||||
// What was actually recorded is the server's number, not ours.
|
||||
amount: res.payment?.amount ?? e.amount,
|
||||
}
|
||||
: e,
|
||||
),
|
||||
);
|
||||
@@ -508,10 +533,10 @@ export default function AdminDoorPage() {
|
||||
);
|
||||
|
||||
const handleRowPay = useCallback(
|
||||
(attendee: DoorAttendee, method: DoorPaymentMethod, amount: number) => {
|
||||
(attendee: DoorAttendee, method: DoorPaymentMethod, charge: DoorCharge) => {
|
||||
runAction({
|
||||
ticketId: attendee.ticketId,
|
||||
payment: { method, amount },
|
||||
payment: { method, charge },
|
||||
entry: 'search',
|
||||
displayName: attendee.fullName,
|
||||
optimisticTicketId: attendee.ticketId,
|
||||
@@ -522,7 +547,7 @@ export default function AdminDoorPage() {
|
||||
);
|
||||
|
||||
const handleWalkInPay = useCallback(
|
||||
(method: DoorPaymentMethod, amount: number) => {
|
||||
(method: DoorPaymentMethod, charge: DoorCharge) => {
|
||||
const firstName = walkInDraft.firstName.trim();
|
||||
if (!firstName) return;
|
||||
const displayName = walkInDraft.lastName.trim()
|
||||
@@ -536,7 +561,7 @@ export default function AdminDoorPage() {
|
||||
email: walkInDraft.email.trim() || undefined,
|
||||
ruc: walkInDraft.ruc.trim() || undefined,
|
||||
},
|
||||
payment: { method, amount },
|
||||
payment: { method, charge },
|
||||
entry: 'walkin',
|
||||
displayName,
|
||||
busyKey: 'walk-in',
|
||||
@@ -764,11 +789,13 @@ export default function AdminDoorPage() {
|
||||
attendee={attendee}
|
||||
currency={currency}
|
||||
price={price}
|
||||
methods={doorMethods}
|
||||
canOverrideAmount={canOverrideAmount}
|
||||
expanded={expandedId === attendee.ticketId}
|
||||
flashing={flashId === attendee.ticketId}
|
||||
busy={busyId === attendee.ticketId}
|
||||
onTap={() => handleRowTap(attendee)}
|
||||
onPay={(method, amount) => handleRowPay(attendee, method, amount)}
|
||||
onPay={(method, charge) => handleRowPay(attendee, method, charge)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -778,8 +805,11 @@ export default function AdminDoorPage() {
|
||||
typedText={trimmedQuery}
|
||||
expanded={walkInOpen}
|
||||
draft={walkInDraft}
|
||||
price={price}
|
||||
price={walkInPrice}
|
||||
priceSource={walkInPriceSource}
|
||||
currency={currency}
|
||||
methods={doorMethods}
|
||||
canOverrideAmount={canOverrideAmount}
|
||||
busy={busyId === 'walk-in'}
|
||||
onExpand={() => {
|
||||
setWalkInDraft(emptyWalkIn(trimmedQuery));
|
||||
|
||||
@@ -294,7 +294,13 @@
|
||||
"edit": "Edit Event",
|
||||
"delete": "Delete Event",
|
||||
"publish": "Publish",
|
||||
"unpublish": "Unpublish"
|
||||
"unpublish": "Unpublish",
|
||||
"form": {
|
||||
"walkInPrice": "Walk-in price",
|
||||
"walkInPricePlaceholder": "Same as ticket price ({price})",
|
||||
"walkInPriceHelp": "Charged at the door via the Scanner. Not shown on the public event page.",
|
||||
"walkInPriceInvalid": "Walk-in price must be empty or a whole number of 0 or more"
|
||||
}
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Manage Tickets",
|
||||
|
||||
@@ -294,7 +294,13 @@
|
||||
"edit": "Editar Evento",
|
||||
"delete": "Eliminar Evento",
|
||||
"publish": "Publicar",
|
||||
"unpublish": "Despublicar"
|
||||
"unpublish": "Despublicar",
|
||||
"form": {
|
||||
"walkInPrice": "Precio en puerta",
|
||||
"walkInPricePlaceholder": "Igual al precio de la entrada ({price})",
|
||||
"walkInPriceHelp": "Se cobra en la puerta desde el Escáner. No se muestra en la página pública del evento.",
|
||||
"walkInPriceInvalid": "El precio en puerta debe quedar vacío o ser un número entero de 0 o más"
|
||||
}
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Gestionar Tickets",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fetchApi } from './client';
|
||||
// fire actions optimistically and retry on flaky venue wifi without ever
|
||||
// creating a duplicate ticket, payment or check-in.
|
||||
|
||||
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
|
||||
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const;
|
||||
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
|
||||
|
||||
export type DoorEntryMethod = 'scan' | 'search' | 'walkin';
|
||||
@@ -32,7 +32,20 @@ export interface DoorAttendee {
|
||||
}
|
||||
|
||||
export interface DoorAttendeesResponse {
|
||||
event: { id: string; title: string; price: number; currency: string; capacity: number };
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
/** The event's walk-in price as configured; null = not set. */
|
||||
walkInPrice: number | null;
|
||||
/** What one walk-in ticket costs, resolved by the server. */
|
||||
walkInUnitPrice: number;
|
||||
walkInPriceSource: 'walk_in' | 'ticket';
|
||||
currency: string;
|
||||
capacity: number;
|
||||
};
|
||||
/** Tenders enabled for this event (POS can be switched off in payment options). */
|
||||
doorMethods: DoorPaymentMethod[];
|
||||
attendees: DoorAttendee[];
|
||||
stats: { checkedIn: number; totalActive: number; capacity: number };
|
||||
}
|
||||
@@ -46,7 +59,11 @@ export interface DoorCheckinRequest {
|
||||
email?: string;
|
||||
ruc?: string;
|
||||
};
|
||||
payment?: { method: DoorPaymentMethod; amount?: number };
|
||||
/**
|
||||
* The server prices the charge (walk-in or ticket price x quantity). `amount`
|
||||
* is only honoured with `amountOverride`, which is admin/organizer only.
|
||||
*/
|
||||
payment?: { method: DoorPaymentMethod; quantity?: number; amount?: number; amountOverride?: boolean };
|
||||
entryMethod?: DoorEntryMethod;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
@@ -55,7 +72,13 @@ export interface DoorCheckinResponse {
|
||||
ok: true;
|
||||
action: 'checkin' | 'walkin';
|
||||
attendee: DoorAttendee;
|
||||
payment: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null;
|
||||
payment: {
|
||||
id: string;
|
||||
method: DoorPaymentMethod;
|
||||
amount: number;
|
||||
currency: string;
|
||||
amountOverridden: boolean;
|
||||
} | null;
|
||||
/** 'at_capacity' — the event is full; the attendee was added anyway. */
|
||||
warnings: string[];
|
||||
idempotencyKey: string;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Payment, PaymentWithDetails } from './types';
|
||||
|
||||
// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to
|
||||
// verify the money arrived; automatic ones (lightning) confirm themselves.
|
||||
export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash'];
|
||||
export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash', 'pos'];
|
||||
|
||||
export function isManualProvider(provider: string): boolean {
|
||||
return MANUAL_PAYMENT_PROVIDERS.includes(provider);
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface Event {
|
||||
location: string;
|
||||
locationUrl?: string;
|
||||
price: number;
|
||||
// Door price for walk-ins. Only returned to admin/organizer/staff; null = not
|
||||
// set (walk-ins pay `price`), 0 = free walk-in.
|
||||
walkInPrice?: number | null;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||
@@ -57,6 +60,8 @@ export interface Ticket {
|
||||
adminNote?: string;
|
||||
isGuest?: boolean;
|
||||
paymentStatus?: 'paid' | 'unpaid' | 'comp';
|
||||
// How the booking was made: public checkout, a door walk-in, or added by an admin
|
||||
bookingSource?: 'online' | 'walk_in' | 'admin';
|
||||
createdAt: string;
|
||||
event?: Event;
|
||||
payment?: Payment;
|
||||
@@ -124,7 +129,7 @@ export interface LiveSearchResult {
|
||||
export interface Payment {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
||||
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago' | 'pos';
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold';
|
||||
@@ -178,6 +183,8 @@ export interface PaymentOptionsConfig {
|
||||
cashEnabled: boolean;
|
||||
cashInstructions?: string | null;
|
||||
cashInstructionsEs?: string | null;
|
||||
// POS card terminal on the door screen (never offered at online checkout)
|
||||
posEnabled?: boolean;
|
||||
// Booking settings
|
||||
allowDuplicateBookings?: boolean;
|
||||
}
|
||||
@@ -362,6 +369,7 @@ export interface FinancialSummary {
|
||||
cash: number;
|
||||
bank_transfer: number;
|
||||
tpago: number;
|
||||
pos?: number;
|
||||
};
|
||||
paidCount: number;
|
||||
pendingCount: number;
|
||||
|
||||
Reference in New Issue
Block a user