diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 1775104..bf789a9 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -169,6 +169,11 @@ async function migrate() { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id TEXT REFERENCES users(id)`); } catch (e) { /* column may already exist */ } + // Migration: Add booking_id column to tickets for multi-ticket bookings + try { + await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`); + } 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 @@ -201,6 +206,9 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN admin_note TEXT`); } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN payer_name TEXT`); + } catch (e) { /* column may already exist */ } // Invoices table await (db as any).run(sql` @@ -534,6 +542,11 @@ async function migrate() { try { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id UUID REFERENCES users(id)`); } catch (e) { /* column may already exist */ } + + // Migration: Add booking_id column to tickets for multi-ticket bookings + try { + await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`); + } catch (e) { /* column may already exist */ } await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS payments ( @@ -545,6 +558,7 @@ async function migrate() { status VARCHAR(20) NOT NULL DEFAULT 'pending', reference VARCHAR(255), user_marked_paid_at TIMESTAMP, + payer_name VARCHAR(255), paid_at TIMESTAMP, paid_by_admin_id UUID, admin_note TEXT, @@ -552,6 +566,11 @@ async function migrate() { updated_at TIMESTAMP NOT NULL ) `); + + // Add payer_name column if it doesn't exist + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN payer_name VARCHAR(255)`); + } catch (e) { /* column may already exist */ } // Invoices table await (db as any).execute(sql` diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 30762c8..4d348cc 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -85,6 +85,7 @@ export const sqliteEvents = sqliteTable('events', { export const sqliteTickets = sqliteTable('tickets', { id: text('id').primaryKey(), + bookingId: text('booking_id'), // Groups multiple tickets from same booking userId: text('user_id').notNull().references(() => sqliteUsers.id), eventId: text('event_id').notNull().references(() => sqliteEvents.id), attendeeFirstName: text('attendee_first_name').notNull(), @@ -110,6 +111,7 @@ export const sqlitePayments = sqliteTable('payments', { status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'), reference: text('reference'), userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid" + payerName: text('payer_name'), // Name of payer if different from attendee paidAt: text('paid_at'), paidByAdminId: text('paid_by_admin_id'), adminNote: text('admin_note'), // Internal admin notes @@ -371,6 +373,7 @@ export const pgEvents = pgTable('events', { export const pgTickets = pgTable('tickets', { id: uuid('id').primaryKey(), + bookingId: uuid('booking_id'), // Groups multiple tickets from same booking userId: uuid('user_id').notNull().references(() => pgUsers.id), eventId: uuid('event_id').notNull().references(() => pgEvents.id), attendeeFirstName: varchar('attendee_first_name', { length: 255 }).notNull(), @@ -396,6 +399,7 @@ export const pgPayments = pgTable('payments', { status: varchar('status', { length: 20 }).notNull().default('pending'), reference: varchar('reference', { length: 255 }), userMarkedPaidAt: timestamp('user_marked_paid_at'), + payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee paidAt: timestamp('paid_at'), paidByAdminId: uuid('paid_by_admin_id'), adminNote: pgText('admin_note'), diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 2d9a631..26a42da 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -5,7 +5,7 @@ import crypto from 'crypto'; import { Context } from 'hono'; import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js'; import { eq, and, gt } from 'drizzle-orm'; -import { generateId, getNow } from './utils.js'; +import { generateId, getNow, toDbDate } from './utils.js'; const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production'); const JWT_ISSUER = 'spanglish'; @@ -51,7 +51,7 @@ export async function createMagicLinkToken( ): Promise { const token = generateSecureToken(); const now = getNow(); - const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString(); + const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000)); await (db as any).insert(magicLinkTokens).values({ id: generateId(), @@ -113,7 +113,7 @@ export async function createUserSession( ): Promise { const sessionToken = generateSecureToken(); const now = getNow(); - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days + const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days await (db as any).insert(userSessions).values({ id: generateId(), diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index a49fa58..bbaa50d 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -522,6 +522,7 @@ export const emailService = { /** * Send booking confirmation email + * Supports multi-ticket bookings - includes all tickets in the booking */ async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> { // Get ticket with event info @@ -547,14 +548,37 @@ export const emailService = { return { success: false, error: 'Event not found' }; } + // Get all tickets in this booking (if multi-ticket) + let allTickets: any[] = [ticket]; + if (ticket.bookingId) { + allTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + } + + const ticketCount = allTickets.length; const locale = ticket.preferredLanguage || 'en'; const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; - // Generate ticket PDF URL + // Generate ticket PDF URL (primary ticket, or use combined endpoint for multi) const apiUrl = process.env.API_URL || 'http://localhost:3001'; - const ticketPdfUrl = `${apiUrl}/api/tickets/${ticket.id}/pdf`; + const ticketPdfUrl = ticketCount > 1 && ticket.bookingId + ? `${apiUrl}/api/tickets/booking/${ticket.bookingId}/pdf` + : `${apiUrl}/api/tickets/${ticket.id}/pdf`; const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); + + // Build attendee list for multi-ticket emails + const attendeeNames = allTickets.map(t => + `${t.attendeeFirstName} ${t.attendeeLastName || ''}`.trim() + ).join(', '); + + // Calculate total price for multi-ticket bookings + const totalPrice = event.price * ticketCount; + return this.sendTemplateEmail({ templateSlug: 'booking-confirmation', to: ticket.attendeeEmail, @@ -565,6 +589,7 @@ export const emailService = { attendeeName: attendeeFullName, attendeeEmail: ticket.attendeeEmail, ticketId: ticket.id, + bookingId: ticket.bookingId || ticket.id, qrCode: ticket.qrCode || '', ticketPdfUrl, eventTitle, @@ -573,6 +598,11 @@ export const emailService = { eventLocation: event.location, eventLocationUrl: event.locationUrl || '', eventPrice: this.formatCurrency(event.price, event.currency), + // Multi-ticket specific variables + ticketCount: ticketCount.toString(), + totalPrice: this.formatCurrency(totalPrice, event.currency), + attendeeNames, + isMultiTicket: ticketCount > 1 ? 'true' : 'false', }, }); }, @@ -615,15 +645,48 @@ export const emailService = { return { success: false, error: 'Event not found' }; } + // Calculate total amount for multi-ticket bookings + let totalAmount = payment.amount; + let ticketCount = 1; + + if (ticket.bookingId) { + // Get all payments for this booking + const bookingTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + + ticketCount = bookingTickets.length; + + // Sum up all payment amounts for the booking + const bookingPayments = await Promise.all( + bookingTickets.map((t: any) => + dbGet((db as any).select().from(payments).where(eq((payments as any).ticketId, t.id))) + ) + ); + + totalAmount = bookingPayments + .filter((p: any) => p) + .reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0); + } + const locale = ticket.preferredLanguage || 'en'; const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; const paymentMethodNames: Record> = { - en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash' }, - es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo' }, + en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' }, + es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' }, }; const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); + + // Format amount with ticket count info for multi-ticket bookings + const amountDisplay = ticketCount > 1 + ? `${this.formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)` + : this.formatCurrency(totalAmount, payment.currency); + return this.sendTemplateEmail({ templateSlug: 'payment-receipt', to: ticket.attendeeEmail, @@ -632,10 +695,10 @@ export const emailService = { eventId: event.id, variables: { attendeeName: receiptFullName, - ticketId: ticket.id, + ticketId: ticket.bookingId || ticket.id, eventTitle, eventDate: this.formatDate(event.startDatetime, locale), - paymentAmount: this.formatCurrency(payment.amount, payment.currency), + paymentAmount: amountDisplay, paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider, paymentReference: payment.reference || payment.id, paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale), @@ -750,8 +813,24 @@ export const emailService = { const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); - // Generate a payment reference using ticket ID - const paymentReference = `SPG-${ticket.id.substring(0, 8).toUpperCase()}`; + // Calculate total price for multi-ticket bookings + let totalPrice = event.price; + let ticketCount = 1; + + if (ticket.bookingId) { + // Count all tickets in this booking + const bookingTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + ticketCount = bookingTickets.length; + totalPrice = event.price * ticketCount; + } + + // Generate a payment reference using booking ID or ticket ID + const paymentReference = `SPG-${(ticket.bookingId || ticket.id).substring(0, 8).toUpperCase()}`; // Generate the booking URL for returning to payment page const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com'; @@ -762,17 +841,22 @@ export const emailService = { ? 'payment-instructions-tpago' : 'payment-instructions-bank-transfer'; + // Format amount with ticket count info for multi-ticket bookings + const amountDisplay = ticketCount > 1 + ? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)` + : this.formatCurrency(totalPrice, event.currency); + // Build variables based on payment method const variables: Record = { attendeeName: attendeeFullName, attendeeEmail: ticket.attendeeEmail, - ticketId: ticket.id, + ticketId: ticket.bookingId || ticket.id, eventTitle, eventDate: this.formatDate(event.startDatetime, locale), eventTime: this.formatTime(event.startDatetime, locale), eventLocation: event.location, eventLocationUrl: event.locationUrl || '', - paymentAmount: this.formatCurrency(event.price, event.currency), + paymentAmount: amountDisplay, paymentReference, bookingUrl, }; diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index c88fa7d..554b5e0 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -74,7 +74,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => .where(eq((payments as any).status, 'paid')) ); - const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + (p.amount || 0), 0); + const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0); const newContacts = await dbGet( (db as any) diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 02191b1..8ed934a 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -136,6 +136,8 @@ eventsRouter.get('/', async (c) => { // Get ticket counts for each event const eventsWithCounts = await Promise.all( result.map(async (event: any) => { + // Count confirmed AND checked_in tickets (checked_in were previously confirmed) + // This ensures check-in doesn't affect capacity/spots_left const ticketCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) @@ -143,7 +145,7 @@ eventsRouter.get('/', async (c) => { .where( and( eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'confirmed') + sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` ) ) ); @@ -172,7 +174,8 @@ eventsRouter.get('/:id', async (c) => { return c.json({ error: 'Event not found' }, 404); } - // Get ticket count + // Count confirmed AND checked_in tickets (checked_in were previously confirmed) + // This ensures check-in doesn't affect capacity/spots_left const ticketCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) @@ -180,7 +183,7 @@ eventsRouter.get('/:id', async (c) => { .where( and( eq((tickets as any).eventId, id), - eq((tickets as any).status, 'confirmed') + sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` ) ) ); @@ -217,6 +220,8 @@ eventsRouter.get('/next/upcoming', async (c) => { return c.json({ event: null }); } + // Count confirmed AND checked_in tickets (checked_in were previously confirmed) + // This ensures check-in doesn't affect capacity/spots_left const ticketCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) @@ -224,7 +229,7 @@ eventsRouter.get('/next/upcoming', async (c) => { .where( and( eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'confirmed') + sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` ) ) ); diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index 4226e97..b433b33 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; -import { db, dbGet, tickets, payments } from '../db/index.js'; +import { db, dbGet, dbAll, tickets, payments } from '../db/index.js'; import { eq } from 'drizzle-orm'; import { getNow } from '../lib/utils.js'; import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js'; @@ -152,40 +152,63 @@ lnbitsRouter.post('/webhook', async (c) => { /** * Handle successful payment + * Supports multi-ticket bookings - confirms all tickets in the booking */ async function handlePaymentComplete(ticketId: string, paymentHash: string) { const now = getNow(); - // Check if already confirmed to avoid duplicate updates + // Get the ticket to check for booking ID const existingTicket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) ); - if (existingTicket?.status === 'confirmed') { + if (!existingTicket) { + console.error(`Ticket ${ticketId} not found for payment confirmation`); + return; + } + + if (existingTicket.status === 'confirmed') { console.log(`Ticket ${ticketId} already confirmed, skipping update`); return; } - // Update ticket status to confirmed - await (db as any) - .update(tickets) - .set({ status: 'confirmed' }) - .where(eq((tickets as any).id, ticketId)); + // Get all tickets in this booking (if multi-ticket) + let ticketsToConfirm: any[] = [existingTicket]; - // Update payment status to paid - await (db as any) - .update(payments) - .set({ - status: 'paid', - reference: paymentHash, - paidAt: now, - updatedAt: now, - }) - .where(eq((payments as any).ticketId, ticketId)); + if (existingTicket.bookingId) { + // This is a multi-ticket booking - get all tickets with same bookingId + ticketsToConfirm = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).bookingId, existingTicket.bookingId)) + ); + console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`); + } - console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`); + // Confirm all tickets in the booking + for (const ticket of ticketsToConfirm) { + // Update ticket status to confirmed + await (db as any) + .update(tickets) + .set({ status: 'confirmed' }) + .where(eq((tickets as any).id, ticket.id)); + + // Update payment status to paid + await (db as any) + .update(payments) + .set({ + status: 'paid', + reference: paymentHash, + paidAt: now, + updatedAt: now, + }) + .where(eq((payments as any).ticketId, ticket.id)); + + console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`); + } - // Get payment for sending receipt + // Get primary payment for sending receipt const payment = await dbGet( (db as any) .select() @@ -194,6 +217,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) { ); // Send confirmation emails asynchronously + // For multi-ticket bookings, send email with all ticket info Promise.all([ emailService.sendBookingConfirmation(ticketId), payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(), diff --git a/backend/src/routes/payment-options.ts b/backend/src/routes/payment-options.ts index c034aed..2ec11c9 100644 --- a/backend/src/routes/payment-options.ts +++ b/backend/src/routes/payment-options.ts @@ -8,13 +8,19 @@ import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js'; const paymentOptionsRouter = new Hono(); +// Helper to normalize boolean (handles true/false and 0/1 from database) +const booleanOrNumber = z.union([z.boolean(), z.number()]).transform((val) => { + if (typeof val === 'boolean') return val; + return val !== 0; +}); + // Schema for updating global payment options const updatePaymentOptionsSchema = z.object({ - tpagoEnabled: z.boolean().optional(), + tpagoEnabled: booleanOrNumber.optional(), tpagoLink: z.string().optional().nullable(), tpagoInstructions: z.string().optional().nullable(), tpagoInstructionsEs: z.string().optional().nullable(), - bankTransferEnabled: z.boolean().optional(), + bankTransferEnabled: booleanOrNumber.optional(), bankName: z.string().optional().nullable(), bankAccountHolder: z.string().optional().nullable(), bankAccountNumber: z.string().optional().nullable(), @@ -22,21 +28,21 @@ const updatePaymentOptionsSchema = z.object({ bankPhone: z.string().optional().nullable(), bankNotes: z.string().optional().nullable(), bankNotesEs: z.string().optional().nullable(), - lightningEnabled: z.boolean().optional(), - cashEnabled: z.boolean().optional(), + lightningEnabled: booleanOrNumber.optional(), + cashEnabled: booleanOrNumber.optional(), cashInstructions: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(), // Booking settings - allowDuplicateBookings: z.boolean().optional(), + allowDuplicateBookings: booleanOrNumber.optional(), }); // Schema for event-level overrides const updateEventOverridesSchema = z.object({ - tpagoEnabled: z.boolean().optional().nullable(), + tpagoEnabled: booleanOrNumber.optional().nullable(), tpagoLink: z.string().optional().nullable(), tpagoInstructions: z.string().optional().nullable(), tpagoInstructionsEs: z.string().optional().nullable(), - bankTransferEnabled: z.boolean().optional().nullable(), + bankTransferEnabled: booleanOrNumber.optional().nullable(), bankName: z.string().optional().nullable(), bankAccountHolder: z.string().optional().nullable(), bankAccountNumber: z.string().optional().nullable(), @@ -44,8 +50,8 @@ const updateEventOverridesSchema = z.object({ bankPhone: z.string().optional().nullable(), bankNotes: z.string().optional().nullable(), bankNotesEs: z.string().optional().nullable(), - lightningEnabled: z.boolean().optional().nullable(), - cashEnabled: z.boolean().optional().nullable(), + lightningEnabled: booleanOrNumber.optional().nullable(), + cashEnabled: booleanOrNumber.optional().nullable(), cashInstructions: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(), }); diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index f35e3a9..eae3ea4 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -76,6 +76,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => { ...payment, ticket: ticket ? { id: ticket.id, + bookingId: ticket.bookingId, attendeeFirstName: ticket.attendeeFirstName, attendeeLastName: ticket.attendeeLastName, attendeeEmail: ticket.attendeeEmail, @@ -128,6 +129,7 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy ...payment, ticket: ticket ? { id: ticket.id, + bookingId: ticket.bookingId, attendeeFirstName: ticket.attendeeFirstName, attendeeLastName: ticket.attendeeLastName, attendeeEmail: ticket.attendeeEmail, @@ -199,17 +201,42 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json updateData.paidByAdminId = user.id; } - await (db as any) - .update(payments) - .set(updateData) - .where(eq((payments as any).id, id)); - - // If payment confirmed, update ticket status and send emails + // If payment confirmed, handle multi-ticket booking if (data.status === 'paid') { - await (db as any) - .update(tickets) - .set({ status: 'confirmed' }) - .where(eq((tickets as any).id, existing.ticketId)); + // Get the ticket associated with this payment + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, existing.ticketId)) + ); + + // Check if this is part of a multi-ticket booking + let ticketsToConfirm: any[] = [ticket]; + + if (ticket?.bookingId) { + // Get all tickets in this booking + ticketsToConfirm = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + console.log(`[Payment] Confirming multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`); + } + + // Update all payments and tickets in the booking + for (const t of ticketsToConfirm) { + await (db as any) + .update(payments) + .set(updateData) + .where(eq((payments as any).ticketId, (t as any).id)); + + await (db as any) + .update(tickets) + .set({ status: 'confirmed' }) + .where(eq((tickets as any).id, (t as any).id)); + } // Send confirmation emails asynchronously (don't block the response) Promise.all([ @@ -218,6 +245,12 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json ]).catch(err => { console.error('[Email] Failed to send confirmation emails:', err); }); + } else { + // For non-paid status updates, just update this payment + await (db as any) + .update(payments) + .set(updateData) + .where(eq((payments as any).id, id)); } const updated = await dbGet( @@ -254,23 +287,47 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida const now = getNow(); - // Update payment status to paid - await (db as any) - .update(payments) - .set({ - status: 'paid', - paidAt: now, - paidByAdminId: user.id, - adminNote: adminNote || payment.adminNote, - updatedAt: now, - }) - .where(eq((payments as any).id, id)); + // Get the ticket associated with this payment + const ticket = await dbGet( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).id, payment.ticketId)) + ); - // Update ticket status to confirmed - await (db as any) - .update(tickets) - .set({ status: 'confirmed' }) - .where(eq((tickets as any).id, payment.ticketId)); + // Check if this is part of a multi-ticket booking + let ticketsToConfirm: any[] = [ticket]; + + if (ticket?.bookingId) { + // Get all tickets in this booking + ticketsToConfirm = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`); + } + + // Update all payments in the booking to paid + for (const t of ticketsToConfirm) { + await (db as any) + .update(payments) + .set({ + status: 'paid', + paidAt: now, + paidByAdminId: user.id, + adminNote: adminNote || payment.adminNote, + updatedAt: now, + }) + .where(eq((payments as any).ticketId, (t as any).id)); + + // Update ticket status to confirmed + await (db as any) + .update(tickets) + .set({ status: 'confirmed' }) + .where(eq((tickets as any).id, (t as any).id)); + } // Send confirmation emails asynchronously Promise.all([ @@ -453,7 +510,7 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { failed: allPayments.filter((p: any) => p.status === 'failed').length, totalRevenue: allPayments .filter((p: any) => p.status === 'paid') - .reduce((sum: number, p: any) => sum + (p.amount || 0), 0), + .reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0), }; return c.json({ stats }); diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index ce7c0ff..dcaf5c0 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -7,10 +7,16 @@ import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, generateTicketCode, getNow } from '../lib/utils.js'; import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; import emailService from '../lib/email.js'; -import { generateTicketPDF } from '../lib/pdf.js'; +import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; const ticketsRouter = new Hono(); +// Attendee info schema for multi-ticket bookings +const attendeeSchema = z.object({ + firstName: z.string().min(2), + lastName: z.string().min(2).optional().or(z.literal('')), +}); + const createTicketSchema = z.object({ eventId: z.string(), firstName: z.string().min(2), @@ -20,6 +26,8 @@ const createTicketSchema = z.object({ preferredLanguage: z.enum(['en', 'es']).optional(), paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'), ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(), + // Optional: array of attendees for multi-ticket booking + attendees: z.array(attendeeSchema).optional(), }); const updateTicketSchema = z.object({ @@ -42,10 +50,17 @@ const adminCreateTicketSchema = z.object({ adminNote: z.string().max(1000).optional(), }); -// Book a ticket (public) +// Book a ticket (public) - supports single or multi-ticket bookings ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { const data = c.req.valid('json'); + // Determine attendees list (use attendees array if provided, otherwise single attendee from main fields) + const attendeesList = data.attendees && data.attendees.length > 0 + ? data.attendees + : [{ firstName: data.firstName, lastName: data.lastName }]; + + const ticketCount = attendeesList.length; + // Get event const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, data.eventId)) @@ -58,23 +73,32 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { return c.json({ error: 'Event is not available for booking' }, 400); } - // Check capacity - const ticketCount = await dbGet( + // Check capacity - count confirmed AND checked_in tickets + // (checked_in were previously confirmed, check-in doesn't affect capacity) + const existingTicketCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where( and( eq((tickets as any).eventId, data.eventId), - eq((tickets as any).status, 'confirmed') + sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` ) ) ); - if ((ticketCount?.count || 0) >= event.capacity) { + const availableSeats = event.capacity - (existingTicketCount?.count || 0); + + if (availableSeats <= 0) { return c.json({ error: 'Event is sold out' }, 400); } + if (ticketCount > availableSeats) { + return c.json({ + error: `Not enough seats available. Only ${availableSeats} spot(s) remaining.` + }, 400); + } + // Find or create user let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, data.email)) @@ -129,55 +153,67 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { } } - // Create ticket - const ticketId = generateId(); - const qrCode = generateTicketCode(); + // Generate booking ID to group multiple tickets + const bookingId = generateId(); - // Cash payments start as pending, card/lightning start as pending until payment confirmed - const ticketStatus = 'pending'; + // Create tickets for each attendee + const createdTickets: any[] = []; + const createdPayments: any[] = []; - const newTicket = { - id: ticketId, - userId: user.id, - eventId: data.eventId, - attendeeFirstName: data.firstName, - attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, - attendeeEmail: data.email, - attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, - attendeeRuc: data.ruc || null, - preferredLanguage: data.preferredLanguage || null, - status: ticketStatus, - qrCode, - checkinAt: null, - createdAt: now, - }; + for (let i = 0; i < attendeesList.length; i++) { + const attendee = attendeesList[i]; + const ticketId = generateId(); + const qrCode = generateTicketCode(); + + const newTicket = { + id: ticketId, + bookingId: ticketCount > 1 ? bookingId : null, // Only set bookingId for multi-ticket bookings + userId: user.id, + eventId: data.eventId, + attendeeFirstName: attendee.firstName, + attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null, + attendeeEmail: data.email, // Buyer's email for all tickets + attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, + attendeeRuc: data.ruc || null, + preferredLanguage: data.preferredLanguage || null, + status: 'pending', + qrCode, + checkinAt: null, + createdAt: now, + }; + + await (db as any).insert(tickets).values(newTicket); + createdTickets.push(newTicket); + + // Create payment record for each ticket + const paymentId = generateId(); + const newPayment = { + id: paymentId, + ticketId, + provider: data.paymentMethod, + amount: event.price, + currency: event.currency, + status: 'pending', + reference: null, + createdAt: now, + updatedAt: now, + }; + + await (db as any).insert(payments).values(newPayment); + createdPayments.push(newPayment); + } - await (db as any).insert(tickets).values(newTicket); - - // Create payment record - const paymentId = generateId(); - const newPayment = { - id: paymentId, - ticketId, - provider: data.paymentMethod, - amount: event.price, - currency: event.currency, - status: 'pending', - reference: null, - createdAt: now, - updatedAt: now, - }; - - await (db as any).insert(payments).values(newPayment); + const primaryTicket = createdTickets[0]; + const primaryPayment = createdPayments[0]; // Send payment instructions email for manual payment methods (TPago, Bank Transfer) if (['bank_transfer', 'tpago'].includes(data.paymentMethod)) { // Send asynchronously - don't block the response - emailService.sendPaymentInstructions(ticketId).then(result => { + emailService.sendPaymentInstructions(primaryTicket.id).then(result => { if (result.success) { - console.log(`[Email] Payment instructions email sent successfully for ticket ${ticketId}`); + console.log(`[Email] Payment instructions email sent successfully for ticket ${primaryTicket.id}`); } else { - console.error(`[Email] Failed to send payment instructions email for ticket ${ticketId}:`, result.error); + console.error(`[Email] Failed to send payment instructions email for ticket ${primaryTicket.id}:`, result.error); } }).catch(err => { console.error('[Email] Exception sending payment instructions email:', err); @@ -186,11 +222,17 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { // If Lightning payment, create LNbits invoice let lnbitsInvoice = null; - if (data.paymentMethod === 'lightning' && event.price > 0) { + const totalPrice = event.price * ticketCount; + + if (data.paymentMethod === 'lightning' && totalPrice > 0) { if (!isLNbitsConfigured()) { - // Delete the ticket and payment we just created - await (db as any).delete(payments).where(eq((payments as any).id, paymentId)); - await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId)); + // Delete the tickets and payments we just created + for (const payment of createdPayments) { + await (db as any).delete(payments).where(eq((payments as any).id, payment.id)); + } + for (const ticket of createdTickets) { + await (db as any).delete(tickets).where(eq((tickets as any).id, ticket.id)); + } return c.json({ error: 'Bitcoin Lightning payments are not available at this time' }, 400); @@ -200,49 +242,68 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { const apiUrl = process.env.API_URL || 'http://localhost:3001'; // Pass the fiat currency directly to LNbits - it handles conversion automatically + // For multi-ticket, use total price lnbitsInvoice = await createInvoice({ - amount: event.price, + amount: totalPrice, unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc. - memo: `Spanglish: ${event.title} - ${fullName}`, + memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`, webhookUrl: `${apiUrl}/api/lnbits/webhook`, expiry: 900, // 15 minutes expiry for faster UX extra: { - ticketId, + ticketId: primaryTicket.id, + bookingId: ticketCount > 1 ? bookingId : null, + ticketIds: createdTickets.map(t => t.id), eventId: event.id, eventTitle: event.title, attendeeName: fullName, attendeeEmail: data.email, + ticketCount, }, }); - // Update payment with LNbits payment hash reference + // Update primary payment with LNbits payment hash reference await (db as any) .update(payments) .set({ reference: lnbitsInvoice.paymentHash }) - .where(eq((payments as any).id, paymentId)); + .where(eq((payments as any).id, primaryPayment.id)); - (newPayment as any).reference = lnbitsInvoice.paymentHash; + (primaryPayment as any).reference = lnbitsInvoice.paymentHash; } catch (error: any) { console.error('Failed to create Lightning invoice:', error); - // Delete the ticket and payment we just created since Lightning payment failed - await (db as any).delete(payments).where(eq((payments as any).id, paymentId)); - await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId)); + // Delete the tickets and payments we just created since Lightning payment failed + for (const payment of createdPayments) { + await (db as any).delete(payments).where(eq((payments as any).id, payment.id)); + } + for (const ticket of createdTickets) { + await (db as any).delete(tickets).where(eq((tickets as any).id, ticket.id)); + } return c.json({ error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}` }, 500); } } + // Response format depends on single vs multi-ticket + const eventInfo = { + title: event.title, + startDatetime: event.startDatetime, + location: event.location, + }; + return c.json({ + // For backward compatibility, include primary ticket as 'ticket' ticket: { - ...newTicket, - event: { - title: event.title, - startDatetime: event.startDatetime, - location: event.location, - }, + ...primaryTicket, + event: eventInfo, }, - payment: newPayment, + // For multi-ticket bookings, include all tickets + tickets: createdTickets.map(t => ({ + ...t, + event: eventInfo, + })), + bookingId: ticketCount > 1 ? bookingId : null, + payment: primaryPayment, + payments: createdPayments, lightningInvoice: lnbitsInvoice ? { paymentHash: lnbitsInvoice.paymentHash, paymentRequest: lnbitsInvoice.paymentRequest, @@ -251,11 +312,102 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { fiatCurrency: lnbitsInvoice.fiatCurrency, expiry: lnbitsInvoice.expiry, } : null, - message: 'Booking created successfully', + message: ticketCount > 1 + ? `${ticketCount} tickets booked successfully` + : 'Booking created successfully', }, 201); }); -// Download ticket as PDF +// Download combined PDF for multi-ticket booking +// NOTE: This route MUST be defined before /:id/pdf to prevent the wildcard from matching "booking" +ticketsRouter.get('/booking/:bookingId/pdf', async (c) => { + const bookingId = c.req.param('bookingId'); + const user: any = await getAuthUser(c); + + console.log(`[PDF] Generating combined PDF for booking: ${bookingId}`); + + // Get all tickets in this booking + const bookingTickets = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).bookingId, bookingId)) + ); + + console.log(`[PDF] Found ${bookingTickets?.length || 0} tickets for booking ${bookingId}`); + + if (!bookingTickets || bookingTickets.length === 0) { + return c.json({ error: 'Booking not found' }, 404); + } + + const primaryTicket = bookingTickets[0] as any; + + // Check authorization - must be ticket owner or admin + if (user) { + const isAdmin = ['admin', 'organizer', 'staff'].includes(user.role); + const isOwner = user.id === primaryTicket.userId; + + if (!isAdmin && !isOwner) { + return c.json({ error: 'Unauthorized' }, 403); + } + } + + // Check that at least one ticket is confirmed + const hasConfirmedTicket = bookingTickets.some((t: any) => + ['confirmed', 'checked_in'].includes(t.status) + ); + + if (!hasConfirmedTicket) { + return c.json({ error: 'No confirmed tickets in this booking' }, 400); + } + + // Get event + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, primaryTicket.eventId)) + ); + + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + try { + // Filter to only confirmed/checked_in tickets + const confirmedTickets = bookingTickets.filter((t: any) => + ['confirmed', 'checked_in'].includes(t.status) + ); + + console.log(`[PDF] Generating PDF with ${confirmedTickets.length} confirmed tickets`); + + const ticketsData = confirmedTickets.map((ticket: any) => ({ + id: ticket.id, + qrCode: ticket.qrCode, + attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), + attendeeEmail: ticket.attendeeEmail, + event: { + title: event.title, + startDatetime: event.startDatetime, + endDatetime: event.endDatetime, + location: event.location, + locationUrl: event.locationUrl, + }, + })); + + const pdfBuffer = await generateCombinedTicketsPDF(ticketsData); + + // Set response headers for PDF download + return new Response(new Uint8Array(pdfBuffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="spanglish-booking-${bookingId}.pdf"`, + }, + }); + } catch (error: any) { + console.error('Combined PDF generation error:', error); + return c.json({ error: 'Failed to generate PDF' }, 500); + } +}); + +// Download ticket as PDF (single ticket) ticketsRouter.get('/:id/pdf', async (c) => { const id = c.req.param('id'); const user: any = await getAuthUser(c); @@ -544,6 +696,7 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), }); // Mark payment as received (for cash payments - admin only) +// Supports multi-ticket bookings - confirms all tickets in the booking ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']), async (c) => { const id = c.req.param('id'); const user = (c as any).get('user'); @@ -566,22 +719,38 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] const now = getNow(); - // Update ticket status - await (db as any) - .update(tickets) - .set({ status: 'confirmed' }) - .where(eq((tickets as any).id, id)); + // Get all tickets in this booking (if multi-ticket) + let ticketsToConfirm: any[] = [ticket]; - // Update payment status - await (db as any) - .update(payments) - .set({ - status: 'paid', - paidAt: now, - paidByAdminId: user.id, - updatedAt: now, - }) - .where(eq((payments as any).ticketId, id)); + if (ticket.bookingId) { + // This is a multi-ticket booking - get all tickets with same bookingId + ticketsToConfirm = await dbAll( + (db as any) + .select() + .from(tickets) + .where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + } + + // Confirm all tickets in the booking + for (const t of ticketsToConfirm) { + // Update ticket status + await (db as any) + .update(tickets) + .set({ status: 'confirmed' }) + .where(eq((tickets as any).id, t.id)); + + // Update payment status + await (db as any) + .update(payments) + .set({ + status: 'paid', + paidAt: now, + paidByAdminId: user.id, + updatedAt: now, + }) + .where(eq((payments as any).ticketId, t.id)); + } // Get payment for sending receipt const payment = await dbGet( @@ -603,13 +772,20 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); - return c.json({ ticket: updated, message: 'Payment marked as received' }); + return c.json({ + ticket: updated, + message: ticketsToConfirm.length > 1 + ? `${ticketsToConfirm.length} tickets marked as paid` + : 'Payment marked as received' + }); }); // User marks payment as sent (for manual payment methods: bank_transfer, tpago) // This sets status to "pending_approval" and notifies admin ticketsRouter.post('/:id/mark-payment-sent', async (c) => { const id = c.req.param('id'); + const body = await c.req.json().catch(() => ({})); + const { payerName } = body; const ticket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) @@ -666,6 +842,7 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => { .set({ status: 'pending_approval', userMarkedPaidAt: now, + payerName: payerName?.trim() || null, updatedAt: now, }) .where(eq((payments as any).id, payment.id)); diff --git a/frontend/public/images/carrousel/2026-02-02 00.33.56.jpg b/frontend/public/images/carrousel/2026-02-02 00.33.56.jpg deleted file mode 100644 index 94d4c43..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.33.56.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.12.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.12.jpg deleted file mode 100644 index f2ae9ba..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.12.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.15.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.15.jpg deleted file mode 100644 index 7df1aff..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.15.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.18.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.18.jpg deleted file mode 100644 index a57b697..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.18.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.21.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.21.jpg deleted file mode 100644 index 567f703..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.21.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.23.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.23.jpg deleted file mode 100644 index 58433c2..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.23.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.26.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.26.jpg deleted file mode 100644 index 34383a0..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.26.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.28.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.28.jpg deleted file mode 100644 index 1117acd..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.28.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.31.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.31.jpg deleted file mode 100644 index 66c721f..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.31.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.33.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.33.jpg deleted file mode 100644 index aa29cc9..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.33.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.38.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.38.jpg deleted file mode 100644 index 7f3ced3..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.38.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 00.34.40.jpg b/frontend/public/images/carrousel/2026-02-02 00.34.40.jpg deleted file mode 100644 index f5f43ef..0000000 Binary files a/frontend/public/images/carrousel/2026-02-02 00.34.40.jpg and /dev/null differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.34.55.jpg b/frontend/public/images/carrousel/2026-02-02 11.34.55.jpg new file mode 100644 index 0000000..09676f5 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.34.55.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.35.14.jpg b/frontend/public/images/carrousel/2026-02-02 11.35.14.jpg new file mode 100644 index 0000000..d22b63f Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.35.14.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.35.17.jpg b/frontend/public/images/carrousel/2026-02-02 11.35.17.jpg new file mode 100644 index 0000000..9552aef Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.35.17.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.35.19.jpg b/frontend/public/images/carrousel/2026-02-02 11.35.19.jpg new file mode 100644 index 0000000..4c0a134 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.35.19.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.35.22.jpg b/frontend/public/images/carrousel/2026-02-02 11.35.22.jpg new file mode 100644 index 0000000..34ba205 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.35.22.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.35.24.jpg b/frontend/public/images/carrousel/2026-02-02 11.35.24.jpg new file mode 100644 index 0000000..5b6e9b7 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.35.24.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.35.27.jpg b/frontend/public/images/carrousel/2026-02-02 11.35.27.jpg new file mode 100644 index 0000000..3e56352 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.35.27.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.35.29.jpg b/frontend/public/images/carrousel/2026-02-02 11.35.29.jpg new file mode 100644 index 0000000..a9f7d3f Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.35.29.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.35.32.jpg b/frontend/public/images/carrousel/2026-02-02 11.35.32.jpg new file mode 100644 index 0000000..ca17b3d Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.35.32.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.35.35.jpg b/frontend/public/images/carrousel/2026-02-02 11.35.35.jpg new file mode 100644 index 0000000..b705b60 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.35.35.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.37.37.jpg b/frontend/public/images/carrousel/2026-02-02 11.37.37.jpg new file mode 100644 index 0000000..eeccf24 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.37.37.jpg differ diff --git a/frontend/public/images/carrousel/2026-02-02 11.37.39.jpg b/frontend/public/images/carrousel/2026-02-02 11.37.39.jpg new file mode 100644 index 0000000..2b78553 Binary files /dev/null and b/frontend/public/images/carrousel/2026-02-02 11.37.39.jpg differ diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index dbe19ae..62ea1de 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; @@ -26,9 +26,17 @@ import { BuildingLibraryIcon, ClockIcon, ArrowTopRightOnSquareIcon, + UserIcon, + ArrowDownTrayIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; +// Attendee info for each ticket +interface AttendeeInfo { + firstName: string; + lastName: string; +} + type PaymentMethod = 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago'; interface BookingFormData { @@ -52,14 +60,19 @@ interface LightningInvoice { interface BookingResult { ticketId: string; + ticketIds?: string[]; // For multi-ticket bookings + bookingId?: string; qrCode: string; + qrCodes?: string[]; // For multi-ticket bookings paymentMethod: PaymentMethod; lightningInvoice?: LightningInvoice; + ticketCount?: number; } export default function BookingPage() { const params = useParams(); const router = useRouter(); + const searchParams = useSearchParams(); const { t, locale } = useLanguage(); const { user } = useAuth(); const [event, setEvent] = useState(null); @@ -71,6 +84,20 @@ export default function BookingPage() { const [paymentPending, setPaymentPending] = useState(false); const [markingPaid, setMarkingPaid] = useState(false); + // State for payer name (when paid under different name) + const [paidUnderDifferentName, setPaidUnderDifferentName] = useState(false); + const [payerName, setPayerName] = useState(''); + + // Quantity from URL param (default 1) + const initialQuantity = Math.max(1, parseInt(searchParams.get('qty') || '1', 10)); + const [ticketQuantity, setTicketQuantity] = useState(initialQuantity); + + // Attendees for multi-ticket bookings (ticket 1 uses main formData) + const [attendees, setAttendees] = useState(() => + Array(Math.max(0, initialQuantity - 1)).fill(null).map(() => ({ firstName: '', lastName: '' })) + ); + const [attendeeErrors, setAttendeeErrors] = useState<{ [key: number]: string }>({}); + const [formData, setFormData] = useState({ firstName: '', lastName: '', @@ -228,6 +255,7 @@ export default function BookingPage() { const validateForm = (): boolean => { const newErrors: Partial> = {}; + const newAttendeeErrors: { [key: number]: string } = {}; if (!formData.firstName.trim() || formData.firstName.length < 2) { newErrors.firstName = t('booking.form.errors.firstNameRequired'); @@ -257,8 +285,18 @@ export default function BookingPage() { } } + // Validate additional attendees (if multi-ticket) + attendees.forEach((attendee, index) => { + if (!attendee.firstName.trim() || attendee.firstName.length < 2) { + newAttendeeErrors[index] = locale === 'es' + ? 'Ingresa el nombre del asistente' + : 'Enter attendee name'; + } + }); + setErrors(newErrors); - return Object.keys(newErrors).length === 0; + setAttendeeErrors(newAttendeeErrors); + return Object.keys(newErrors).length === 0 && Object.keys(newAttendeeErrors).length === 0; }; // Connect to SSE for real-time payment updates @@ -346,9 +384,20 @@ export default function BookingPage() { const handleMarkPaymentSent = async () => { if (!bookingResult) return; + // Validate payer name if paid under different name + if (paidUnderDifferentName && !payerName.trim()) { + toast.error(locale === 'es' + ? 'Por favor ingresa el nombre del pagador' + : 'Please enter the payer name'); + return; + } + setMarkingPaid(true); try { - await ticketsApi.markPaymentSent(bookingResult.ticketId); + await ticketsApi.markPaymentSent( + bookingResult.ticketId, + paidUnderDifferentName ? payerName.trim() : undefined + ); setStep('pending_approval'); toast.success(locale === 'es' ? 'Pago marcado como enviado. Esperando aprobación.' @@ -366,6 +415,12 @@ export default function BookingPage() { setSubmitting(true); try { + // Build attendees array: first attendee from main form, rest from attendees state + const allAttendees = [ + { firstName: formData.firstName, lastName: formData.lastName }, + ...attendees + ]; + const response = await ticketsApi.book({ eventId: event.id, firstName: formData.firstName, @@ -375,16 +430,24 @@ export default function BookingPage() { preferredLanguage: formData.preferredLanguage, paymentMethod: formData.paymentMethod, ...(formData.ruc.trim() && { ruc: formData.ruc }), + // Include attendees array for multi-ticket bookings + ...(allAttendees.length > 1 && { attendees: allAttendees }), }); - const { ticket, lightningInvoice } = response as any; + const { ticket, tickets: ticketsList, bookingId, lightningInvoice } = response as any; + const ticketCount = ticketsList?.length || 1; + const primaryTicket = ticket || ticketsList?.[0]; // If Lightning payment with invoice, go to paying step if (formData.paymentMethod === 'lightning' && lightningInvoice?.paymentRequest) { const result: BookingResult = { - ticketId: ticket.id, - qrCode: ticket.qrCode, + ticketId: primaryTicket.id, + ticketIds: ticketsList?.map((t: any) => t.id), + bookingId, + qrCode: primaryTicket.qrCode, + qrCodes: ticketsList?.map((t: any) => t.qrCode), paymentMethod: formData.paymentMethod as PaymentMethod, + ticketCount, lightningInvoice: { paymentHash: lightningInvoice.paymentHash, paymentRequest: lightningInvoice.paymentRequest, @@ -399,21 +462,29 @@ export default function BookingPage() { setPaymentPending(true); // Connect to SSE for real-time payment updates - connectPaymentStream(ticket.id); + connectPaymentStream(primaryTicket.id); } else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') { // Manual payment methods - show payment details setBookingResult({ - ticketId: ticket.id, - qrCode: ticket.qrCode, + ticketId: primaryTicket.id, + ticketIds: ticketsList?.map((t: any) => t.id), + bookingId, + qrCode: primaryTicket.qrCode, + qrCodes: ticketsList?.map((t: any) => t.qrCode), paymentMethod: formData.paymentMethod, + ticketCount, }); setStep('manual_payment'); } else { // Cash payment - go straight to success setBookingResult({ - ticketId: ticket.id, - qrCode: ticket.qrCode, + ticketId: primaryTicket.id, + ticketIds: ticketsList?.map((t: any) => t.id), + bookingId, + qrCode: primaryTicket.qrCode, + qrCodes: ticketsList?.map((t: any) => t.qrCode), paymentMethod: formData.paymentMethod, + ticketCount, }); setStep('success'); toast.success(t('booking.success.message')); @@ -592,6 +663,8 @@ export default function BookingPage() { if (step === 'manual_payment' && bookingResult && paymentConfig) { const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer'; const isTpago = bookingResult.paymentMethod === 'tpago'; + const ticketCount = bookingResult.ticketCount || 1; + const totalAmount = (event?.price || 0) * ticketCount; return (
@@ -621,8 +694,13 @@ export default function BookingPage() { {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}

- {event?.price !== undefined ? formatPrice(event.price, event.currency) : ''} + {event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''}

+ {ticketCount > 1 && ( +

+ {ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')} +

+ )}
{/* Bank Transfer Details */} @@ -725,6 +803,45 @@ export default function BookingPage() { + {/* Paid under different name option */} +
+ + + {paidUnderDifferentName && ( +
+ setPayerName(e.target.value)} + placeholder={locale === 'es' ? 'Nombre completo del titular de la cuenta' : 'Full name of account holder'} + required + /> +
+ )} +
+ {/* Warning before I Have Paid button */}

{locale === 'es' @@ -738,6 +855,7 @@ export default function BookingPage() { isLoading={markingPaid} size="lg" className="w-full" + disabled={paidUnderDifferentName && !payerName.trim()} > {locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'} @@ -829,9 +947,30 @@ export default function BookingPage() {

+ {/* Multi-ticket indicator */} + {bookingResult.ticketCount && bookingResult.ticketCount > 1 && ( +
+

+ {locale === 'es' + ? `${bookingResult.ticketCount} tickets reservados` + : `${bookingResult.ticketCount} tickets booked`} +

+

+ {locale === 'es' + ? 'Cada asistente recibirá su propio código QR' + : 'Each attendee will receive their own QR code'} +

+
+ )} +
{bookingResult.qrCode} + {bookingResult.ticketCount && bookingResult.ticketCount > 1 && ( + + +{bookingResult.ticketCount - 1} {locale === 'es' ? 'más' : 'more'} + + )}
@@ -873,6 +1012,25 @@ export default function BookingPage() { {t('booking.success.emailSent')}

+ {/* Download Ticket Button - only for instant confirmation (Lightning) */} + {bookingResult.paymentMethod === 'lightning' && ( + + )} +
@@ -927,7 +1085,25 @@ export default function BookingPage() { ? t('events.details.free') : formatPrice(event.price, event.currency)} + {event.price > 0 && ( + + {locale === 'es' ? 'por persona' : 'per person'} + + )}
+ {/* Ticket quantity and total */} + {ticketQuantity > 1 && ( +
+
+ + {locale === 'es' ? 'Tickets' : 'Tickets'}: {ticketQuantity} + + + {locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)} + +
+
+ )}
@@ -941,8 +1117,18 @@ export default function BookingPage() {
{/* User Information Section */} -

+

+ {attendees.length > 0 && ( + + 1 + + )} {t('booking.form.personalInfo')} + {attendees.length > 0 && ( + + ({locale === 'es' ? 'Asistente principal' : 'Primary attendee'}) + + )}

@@ -1040,6 +1226,74 @@ export default function BookingPage() {
+ {/* Additional Attendees Section (for multi-ticket bookings) */} + {attendees.length > 0 && ( + +

+ + {locale === 'es' ? 'Información de los Otros Asistentes' : 'Other Attendees Information'} +

+

+ {locale === 'es' + ? 'Ingresa el nombre de cada asistente adicional. Cada persona recibirá su propio ticket.' + : 'Enter the name for each additional attendee. Each person will receive their own ticket.'} +

+ +
+ {attendees.map((attendee, index) => ( +
+
+ + {index + 2} + + + {locale === 'es' ? `Asistente ${index + 2}` : `Attendee ${index + 2}`} + +
+
+ { + const newAttendees = [...attendees]; + newAttendees[index].firstName = e.target.value; + setAttendees(newAttendees); + if (attendeeErrors[index]) { + const newErrors = { ...attendeeErrors }; + delete newErrors[index]; + setAttendeeErrors(newErrors); + } + }} + placeholder={t('booking.form.firstNamePlaceholder')} + error={attendeeErrors[index]} + required + /> +
+
+ + + ({locale === 'es' ? 'Opcional' : 'Optional'}) + +
+ { + const newAttendees = [...attendees]; + newAttendees[index].lastName = e.target.value; + setAttendees(newAttendees); + }} + placeholder={t('booking.form.lastNamePlaceholder')} + /> +
+
+
+ ))} +
+
+ )} + {/* Payment Selection Section */}

@@ -1098,45 +1352,6 @@ export default function BookingPage() { ))} - {/* Manual payment instructions - shown when TPago or Bank Transfer is selected */} - {(formData.paymentMethod === 'tpago' || formData.paymentMethod === 'bank_transfer') && ( -
-
-
- - - -
-
-

- {locale === 'es' ? 'Proceso de pago manual' : 'Manual payment process'} -

-
    -
  1. - {locale === 'es' - ? 'Por favor completa el pago primero.' - : 'Please complete the payment first.'} -
  2. -
  3. - {locale === 'es' - ? 'Después de pagar, haz clic en "Ya pagué" para notificarnos.' - : 'After you have paid, click "I have paid" to notify us.'} -
  4. -
  5. - {locale === 'es' - ? 'Nuestro equipo verificará el pago manualmente.' - : 'Our team will manually verify the payment.'} -
  6. -
  7. - {locale === 'es' - ? 'Una vez aprobado, recibirás un email confirmando tu reserva.' - : 'Once approved, you will receive an email confirming your booking.'} -
  8. -
-
-
-
- )} )}

diff --git a/frontend/src/app/(public)/booking/success/[ticketId]/page.tsx b/frontend/src/app/(public)/booking/success/[ticketId]/page.tsx index d121984..43fd1ef 100644 --- a/frontend/src/app/(public)/booking/success/[ticketId]/page.tsx +++ b/frontend/src/app/(public)/booking/success/[ticketId]/page.tsx @@ -229,12 +229,15 @@ export default function BookingSuccessPage() { {isPaid && ( )} diff --git a/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx b/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx index a090c11..647e505 100644 --- a/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx @@ -170,6 +170,20 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) { {language === 'es' ? 'Ver Entrada' : 'View Ticket'} + {(ticket.status === 'confirmed' || ticket.status === 'checked_in') && ( + + + + )} {ticket.invoice && ( (initialEvent); const [mounted, setMounted] = useState(false); + const [ticketQuantity, setTicketQuantity] = useState(1); // Ensure consistent hydration by only rendering dynamic content after mount useEffect(() => { @@ -38,6 +41,17 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail .catch(console.error); }, [eventId]); + // Max tickets is remaining capacity + const maxTickets = Math.max(1, event.availableSeats || 1); + + const decreaseQuantity = () => { + setTicketQuantity(prev => Math.max(1, prev - 1)); + }; + + const increaseQuantity = () => { + setTicketQuantity(prev => Math.min(maxTickets, prev + 1)); + }; + const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { weekday: 'long', @@ -60,6 +74,92 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false; const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published'; + // Booking card content - reused for mobile and desktop positions + const BookingCardContent = () => ( + <> +
+

{t('events.details.price')}

+

+ {event.price === 0 + ? t('events.details.free') + : formatPrice(event.price, event.currency)} +

+ {event.price > 0 && ( +

+ {locale === 'es' ? 'por persona' : 'per person'} +

+ )} +
+ + {/* Ticket Quantity Selector */} + {canBook && !event.externalBookingEnabled && ( +
+ +
+ + {ticketQuantity} + +
+ {ticketQuantity > 1 && event.price > 0 && ( +

+ {locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)} +

+ )} +
+ )} + + {canBook ? ( + event.externalBookingEnabled && event.externalBookingUrl ? ( +
+ + + ) : ( + + + + ) + ) : ( + + )} + + {!event.externalBookingEnabled && ( +

+ {event.availableSeats} {t('events.details.spotsLeft')} +

+ )} + + ); + return (
@@ -73,157 +173,128 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
{/* Event Details */} -
+
+ {/* Top section: Image + Event Info side by side on desktop */} - {/* Banner - LCP element, loaded with high priority */} - {/* Using unoptimized for backend-served images via /uploads/ rewrite */} - {event.bannerUrl ? ( -
- {`${event.title} -
- ) : ( -
- -
- )} - -
-
-

- {locale === 'es' && event.titleEs ? event.titleEs : event.title} -

- {isCancelled && ( - {t('events.details.cancelled')} - )} - {isSoldOut && !isCancelled && ( - {t('events.details.soldOut')} - )} -
+
+ {/* Image - smaller on desktop, side by side */} + {event.bannerUrl ? ( +
+ {`${event.title} +
+ ) : ( +
+ +
+ )} -
-
- -
-

{t('events.details.date')}

-

{formatDate(event.startDatetime)}

-
-
- -
- -
-

{t('events.details.time')}

-

- {formatTime(event.startDatetime)} - {event.endDatetime && ` - ${formatTime(event.endDatetime)}`} -

-
-
- -
- -
-

{t('events.details.location')}

-

{event.location}

- {event.locationUrl && ( - - View on map - + {/* Event title and key info */} +
+
+

+ {locale === 'es' && event.titleEs ? event.titleEs : event.title} +

+
+ {isCancelled && ( + {t('events.details.cancelled')} + )} + {isSoldOut && !isCancelled && ( + {t('events.details.soldOut')} )}
- {!event.externalBookingEnabled && ( +
- +
-

{t('events.details.capacity')}

-

- {event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} +

{t('events.details.date')}

+

{formatDate(event.startDatetime)}

+
+
+ +
+ +
+

{t('events.details.time')}

+

+ {formatTime(event.startDatetime)} + {event.endDatetime && ` - ${formatTime(event.endDatetime)}`}

- )} -
- -
-

About this event

-

- {locale === 'es' && event.descriptionEs - ? event.descriptionEs - : event.description} -

-
- - {/* Social Sharing */} -
- + +
+ +
+

{t('events.details.location')}

+

{event.location}

+ {event.locationUrl && ( + + View on map + + )} +
+
+ + {!event.externalBookingEnabled && ( +
+ +
+

{t('events.details.capacity')}

+

+ {event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} +

+
+
+ )} +
+ + {/* Mobile Booking Card - shown between event details and description on mobile */} + + + + + {/* Description section - separate card below */} + +

About this event

+

+ {locale === 'es' && event.descriptionEs + ? event.descriptionEs + : event.description} +

+ + {/* Social Sharing */} +
+ +
+
- {/* Booking Card */} -
+ {/* Desktop Booking Card - hidden on mobile, shown in sidebar on desktop */} +
-
-

{t('events.details.price')}

-

- {event.price === 0 - ? t('events.details.free') - : formatPrice(event.price, event.currency)} -

-
- - {canBook ? ( - event.externalBookingEnabled && event.externalBookingUrl ? ( - - - - ) : ( - - - - ) - ) : ( - - )} - - {!event.externalBookingEnabled && ( -

- {event.availableSeats} {t('events.details.spotsLeft')} -

- )} +
diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index c5666f9..b346dc6 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -18,6 +18,7 @@ import { import toast from 'react-hot-toast'; interface TicketWithDetails extends Omit { + bookingId?: string; event?: Event; payment?: { id: string; @@ -194,6 +195,23 @@ export default function AdminBookingsPage() { pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length, }; + // Helper to get booking info for a ticket (ticket count and total) + const getBookingInfo = (ticket: TicketWithDetails) => { + if (!ticket.bookingId) { + return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) }; + } + + // Count all tickets with the same bookingId + const bookingTickets = tickets.filter( + t => t.bookingId === ticket.bookingId + ); + + return { + ticketCount: bookingTickets.length, + bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 0), + }; + }; + if (loading) { return (
@@ -309,7 +327,9 @@ export default function AdminBookingsPage() { ) : ( - sortedTickets.map((ticket) => ( + sortedTickets.map((ticket) => { + const bookingInfo = getBookingInfo(ticket); + return (
@@ -341,9 +361,16 @@ export default function AdminBookingsPage() { {getPaymentMethodLabel(ticket.payment?.provider || 'cash')}

{ticket.payment && ( -

- {ticket.payment.amount?.toLocaleString()} {ticket.payment.currency} -

+
+

+ {bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency} +

+ {bookingInfo.ticketCount > 1 && ( +

+ 📦 {bookingInfo.ticketCount} × {Number(ticket.payment.amount).toLocaleString()} {ticket.payment.currency} +

+ )} +
)}
@@ -354,6 +381,11 @@ export default function AdminBookingsPage() { {ticket.qrCode && (

{ticket.qrCode}

)} + {ticket.bookingId && ( +

+ 📦 Group Booking +

+ )} {formatDate(ticket.createdAt)} @@ -415,7 +447,8 @@ export default function AdminBookingsPage() {
- )) + ); + }) )} diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 193c034..9a34843 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -678,6 +678,11 @@ export default function AdminEventDetailPage() {

{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}

ID: {ticket.id.slice(0, 8)}...

+ {ticket.bookingId && ( +

+ 📦 {locale === 'es' ? 'Reserva grupal' : 'Group booking'} +

+ )}

{ticket.attendeeEmail}

diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index 84c192a..da18b7a 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -230,13 +230,69 @@ export default function AdminPaymentsPage() { return labels[provider] || provider; }; - // Calculate totals + // Helper to get booking info for a payment (ticket count and total) + const getBookingInfo = (payment: PaymentWithDetails) => { + if (!payment.ticket?.bookingId) { + return { ticketCount: 1, bookingTotal: payment.amount }; + } + + // Count all payments with the same bookingId + const bookingPayments = payments.filter( + p => p.ticket?.bookingId === payment.ticket?.bookingId + ); + + return { + ticketCount: bookingPayments.length, + bookingTotal: bookingPayments.reduce((sum, p) => sum + Number(p.amount), 0), + }; + }; + + // Get booking info for pending approval payments + const getPendingBookingInfo = (payment: PaymentWithDetails) => { + if (!payment.ticket?.bookingId) { + return { ticketCount: 1, bookingTotal: payment.amount }; + } + + // Count all pending payments with the same bookingId + const bookingPayments = pendingApprovalPayments.filter( + p => p.ticket?.bookingId === payment.ticket?.bookingId + ); + + return { + ticketCount: bookingPayments.length, + bookingTotal: bookingPayments.reduce((sum, p) => sum + Number(p.amount), 0), + }; + }; + + // Calculate totals (sum all individual payment amounts) const totalPending = payments .filter(p => p.status === 'pending' || p.status === 'pending_approval') - .reduce((sum, p) => sum + p.amount, 0); + .reduce((sum, p) => sum + Number(p.amount), 0); const totalPaid = payments .filter(p => p.status === 'paid') - .reduce((sum, p) => sum + p.amount, 0); + .reduce((sum, p) => sum + Number(p.amount), 0); + + // Get unique booking count (for summary display) + const getUniqueBookingsCount = (paymentsList: PaymentWithDetails[]) => { + const seen = new Set(); + let count = 0; + paymentsList.forEach(p => { + const bookingKey = p.ticket?.bookingId || p.id; + if (!seen.has(bookingKey)) { + seen.add(bookingKey); + count++; + } + }); + return count; + }; + + const pendingBookingsCount = getUniqueBookingsCount( + payments.filter(p => p.status === 'pending' || p.status === 'pending_approval') + ); + const paidBookingsCount = getUniqueBookingsCount( + payments.filter(p => p.status === 'paid') + ); + const pendingApprovalBookingsCount = getUniqueBookingsCount(pendingApprovalPayments); if (loading) { return ( @@ -257,7 +313,9 @@ export default function AdminPaymentsPage() {
{/* Approval Detail Modal */} - {selectedPayment && ( + {selectedPayment && (() => { + const modalBookingInfo = getBookingInfo(selectedPayment); + return (

@@ -268,8 +326,15 @@ export default function AdminPaymentsPage() {
-

{locale === 'es' ? 'Monto' : 'Amount'}

-

{formatCurrency(selectedPayment.amount, selectedPayment.currency)}

+

{locale === 'es' ? 'Monto Total' : 'Total Amount'}

+

{formatCurrency(modalBookingInfo.bookingTotal, selectedPayment.currency)}

+ {modalBookingInfo.ticketCount > 1 && ( +
+

+ 📦 {modalBookingInfo.ticketCount} tickets × {formatCurrency(selectedPayment.amount, selectedPayment.currency)} +

+
+ )}

{locale === 'es' ? 'Método' : 'Method'}

@@ -309,6 +374,15 @@ export default function AdminPaymentsPage() {
)} + {selectedPayment.payerName && ( +
+

+ {locale === 'es' ? '⚠️ Pagado por otra persona:' : '⚠️ Paid by someone else:'} +

+

{selectedPayment.payerName}

+
+ )} +
- )} + ); + })()} {/* Export Modal */} {showExportModal && ( @@ -481,7 +556,10 @@ export default function AdminPaymentsPage() {

{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}

-

{pendingApprovalPayments.length}

+

{pendingApprovalBookingsCount}

+ {pendingApprovalPayments.length !== pendingApprovalBookingsCount && ( +

({pendingApprovalPayments.length} tickets)

+ )}
@@ -493,6 +571,7 @@ export default function AdminPaymentsPage() {

{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}

{formatCurrency(totalPending, 'PYG')}

+

{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}

@@ -504,6 +583,7 @@ export default function AdminPaymentsPage() {

{locale === 'es' ? 'Total Pagado' : 'Total Paid'}

{formatCurrency(totalPaid, 'PYG')}

+

{paidBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}

@@ -513,7 +593,7 @@ export default function AdminPaymentsPage() {
-

{locale === 'es' ? 'Total Pagos' : 'Total Payments'}

+

{locale === 'es' ? 'Total Tickets' : 'Total Tickets'}

{payments.length}

@@ -565,46 +645,60 @@ export default function AdminPaymentsPage() { ) : (
- {pendingApprovalPayments.map((payment) => ( - -
-
-
- {getProviderIcon(payment.provider)} -
-
-
-

{formatCurrency(payment.amount, payment.currency)}

- {getStatusBadge(payment.status)} + {pendingApprovalPayments.map((payment) => { + const bookingInfo = getPendingBookingInfo(payment); + return ( + +
+
+
+ {getProviderIcon(payment.provider)}
- {payment.ticket && ( -

- {payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName} -

- )} - {payment.event && ( -

{payment.event.title}

- )} -
- - {getProviderIcon(payment.provider)} - {getProviderLabel(payment.provider)} - - {payment.userMarkedPaidAt && ( +
+
+

{formatCurrency(bookingInfo.bookingTotal, payment.currency)}

+ {bookingInfo.ticketCount > 1 && ( + + 📦 {bookingInfo.ticketCount} tickets × {formatCurrency(payment.amount, payment.currency)} + + )} + {getStatusBadge(payment.status)} +
+ {payment.ticket && ( +

+ {payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName} + {bookingInfo.ticketCount > 1 && +{bookingInfo.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}} +

+ )} + {payment.event && ( +

{payment.event.title}

+ )} +
- - {locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)} + {getProviderIcon(payment.provider)} + {getProviderLabel(payment.provider)} + {payment.userMarkedPaidAt && ( + + + {locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)} + + )} +
+ {payment.payerName && ( +

+ ⚠️ {locale === 'es' ? 'Pago por:' : 'Paid by:'} {payment.payerName} +

)}
+
- -
-
- ))} + + ); + })}
)} @@ -671,67 +765,89 @@ export default function AdminPaymentsPage() { ) : ( - payments.map((payment) => ( - - - {payment.ticket ? ( + payments.map((payment) => { + const bookingInfo = getBookingInfo(payment); + return ( + + + {payment.ticket ? ( +
+

+ {payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName} +

+

{payment.ticket.attendeeEmail}

+ {payment.payerName && ( +

+ ⚠️ {locale === 'es' ? 'Pagado por:' : 'Paid by:'} {payment.payerName} +

+ )} +
+ ) : ( + - + )} + + + {payment.event ? ( +

{payment.event.title}

+ ) : ( + - + )} + +
-

- {payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName} -

-

{payment.ticket.attendeeEmail}

+

{formatCurrency(bookingInfo.bookingTotal, payment.currency)}

+ {bookingInfo.ticketCount > 1 && ( +

+ 📦 {bookingInfo.ticketCount} × {formatCurrency(payment.amount, payment.currency)} +

+ )}
- ) : ( - - - )} - - - {payment.event ? ( -

{payment.event.title}

- ) : ( - - - )} - - - {formatCurrency(payment.amount, payment.currency)} - - -
- {getProviderIcon(payment.provider)} - {getProviderLabel(payment.provider)} -
- - - {formatDate(payment.createdAt)} - - - {getStatusBadge(payment.status)} - - -
- {(payment.status === 'pending' || payment.status === 'pending_approval') && ( - - )} - {payment.status === 'paid' && ( - - )} -
- - - )) + + +
+ {getProviderIcon(payment.provider)} + {getProviderLabel(payment.provider)} +
+ + + {formatDate(payment.createdAt)} + + +
+ {getStatusBadge(payment.status)} + {payment.ticket?.bookingId && ( +

+ 📦 {locale === 'es' ? 'Grupo' : 'Group'} +

+ )} +
+ + +
+ {(payment.status === 'pending' || payment.status === 'pending_approval') && ( + + )} + {payment.status === 'paid' && ( + + )} +
+ + + ); + }) )} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c9681e7..fa6d1ad 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -119,6 +119,8 @@ "nameRequired": "Please enter your full name", "firstNameRequired": "Please enter your first name", "lastNameRequired": "Please enter your last name", + "lastNameTooShort": "Last name must be at least 2 characters", + "phoneTooShort": "Phone number must be at least 6 digits", "emailInvalid": "Please enter a valid email address", "phoneRequired": "Phone number is required", "bookingFailed": "Booking failed. Please try again.", @@ -177,12 +179,13 @@ "button": "Follow Us" }, "guidelines": { - "title": "Community Guidelines", + "title": "Community Rules", "items": [ - "Be respectful to all participants", - "Help others practice - we're all learning", - "Speak in the language you're practicing", - "Have fun and be open to making new friends" + "Respect above all. Treat others the way you would like to be treated.", + "We are all learning, let's help each other practice.", + "Use this space to practice the event languages, mistakes are part of the process.", + "Keep an open attitude to meet new people and have fun.", + "This is a space to connect, please avoid spam and unsolicited promotions." ] }, "volunteer": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index d4b72b9..2d563ed 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -119,6 +119,8 @@ "nameRequired": "Por favor ingresa tu nombre completo", "firstNameRequired": "Por favor ingresa tu nombre", "lastNameRequired": "Por favor ingresa tu apellido", + "lastNameTooShort": "El apellido debe tener al menos 2 caracteres", + "phoneTooShort": "El teléfono debe tener al menos 6 dígitos", "emailInvalid": "Por favor ingresa un correo electrónico válido", "phoneRequired": "El número de teléfono es requerido", "bookingFailed": "La reserva falló. Por favor intenta de nuevo.", @@ -158,37 +160,38 @@ "subtitle": "Conéctate con nosotros en redes sociales", "whatsapp": { "title": "Grupo de WhatsApp", - "description": "Únete a nuestro grupo de WhatsApp para actualizaciones y chat comunitario", + "description": "Sumate a nuestro grupo de WhatsApp para recibir novedades y conversar con la comunidad.", "button": "Unirse a WhatsApp" }, "instagram": { "title": "Instagram", - "description": "Síguenos para fotos, historias y anuncios", - "button": "Seguirnos" + "description": "Seguinos en Instagram para ver fotos, historias y momentos del Spanglish.", + "button": "Seguir en Instagram" }, "telegram": { "title": "Canal de Telegram", - "description": "Únete a nuestro canal de Telegram para noticias y anuncios", + "description": "Seguinos en nuestro canal de Telegram para recibir noticias y anuncios de próximos eventos.", "button": "Unirse a Telegram" }, "tiktok": { "title": "TikTok", - "description": "Mira nuestros videos y síguenos para contenido divertido", - "button": "Seguirnos" + "description": "Mirá nuestros videos y viví la experiencia Spanglish.", + "button": "Seguir en TikTok" }, "guidelines": { - "title": "Reglas de la Comunidad", + "title": "Normas de la comunidad", "items": [ - "Sé respetuoso con todos los participantes", - "Ayuda a otros a practicar - todos estamos aprendiendo", - "Habla en el idioma que estás practicando", - "Diviértete y abierto a hacer nuevos amigos" + "Respeto ante todo. Tratemos a los demás como nos gustaría que nos traten.", + "Todos estamos aprendiendo, ayudemos a otros a practicar.", + "Aprovechemos este espacio para usar los idiomas del evento, sin miedo al éxito.", + "Mantengamos una actitud abierta para conocer personas y pasarla bien.", + "Este es un espacio para conectar, evitemos el spam y las promociones no solicitadas." ] }, "volunteer": { - "title": "Conviértete en Voluntario", - "description": "Ayúdanos a organizar eventos y hacer crecer la comunidad", - "button": "Contáctanos" + "title": "Sumate como voluntario/a", + "description": "Ayudanos a organizar los encuentros y a hacer crecer la comunidad Spanglish.", + "button": "Contactanos" } }, "contact": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e74aa80..401f813 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -124,9 +124,10 @@ export const ticketsApi = { }), // For manual payment methods (bank_transfer, tpago) - user marks payment as sent - markPaymentSent: (id: string) => + markPaymentSent: (id: string, payerName?: string) => fetchApi<{ payment: Payment; message: string }>(`/api/tickets/${id}/mark-payment-sent`, { method: 'POST', + body: JSON.stringify({ payerName }), }), adminCreate: (data: { @@ -444,12 +445,14 @@ export interface Event { export interface Ticket { id: string; + bookingId?: string; // Groups multiple tickets from same booking userId: string; eventId: string; attendeeFirstName: string; attendeeLastName?: string; attendeeEmail?: string; attendeePhone?: string; + attendeeRuc?: string; preferredLanguage?: string; status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in'; checkinAt?: string; @@ -494,6 +497,7 @@ export interface Payment { status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed'; reference?: string; userMarkedPaidAt?: string; + payerName?: string; // Name of payer if different from attendee paidAt?: string; paidByAdminId?: string; adminNote?: string; @@ -504,6 +508,7 @@ export interface Payment { export interface PaymentWithDetails extends Payment { ticket: { id: string; + bookingId?: string; attendeeFirstName: string; attendeeLastName?: string; attendeeEmail?: string; @@ -560,6 +565,11 @@ export interface Contact { createdAt: string; } +export interface AttendeeData { + firstName: string; + lastName?: string; +} + export interface BookingData { eventId: string; firstName: string; @@ -569,6 +579,8 @@ export interface BookingData { preferredLanguage?: 'en' | 'es'; paymentMethod: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago'; ruc?: string; + // For multi-ticket bookings + attendees?: AttendeeData[]; } export interface DashboardData {