diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index b998a3b..bab2ed9 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -199,7 +199,19 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } - + + // Migration: Add payment_status column to tickets (paid | unpaid | comp), + // backfilled from is_guest and the payments table on first run + try { + await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'unpaid'`); + await (db as any).run(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`); + await (db as any).run(sql` + UPDATE tickets SET payment_status = 'paid' + WHERE is_guest = 0 + AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid') + `); + } catch (e) { /* column may already exist */ } + // Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries) // SQLite doesn't support altering column constraints, so we'll just ensure new entries work @@ -702,6 +714,18 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } + // Migration: Add payment_status column to tickets (paid | unpaid | comp), + // backfilled from is_guest and the payments table on first run + try { + await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN payment_status VARCHAR(10) NOT NULL DEFAULT 'unpaid'`); + await (db as any).execute(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`); + await (db as any).execute(sql` + UPDATE tickets SET payment_status = 'paid' + WHERE is_guest = 0 + AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid') + `); + } catch (e) { /* column may already exist */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS payments ( id UUID PRIMARY KEY, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index ee75c02..fc964f6 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -110,6 +110,8 @@ export const sqliteTickets = sqliteTable('tickets', { qrCode: text('qr_code'), adminNote: text('admin_note'), isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false), + // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue + paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'), createdAt: text('created_at').notNull(), }); @@ -468,6 +470,8 @@ export const pgTickets = pgTable('tickets', { qrCode: varchar('qr_code', { length: 255 }), adminNote: pgText('admin_note'), isGuest: pgInteger('is_guest').notNull().default(0), + // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue + paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'), createdAt: timestamp('created_at').notNull(), }); diff --git a/backend/src/lib/holdRecovery.ts b/backend/src/lib/holdRecovery.ts index 1983430..2888dda 100644 --- a/backend/src/lib/holdRecovery.ts +++ b/backend/src/lib/holdRecovery.ts @@ -75,6 +75,12 @@ export async function reserveOnHoldBooking( if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId; } + // Keep the ticket-level payment flag in sync when the payment settles + const ticketUpdate: Record = { status: targetTicketStatus }; + if (targetPaymentStatus === 'paid') { + ticketUpdate.paymentStatus = 'paid'; + } + // `needed` is how many of these tickets don't currently hold a seat and so must // be found new capacity; tickets already in a seat-holding state cost nothing. const assertCapacity = (reserved: number, needed: number) => { @@ -97,7 +103,7 @@ export async function reserveOnHoldBooking( } tx.update(tickets) - .set({ status: targetTicketStatus }) + .set(ticketUpdate) .where(and( inArray((tickets as any).id, ticketIds), inArray((tickets as any).status, fromTicketStatuses) @@ -118,7 +124,7 @@ export async function reserveOnHoldBooking( } await tx.update(tickets) - .set({ status: targetTicketStatus }) + .set(ticketUpdate) .where(and( inArray((tickets as any).id, ticketIds), inArray((tickets as any).status, fromTicketStatuses) diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index 46efe29..0a33c2c 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -288,7 +288,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) { for (const ticket of ticketsToConfirm) { const result: any = await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: 'confirmed', paymentStatus: 'paid' }) .where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending'))); transitioned += result?.changes ?? result?.rowCount ?? 0; diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index 692ad0d..72ad423 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -288,7 +288,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: 'confirmed', paymentStatus: 'paid' }) .where(eq((tickets as any).id, (t as any).id)); } diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 4c04f92..ff2c0fd 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -361,7 +361,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { for (const t of createdTickets) { await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: 'confirmed', paymentStatus: 'paid' }) .where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending'))); await (db as any) .update(payments) @@ -1002,6 +1002,9 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as attendeeEmail: ticket.attendeeEmail, attendeePhone: ticket.attendeePhone, status: ticket.status, + paymentStatus: ticket.paymentStatus, + // Balance to collect at the door for unpaid tickets + amountDue: ticket.paymentStatus === 'unpaid' && event ? event.price : 0, checkinAt: ticket.checkinAt, checkedInBy, }, @@ -1082,10 +1085,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] return c.json({ error: 'Ticket not found' }, 404); } - if (ticket.status === 'confirmed') { + // Confirmed/checked-in tickets can still be marked paid when they carry an + // unpaid balance (admin-added unpaid tickets collected at the door) + if (['confirmed', 'checked_in'].includes(ticket.status) && ticket.paymentStatus !== 'unpaid') { return c.json({ error: 'Ticket already confirmed' }, 400); } - + if (ticket.status === 'cancelled') { return c.json({ error: 'Cannot confirm cancelled ticket' }, 400); } @@ -1125,12 +1130,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] throw err; } } else { - // Confirm all tickets in the booking + // Confirm all tickets in the booking (checked-in tickets keep their status) for (const t of ticketsToConfirm) { // Update ticket status await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' }) .where(eq((tickets as any).id, t.id)); // Update payment status @@ -1471,6 +1476,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, preferredLanguage: data.preferredLanguage || null, status: ticketStatus, + paymentStatus: 'paid', qrCode, checkinAt: data.autoCheckin ? now : null, adminNote: data.adminNote || null, @@ -1514,148 +1520,26 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) }, 201); }); -// Admin create manual ticket (sends confirmation email + ticket to attendee) -ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ - eventId: z.string(), - firstName: z.string().min(2), - lastName: z.string().optional().or(z.literal('')), - email: z.string().email('Valid email is required for manual tickets'), - phone: z.string().optional().or(z.literal('')), - preferredLanguage: z.enum(['en', 'es']).optional(), - adminNote: z.string().max(1000).optional(), -})), async (c) => { - const data = c.req.valid('json'); - - // Get event - const event = await dbGet( - (db as any).select().from(events).where(eq((events as any).id, data.eventId)) - ); - if (!event) { - return c.json({ error: 'Event not found' }, 404); - } - - // Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets) - - const now = getNow(); - const attendeeEmail = data.email.trim(); - - // Find or create user - let user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) - ); - - const fullName = data.lastName && data.lastName.trim() - ? `${data.firstName} ${data.lastName}`.trim() - : data.firstName; - - if (!user) { - const userId = generateId(); - user = { - id: userId, - email: attendeeEmail, - password: '', - name: fullName, - phone: data.phone || null, - role: 'user', - languagePreference: null, - createdAt: now, - updatedAt: now, - }; - await (db as any).insert(users).values(user); - } - - // Check for existing active ticket for this user and event - const existingTicket = await dbGet( - (db as any) - .select() - .from(tickets) - .where( - and( - eq((tickets as any).userId, user.id), - eq((tickets as any).eventId, data.eventId) - ) - ) - ); - - if (existingTicket && existingTicket.status !== 'cancelled') { - return c.json({ error: 'This person already has a ticket for this event' }, 400); - } - - // Create ticket as confirmed - const ticketId = generateId(); - const qrCode = generateTicketCode(); - - const newTicket = { - id: ticketId, - userId: user.id, - eventId: data.eventId, - attendeeFirstName: data.firstName, - attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, - attendeeEmail: attendeeEmail, - attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, - preferredLanguage: data.preferredLanguage || null, - status: 'confirmed', - qrCode, - checkinAt: null, - adminNote: data.adminNote || null, - createdAt: now, - }; - - await (db as any).insert(tickets).values(newTicket); - - // Create payment record (marked as paid - manual entry) - const paymentId = generateId(); - const adminUser = (c as any).get('user'); - const newPayment = { - id: paymentId, - ticketId, - provider: 'cash', - amount: event.price, - currency: event.currency, - status: 'paid', - reference: 'Manual ticket', - paidAt: now, - paidByAdminId: adminUser?.id || null, - createdAt: now, - updatedAt: now, - }; - - await (db as any).insert(payments).values(newPayment); - - // Send booking confirmation email + ticket (asynchronously) - emailService.sendBookingConfirmation(ticketId).then(result => { - if (result.success) { - console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`); - } else { - console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error); - } - }).catch(err => { - console.error('[Email] Exception sending booking confirmation for manual ticket:', err); - }); - - return c.json({ - ticket: { - ...newTicket, - event: { - title: event.title, - startDatetime: event.startDatetime, - location: event.location, - }, - }, - payment: newPayment, - message: 'Manual ticket created and confirmation email sent', - }, 201); -}); - -// Admin invite guest ticket (free, confirmed, not counted in revenue) -ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ +// 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 +// 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), lastName: z.string().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal('')), phone: z.string().optional().or(z.literal('')), preferredLanguage: z.enum(['en', 'es']).optional(), + checkinNow: z.boolean().optional().default(false), adminNote: z.string().max(1000).optional(), +}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), { + message: 'Email is required for paid tickets', + path: ['email'], })), async (c) => { const data = c.req.valid('json'); @@ -1666,18 +1550,20 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), return c.json({ error: 'Event not found' }, 404); } + // Admin-added tickets bypass the capacity check (intentional over-capacity) + const now = getNow(); const adminUser = (c as any).get('user'); - - // Find or create user (use placeholder email if none provided) - const attendeeEmail = data.email && data.email.trim() - ? data.email.trim() - : `guest-${generateId()}@guestinvite.local`; + const hasEmail = !!(data.email && data.email.trim()); + const attendeeEmail = hasEmail + ? data.email!.trim() + : `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`; const fullName = data.lastName && data.lastName.trim() ? `${data.firstName} ${data.lastName}`.trim() : data.firstName; + // Find or create user let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) ); @@ -1698,8 +1584,8 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), await (db as any).insert(users).values(user); } - // Check for existing active ticket (only for real emails, not placeholder) - if (data.email && data.email.trim()) { + // Check for existing active ticket (only when a real email was provided) + if (hasEmail) { const existingTicket = await dbGet( (db as any) .select() @@ -1718,6 +1604,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), const ticketId = generateId(); const qrCode = generateTicketCode(); + const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid'; const newTicket = { id: ticketId, @@ -1725,50 +1612,85 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), eventId: data.eventId, attendeeFirstName: data.firstName, attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, - attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null, + attendeeEmail: hasEmail ? data.email!.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, preferredLanguage: data.preferredLanguage || null, - status: 'confirmed', - isGuest: 1, + status: data.checkinNow ? 'checked_in' : 'confirmed', + isGuest: data.type === 'guest' ? 1 : 0, + paymentStatus, qrCode, - checkinAt: null, + checkinAt: data.checkinNow ? now : null, + checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null, adminNote: data.adminNote || null, createdAt: now, }; await (db as any).insert(tickets).values(newTicket); - // Create a $0 payment record to track the invite + // Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid const paymentId = generateId(); - const newPayment = { - id: paymentId, - ticketId, - provider: 'cash', - amount: 0, - currency: event.currency, - status: 'paid', - reference: 'Guest invite', - paidAt: now, - paidByAdminId: adminUser?.id || null, - createdAt: now, - updatedAt: now, - }; + const newPayment = data.type === 'unpaid' + ? { + id: paymentId, + ticketId, + provider: 'tpago', + amount: event.price, + currency: event.currency, + status: 'pending', + reference: 'Unpaid ticket — collect at door', + paidAt: null, + paidByAdminId: null, + createdAt: now, + updatedAt: now, + } + : { + id: paymentId, + ticketId, + provider: 'cash', + amount: data.type === 'guest' ? 0 : event.price, + currency: event.currency, + status: 'paid', + reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket', + paidAt: now, + paidByAdminId: adminUser?.id || null, + createdAt: now, + updatedAt: now, + }; await (db as any).insert(payments).values(newPayment); - // Send booking confirmation email if a real email was provided - if (data.email && data.email.trim()) { + // Emails (asynchronous): paid always confirms; guest confirms when an email + // exists; unpaid sends the TPago (Bancard) pay-link instructions instead + if (data.type === 'unpaid') { + if (hasEmail) { + emailService.sendPaymentInstructions(ticketId).then(result => { + if (!result.success) { + console.error(`[Email] Failed to send pay link for unpaid ticket ${ticketId}:`, result.error); + } + }).catch(err => { + console.error('[Email] Exception sending pay link for unpaid ticket:', err); + }); + } + } else if (data.type === 'paid' || hasEmail) { emailService.sendBookingConfirmation(ticketId).then(result => { - if (result.success) { - console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`); - } else { - console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error); + if (!result.success) { + console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error); } }).catch(err => { - console.error('[Email] Exception sending booking confirmation for guest ticket:', err); + console.error(`[Email] Exception sending booking confirmation for ${data.type} ticket:`, err); }); } + const messages: Record = { + paid: 'Ticket created — confirmation email sent', + unpaid: hasEmail + ? 'Unpaid ticket created — payment link sent' + : 'Unpaid ticket created — collect payment at the door', + guest: hasEmail + ? 'Guest invited — confirmation email sent' + : 'Guest invited', + }; + return c.json({ ticket: { ...newTicket, @@ -1779,7 +1701,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), }, }, payment: newPayment, - message: 'Guest ticket created successfully', + message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type], }, 201); }); diff --git a/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx b/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx index a410096..e4be1db 100644 --- a/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx +++ b/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx @@ -18,3 +18,23 @@ export function StatusBadge({ status, compact = false }: { status: string; compa ); } + +// Ticket payment status: Paid (revenue), Unpaid (balance due at door), Comp (free guest). +// Tickets created before the payment_status column default to unpaid via backfill. +export function PaymentBadge({ paymentStatus, compact = false }: { paymentStatus?: string; compact?: boolean }) { + if (!paymentStatus) return null; + const styles: Record = { + paid: 'bg-emerald-100 text-emerald-700', + unpaid: 'bg-orange-100 text-orange-700', + comp: 'bg-amber-100 text-amber-700', + }; + return ( + + {paymentStatus === 'comp' ? 'Comp' : paymentStatus === 'unpaid' ? 'Unpaid' : 'Paid'} + + ); +} diff --git a/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx b/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx new file mode 100644 index 0000000..d971606 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx @@ -0,0 +1,209 @@ +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>; + onSubmit: (e: FormEvent) => void; + submitting: boolean; + eventPriceLabel: string; +} + +const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [ + { value: 'paid', label: 'Paid' }, + { value: 'unpaid', label: 'Unpaid' }, + { value: 'guest', label: 'Guest' }, +]; + +const SUBMIT_LABELS: Record = { + paid: 'Create & send ticket', + unpaid: 'Create & send pay link', + guest: 'Invite guest', +}; + +const SUBMIT_ICONS: Record = { + paid: EnvelopeIcon, + 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 === '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 = { + paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-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, + unpaid: BanknotesIcon, + guest: StarIcon, +}; + +export function AddTicketModal({ + open, + onClose, + form, + setForm, + onSubmit, + submitting, + eventPriceLabel, +}: AddTicketModalProps) { + if (!open) return null; + + const emailRequired = form.type === 'paid'; + const style = PREVIEW_STYLES[form.type]; + const PreviewIcon = PREVIEW_ICONS[form.type]; + const SubmitIcon = SUBMIT_ICONS[form.type]; + + return ( +
+ e.stopPropagation()} + > +
+

Add Ticket

+ +
+
+ {/* Segmented ticket-type control */} +
+ {TYPE_OPTIONS.map((option) => ( + + ))} +
+ +
+
+ + 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" /> +
+
+ + 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" /> +
+
+
+ + 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)'} /> +

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

+
+
+ + 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" /> +
+
+ +