From 9b2668f498f57327a9cb4f9f5879cfeef6340949 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 26 Jul 2026 05:25:00 +0000 Subject: [PATCH] Unify admin ticket creation into one modal with first-class payment status. - Replace the three Attendees-tab modals (Manual Ticket / Add at Door / Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented control, shared fields, "Check in now" for all types, and a live "what happens" preview, backed by one POST /api/tickets/admin/add. - Add tickets.payment_status (paid | unpaid | comp) with a backfill migration; keep it in sync on every payment-settlement path (mark-paid, admin approval, Lightning, free bookings, hold recovery). - Show Paid/Unpaid/Comp badges in the attendee list, count only paid tickets toward revenue, let unpaid tickets be resolved via Mark Paid, and flag unpaid tickets with their balance due in the door scanner. - Replace the per-page useStatsPrivacy hook with an admin-wide PrivacyContext + SensitiveValue mask, toggled from the admin layout. - Add server-side pagination with page-size options to the users page. Co-Authored-By: Claude Fable 5 --- backend/src/db/migrate.ts | 26 +- backend/src/db/schema.ts | 4 + backend/src/lib/holdRecovery.ts | 10 +- backend/src/routes/lnbits.ts | 2 +- backend/src/routes/payments.ts | 2 +- backend/src/routes/tickets.ts | 268 ++++++---------- .../events/[id]/_components/StatusBadge.tsx | 20 ++ .../events/[id]/_modals/AddTicketModal.tsx | 209 +++++++++++++ .../admin/events/[id]/_modals/EventModals.tsx | 293 +----------------- .../admin/events/[id]/_tabs/AttendeesTab.tsx | 56 ++-- .../admin/events/[id]/_tabs/OverviewTab.tsx | 5 +- frontend/src/app/admin/events/[id]/_types.ts | 14 +- frontend/src/app/admin/events/[id]/page.tsx | 178 ++++------- frontend/src/app/admin/layout.tsx | 31 ++ frontend/src/app/admin/page.tsx | 31 +- frontend/src/app/admin/scanner/page.tsx | 13 +- frontend/src/app/admin/users/page.tsx | 100 +++++- .../src/components/admin/SensitiveValue.tsx | 22 ++ frontend/src/context/PrivacyContext.tsx | 66 ++++ frontend/src/hooks/useStatsPrivacy.ts | 41 --- frontend/src/i18n/locales/en.json | 4 + frontend/src/i18n/locales/es.json | 4 + frontend/src/lib/api/tickets.ts | 22 +- frontend/src/lib/api/types.ts | 3 + 24 files changed, 748 insertions(+), 676 deletions(-) create mode 100644 frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx create mode 100644 frontend/src/components/admin/SensitiveValue.tsx create mode 100644 frontend/src/context/PrivacyContext.tsx delete mode 100644 frontend/src/hooks/useStatsPrivacy.ts 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" /> +
+
+ +