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>
227 lines
9.8 KiB
TypeScript
227 lines
9.8 KiB
TypeScript
import type { Dispatch, FormEvent, SetStateAction } from 'react';
|
|
import Card from '@/components/ui/Card';
|
|
import Button from '@/components/ui/Button';
|
|
import clsx from 'clsx';
|
|
import {
|
|
BanknotesIcon,
|
|
CheckCircleIcon,
|
|
EnvelopeIcon,
|
|
LinkIcon,
|
|
StarIcon,
|
|
XMarkIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import type { AddTicketType, AddTicketFormState } from '../_types';
|
|
|
|
interface AddTicketModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
form: AddTicketFormState;
|
|
setForm: Dispatch<SetStateAction<AddTicketFormState>>;
|
|
onSubmit: (e: FormEvent) => void;
|
|
submitting: boolean;
|
|
eventPriceLabel: string;
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
// Live "what happens" preview lines for the selected type / email / check-in combo
|
|
function previewLines(form: AddTicketFormState, eventPriceLabel: string): string[] {
|
|
const hasEmail = !!form.email.trim();
|
|
const lines: 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');
|
|
lines.push(hasEmail
|
|
? 'Bancard (TPago) payment link emailed to the attendee'
|
|
: 'No email — no pay link sent, payment collected at the door');
|
|
} else {
|
|
lines.push('Free guest ticket (comp) — not counted in revenue');
|
|
lines.push('Auto-confirmed with QR code');
|
|
lines.push(hasEmail ? 'Confirmation email sent' : 'No email — nothing is sent');
|
|
}
|
|
if (form.checkinNow) {
|
|
lines.push('Checked in immediately');
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
export function AddTicketModal({
|
|
open,
|
|
onClose,
|
|
form,
|
|
setForm,
|
|
onSubmit,
|
|
submitting,
|
|
eventPriceLabel,
|
|
}: AddTicketModalProps) {
|
|
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];
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
|
onClick={onClose}
|
|
role="presentation"
|
|
>
|
|
<Card
|
|
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
|
<h2 className="text-base font-bold">Add Ticket</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
|
|
>
|
|
<XMarkIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<form onSubmit={onSubmit} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
|
{/* Segmented ticket-type control */}
|
|
<div className="flex rounded-btn bg-gray-100 p-1">
|
|
{TYPE_OPTIONS.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
|
|
className={clsx(
|
|
'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'
|
|
)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<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={nameRequired ? 'First name' : 'First name (optional)'} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium mb-1">Last Name</label>
|
|
<input type="text" value={form.lastName}
|
|
onChange={(e) => setForm((f) => ({ ...f, lastName: 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="Last name" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium mb-1">Email {emailRequired && '*'}</label>
|
|
<input type="email" required={emailRequired} value={form.email}
|
|
onChange={(e) => setForm((f) => ({ ...f, email: 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={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>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium mb-1">Phone</label>
|
|
<input type="tel" value={form.phone}
|
|
onChange={(e) => setForm((f) => ({ ...f, phone: 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="+595 981 123456" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
|
<textarea value={form.adminNote}
|
|
onChange={(e) => setForm((f) => ({ ...f, adminNote: 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"
|
|
rows={2} placeholder="Internal note..." />
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<input type="checkbox" id="checkinNow" checked={form.checkinNow}
|
|
onChange={(e) => setForm((f) => ({ ...f, checkinNow: e.target.checked }))}
|
|
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
|
|
<label htmlFor="checkinNow" className="text-sm font-medium">Check in now</label>
|
|
</div>
|
|
|
|
{/* Live preview of what this submission does */}
|
|
<div className={clsx('border rounded-lg p-3', style.box)}>
|
|
<div className="flex items-start gap-2">
|
|
<PreviewIcon className={clsx('w-4 h-4 mt-0.5 flex-shrink-0', style.icon)} />
|
|
<div className={clsx('text-xs', style.text)}>
|
|
<p className="font-medium">What happens:</p>
|
|
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
|
|
{previewLines(form, eventPriceLabel).map((line) => (
|
|
<li key={line}>{line}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3 pt-2">
|
|
<Button type="button" variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">Cancel</Button>
|
|
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
|
<SubmitIcon className="w-4 h-4 mr-1.5" />
|
|
{SUBMIT_LABELS[form.type]}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|