Backend and frontend updates: auth, email, payments, events, tickets; carrousel images; mobile event detail layout; i18n
@@ -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 (
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,40 +152,63 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update ticket status to confirmed
|
// Get all tickets in this booking (if multi-ticket)
|
||||||
await (db as any)
|
let ticketsToConfirm: any[] = [existingTicket];
|
||||||
.update(tickets)
|
|
||||||
.set({ status: 'confirmed' })
|
|
||||||
.where(eq((tickets as any).id, ticketId));
|
|
||||||
|
|
||||||
// Update payment status to paid
|
if (existingTicket.bookingId) {
|
||||||
await (db as any)
|
// This is a multi-ticket booking - get all tickets with same bookingId
|
||||||
.update(payments)
|
ticketsToConfirm = await dbAll(
|
||||||
.set({
|
(db as any)
|
||||||
status: 'paid',
|
.select()
|
||||||
reference: paymentHash,
|
.from(tickets)
|
||||||
paidAt: now,
|
.where(eq((tickets as any).bookingId, existingTicket.bookingId))
|
||||||
updatedAt: now,
|
);
|
||||||
})
|
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
|
||||||
.where(eq((payments as any).ticketId, ticketId));
|
}
|
||||||
|
|
||||||
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`);
|
// Confirm all tickets in the booking
|
||||||
|
for (const ticket of ticketsToConfirm) {
|
||||||
|
// Update ticket status to confirmed
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'confirmed' })
|
||||||
|
.where(eq((tickets as any).id, ticket.id));
|
||||||
|
|
||||||
// Get payment for sending receipt
|
// Update payment status to paid
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
status: 'paid',
|
||||||
|
reference: paymentHash,
|
||||||
|
paidAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((payments as any).ticketId, ticket.id));
|
||||||
|
|
||||||
|
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
await (db as any)
|
// If payment confirmed, handle multi-ticket booking
|
||||||
.update(payments)
|
|
||||||
.set(updateData)
|
|
||||||
.where(eq((payments as any).id, id));
|
|
||||||
|
|
||||||
// If payment confirmed, update ticket status and send emails
|
|
||||||
if (data.status === 'paid') {
|
if (data.status === 'paid') {
|
||||||
await (db as any)
|
// Get the ticket associated with this payment
|
||||||
.update(tickets)
|
const ticket = await dbGet<any>(
|
||||||
.set({ status: 'confirmed' })
|
(db as any)
|
||||||
.where(eq((tickets as any).id, existing.ticketId));
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).id, existing.ticketId))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if this is part of a multi-ticket booking
|
||||||
|
let ticketsToConfirm: any[] = [ticket];
|
||||||
|
|
||||||
|
if (ticket?.bookingId) {
|
||||||
|
// Get all tickets in this booking
|
||||||
|
ticketsToConfirm = await dbAll(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||||
|
);
|
||||||
|
console.log(`[Payment] Confirming multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all payments and tickets in the booking
|
||||||
|
for (const t of ticketsToConfirm) {
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq((payments as any).ticketId, (t as any).id));
|
||||||
|
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'confirmed' })
|
||||||
|
.where(eq((tickets as any).id, (t as any).id));
|
||||||
|
}
|
||||||
|
|
||||||
// Send confirmation emails asynchronously (don't block the response)
|
// 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,23 +287,47 @@ 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
|
||||||
await (db as any)
|
const ticket = await dbGet<any>(
|
||||||
.update(payments)
|
(db as any)
|
||||||
.set({
|
.select()
|
||||||
status: 'paid',
|
.from(tickets)
|
||||||
paidAt: now,
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
paidByAdminId: user.id,
|
);
|
||||||
adminNote: adminNote || payment.adminNote,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq((payments as any).id, id));
|
|
||||||
|
|
||||||
// Update ticket status to confirmed
|
// Check if this is part of a multi-ticket booking
|
||||||
await (db as any)
|
let ticketsToConfirm: any[] = [ticket];
|
||||||
.update(tickets)
|
|
||||||
.set({ status: 'confirmed' })
|
if (ticket?.bookingId) {
|
||||||
.where(eq((tickets as any).id, payment.ticketId));
|
// Get all tickets in this booking
|
||||||
|
ticketsToConfirm = await dbAll(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||||
|
);
|
||||||
|
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all payments in the booking to paid
|
||||||
|
for (const t of ticketsToConfirm) {
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
status: 'paid',
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: user.id,
|
||||||
|
adminNote: adminNote || payment.adminNote,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((payments as any).ticketId, (t as any).id));
|
||||||
|
|
||||||
|
// Update ticket status to confirmed
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'confirmed' })
|
||||||
|
.where(eq((tickets as any).id, (t as any).id));
|
||||||
|
}
|
||||||
|
|
||||||
// Send confirmation emails asynchronously
|
// 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 });
|
||||||
|
|||||||
@@ -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,55 +153,67 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ticket
|
// Generate booking ID to group multiple tickets
|
||||||
const ticketId = generateId();
|
const bookingId = generateId();
|
||||||
const qrCode = generateTicketCode();
|
|
||||||
|
|
||||||
// Cash payments start as pending, card/lightning start as pending until payment confirmed
|
// Create tickets for each attendee
|
||||||
const ticketStatus = 'pending';
|
const createdTickets: any[] = [];
|
||||||
|
const createdPayments: any[] = [];
|
||||||
|
|
||||||
const newTicket = {
|
for (let i = 0; i < attendeesList.length; i++) {
|
||||||
id: ticketId,
|
const attendee = attendeesList[i];
|
||||||
userId: user.id,
|
const ticketId = generateId();
|
||||||
eventId: data.eventId,
|
const qrCode = generateTicketCode();
|
||||||
attendeeFirstName: data.firstName,
|
|
||||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
|
||||||
attendeeEmail: data.email,
|
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
|
||||||
attendeeRuc: data.ruc || null,
|
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
|
||||||
status: ticketStatus,
|
|
||||||
qrCode,
|
|
||||||
checkinAt: null,
|
|
||||||
createdAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
await (db as any).insert(tickets).values(newTicket);
|
const newTicket = {
|
||||||
|
id: ticketId,
|
||||||
|
bookingId: ticketCount > 1 ? bookingId : null, // Only set bookingId for multi-ticket bookings
|
||||||
|
userId: user.id,
|
||||||
|
eventId: data.eventId,
|
||||||
|
attendeeFirstName: attendee.firstName,
|
||||||
|
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||||
|
attendeeEmail: data.email, // Buyer's email for all tickets
|
||||||
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
|
attendeeRuc: data.ruc || null,
|
||||||
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
|
status: 'pending',
|
||||||
|
qrCode,
|
||||||
|
checkinAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
// Create payment record
|
await (db as any).insert(tickets).values(newTicket);
|
||||||
const paymentId = generateId();
|
createdTickets.push(newTicket);
|
||||||
const newPayment = {
|
|
||||||
id: paymentId,
|
|
||||||
ticketId,
|
|
||||||
provider: data.paymentMethod,
|
|
||||||
amount: event.price,
|
|
||||||
currency: event.currency,
|
|
||||||
status: 'pending',
|
|
||||||
reference: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
await (db as any).insert(payments).values(newPayment);
|
// Create payment record for each ticket
|
||||||
|
const paymentId = generateId();
|
||||||
|
const newPayment = {
|
||||||
|
id: paymentId,
|
||||||
|
ticketId,
|
||||||
|
provider: data.paymentMethod,
|
||||||
|
amount: event.price,
|
||||||
|
currency: event.currency,
|
||||||
|
status: 'pending',
|
||||||
|
reference: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await (db as any).insert(payments).values(newPayment);
|
||||||
|
createdPayments.push(newPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Response format depends on single vs multi-ticket
|
||||||
|
const eventInfo = {
|
||||||
|
title: event.title,
|
||||||
|
startDatetime: event.startDatetime,
|
||||||
|
location: event.location,
|
||||||
|
};
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
|
// For backward compatibility, include primary ticket as 'ticket'
|
||||||
ticket: {
|
ticket: {
|
||||||
...newTicket,
|
...primaryTicket,
|
||||||
event: {
|
event: eventInfo,
|
||||||
title: event.title,
|
|
||||||
startDatetime: event.startDatetime,
|
|
||||||
location: event.location,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
payment: newPayment,
|
// For multi-ticket bookings, include all tickets
|
||||||
|
tickets: createdTickets.map(t => ({
|
||||||
|
...t,
|
||||||
|
event: eventInfo,
|
||||||
|
})),
|
||||||
|
bookingId: ticketCount > 1 ? bookingId : null,
|
||||||
|
payment: primaryPayment,
|
||||||
|
payments: createdPayments,
|
||||||
lightningInvoice: lnbitsInvoice ? {
|
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,22 +719,38 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
|||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
// Update ticket status
|
// Get all tickets in this booking (if multi-ticket)
|
||||||
await (db as any)
|
let ticketsToConfirm: any[] = [ticket];
|
||||||
.update(tickets)
|
|
||||||
.set({ status: 'confirmed' })
|
|
||||||
.where(eq((tickets as any).id, id));
|
|
||||||
|
|
||||||
// Update payment status
|
if (ticket.bookingId) {
|
||||||
await (db as any)
|
// This is a multi-ticket booking - get all tickets with same bookingId
|
||||||
.update(payments)
|
ticketsToConfirm = await dbAll(
|
||||||
.set({
|
(db as any)
|
||||||
status: 'paid',
|
.select()
|
||||||
paidAt: now,
|
.from(tickets)
|
||||||
paidByAdminId: user.id,
|
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||||
updatedAt: now,
|
);
|
||||||
})
|
}
|
||||||
.where(eq((payments as any).ticketId, id));
|
|
||||||
|
// Confirm all tickets in the booking
|
||||||
|
for (const t of ticketsToConfirm) {
|
||||||
|
// Update ticket status
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ status: 'confirmed' })
|
||||||
|
.where(eq((tickets as any).id, t.id));
|
||||||
|
|
||||||
|
// Update payment status
|
||||||
|
await (db as any)
|
||||||
|
.update(payments)
|
||||||
|
.set({
|
||||||
|
status: 'paid',
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: user.id,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((payments as any).ticketId, t.id));
|
||||||
|
}
|
||||||
|
|
||||||
// Get payment for sending receipt
|
// 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));
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 121 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.34.55.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.14.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.17.jpg
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.19.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.22.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.24.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.27.jpg
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.29.jpg
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.32.jpg
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.35.jpg
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.37.37.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.37.39.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 || '#'}
|
||||||
|
|||||||
@@ -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,6 +74,92 @@ 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';
|
||||||
|
|
||||||
|
// Booking card content - reused for mobile and desktop positions
|
||||||
|
const BookingCardContent = () => (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
||||||
|
<p className="text-4xl font-bold text-primary-dark">
|
||||||
|
{event.price === 0
|
||||||
|
? t('events.details.free')
|
||||||
|
: formatPrice(event.price, event.currency)}
|
||||||
|
</p>
|
||||||
|
{event.price > 0 && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{locale === 'es' ? 'por persona' : 'per person'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</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 ? (
|
||||||
|
event.externalBookingEnabled && event.externalBookingUrl ? (
|
||||||
|
<a
|
||||||
|
href={event.externalBookingUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button className="w-full" size="lg">
|
||||||
|
{t('events.booking.join')}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link href={`/book/${event.id}?qty=${ticketQuantity}`}>
|
||||||
|
<Button className="w-full" size="lg">
|
||||||
|
{t('events.booking.join')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Button className="w-full" size="lg" disabled>
|
||||||
|
{isPastEvent
|
||||||
|
? t('events.details.eventEnded')
|
||||||
|
: isSoldOut
|
||||||
|
? t('events.details.soldOut')
|
||||||
|
: t('events.details.cancelled')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!event.externalBookingEnabled && (
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-500">
|
||||||
|
{event.availableSeats} {t('events.details.spotsLeft')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding">
|
<div className="section-padding">
|
||||||
<div className="container-page">
|
<div className="container-page">
|
||||||
@@ -73,157 +173,128 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
{/* Event Details */}
|
{/* Event Details */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Top section: Image + Event Info side by side on desktop */}
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
{/* Banner - LCP element, loaded with high priority */}
|
<div className="flex flex-col md:flex-row">
|
||||||
{/* Using unoptimized for backend-served images via /uploads/ rewrite */}
|
{/* Image - smaller on desktop, side by side */}
|
||||||
{event.bannerUrl ? (
|
{event.bannerUrl ? (
|
||||||
<div className="relative h-64 w-full">
|
<div className="relative md:w-2/5 flex-shrink-0 bg-gray-100">
|
||||||
<Image
|
<Image
|
||||||
src={event.bannerUrl}
|
src={event.bannerUrl}
|
||||||
alt={`${event.title} - Spanglish language exchange event in Asunción`}
|
alt={`${event.title} - Spanglish language exchange event in Asunción`}
|
||||||
fill
|
width={400}
|
||||||
className="object-cover"
|
height={400}
|
||||||
sizes="(max-width: 1024px) 100vw, 66vw"
|
className="w-full h-auto md:h-full object-cover"
|
||||||
priority
|
sizes="(max-width: 768px) 100vw, 300px"
|
||||||
unoptimized
|
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>
|
||||||
|
) : (
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
||||||
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl">⏰</span>
|
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
|
||||||
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
{/* Event title and key info */}
|
||||||
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
<div className="flex-1 p-6">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4 mb-6">
|
||||||
<p className="font-medium">{t('events.details.location')}</p>
|
<h1 className="text-2xl md:text-3xl font-bold text-primary-dark" suppressHydrationWarning>
|
||||||
<p className="text-gray-600">{event.location}</p>
|
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||||
{event.locationUrl && (
|
</h1>
|
||||||
<a
|
<div className="flex-shrink-0">
|
||||||
href={event.locationUrl}
|
{isCancelled && (
|
||||||
target="_blank"
|
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
|
||||||
rel="noopener noreferrer"
|
)}
|
||||||
className="text-secondary-blue hover:underline text-sm"
|
{isSoldOut && !isCancelled && (
|
||||||
>
|
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
|
||||||
View on map
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!event.externalBookingEnabled && (
|
<div className="space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
<CalendarIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{t('events.details.capacity')}</p>
|
<p className="font-medium text-sm">{t('events.details.date')}</p>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600" suppressHydrationWarning>{formatDate(event.startDatetime)}</p>
|
||||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
<div className="flex items-start gap-3">
|
||||||
<h2 className="font-semibold text-lg mb-4">About this event</h2>
|
<MapPinIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-gray-700 whitespace-pre-line" suppressHydrationWarning>
|
<div>
|
||||||
{locale === 'es' && event.descriptionEs
|
<p className="font-medium text-sm">{t('events.details.location')}</p>
|
||||||
? event.descriptionEs
|
<p className="text-gray-600">{event.location}</p>
|
||||||
: event.description}
|
{event.locationUrl && (
|
||||||
</p>
|
<a
|
||||||
</div>
|
href={event.locationUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-secondary-blue hover:underline text-sm"
|
||||||
|
>
|
||||||
|
View on map
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Social Sharing */}
|
{!event.externalBookingEnabled && (
|
||||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray" suppressHydrationWarning>
|
<div className="flex items-start gap-3">
|
||||||
<ShareButtons
|
<UserGroupIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
|
||||||
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
<div>
|
||||||
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Booking Card */}
|
{/* Desktop Booking Card - hidden on mobile, shown in sidebar on desktop */}
|
||||||
<div className="lg:col-span-1">
|
<div className="hidden lg:block lg:col-span-1">
|
||||||
<Card className="p-6 sticky top-24">
|
<Card className="p-6 sticky top-24">
|
||||||
<div className="text-center mb-6">
|
<BookingCardContent />
|
||||||
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
|
||||||
<p className="text-4xl font-bold text-primary-dark">
|
|
||||||
{event.price === 0
|
|
||||||
? t('events.details.free')
|
|
||||||
: formatPrice(event.price, event.currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canBook ? (
|
|
||||||
event.externalBookingEnabled && event.externalBookingUrl ? (
|
|
||||||
<a
|
|
||||||
href={event.externalBookingUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Button className="w-full" size="lg">
|
|
||||||
{t('events.booking.join')}
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<Link href={`/book/${event.id}`}>
|
|
||||||
<Button className="w-full" size="lg">
|
|
||||||
{t('events.booking.join')}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Button className="w-full" size="lg" disabled>
|
|
||||||
{isPastEvent
|
|
||||||
? t('events.details.eventEnded')
|
|
||||||
: isSoldOut
|
|
||||||
? t('events.details.soldOut')
|
|
||||||
: t('events.details.cancelled')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!event.externalBookingEnabled && (
|
|
||||||
<p className="mt-4 text-center text-sm text-gray-500">
|
|
||||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<p className="text-sm font-medium">
|
<div>
|
||||||
{ticket.payment.amount?.toLocaleString()} {ticket.payment.currency}
|
<p className="text-sm font-medium">
|
||||||
</p>
|
{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,46 +645,60 @@ export default function AdminPaymentsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pendingApprovalPayments.map((payment) => (
|
{pendingApprovalPayments.map((payment) => {
|
||||||
<Card key={payment.id} className="p-4">
|
const bookingInfo = getPendingBookingInfo(payment);
|
||||||
<div className="flex items-start justify-between">
|
return (
|
||||||
<div className="flex items-start gap-4">
|
<Card key={payment.id} className="p-4">
|
||||||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
|
<div className="flex items-start justify-between">
|
||||||
{getProviderIcon(payment.provider)}
|
<div className="flex items-start gap-4">
|
||||||
</div>
|
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
<div>
|
{getProviderIcon(payment.provider)}
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<p className="font-bold text-lg">{formatCurrency(payment.amount, payment.currency)}</p>
|
|
||||||
{getStatusBadge(payment.status)}
|
|
||||||
</div>
|
</div>
|
||||||
{payment.ticket && (
|
<div>
|
||||||
<p className="text-sm font-medium">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
<p className="font-bold text-lg">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
|
||||||
</p>
|
{bookingInfo.ticketCount > 1 && (
|
||||||
)}
|
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
|
||||||
{payment.event && (
|
📦 {bookingInfo.ticketCount} tickets × {formatCurrency(payment.amount, payment.currency)}
|
||||||
<p className="text-sm text-gray-500">{payment.event.title}</p>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
{getStatusBadge(payment.status)}
|
||||||
<span className="flex items-center gap-1">
|
</div>
|
||||||
{getProviderIcon(payment.provider)}
|
{payment.ticket && (
|
||||||
{getProviderLabel(payment.provider)}
|
<p className="text-sm font-medium">
|
||||||
</span>
|
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
||||||
{payment.userMarkedPaidAt && (
|
{bookingInfo.ticketCount > 1 && <span className="text-gray-400 font-normal"> +{bookingInfo.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{payment.event && (
|
||||||
|
<p className="text-sm text-gray-500">{payment.event.title}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<ClockIcon className="w-3 h-3" />
|
{getProviderIcon(payment.provider)}
|
||||||
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
{getProviderLabel(payment.provider)}
|
||||||
</span>
|
</span>
|
||||||
|
{payment.userMarkedPaidAt && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ClockIcon className="w-3 h-3" />
|
||||||
|
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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)}>
|
||||||
|
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setSelectedPayment(payment)}>
|
</Card>
|
||||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
);
|
||||||
</Button>
|
})}
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -671,67 +765,89 @@ export default function AdminPaymentsPage() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
payments.map((payment) => (
|
payments.map((payment) => {
|
||||||
<tr key={payment.id} className="hover:bg-gray-50">
|
const bookingInfo = getBookingInfo(payment);
|
||||||
<td className="px-6 py-4">
|
return (
|
||||||
{payment.ticket ? (
|
<tr key={payment.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{payment.ticket ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{payment.event ? (
|
||||||
|
<p className="text-sm">{payment.event.title}</p>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">
|
<p className="font-medium">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
|
||||||
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
{bookingInfo.ticketCount > 1 && (
|
||||||
</p>
|
<p className="text-xs text-purple-600 mt-1">
|
||||||
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
|
📦 {bookingInfo.ticketCount} × {formatCurrency(payment.amount, payment.currency)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</td>
|
||||||
<span className="text-gray-400 text-sm">-</span>
|
<td className="px-6 py-4">
|
||||||
)}
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
</td>
|
{getProviderIcon(payment.provider)}
|
||||||
<td className="px-6 py-4">
|
{getProviderLabel(payment.provider)}
|
||||||
{payment.event ? (
|
</div>
|
||||||
<p className="text-sm">{payment.event.title}</p>
|
</td>
|
||||||
) : (
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
<span className="text-gray-400 text-sm">-</span>
|
{formatDate(payment.createdAt)}
|
||||||
)}
|
</td>
|
||||||
</td>
|
<td className="px-6 py-4">
|
||||||
<td className="px-6 py-4 font-medium">
|
<div className="space-y-1">
|
||||||
{formatCurrency(payment.amount, payment.currency)}
|
{getStatusBadge(payment.status)}
|
||||||
</td>
|
{payment.ticket?.bookingId && (
|
||||||
<td className="px-6 py-4">
|
<p className="text-xs text-purple-600" title="Part of multi-ticket booking">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
📦 {locale === 'es' ? 'Grupo' : 'Group'}
|
||||||
{getProviderIcon(payment.provider)}
|
</p>
|
||||||
{getProviderLabel(payment.provider)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<td className="px-6 py-4">
|
||||||
{formatDate(payment.createdAt)}
|
<div className="flex items-center justify-end gap-2">
|
||||||
</td>
|
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
||||||
<td className="px-6 py-4">
|
<Button
|
||||||
{getStatusBadge(payment.status)}
|
size="sm"
|
||||||
</td>
|
onClick={() => setSelectedPayment(payment)}
|
||||||
<td className="px-6 py-4">
|
>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||||
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||||
<Button
|
</Button>
|
||||||
size="sm"
|
)}
|
||||||
onClick={() => setSelectedPayment(payment)}
|
{payment.status === 'paid' && (
|
||||||
>
|
<Button
|
||||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
size="sm"
|
||||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
variant="outline"
|
||||||
</Button>
|
onClick={() => handleRefund(payment.id)}
|
||||||
)}
|
>
|
||||||
{payment.status === 'paid' && (
|
<ArrowPathIcon className="w-4 h-4 mr-1" />
|
||||||
<Button
|
{t('admin.payments.refund')}
|
||||||
size="sm"
|
</Button>
|
||||||
variant="outline"
|
)}
|
||||||
onClick={() => handleRefund(payment.id)}
|
</div>
|
||||||
>
|
</td>
|
||||||
<ArrowPathIcon className="w-4 h-4 mr-1" />
|
</tr>
|
||||||
{t('admin.payments.refund')}
|
);
|
||||||
</Button>
|
})
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||