Backend and frontend updates: auth, email, payments, events, tickets; carrousel images; mobile event detail layout; i18n

This commit is contained in:
Michilis
2026-02-02 20:58:21 +00:00
parent bafd1425c4
commit 4a84ad22c7
44 changed files with 1323 additions and 472 deletions

View File

@@ -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)`); 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 */ } } 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) // 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 // SQLite doesn't support altering column constraints, so we'll just ensure new entries work
@@ -201,6 +206,9 @@ async function migrate() {
try { try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN admin_note TEXT`); await (db as any).run(sql`ALTER TABLE payments ADD COLUMN admin_note TEXT`);
} catch (e) { /* column may already exist */ } } 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 // Invoices table
await (db as any).run(sql` await (db as any).run(sql`
@@ -535,6 +543,11 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id UUID REFERENCES users(id)`); 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 */ } } 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` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS payments ( CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -545,6 +558,7 @@ async function migrate() {
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
reference VARCHAR(255), reference VARCHAR(255),
user_marked_paid_at TIMESTAMP, user_marked_paid_at TIMESTAMP,
payer_name VARCHAR(255),
paid_at TIMESTAMP, paid_at TIMESTAMP,
paid_by_admin_id UUID, paid_by_admin_id UUID,
admin_note TEXT, admin_note TEXT,
@@ -553,6 +567,11 @@ async function migrate() {
) )
`); `);
// 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 // Invoices table
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS invoices ( CREATE TABLE IF NOT EXISTS invoices (

View File

@@ -85,6 +85,7 @@ export const sqliteEvents = sqliteTable('events', {
export const sqliteTickets = sqliteTable('tickets', { export const sqliteTickets = sqliteTable('tickets', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
bookingId: text('booking_id'), // Groups multiple tickets from same booking
userId: text('user_id').notNull().references(() => sqliteUsers.id), userId: text('user_id').notNull().references(() => sqliteUsers.id),
eventId: text('event_id').notNull().references(() => sqliteEvents.id), eventId: text('event_id').notNull().references(() => sqliteEvents.id),
attendeeFirstName: text('attendee_first_name').notNull(), 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'), status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
reference: text('reference'), reference: text('reference'),
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid" 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'), paidAt: text('paid_at'),
paidByAdminId: text('paid_by_admin_id'), paidByAdminId: text('paid_by_admin_id'),
adminNote: text('admin_note'), // Internal admin notes adminNote: text('admin_note'), // Internal admin notes
@@ -371,6 +373,7 @@ export const pgEvents = pgTable('events', {
export const pgTickets = pgTable('tickets', { export const pgTickets = pgTable('tickets', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
bookingId: uuid('booking_id'), // Groups multiple tickets from same booking
userId: uuid('user_id').notNull().references(() => pgUsers.id), userId: uuid('user_id').notNull().references(() => pgUsers.id),
eventId: uuid('event_id').notNull().references(() => pgEvents.id), eventId: uuid('event_id').notNull().references(() => pgEvents.id),
attendeeFirstName: varchar('attendee_first_name', { length: 255 }).notNull(), 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'), status: varchar('status', { length: 20 }).notNull().default('pending'),
reference: varchar('reference', { length: 255 }), reference: varchar('reference', { length: 255 }),
userMarkedPaidAt: timestamp('user_marked_paid_at'), userMarkedPaidAt: timestamp('user_marked_paid_at'),
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
paidAt: timestamp('paid_at'), paidAt: timestamp('paid_at'),
paidByAdminId: uuid('paid_by_admin_id'), paidByAdminId: uuid('paid_by_admin_id'),
adminNote: pgText('admin_note'), adminNote: pgText('admin_note'),

View File

@@ -5,7 +5,7 @@ import crypto from 'crypto';
import { Context } from 'hono'; import { Context } from 'hono';
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js'; import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
import { eq, and, gt } from 'drizzle-orm'; 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_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
const JWT_ISSUER = 'spanglish'; const JWT_ISSUER = 'spanglish';
@@ -51,7 +51,7 @@ export async function createMagicLinkToken(
): Promise<string> { ): Promise<string> {
const token = generateSecureToken(); const token = generateSecureToken();
const now = getNow(); 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({ await (db as any).insert(magicLinkTokens).values({
id: generateId(), id: generateId(),
@@ -113,7 +113,7 @@ export async function createUserSession(
): Promise<string> { ): Promise<string> {
const sessionToken = generateSecureToken(); const sessionToken = generateSecureToken();
const now = getNow(); 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({ await (db as any).insert(userSessions).values({
id: generateId(), id: generateId(),

View File

@@ -522,6 +522,7 @@ export const emailService = {
/** /**
* Send booking confirmation email * Send booking confirmation email
* Supports multi-ticket bookings - includes all tickets in the booking
*/ */
async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> { async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket with event info // Get ticket with event info
@@ -547,14 +548,37 @@ export const emailService = {
return { success: false, error: 'Event not found' }; 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 locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; 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 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(); 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({ return this.sendTemplateEmail({
templateSlug: 'booking-confirmation', templateSlug: 'booking-confirmation',
to: ticket.attendeeEmail, to: ticket.attendeeEmail,
@@ -565,6 +589,7 @@ export const emailService = {
attendeeName: attendeeFullName, attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id, ticketId: ticket.id,
bookingId: ticket.bookingId || ticket.id,
qrCode: ticket.qrCode || '', qrCode: ticket.qrCode || '',
ticketPdfUrl, ticketPdfUrl,
eventTitle, eventTitle,
@@ -573,6 +598,11 @@ export const emailService = {
eventLocation: event.location, eventLocation: event.location,
eventLocationUrl: event.locationUrl || '', eventLocationUrl: event.locationUrl || '',
eventPrice: this.formatCurrency(event.price, event.currency), 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' }; 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<any>(
(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<any>((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 locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const paymentMethodNames: Record<string, Record<string, string>> = { const paymentMethodNames: Record<string, Record<string, string>> = {
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash' }, en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo' }, es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
}; };
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); 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({ return this.sendTemplateEmail({
templateSlug: 'payment-receipt', templateSlug: 'payment-receipt',
to: ticket.attendeeEmail, to: ticket.attendeeEmail,
@@ -632,10 +695,10 @@ export const emailService = {
eventId: event.id, eventId: event.id,
variables: { variables: {
attendeeName: receiptFullName, attendeeName: receiptFullName,
ticketId: ticket.id, ticketId: ticket.bookingId || ticket.id,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale),
paymentAmount: this.formatCurrency(payment.amount, payment.currency), paymentAmount: amountDisplay,
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider, paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
paymentReference: payment.reference || payment.id, paymentReference: payment.reference || payment.id,
paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale), 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 eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Generate a payment reference using ticket ID // Calculate total price for multi-ticket bookings
const paymentReference = `SPG-${ticket.id.substring(0, 8).toUpperCase()}`; let totalPrice = event.price;
let ticketCount = 1;
if (ticket.bookingId) {
// Count all tickets in this booking
const bookingTickets = await dbAll<any>(
(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 // Generate the booking URL for returning to payment page
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com'; const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
@@ -762,17 +841,22 @@ export const emailService = {
? 'payment-instructions-tpago' ? 'payment-instructions-tpago'
: 'payment-instructions-bank-transfer'; : '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 // Build variables based on payment method
const variables: Record<string, any> = { const variables: Record<string, any> = {
attendeeName: attendeeFullName, attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id, ticketId: ticket.bookingId || ticket.id,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale),
eventTime: this.formatTime(event.startDatetime, locale), eventTime: this.formatTime(event.startDatetime, locale),
eventLocation: event.location, eventLocation: event.location,
eventLocationUrl: event.locationUrl || '', eventLocationUrl: event.locationUrl || '',
paymentAmount: this.formatCurrency(event.price, event.currency), paymentAmount: amountDisplay,
paymentReference, paymentReference,
bookingUrl, bookingUrl,
}; };

View File

@@ -74,7 +74,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.where(eq((payments as any).status, 'paid')) .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<any>( const newContacts = await dbGet<any>(
(db as any) (db as any)

View File

@@ -136,6 +136,8 @@ eventsRouter.get('/', async (c) => {
// Get ticket counts for each event // Get ticket counts for each event
const eventsWithCounts = await Promise.all( const eventsWithCounts = await Promise.all(
result.map(async (event: any) => { 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<any>( const ticketCount = await dbGet<any>(
(db as any) (db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
@@ -143,7 +145,7 @@ eventsRouter.get('/', async (c) => {
.where( .where(
and( and(
eq((tickets as any).eventId, event.id), 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); 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<any>( const ticketCount = await dbGet<any>(
(db as any) (db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
@@ -180,7 +183,7 @@ eventsRouter.get('/:id', async (c) => {
.where( .where(
and( and(
eq((tickets as any).eventId, id), 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 }); 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<any>( const ticketCount = await dbGet<any>(
(db as any) (db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
@@ -224,7 +229,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
.where( .where(
and( and(
eq((tickets as any).eventId, event.id), eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed') sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
) )
) )
); );

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming'; 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 { eq } from 'drizzle-orm';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js'; import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
@@ -152,25 +152,47 @@ lnbitsRouter.post('/webhook', async (c) => {
/** /**
* Handle successful payment * Handle successful payment
* Supports multi-ticket bookings - confirms all tickets in the booking
*/ */
async function handlePaymentComplete(ticketId: string, paymentHash: string) { async function handlePaymentComplete(ticketId: string, paymentHash: string) {
const now = getNow(); const now = getNow();
// Check if already confirmed to avoid duplicate updates // Get the ticket to check for booking ID
const existingTicket = await dbGet<any>( const existingTicket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) (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`); console.log(`Ticket ${ticketId} already confirmed, skipping update`);
return; return;
} }
// Get all tickets in this booking (if multi-ticket)
let ticketsToConfirm: any[] = [existingTicket];
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`);
}
// Confirm all tickets in the booking
for (const ticket of ticketsToConfirm) {
// Update ticket status to confirmed // Update ticket status to confirmed
await (db as any) await (db as any)
.update(tickets) .update(tickets)
.set({ status: 'confirmed' }) .set({ status: 'confirmed' })
.where(eq((tickets as any).id, ticketId)); .where(eq((tickets as any).id, ticket.id));
// Update payment status to paid // Update payment status to paid
await (db as any) await (db as any)
@@ -181,11 +203,12 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
paidAt: now, paidAt: now,
updatedAt: now, updatedAt: now,
}) })
.where(eq((payments as any).ticketId, ticketId)); .where(eq((payments as any).ticketId, ticket.id));
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`); 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<any>( const payment = await dbGet<any>(
(db as any) (db as any)
.select() .select()
@@ -194,6 +217,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
); );
// Send confirmation emails asynchronously // Send confirmation emails asynchronously
// For multi-ticket bookings, send email with all ticket info
Promise.all([ Promise.all([
emailService.sendBookingConfirmation(ticketId), emailService.sendBookingConfirmation(ticketId),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(), payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),

View File

@@ -8,13 +8,19 @@ import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
const paymentOptionsRouter = new Hono(); 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 // Schema for updating global payment options
const updatePaymentOptionsSchema = z.object({ const updatePaymentOptionsSchema = z.object({
tpagoEnabled: z.boolean().optional(), tpagoEnabled: booleanOrNumber.optional(),
tpagoLink: z.string().optional().nullable(), tpagoLink: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(), tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(), tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: z.boolean().optional(), bankTransferEnabled: booleanOrNumber.optional(),
bankName: z.string().optional().nullable(), bankName: z.string().optional().nullable(),
bankAccountHolder: z.string().optional().nullable(), bankAccountHolder: z.string().optional().nullable(),
bankAccountNumber: z.string().optional().nullable(), bankAccountNumber: z.string().optional().nullable(),
@@ -22,21 +28,21 @@ const updatePaymentOptionsSchema = z.object({
bankPhone: z.string().optional().nullable(), bankPhone: z.string().optional().nullable(),
bankNotes: z.string().optional().nullable(), bankNotes: z.string().optional().nullable(),
bankNotesEs: z.string().optional().nullable(), bankNotesEs: z.string().optional().nullable(),
lightningEnabled: z.boolean().optional(), lightningEnabled: booleanOrNumber.optional(),
cashEnabled: z.boolean().optional(), cashEnabled: booleanOrNumber.optional(),
cashInstructions: z.string().optional().nullable(), cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(),
// Booking settings // Booking settings
allowDuplicateBookings: z.boolean().optional(), allowDuplicateBookings: booleanOrNumber.optional(),
}); });
// Schema for event-level overrides // Schema for event-level overrides
const updateEventOverridesSchema = z.object({ const updateEventOverridesSchema = z.object({
tpagoEnabled: z.boolean().optional().nullable(), tpagoEnabled: booleanOrNumber.optional().nullable(),
tpagoLink: z.string().optional().nullable(), tpagoLink: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(), tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(), tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: z.boolean().optional().nullable(), bankTransferEnabled: booleanOrNumber.optional().nullable(),
bankName: z.string().optional().nullable(), bankName: z.string().optional().nullable(),
bankAccountHolder: z.string().optional().nullable(), bankAccountHolder: z.string().optional().nullable(),
bankAccountNumber: z.string().optional().nullable(), bankAccountNumber: z.string().optional().nullable(),
@@ -44,8 +50,8 @@ const updateEventOverridesSchema = z.object({
bankPhone: z.string().optional().nullable(), bankPhone: z.string().optional().nullable(),
bankNotes: z.string().optional().nullable(), bankNotes: z.string().optional().nullable(),
bankNotesEs: z.string().optional().nullable(), bankNotesEs: z.string().optional().nullable(),
lightningEnabled: z.boolean().optional().nullable(), lightningEnabled: booleanOrNumber.optional().nullable(),
cashEnabled: z.boolean().optional().nullable(), cashEnabled: booleanOrNumber.optional().nullable(),
cashInstructions: z.string().optional().nullable(), cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(),
}); });

View File

@@ -76,6 +76,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
...payment, ...payment,
ticket: ticket ? { ticket: ticket ? {
id: ticket.id, id: ticket.id,
bookingId: ticket.bookingId,
attendeeFirstName: ticket.attendeeFirstName, attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName, attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
@@ -128,6 +129,7 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
...payment, ...payment,
ticket: ticket ? { ticket: ticket ? {
id: ticket.id, id: ticket.id,
bookingId: ticket.bookingId,
attendeeFirstName: ticket.attendeeFirstName, attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName, attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
@@ -199,17 +201,42 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
updateData.paidByAdminId = user.id; updateData.paidByAdminId = user.id;
} }
// If payment confirmed, handle multi-ticket booking
if (data.status === 'paid') {
// Get the ticket associated with this payment
const ticket = await dbGet<any>(
(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) await (db as any)
.update(payments) .update(payments)
.set(updateData) .set(updateData)
.where(eq((payments as any).id, id)); .where(eq((payments as any).ticketId, (t as any).id));
// If payment confirmed, update ticket status and send emails
if (data.status === 'paid') {
await (db as any) await (db as any)
.update(tickets) .update(tickets)
.set({ status: 'confirmed' }) .set({ status: 'confirmed' })
.where(eq((tickets as any).id, existing.ticketId)); .where(eq((tickets as any).id, (t as any).id));
}
// Send confirmation emails asynchronously (don't block the response) // Send confirmation emails asynchronously (don't block the response)
Promise.all([ Promise.all([
@@ -218,6 +245,12 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
]).catch(err => { ]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', 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( const updated = await dbGet(
@@ -254,7 +287,30 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
const now = getNow(); const now = getNow();
// Update payment status to paid // Get the ticket associated with this payment
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.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) await (db as any)
.update(payments) .update(payments)
.set({ .set({
@@ -264,13 +320,14 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
adminNote: adminNote || payment.adminNote, adminNote: adminNote || payment.adminNote,
updatedAt: now, updatedAt: now,
}) })
.where(eq((payments as any).id, id)); .where(eq((payments as any).ticketId, (t as any).id));
// Update ticket status to confirmed // Update ticket status to confirmed
await (db as any) await (db as any)
.update(tickets) .update(tickets)
.set({ status: 'confirmed' }) .set({ status: 'confirmed' })
.where(eq((tickets as any).id, payment.ticketId)); .where(eq((tickets as any).id, (t as any).id));
}
// Send confirmation emails asynchronously // Send confirmation emails asynchronously
Promise.all([ Promise.all([
@@ -453,7 +510,7 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
failed: allPayments.filter((p: any) => p.status === 'failed').length, failed: allPayments.filter((p: any) => p.status === 'failed').length,
totalRevenue: allPayments totalRevenue: allPayments
.filter((p: any) => p.status === 'paid') .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 }); return c.json({ stats });

View File

@@ -7,10 +7,16 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow } from '../lib/utils.js'; import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
import { generateTicketPDF } from '../lib/pdf.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
const ticketsRouter = new Hono(); 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({ const createTicketSchema = z.object({
eventId: z.string(), eventId: z.string(),
firstName: z.string().min(2), firstName: z.string().min(2),
@@ -20,6 +26,8 @@ const createTicketSchema = z.object({
preferredLanguage: z.enum(['en', 'es']).optional(), preferredLanguage: z.enum(['en', 'es']).optional(),
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'), 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(), 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({ const updateTicketSchema = z.object({
@@ -42,10 +50,17 @@ const adminCreateTicketSchema = z.object({
adminNote: z.string().max(1000).optional(), 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) => { ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const data = c.req.valid('json'); 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 // Get event
const event = await dbGet<any>( const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, data.eventId)) (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); return c.json({ error: 'Event is not available for booking' }, 400);
} }
// Check capacity // Check capacity - count confirmed AND checked_in tickets
const ticketCount = await dbGet<any>( // (checked_in were previously confirmed, check-in doesn't affect capacity)
const existingTicketCount = await dbGet<any>(
(db as any) (db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(tickets) .from(tickets)
.where( .where(
and( and(
eq((tickets as any).eventId, data.eventId), 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); 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 // Find or create user
let user = await dbGet<any>( let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email)) (db as any).select().from(users).where(eq((users as any).email, data.email))
@@ -129,32 +153,39 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
} }
} }
// Create ticket // Generate booking ID to group multiple tickets
const bookingId = generateId();
// Create tickets for each attendee
const createdTickets: any[] = [];
const createdPayments: any[] = [];
for (let i = 0; i < attendeesList.length; i++) {
const attendee = attendeesList[i];
const ticketId = generateId(); const ticketId = generateId();
const qrCode = generateTicketCode(); const qrCode = generateTicketCode();
// Cash payments start as pending, card/lightning start as pending until payment confirmed
const ticketStatus = 'pending';
const newTicket = { const newTicket = {
id: ticketId, id: ticketId,
bookingId: ticketCount > 1 ? bookingId : null, // Only set bookingId for multi-ticket bookings
userId: user.id, userId: user.id,
eventId: data.eventId, eventId: data.eventId,
attendeeFirstName: data.firstName, attendeeFirstName: attendee.firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
attendeeEmail: data.email, attendeeEmail: data.email, // Buyer's email for all tickets
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: data.ruc || null, attendeeRuc: data.ruc || null,
preferredLanguage: data.preferredLanguage || null, preferredLanguage: data.preferredLanguage || null,
status: ticketStatus, status: 'pending',
qrCode, qrCode,
checkinAt: null, checkinAt: null,
createdAt: now, createdAt: now,
}; };
await (db as any).insert(tickets).values(newTicket); await (db as any).insert(tickets).values(newTicket);
createdTickets.push(newTicket);
// Create payment record // Create payment record for each ticket
const paymentId = generateId(); const paymentId = generateId();
const newPayment = { const newPayment = {
id: paymentId, id: paymentId,
@@ -169,15 +200,20 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}; };
await (db as any).insert(payments).values(newPayment); await (db as any).insert(payments).values(newPayment);
createdPayments.push(newPayment);
}
const primaryTicket = createdTickets[0];
const primaryPayment = createdPayments[0];
// Send payment instructions email for manual payment methods (TPago, Bank Transfer) // Send payment instructions email for manual payment methods (TPago, Bank Transfer)
if (['bank_transfer', 'tpago'].includes(data.paymentMethod)) { if (['bank_transfer', 'tpago'].includes(data.paymentMethod)) {
// Send asynchronously - don't block the response // Send asynchronously - don't block the response
emailService.sendPaymentInstructions(ticketId).then(result => { emailService.sendPaymentInstructions(primaryTicket.id).then(result => {
if (result.success) { 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 { } 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 => { }).catch(err => {
console.error('[Email] Exception sending payment instructions email:', 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 // If Lightning payment, create LNbits invoice
let lnbitsInvoice = null; let lnbitsInvoice = null;
if (data.paymentMethod === 'lightning' && event.price > 0) { const totalPrice = event.price * ticketCount;
if (data.paymentMethod === 'lightning' && totalPrice > 0) {
if (!isLNbitsConfigured()) { if (!isLNbitsConfigured()) {
// Delete the ticket and payment we just created // Delete the tickets and payments we just created
await (db as any).delete(payments).where(eq((payments as any).id, paymentId)); for (const payment of createdPayments) {
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId)); 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({ return c.json({
error: 'Bitcoin Lightning payments are not available at this time' error: 'Bitcoin Lightning payments are not available at this time'
}, 400); }, 400);
@@ -200,49 +242,68 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const apiUrl = process.env.API_URL || 'http://localhost:3001'; const apiUrl = process.env.API_URL || 'http://localhost:3001';
// Pass the fiat currency directly to LNbits - it handles conversion automatically // Pass the fiat currency directly to LNbits - it handles conversion automatically
// For multi-ticket, use total price
lnbitsInvoice = await createInvoice({ lnbitsInvoice = await createInvoice({
amount: event.price, amount: totalPrice,
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc. 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`, webhookUrl: `${apiUrl}/api/lnbits/webhook`,
expiry: 900, // 15 minutes expiry for faster UX expiry: 900, // 15 minutes expiry for faster UX
extra: { extra: {
ticketId, ticketId: primaryTicket.id,
bookingId: ticketCount > 1 ? bookingId : null,
ticketIds: createdTickets.map(t => t.id),
eventId: event.id, eventId: event.id,
eventTitle: event.title, eventTitle: event.title,
attendeeName: fullName, attendeeName: fullName,
attendeeEmail: data.email, attendeeEmail: data.email,
ticketCount,
}, },
}); });
// Update payment with LNbits payment hash reference // Update primary payment with LNbits payment hash reference
await (db as any) await (db as any)
.update(payments) .update(payments)
.set({ reference: lnbitsInvoice.paymentHash }) .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) { } catch (error: any) {
console.error('Failed to create Lightning invoice:', error); console.error('Failed to create Lightning invoice:', error);
// Delete the ticket and payment we just created since Lightning payment failed // Delete the tickets and payments we just created since Lightning payment failed
await (db as any).delete(payments).where(eq((payments as any).id, paymentId)); for (const payment of createdPayments) {
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId)); 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({ return c.json({
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}` error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`
}, 500); }, 500);
} }
} }
return c.json({ // Response format depends on single vs multi-ticket
ticket: { const eventInfo = {
...newTicket,
event: {
title: event.title, title: event.title,
startDatetime: event.startDatetime, startDatetime: event.startDatetime,
location: event.location, location: event.location,
};
return c.json({
// For backward compatibility, include primary ticket as 'ticket'
ticket: {
...primaryTicket,
event: eventInfo,
}, },
}, // For multi-ticket bookings, include all tickets
payment: newPayment, tickets: createdTickets.map(t => ({
...t,
event: eventInfo,
})),
bookingId: ticketCount > 1 ? bookingId : null,
payment: primaryPayment,
payments: createdPayments,
lightningInvoice: lnbitsInvoice ? { lightningInvoice: lnbitsInvoice ? {
paymentHash: lnbitsInvoice.paymentHash, paymentHash: lnbitsInvoice.paymentHash,
paymentRequest: lnbitsInvoice.paymentRequest, paymentRequest: lnbitsInvoice.paymentRequest,
@@ -251,11 +312,102 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
fiatCurrency: lnbitsInvoice.fiatCurrency, fiatCurrency: lnbitsInvoice.fiatCurrency,
expiry: lnbitsInvoice.expiry, expiry: lnbitsInvoice.expiry,
} : null, } : null,
message: 'Booking created successfully', message: ticketCount > 1
? `${ticketCount} tickets booked successfully`
: 'Booking created successfully',
}, 201); }, 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<any>(
(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) => { ticketsRouter.get('/:id/pdf', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const user: any = await getAuthUser(c); 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) // 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) => { ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const user = (c as any).get('user'); const user = (c as any).get('user');
@@ -566,11 +719,26 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
const now = getNow(); const now = getNow();
// Get all tickets in this booking (if multi-ticket)
let ticketsToConfirm: any[] = [ticket];
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 // Update ticket status
await (db as any) await (db as any)
.update(tickets) .update(tickets)
.set({ status: 'confirmed' }) .set({ status: 'confirmed' })
.where(eq((tickets as any).id, id)); .where(eq((tickets as any).id, t.id));
// Update payment status // Update payment status
await (db as any) await (db as any)
@@ -581,7 +749,8 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
paidByAdminId: user.id, paidByAdminId: user.id,
updatedAt: now, updatedAt: now,
}) })
.where(eq((payments as any).ticketId, id)); .where(eq((payments as any).ticketId, t.id));
}
// Get payment for sending receipt // Get payment for sending receipt
const payment = await dbGet<any>( const payment = await dbGet<any>(
@@ -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)) (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) // User marks payment as sent (for manual payment methods: bank_transfer, tpago)
// This sets status to "pending_approval" and notifies admin // This sets status to "pending_approval" and notifies admin
ticketsRouter.post('/:id/mark-payment-sent', async (c) => { ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const body = await c.req.json().catch(() => ({}));
const { payerName } = body;
const ticket = await dbGet<any>( const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id)) (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({ .set({
status: 'pending_approval', status: 'pending_approval',
userMarkedPaidAt: now, userMarkedPaidAt: now,
payerName: payerName?.trim() || null,
updatedAt: now, updatedAt: now,
}) })
.where(eq((payments as any).id, payment.id)); .where(eq((payments as any).id, payment.id));

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
@@ -26,9 +26,17 @@ import {
BuildingLibraryIcon, BuildingLibraryIcon,
ClockIcon, ClockIcon,
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
UserIcon,
ArrowDownTrayIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; 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'; type PaymentMethod = 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
interface BookingFormData { interface BookingFormData {
@@ -52,14 +60,19 @@ interface LightningInvoice {
interface BookingResult { interface BookingResult {
ticketId: string; ticketId: string;
ticketIds?: string[]; // For multi-ticket bookings
bookingId?: string;
qrCode: string; qrCode: string;
qrCodes?: string[]; // For multi-ticket bookings
paymentMethod: PaymentMethod; paymentMethod: PaymentMethod;
lightningInvoice?: LightningInvoice; lightningInvoice?: LightningInvoice;
ticketCount?: number;
} }
export default function BookingPage() { export default function BookingPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const { user } = useAuth(); const { user } = useAuth();
const [event, setEvent] = useState<Event | null>(null); const [event, setEvent] = useState<Event | null>(null);
@@ -71,6 +84,20 @@ export default function BookingPage() {
const [paymentPending, setPaymentPending] = useState(false); const [paymentPending, setPaymentPending] = useState(false);
const [markingPaid, setMarkingPaid] = 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<AttendeeInfo[]>(() =>
Array(Math.max(0, initialQuantity - 1)).fill(null).map(() => ({ firstName: '', lastName: '' }))
);
const [attendeeErrors, setAttendeeErrors] = useState<{ [key: number]: string }>({});
const [formData, setFormData] = useState<BookingFormData>({ const [formData, setFormData] = useState<BookingFormData>({
firstName: '', firstName: '',
lastName: '', lastName: '',
@@ -228,6 +255,7 @@ export default function BookingPage() {
const validateForm = (): boolean => { const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof BookingFormData, string>> = {}; const newErrors: Partial<Record<keyof BookingFormData, string>> = {};
const newAttendeeErrors: { [key: number]: string } = {};
if (!formData.firstName.trim() || formData.firstName.length < 2) { if (!formData.firstName.trim() || formData.firstName.length < 2) {
newErrors.firstName = t('booking.form.errors.firstNameRequired'); 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); 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 // Connect to SSE for real-time payment updates
@@ -346,9 +384,20 @@ export default function BookingPage() {
const handleMarkPaymentSent = async () => { const handleMarkPaymentSent = async () => {
if (!bookingResult) return; 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); setMarkingPaid(true);
try { try {
await ticketsApi.markPaymentSent(bookingResult.ticketId); await ticketsApi.markPaymentSent(
bookingResult.ticketId,
paidUnderDifferentName ? payerName.trim() : undefined
);
setStep('pending_approval'); setStep('pending_approval');
toast.success(locale === 'es' toast.success(locale === 'es'
? 'Pago marcado como enviado. Esperando aprobación.' ? 'Pago marcado como enviado. Esperando aprobación.'
@@ -366,6 +415,12 @@ export default function BookingPage() {
setSubmitting(true); setSubmitting(true);
try { 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({ const response = await ticketsApi.book({
eventId: event.id, eventId: event.id,
firstName: formData.firstName, firstName: formData.firstName,
@@ -375,16 +430,24 @@ export default function BookingPage() {
preferredLanguage: formData.preferredLanguage, preferredLanguage: formData.preferredLanguage,
paymentMethod: formData.paymentMethod, paymentMethod: formData.paymentMethod,
...(formData.ruc.trim() && { ruc: formData.ruc }), ...(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 Lightning payment with invoice, go to paying step
if (formData.paymentMethod === 'lightning' && lightningInvoice?.paymentRequest) { if (formData.paymentMethod === 'lightning' && lightningInvoice?.paymentRequest) {
const result: BookingResult = { const result: BookingResult = {
ticketId: ticket.id, ticketId: primaryTicket.id,
qrCode: ticket.qrCode, ticketIds: ticketsList?.map((t: any) => t.id),
bookingId,
qrCode: primaryTicket.qrCode,
qrCodes: ticketsList?.map((t: any) => t.qrCode),
paymentMethod: formData.paymentMethod as PaymentMethod, paymentMethod: formData.paymentMethod as PaymentMethod,
ticketCount,
lightningInvoice: { lightningInvoice: {
paymentHash: lightningInvoice.paymentHash, paymentHash: lightningInvoice.paymentHash,
paymentRequest: lightningInvoice.paymentRequest, paymentRequest: lightningInvoice.paymentRequest,
@@ -399,21 +462,29 @@ export default function BookingPage() {
setPaymentPending(true); setPaymentPending(true);
// Connect to SSE for real-time payment updates // Connect to SSE for real-time payment updates
connectPaymentStream(ticket.id); connectPaymentStream(primaryTicket.id);
} else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') { } else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') {
// Manual payment methods - show payment details // Manual payment methods - show payment details
setBookingResult({ setBookingResult({
ticketId: ticket.id, ticketId: primaryTicket.id,
qrCode: ticket.qrCode, ticketIds: ticketsList?.map((t: any) => t.id),
bookingId,
qrCode: primaryTicket.qrCode,
qrCodes: ticketsList?.map((t: any) => t.qrCode),
paymentMethod: formData.paymentMethod, paymentMethod: formData.paymentMethod,
ticketCount,
}); });
setStep('manual_payment'); setStep('manual_payment');
} else { } else {
// Cash payment - go straight to success // Cash payment - go straight to success
setBookingResult({ setBookingResult({
ticketId: ticket.id, ticketId: primaryTicket.id,
qrCode: ticket.qrCode, ticketIds: ticketsList?.map((t: any) => t.id),
bookingId,
qrCode: primaryTicket.qrCode,
qrCodes: ticketsList?.map((t: any) => t.qrCode),
paymentMethod: formData.paymentMethod, paymentMethod: formData.paymentMethod,
ticketCount,
}); });
setStep('success'); setStep('success');
toast.success(t('booking.success.message')); toast.success(t('booking.success.message'));
@@ -592,6 +663,8 @@ export default function BookingPage() {
if (step === 'manual_payment' && bookingResult && paymentConfig) { if (step === 'manual_payment' && bookingResult && paymentConfig) {
const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer'; const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer';
const isTpago = bookingResult.paymentMethod === 'tpago'; const isTpago = bookingResult.paymentMethod === 'tpago';
const ticketCount = bookingResult.ticketCount || 1;
const totalAmount = (event?.price || 0) * ticketCount;
return ( return (
<div className="section-padding"> <div className="section-padding">
@@ -621,8 +694,13 @@ export default function BookingPage() {
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'} {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
</p> </p>
<p className="text-2xl font-bold text-primary-dark"> <p className="text-2xl font-bold text-primary-dark">
{event?.price !== undefined ? formatPrice(event.price, event.currency) : ''} {event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''}
</p> </p>
{ticketCount > 1 && (
<p className="text-sm text-gray-500 mt-1">
{ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')}
</p>
)}
</div> </div>
{/* Bank Transfer Details */} {/* Bank Transfer Details */}
@@ -725,6 +803,45 @@ export default function BookingPage() {
</div> </div>
</div> </div>
{/* Paid under different name option */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={paidUnderDifferentName}
onChange={(e) => {
setPaidUnderDifferentName(e.target.checked);
if (!e.target.checked) setPayerName('');
}}
className="mt-1 w-4 h-4 text-primary-yellow border-gray-300 rounded focus:ring-primary-yellow"
/>
<div>
<span className="font-medium text-gray-700">
{locale === 'es'
? 'El pago está a nombre de otra persona'
: 'The payment is under another person\'s name'}
</span>
<p className="text-xs text-gray-500 mt-1">
{locale === 'es'
? 'Marcá esta opción si el pago fue realizado por un familiar o tercero.'
: 'Check this option if the payment was made by a family member or a third party.'}
</p>
</div>
</label>
{paidUnderDifferentName && (
<div className="mt-3 pl-7">
<Input
label={locale === 'es' ? 'Nombre del pagador' : 'Payer name'}
value={payerName}
onChange={(e) => setPayerName(e.target.value)}
placeholder={locale === 'es' ? 'Nombre completo del titular de la cuenta' : 'Full name of account holder'}
required
/>
</div>
)}
</div>
{/* Warning before I Have Paid button */} {/* Warning before I Have Paid button */}
<p className="text-sm text-center text-amber-700 font-medium mb-3"> <p className="text-sm text-center text-amber-700 font-medium mb-3">
{locale === 'es' {locale === 'es'
@@ -738,6 +855,7 @@ export default function BookingPage() {
isLoading={markingPaid} isLoading={markingPaid}
size="lg" size="lg"
className="w-full" className="w-full"
disabled={paidUnderDifferentName && !payerName.trim()}
> >
<CheckCircleIcon className="w-5 h-5 mr-2" /> <CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'} {locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'}
@@ -829,9 +947,30 @@ export default function BookingPage() {
</p> </p>
<div className="bg-secondary-gray rounded-lg p-6 mb-6"> <div className="bg-secondary-gray rounded-lg p-6 mb-6">
{/* Multi-ticket indicator */}
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
<div className="mb-4 pb-4 border-b border-gray-300">
<p className="text-lg font-semibold text-primary-dark">
{locale === 'es'
? `${bookingResult.ticketCount} tickets reservados`
: `${bookingResult.ticketCount} tickets booked`}
</p>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Cada asistente recibirá su propio código QR'
: 'Each attendee will receive their own QR code'}
</p>
</div>
)}
<div className="flex items-center justify-center gap-2 mb-4"> <div className="flex items-center justify-center gap-2 mb-4">
<TicketIcon className="w-6 h-6 text-primary-yellow" /> <TicketIcon className="w-6 h-6 text-primary-yellow" />
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span> <span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full">
+{bookingResult.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}
</span>
)}
</div> </div>
<div className="text-sm text-gray-600 space-y-2"> <div className="text-sm text-gray-600 space-y-2">
@@ -873,6 +1012,25 @@ export default function BookingPage() {
{t('booking.success.emailSent')} {t('booking.success.emailSent')}
</p> </p>
{/* Download Ticket Button - only for instant confirmation (Lightning) */}
{bookingResult.paymentMethod === 'lightning' && (
<div className="mb-6">
<a
href={bookingResult.bookingId
? `/api/tickets/booking/${bookingResult.bookingId}/pdf`
: `/api/tickets/${bookingResult.ticketId}/pdf`
}
download
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
<ArrowDownTrayIcon className="w-5 h-5" />
{locale === 'es'
? (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Descargar Tickets' : 'Descargar Ticket')
: (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Download Tickets' : 'Download Ticket')}
</a>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center"> <div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href="/events"> <Link href="/events">
<Button variant="outline">{t('booking.success.browseEvents')}</Button> <Button variant="outline">{t('booking.success.browseEvents')}</Button>
@@ -927,7 +1085,25 @@ export default function BookingPage() {
? t('events.details.free') ? t('events.details.free')
: formatPrice(event.price, event.currency)} : formatPrice(event.price, event.currency)}
</span> </span>
{event.price > 0 && (
<span className="text-gray-400 text-sm">
{locale === 'es' ? 'por persona' : 'per person'}
</span>
)}
</div> </div>
{/* Ticket quantity and total */}
{ticketQuantity > 1 && (
<div className="mt-3 pt-3 border-t border-secondary-light-gray">
<div className="flex items-center justify-between">
<span className="text-gray-600">
{locale === 'es' ? 'Tickets' : 'Tickets'}: <span className="font-semibold">{ticketQuantity}</span>
</span>
<span className="font-bold text-lg text-primary-dark">
{locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)}
</span>
</div>
</div>
)}
</div> </div>
</Card> </Card>
@@ -941,8 +1117,18 @@ export default function BookingPage() {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{/* User Information Section */} {/* User Information Section */}
<Card className="mb-6 p-6"> <Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark"> <h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
{attendees.length > 0 && (
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
1
</span>
)}
{t('booking.form.personalInfo')} {t('booking.form.personalInfo')}
{attendees.length > 0 && (
<span className="text-sm font-normal text-gray-500">
({locale === 'es' ? 'Asistente principal' : 'Primary attendee'})
</span>
)}
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
@@ -1040,6 +1226,74 @@ export default function BookingPage() {
</div> </div>
</Card> </Card>
{/* Additional Attendees Section (for multi-ticket bookings) */}
{attendees.length > 0 && (
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
<UserIcon className="w-5 h-5 text-primary-yellow" />
{locale === 'es' ? 'Información de los Otros Asistentes' : 'Other Attendees Information'}
</h3>
<p className="text-sm text-gray-600 mb-4">
{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.'}
</p>
<div className="space-y-4">
{attendees.map((attendee, index) => (
<div key={index} className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 mb-3">
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
{index + 2}
</span>
<span className="font-medium text-gray-700">
{locale === 'es' ? `Asistente ${index + 2}` : `Attendee ${index + 2}`}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label={t('booking.form.firstName')}
value={attendee.firstName}
onChange={(e) => {
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
/>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.lastName')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
value={attendee.lastName}
onChange={(e) => {
const newAttendees = [...attendees];
newAttendees[index].lastName = e.target.value;
setAttendees(newAttendees);
}}
placeholder={t('booking.form.lastNamePlaceholder')}
/>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{/* Payment Selection Section */} {/* Payment Selection Section */}
<Card className="mb-6 p-6"> <Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark"> <h3 className="font-bold text-lg mb-4 text-primary-dark">
@@ -1098,45 +1352,6 @@ export default function BookingPage() {
</button> </button>
))} ))}
{/* Manual payment instructions - shown when TPago or Bank Transfer is selected */}
{(formData.paymentMethod === 'tpago' || formData.paymentMethod === 'bank_transfer') && (
<div className="mt-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<div className="text-sm text-amber-800">
<p className="font-medium mb-1">
{locale === 'es' ? 'Proceso de pago manual' : 'Manual payment process'}
</p>
<ol className="list-decimal list-inside space-y-1 text-amber-700">
<li>
{locale === 'es'
? 'Por favor completa el pago primero.'
: 'Please complete the payment first.'}
</li>
<li>
{locale === 'es'
? 'Después de pagar, haz clic en "Ya pagué" para notificarnos.'
: 'After you have paid, click "I have paid" to notify us.'}
</li>
<li>
{locale === 'es'
? 'Nuestro equipo verificará el pago manualmente.'
: 'Our team will manually verify the payment.'}
</li>
<li>
{locale === 'es'
? 'Una vez aprobado, recibirás un email confirmando tu reserva.'
: 'Once approved, you will receive an email confirming your booking.'}
</li>
</ol>
</div>
</div>
</div>
)}
</> </>
)} )}
</div> </div>

View File

@@ -229,12 +229,15 @@ export default function BookingSuccessPage() {
{isPaid && ( {isPaid && (
<div className="mb-6"> <div className="mb-6">
<a <a
href={`/api/tickets/${ticketId}/pdf`} href={ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${ticketId}/pdf`
}
download download
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
> >
<ArrowDownTrayIcon className="w-5 h-5" /> <ArrowDownTrayIcon className="w-5 h-5" />
{locale === 'es' ? 'Descargar Ticket' : 'Download Ticket'} {locale === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
</a> </a>
</div> </div>
)} )}

View File

@@ -170,6 +170,20 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
{language === 'es' ? 'Ver Entrada' : 'View Ticket'} {language === 'es' ? 'Ver Entrada' : 'View Ticket'}
</Button> </Button>
</Link> </Link>
{(ticket.status === 'confirmed' || ticket.status === 'checked_in') && (
<a
href={ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${ticket.id}/pdf`
}
download
className="text-center"
>
<Button variant="outline" size="sm" className="w-full">
{language === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
</Button>
</a>
)}
{ticket.invoice && ( {ticket.invoice && (
<a <a
href={ticket.invoice.pdfUrl || '#'} href={ticket.invoice.pdfUrl || '#'}

View File

@@ -14,6 +14,8 @@ import {
MapPinIcon, MapPinIcon,
UserGroupIcon, UserGroupIcon,
ArrowLeftIcon, ArrowLeftIcon,
MinusIcon,
PlusIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
interface EventDetailClientProps { interface EventDetailClientProps {
@@ -25,6 +27,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [event, setEvent] = useState<Event>(initialEvent); const [event, setEvent] = useState<Event>(initialEvent);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [ticketQuantity, setTicketQuantity] = useState(1);
// Ensure consistent hydration by only rendering dynamic content after mount // Ensure consistent hydration by only rendering dynamic content after mount
useEffect(() => { useEffect(() => {
@@ -38,6 +41,17 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
.catch(console.error); .catch(console.error);
}, [eventId]); }, [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) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long', weekday: 'long',
@@ -60,137 +74,56 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false; const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published'; const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
return ( // Booking card content - reused for mobile and desktop positions
<div className="section-padding"> const BookingCardContent = () => (
<div className="container-page"> <>
<Link <div className="text-center mb-4">
href="/events"
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
>
<ArrowLeftIcon className="w-4 h-4" />
{t('common.back')}
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Event Details */}
<div className="lg:col-span-2">
<Card className="overflow-hidden">
{/* Banner - LCP element, loaded with high priority */}
{/* Using unoptimized for backend-served images via /uploads/ rewrite */}
{event.bannerUrl ? (
<div className="relative h-64 w-full">
<Image
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
fill
className="object-cover"
sizes="(max-width: 1024px) 100vw, 66vw"
priority
unoptimized
/>
</div>
) : (
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
</div>
)}
<div className="p-8">
<div className="flex items-start justify-between gap-4">
<h1 className="text-3xl font-bold text-primary-dark" suppressHydrationWarning>
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h1>
{isCancelled && (
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
)}
{isSoldOut && !isCancelled && (
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
)}
</div>
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="flex items-start gap-3">
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.date')}</p>
<p className="text-gray-600" suppressHydrationWarning>{formatDate(event.startDatetime)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl"></span>
<div>
<p className="font-medium">{t('events.details.time')}</p>
<p className="text-gray-600" suppressHydrationWarning>
{formatTime(event.startDatetime)}
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.location')}</p>
<p className="text-gray-600">{event.location}</p>
{event.locationUrl && (
<a
href={event.locationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:underline text-sm"
>
View on map
</a>
)}
</div>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-start gap-3">
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</p>
</div>
</div>
)}
</div>
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
<h2 className="font-semibold text-lg mb-4">About this event</h2>
<p className="text-gray-700 whitespace-pre-line" suppressHydrationWarning>
{locale === 'es' && event.descriptionEs
? event.descriptionEs
: event.description}
</p>
</div>
{/* Social Sharing */}
<div className="mt-8 pt-8 border-t border-secondary-light-gray" suppressHydrationWarning>
<ShareButtons
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
/>
</div>
</div>
</Card>
</div>
{/* Booking Card */}
<div className="lg:col-span-1">
<Card className="p-6 sticky top-24">
<div className="text-center mb-6">
<p className="text-sm text-gray-500">{t('events.details.price')}</p> <p className="text-sm text-gray-500">{t('events.details.price')}</p>
<p className="text-4xl font-bold text-primary-dark"> <p className="text-4xl font-bold text-primary-dark">
{event.price === 0 {event.price === 0
? t('events.details.free') ? t('events.details.free')
: formatPrice(event.price, event.currency)} : formatPrice(event.price, event.currency)}
</p> </p>
{event.price > 0 && (
<p className="text-xs text-gray-400 mt-1">
{locale === 'es' ? 'por persona' : 'per person'}
</p>
)}
</div> </div>
{/* Ticket Quantity Selector */}
{canBook && !event.externalBookingEnabled && (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 text-center mb-2">
{locale === 'es' ? 'Cantidad de tickets' : 'Number of tickets'}
</label>
<div className="flex items-center justify-center gap-4">
<button
type="button"
onClick={decreaseQuantity}
disabled={ticketQuantity <= 1}
className="w-10 h-10 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-primary-yellow hover:bg-primary-yellow/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<MinusIcon className="w-5 h-5" />
</button>
<span className="text-2xl font-bold w-12 text-center">{ticketQuantity}</span>
<button
type="button"
onClick={increaseQuantity}
disabled={ticketQuantity >= maxTickets}
className="w-10 h-10 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-primary-yellow hover:bg-primary-yellow/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<PlusIcon className="w-5 h-5" />
</button>
</div>
{ticketQuantity > 1 && event.price > 0 && (
<p className="text-center text-sm text-gray-600 mt-2">
{locale === 'es' ? 'Total' : 'Total'}: <span className="font-bold">{formatPrice(event.price * ticketQuantity, event.currency)}</span>
</p>
)}
</div>
)}
{canBook ? ( {canBook ? (
event.externalBookingEnabled && event.externalBookingUrl ? ( event.externalBookingEnabled && event.externalBookingUrl ? (
<a <a
@@ -203,7 +136,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
</Button> </Button>
</a> </a>
) : ( ) : (
<Link href={`/book/${event.id}`}> <Link href={`/book/${event.id}?qty=${ticketQuantity}`}>
<Button className="w-full" size="lg"> <Button className="w-full" size="lg">
{t('events.booking.join')} {t('events.booking.join')}
</Button> </Button>
@@ -224,6 +157,144 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
{event.availableSeats} {t('events.details.spotsLeft')} {event.availableSeats} {t('events.details.spotsLeft')}
</p> </p>
)} )}
</>
);
return (
<div className="section-padding">
<div className="container-page">
<Link
href="/events"
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
>
<ArrowLeftIcon className="w-4 h-4" />
{t('common.back')}
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Event Details */}
<div className="lg:col-span-2 space-y-6">
{/* Top section: Image + Event Info side by side on desktop */}
<Card className="overflow-hidden">
<div className="flex flex-col md:flex-row">
{/* Image - smaller on desktop, side by side */}
{event.bannerUrl ? (
<div className="relative md:w-2/5 flex-shrink-0 bg-gray-100">
<Image
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
width={400}
height={400}
className="w-full h-auto md:h-full object-cover"
sizes="(max-width: 768px) 100vw, 300px"
priority
unoptimized
/>
</div>
) : (
<div className="md:w-2/5 flex-shrink-0 h-48 md:h-auto bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
</div>
)}
{/* Event title and key info */}
<div className="flex-1 p-6">
<div className="flex items-start justify-between gap-4 mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-primary-dark" suppressHydrationWarning>
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h1>
<div className="flex-shrink-0">
{isCancelled && (
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
)}
{isSoldOut && !isCancelled && (
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
)}
</div>
</div>
<div className="space-y-4">
<div className="flex items-start gap-3">
<CalendarIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sm">{t('events.details.date')}</p>
<p className="text-gray-600" suppressHydrationWarning>{formatDate(event.startDatetime)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow text-lg"></span>
<div>
<p className="font-medium text-sm">{t('events.details.time')}</p>
<p className="text-gray-600" suppressHydrationWarning>
{formatTime(event.startDatetime)}
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPinIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sm">{t('events.details.location')}</p>
<p className="text-gray-600">{event.location}</p>
{event.locationUrl && (
<a
href={event.locationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:underline text-sm"
>
View on map
</a>
)}
</div>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-start gap-3">
<UserGroupIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</Card>
{/* Mobile Booking Card - shown between event details and description on mobile */}
<Card className="p-6 lg:hidden">
<BookingCardContent />
</Card>
{/* Description section - separate card below */}
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">About this event</h2>
<p className="text-gray-700 whitespace-pre-line" suppressHydrationWarning>
{locale === 'es' && event.descriptionEs
? event.descriptionEs
: event.description}
</p>
{/* Social Sharing */}
<div className="mt-8 pt-6 border-t border-secondary-light-gray" suppressHydrationWarning>
<ShareButtons
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
/>
</div>
</Card>
</div>
{/* Desktop Booking Card - hidden on mobile, shown in sidebar on desktop */}
<div className="hidden lg:block lg:col-span-1">
<Card className="p-6 sticky top-24">
<BookingCardContent />
</Card> </Card>
</div> </div>
</div> </div>

View File

@@ -18,6 +18,7 @@ import {
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
interface TicketWithDetails extends Omit<Ticket, 'payment'> { interface TicketWithDetails extends Omit<Ticket, 'payment'> {
bookingId?: string;
event?: Event; event?: Event;
payment?: { payment?: {
id: string; id: string;
@@ -194,6 +195,23 @@ export default function AdminBookingsPage() {
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length, 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) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -309,7 +327,9 @@ export default function AdminBookingsPage() {
</td> </td>
</tr> </tr>
) : ( ) : (
sortedTickets.map((ticket) => ( sortedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket);
return (
<tr key={ticket.id} className="hover:bg-gray-50"> <tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -341,9 +361,16 @@ export default function AdminBookingsPage() {
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')} {getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
</p> </p>
{ticket.payment && ( {ticket.payment && (
<div>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{ticket.payment.amount?.toLocaleString()} {ticket.payment.currency} {bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}
</p> </p>
{bookingInfo.ticketCount > 1 && (
<p className="text-xs text-purple-600 mt-1">
📦 {bookingInfo.ticketCount} × {Number(ticket.payment.amount).toLocaleString()} {ticket.payment.currency}
</p>
)}
</div>
)} )}
</div> </div>
</td> </td>
@@ -354,6 +381,11 @@ export default function AdminBookingsPage() {
{ticket.qrCode && ( {ticket.qrCode && (
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p> <p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
)} )}
{ticket.bookingId && (
<p className="text-xs text-purple-600 mt-1" title="Part of multi-ticket booking">
📦 Group Booking
</p>
)}
</td> </td>
<td className="px-6 py-4 text-sm text-gray-600"> <td className="px-6 py-4 text-sm text-gray-600">
{formatDate(ticket.createdAt)} {formatDate(ticket.createdAt)}
@@ -415,7 +447,8 @@ export default function AdminBookingsPage() {
</div> </div>
</td> </td>
</tr> </tr>
)) );
})
)} )}
</tbody> </tbody>
</table> </table>

View File

@@ -678,6 +678,11 @@ export default function AdminEventDetailPage() {
<td className="px-6 py-4"> <td className="px-6 py-4">
<p className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p> <p className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
<p className="text-sm text-gray-500">ID: {ticket.id.slice(0, 8)}...</p> <p className="text-sm text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
{ticket.bookingId && (
<p className="text-xs text-purple-600 mt-1" title={`Booking: ${ticket.bookingId}`}>
📦 {locale === 'es' ? 'Reserva grupal' : 'Group booking'}
</p>
)}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<p className="text-sm">{ticket.attendeeEmail}</p> <p className="text-sm">{ticket.attendeeEmail}</p>

View File

@@ -230,13 +230,69 @@ export default function AdminPaymentsPage() {
return labels[provider] || provider; 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 const totalPending = payments
.filter(p => p.status === 'pending' || p.status === 'pending_approval') .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 const totalPaid = payments
.filter(p => p.status === 'paid') .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<string>();
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) { if (loading) {
return ( return (
@@ -257,7 +313,9 @@ export default function AdminPaymentsPage() {
</div> </div>
{/* Approval Detail Modal */} {/* Approval Detail Modal */}
{selectedPayment && ( {selectedPayment && (() => {
const modalBookingInfo = getBookingInfo(selectedPayment);
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg p-6"> <Card className="w-full max-w-lg p-6">
<h2 className="text-xl font-bold mb-4"> <h2 className="text-xl font-bold mb-4">
@@ -268,8 +326,15 @@ export default function AdminPaymentsPage() {
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p className="text-gray-500">{locale === 'es' ? 'Monto' : 'Amount'}</p> <p className="text-gray-500">{locale === 'es' ? 'Monto Total' : 'Total Amount'}</p>
<p className="font-bold text-lg">{formatCurrency(selectedPayment.amount, selectedPayment.currency)}</p> <p className="font-bold text-lg">{formatCurrency(modalBookingInfo.bookingTotal, selectedPayment.currency)}</p>
{modalBookingInfo.ticketCount > 1 && (
<div className="mt-2 p-2 bg-purple-50 rounded">
<p className="text-xs text-purple-700">
📦 {modalBookingInfo.ticketCount} tickets × {formatCurrency(selectedPayment.amount, selectedPayment.currency)}
</p>
</div>
)}
</div> </div>
<div> <div>
<p className="text-gray-500">{locale === 'es' ? 'Método' : 'Method'}</p> <p className="text-gray-500">{locale === 'es' ? 'Método' : 'Method'}</p>
@@ -309,6 +374,15 @@ export default function AdminPaymentsPage() {
</div> </div>
)} )}
{selectedPayment.payerName && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800 font-medium">
{locale === 'es' ? '⚠️ Pagado por otra persona:' : '⚠️ Paid by someone else:'}
</p>
<p className="text-amber-900 font-bold">{selectedPayment.payerName}</p>
</div>
)}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Nota interna (opcional)' : 'Internal note (optional)'} {locale === 'es' ? 'Nota interna (opcional)' : 'Internal note (optional)'}
@@ -351,7 +425,8 @@ export default function AdminPaymentsPage() {
</button> </button>
</Card> </Card>
</div> </div>
)} );
})()}
{/* Export Modal */} {/* Export Modal */}
{showExportModal && ( {showExportModal && (
@@ -481,7 +556,10 @@ export default function AdminPaymentsPage() {
</div> </div>
<div> <div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p> <p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p>
<p className="text-xl font-bold text-yellow-600">{pendingApprovalPayments.length}</p> <p className="text-xl font-bold text-yellow-600">{pendingApprovalBookingsCount}</p>
{pendingApprovalPayments.length !== pendingApprovalBookingsCount && (
<p className="text-xs text-gray-400">({pendingApprovalPayments.length} tickets)</p>
)}
</div> </div>
</div> </div>
</Card> </Card>
@@ -493,6 +571,7 @@ export default function AdminPaymentsPage() {
<div> <div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p> <p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p> <p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
</div> </div>
</div> </div>
</Card> </Card>
@@ -504,6 +583,7 @@ export default function AdminPaymentsPage() {
<div> <div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p> <p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p>
<p className="text-xl font-bold text-green-600">{formatCurrency(totalPaid, 'PYG')}</p> <p className="text-xl font-bold text-green-600">{formatCurrency(totalPaid, 'PYG')}</p>
<p className="text-xs text-gray-400">{paidBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
</div> </div>
</div> </div>
</Card> </Card>
@@ -513,7 +593,7 @@ export default function AdminPaymentsPage() {
<BoltIcon className="w-5 h-5 text-blue-600" /> <BoltIcon className="w-5 h-5 text-blue-600" />
</div> </div>
<div> <div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagos' : 'Total Payments'}</p> <p className="text-sm text-gray-500">{locale === 'es' ? 'Total Tickets' : 'Total Tickets'}</p>
<p className="text-xl font-bold">{payments.length}</p> <p className="text-xl font-bold">{payments.length}</p>
</div> </div>
</div> </div>
@@ -565,7 +645,9 @@ export default function AdminPaymentsPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{pendingApprovalPayments.map((payment) => ( {pendingApprovalPayments.map((payment) => {
const bookingInfo = getPendingBookingInfo(payment);
return (
<Card key={payment.id} className="p-4"> <Card key={payment.id} className="p-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
@@ -574,12 +656,18 @@ export default function AdminPaymentsPage() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<p className="font-bold text-lg">{formatCurrency(payment.amount, payment.currency)}</p> <p className="font-bold text-lg">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
{bookingInfo.ticketCount > 1 && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
📦 {bookingInfo.ticketCount} tickets × {formatCurrency(payment.amount, payment.currency)}
</span>
)}
{getStatusBadge(payment.status)} {getStatusBadge(payment.status)}
</div> </div>
{payment.ticket && ( {payment.ticket && (
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName} {payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
{bookingInfo.ticketCount > 1 && <span className="text-gray-400 font-normal"> +{bookingInfo.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}</span>}
</p> </p>
)} )}
{payment.event && ( {payment.event && (
@@ -597,6 +685,11 @@ export default function AdminPaymentsPage() {
</span> </span>
)} )}
</div> </div>
{payment.payerName && (
<p className="text-xs text-amber-600 mt-1 font-medium">
{locale === 'es' ? 'Pago por:' : 'Paid by:'} {payment.payerName}
</p>
)}
</div> </div>
</div> </div>
<Button onClick={() => setSelectedPayment(payment)}> <Button onClick={() => setSelectedPayment(payment)}>
@@ -604,7 +697,8 @@ export default function AdminPaymentsPage() {
</Button> </Button>
</div> </div>
</Card> </Card>
))} );
})}
</div> </div>
)} )}
</> </>
@@ -671,7 +765,9 @@ export default function AdminPaymentsPage() {
</td> </td>
</tr> </tr>
) : ( ) : (
payments.map((payment) => ( payments.map((payment) => {
const bookingInfo = getBookingInfo(payment);
return (
<tr key={payment.id} className="hover:bg-gray-50"> <tr key={payment.id} className="hover:bg-gray-50">
<td className="px-6 py-4"> <td className="px-6 py-4">
{payment.ticket ? ( {payment.ticket ? (
@@ -680,6 +776,11 @@ export default function AdminPaymentsPage() {
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName} {payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
</p> </p>
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p> <p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
{payment.payerName && (
<p className="text-xs text-amber-600 mt-1">
{locale === 'es' ? 'Pagado por:' : 'Paid by:'} {payment.payerName}
</p>
)}
</div> </div>
) : ( ) : (
<span className="text-gray-400 text-sm">-</span> <span className="text-gray-400 text-sm">-</span>
@@ -692,8 +793,15 @@ export default function AdminPaymentsPage() {
<span className="text-gray-400 text-sm">-</span> <span className="text-gray-400 text-sm">-</span>
)} )}
</td> </td>
<td className="px-6 py-4 font-medium"> <td className="px-6 py-4">
{formatCurrency(payment.amount, payment.currency)} <div>
<p className="font-medium">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
{bookingInfo.ticketCount > 1 && (
<p className="text-xs text-purple-600 mt-1">
📦 {bookingInfo.ticketCount} × {formatCurrency(payment.amount, payment.currency)}
</p>
)}
</div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-gray-600"> <div className="flex items-center gap-2 text-sm text-gray-600">
@@ -705,7 +813,14 @@ export default function AdminPaymentsPage() {
{formatDate(payment.createdAt)} {formatDate(payment.createdAt)}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="space-y-1">
{getStatusBadge(payment.status)} {getStatusBadge(payment.status)}
{payment.ticket?.bookingId && (
<p className="text-xs text-purple-600" title="Part of multi-ticket booking">
📦 {locale === 'es' ? 'Grupo' : 'Group'}
</p>
)}
</div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
@@ -731,7 +846,8 @@ export default function AdminPaymentsPage() {
</div> </div>
</td> </td>
</tr> </tr>
)) );
})
)} )}
</tbody> </tbody>
</table> </table>

View File

@@ -119,6 +119,8 @@
"nameRequired": "Please enter your full name", "nameRequired": "Please enter your full name",
"firstNameRequired": "Please enter your first name", "firstNameRequired": "Please enter your first name",
"lastNameRequired": "Please enter your last 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", "emailInvalid": "Please enter a valid email address",
"phoneRequired": "Phone number is required", "phoneRequired": "Phone number is required",
"bookingFailed": "Booking failed. Please try again.", "bookingFailed": "Booking failed. Please try again.",
@@ -177,12 +179,13 @@
"button": "Follow Us" "button": "Follow Us"
}, },
"guidelines": { "guidelines": {
"title": "Community Guidelines", "title": "Community Rules",
"items": [ "items": [
"Be respectful to all participants", "Respect above all. Treat others the way you would like to be treated.",
"Help others practice - we're all learning", "We are all learning, let's help each other practice.",
"Speak in the language you're practicing", "Use this space to practice the event languages, mistakes are part of the process.",
"Have fun and be open to making new friends" "Keep an open attitude to meet new people and have fun.",
"This is a space to connect, please avoid spam and unsolicited promotions."
] ]
}, },
"volunteer": { "volunteer": {

View File

@@ -119,6 +119,8 @@
"nameRequired": "Por favor ingresa tu nombre completo", "nameRequired": "Por favor ingresa tu nombre completo",
"firstNameRequired": "Por favor ingresa tu nombre", "firstNameRequired": "Por favor ingresa tu nombre",
"lastNameRequired": "Por favor ingresa tu apellido", "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", "emailInvalid": "Por favor ingresa un correo electrónico válido",
"phoneRequired": "El número de teléfono es requerido", "phoneRequired": "El número de teléfono es requerido",
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.", "bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
@@ -158,37 +160,38 @@
"subtitle": "Conéctate con nosotros en redes sociales", "subtitle": "Conéctate con nosotros en redes sociales",
"whatsapp": { "whatsapp": {
"title": "Grupo de 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" "button": "Unirse a WhatsApp"
}, },
"instagram": { "instagram": {
"title": "Instagram", "title": "Instagram",
"description": "Síguenos para fotos, historias y anuncios", "description": "Seguinos en Instagram para ver fotos, historias y momentos del Spanglish.",
"button": "Seguirnos" "button": "Seguir en Instagram"
}, },
"telegram": { "telegram": {
"title": "Canal de 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" "button": "Unirse a Telegram"
}, },
"tiktok": { "tiktok": {
"title": "TikTok", "title": "TikTok",
"description": "Mira nuestros videos y síguenos para contenido divertido", "description": "Mirá nuestros videos y viví la experiencia Spanglish.",
"button": "Seguirnos" "button": "Seguir en TikTok"
}, },
"guidelines": { "guidelines": {
"title": "Reglas de la Comunidad", "title": "Normas de la comunidad",
"items": [ "items": [
"Sé respetuoso con todos los participantes", "Respeto ante todo. Tratemos a los demás como nos gustaría que nos traten.",
"Ayuda a otros a practicar - todos estamos aprendiendo", "Todos estamos aprendiendo, ayudemos a otros a practicar.",
"Habla en el idioma que estás practicando", "Aprovechemos este espacio para usar los idiomas del evento, sin miedo al éxito.",
"Diviértete y abierto a hacer nuevos amigos" "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": { "volunteer": {
"title": "Conviértete en Voluntario", "title": "Sumate como voluntario/a",
"description": "Ayúdanos a organizar eventos y hacer crecer la comunidad", "description": "Ayudanos a organizar los encuentros y a hacer crecer la comunidad Spanglish.",
"button": "Contáctanos" "button": "Contactanos"
} }
}, },
"contact": { "contact": {

View File

@@ -124,9 +124,10 @@ export const ticketsApi = {
}), }),
// For manual payment methods (bank_transfer, tpago) - user marks payment as sent // 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`, { fetchApi<{ payment: Payment; message: string }>(`/api/tickets/${id}/mark-payment-sent`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ payerName }),
}), }),
adminCreate: (data: { adminCreate: (data: {
@@ -444,12 +445,14 @@ export interface Event {
export interface Ticket { export interface Ticket {
id: string; id: string;
bookingId?: string; // Groups multiple tickets from same booking
userId: string; userId: string;
eventId: string; eventId: string;
attendeeFirstName: string; attendeeFirstName: string;
attendeeLastName?: string; attendeeLastName?: string;
attendeeEmail?: string; attendeeEmail?: string;
attendeePhone?: string; attendeePhone?: string;
attendeeRuc?: string;
preferredLanguage?: string; preferredLanguage?: string;
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in'; status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
checkinAt?: string; checkinAt?: string;
@@ -494,6 +497,7 @@ export interface Payment {
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed'; status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed';
reference?: string; reference?: string;
userMarkedPaidAt?: string; userMarkedPaidAt?: string;
payerName?: string; // Name of payer if different from attendee
paidAt?: string; paidAt?: string;
paidByAdminId?: string; paidByAdminId?: string;
adminNote?: string; adminNote?: string;
@@ -504,6 +508,7 @@ export interface Payment {
export interface PaymentWithDetails extends Payment { export interface PaymentWithDetails extends Payment {
ticket: { ticket: {
id: string; id: string;
bookingId?: string;
attendeeFirstName: string; attendeeFirstName: string;
attendeeLastName?: string; attendeeLastName?: string;
attendeeEmail?: string; attendeeEmail?: string;
@@ -560,6 +565,11 @@ export interface Contact {
createdAt: string; createdAt: string;
} }
export interface AttendeeData {
firstName: string;
lastName?: string;
}
export interface BookingData { export interface BookingData {
eventId: string; eventId: string;
firstName: string; firstName: string;
@@ -569,6 +579,8 @@ export interface BookingData {
preferredLanguage?: 'en' | 'es'; preferredLanguage?: 'en' | 'es';
paymentMethod: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago'; paymentMethod: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
ruc?: string; ruc?: string;
// For multi-ticket bookings
attendees?: AttendeeData[];
} }
export interface DashboardData { export interface DashboardData {