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 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-08-22 04:39:54 +00:00
co-authored by Claude Opus 5
parent be4dd5b47f
commit a0161a67d2
7 changed files with 68 additions and 20 deletions
@@ -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<AddTicketType, string> = {
paid: 'Create & send ticket',
door: 'Record door payment',
unpaid: 'Create & send pay link',
guest: 'Invite guest',
};
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
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<AddTicketType, { box: string; icon: string; text: string }> = {
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<AddTicketType, typeof EnvelopeIcon> = {
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({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={form.firstName}
<label className="block text-xs font-medium mb-1">First Name {nameRequired && '*'}</label>
<input type="text" required={nameRequired} value={form.firstName}
onChange={(e) => 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)'} />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
@@ -155,6 +171,7 @@ export function AddTicketModal({
placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
<p className="text-[10px] text-gray-500 mt-1">
{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'}
</p>
@@ -133,6 +133,16 @@ export function EventModals(props: EventModalsProps) {
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
</div>
</button>
<button
onClick={() => { openAddTicket('door'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<BanknotesIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Paid at Door</p>
<p className="text-xs text-gray-500">Cash taken at the door, all fields optional</p>
</div>
</button>
<button
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
@@ -148,6 +148,9 @@ export function AttendeesTab({
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
</DropdownItem>
<DropdownItem onClick={() => { openAddTicket('door'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Paid at Door
</DropdownItem>
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
</DropdownItem>
+2 -1
View File
@@ -15,9 +15,10 @@ export interface PrimaryAction {
// Ticket type in the unified Add Ticket modal:
// paid = confirmation + QR emailed, counts toward revenue
// door = already paid in cash at the door, counts toward revenue, every field optional
// unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible
// guest = free comp ticket, auto-confirmed, no revenue
export type AddTicketType = 'paid' | 'unpaid' | 'guest';
export type AddTicketType = 'paid' | 'door' | 'unpaid' | 'guest';
export interface AddTicketFormState {
type: AddTicketType;
+2 -2
View File
@@ -84,7 +84,7 @@ export default function AdminEventDetailPage() {
const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState('');
// Unified Add Ticket modal (paid / unpaid / guest via segmented control)
// Unified Add Ticket modal (paid / door / unpaid / guest via segmented control)
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
const [submitting, setSubmitting] = useState(false);
@@ -222,7 +222,7 @@ export default function AdminEventDetailPage() {
const res = await ticketsApi.adminAdd({
eventId: event.id,
type: addTicketForm.type,
firstName: addTicketForm.firstName,
firstName: addTicketForm.firstName || undefined,
lastName: addTicketForm.lastName || undefined,
email: addTicketForm.email || undefined,
phone: addTicketForm.phone || undefined,
+4 -3
View File
@@ -106,11 +106,12 @@ export const ticketsApi = {
}),
// Unified add-attendee endpoint behind the single Add Ticket modal
// (paid = confirmation + QR, unpaid = pay link + door collection, guest = free comp)
// (paid = confirmation + QR, door = cash taken at the door, unpaid = pay link +
// door collection, guest = free comp)
adminAdd: (data: {
eventId: string;
type: 'paid' | 'unpaid' | 'guest';
firstName: string;
type: 'paid' | 'door' | 'unpaid' | 'guest';
firstName?: string;
lastName?: string;
email?: string;
phone?: string;