diff --git a/backend/.env.example b/backend/.env.example index 71c348b..c1339c5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -114,3 +114,16 @@ PENDING_BOOKING_TTL_MINUTES=30 # How often the cleanup job runs, in milliseconds (default: 300000 = 5 min) PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000 +# Auto-Hold Sweep +# Bookings awaiting admin payment approval are put on hold (seat released) after +# this many hours with no confirmation (default: 72) +HOLD_THRESHOLD_HOURS=72 +# How often the hold sweep job runs, in milliseconds (default: 900000 = 15 min) +HOLD_SWEEP_INTERVAL_MS=900000 + +# Ended-Event Payment Sweep +# Once an event is over, any unconfirmed payment (pending / pending_approval / +# on_hold) is auto-rejected and its ticket cancelled. No email is sent. +# How often this sweep runs, in milliseconds (default: 900000 = 15 min) +EVENT_END_SWEEP_INTERVAL_MS=900000 + diff --git a/backend/package.json b/backend/package.json index f2a5bed..a045340 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "hono": "^4.4.7", "ioredis": "^5.11.1", "jose": "^5.4.0", + "moment-timezone": "^0.6.2", "nanoid": "^5.0.7", "nodemailer": "^7.0.13", "pdfkit": "^0.17.2", diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 03c7ca6..b998a3b 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -238,6 +238,15 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`); } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); + } catch (e) { /* column may already exist */ } // Invoices table await (db as any).run(sql` @@ -719,6 +728,15 @@ async function migrate() { try { await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`); } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`); + } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`); + } catch (e) { /* column may already exist */ } // Invoices table await (db as any).execute(sql` diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index addab36..ee75c02 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -104,7 +104,7 @@ export const sqliteTickets = sqliteTable('tickets', { attendeePhone: text('attendee_phone'), attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing preferredLanguage: text('preferred_language'), - status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'), + status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold'] }).notNull().default('pending'), checkinAt: text('checkin_at'), checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in qrCode: text('qr_code'), @@ -119,8 +119,11 @@ export const sqlitePayments = sqliteTable('payments', { provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(), amount: real('amount').notNull(), currency: text('currency').notNull().default('PYG'), - 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', 'on_hold'] }).notNull().default('pending'), reference: text('reference'), + lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call + lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires + lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion) 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'), @@ -476,6 +479,9 @@ export const pgPayments = pgTable('payments', { currency: varchar('currency', { length: 10 }).notNull().default('PYG'), status: varchar('status', { length: 20 }).notNull().default('pending'), reference: varchar('reference', { length: 255 }), + lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call + lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires + lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion) userMarkedPaidAt: timestamp('user_marked_paid_at'), payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee paidAt: timestamp('paid_at'), diff --git a/backend/src/index.ts b/backend/src/index.ts index 9532ae7..d7773c9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,8 @@ import faqRoutes from './routes/faq.js'; import emailService from './lib/email.js'; import { initEmailQueue } from './lib/emailQueue.js'; import { startBookingCleanup } from './lib/bookingCleanup.js'; +import { startHoldSweep } from './lib/holdSweep.js'; +import { startEventEndSweep } from './lib/eventEndSweep.js'; import { getLock } from './lib/stores/lock.js'; import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js'; @@ -1932,6 +1934,12 @@ initEmailQueue(emailService); // Periodically expire abandoned pending bookings so they stop holding seats. startBookingCleanup(); +// Periodically put stale pending-approval payments on hold, releasing their seats. +startHoldSweep(); + +// Periodically auto-reject unconfirmed payments once their event is over (no email). +startEventEndSweep(); + // Initialize email templates on startup. // Guarded by a distributed lock so that, when running multiple replicas, only // one instance seeds/updates templates per boot instead of all of them racing. diff --git a/backend/src/lib/eventEndSweep.ts b/backend/src/lib/eventEndSweep.ts new file mode 100644 index 0000000..1b096e2 --- /dev/null +++ b/backend/src/lib/eventEndSweep.ts @@ -0,0 +1,124 @@ +// Auto-reject unconfirmed payments once their event is over. +// +// After an event ends, any booking whose payment was never confirmed +// (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored. +// This job silently fails those payments and cancels their tickets so they stop +// lingering as "pending" forever. It deliberately sends NO email — unlike the +// admin reject route, this is a housekeeping sweep and users are not notified. +// +// An event is considered over when COALESCE(end_datetime, start_datetime) is in +// the past. Updates are guarded by the current status so re-running is a no-op. + +import { and, eq, inArray } from 'drizzle-orm'; +import { db, dbAll, tickets, payments, events } from '../db/index.js'; +import { getNow } from './utils.js'; +import { getLock } from './stores/lock.js'; + +// Payment statuses that represent an unconfirmed booking. +const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold']; +// Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled). +const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold']; + +/** + * Fail unconfirmed payments (and cancel their tickets) for events that have + * already ended. Returns the number of payments rejected. + */ +export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise { + // Pull candidate rows first, then decide "ended" in JS so the comparison works + // identically for SQLite (ISO text) and Postgres (timestamp) datetime columns. + const rows = await dbAll<{ + paymentId: string; + ticketId: string; + endDatetime: string | Date | null; + startDatetime: string | Date | null; + }>( + (db as any) + .select({ + paymentId: (payments as any).id, + ticketId: (tickets as any).id, + endDatetime: (events as any).endDatetime, + startDatetime: (events as any).startDatetime, + }) + .from(payments) + .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id)) + .innerJoin(events, eq((tickets as any).eventId, (events as any).id)) + .where(and( + inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES), + inArray((tickets as any).status, ACTIVE_TICKET_STATUSES), + )) + ); + + const nowMs = Date.now(); + const ended = rows.filter((r) => { + const ref = r.endDatetime || r.startDatetime; + if (!ref) return false; + return new Date(ref as any).getTime() < nowMs; + }); + + if (ended.length === 0) return 0; + + const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId))); + const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id))); + const now = getNow(); + + // Fail the payments. The status guard keeps this idempotent and avoids + // clobbering anything that changed since we read the candidates. + await (db as any) + .update(payments) + .set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now }) + .where(and( + inArray((payments as any).id, paymentIds), + inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES), + )); + + // Cancel the associated tickets, freeing any seats they still hold. + if (ticketIds.length > 0) { + await (db as any) + .update(tickets) + .set({ status: 'cancelled' }) + .where(and( + inArray((tickets as any).id, ticketIds), + inArray((tickets as any).status, ACTIVE_TICKET_STATUSES), + )); + } + + console.log( + `[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` + + `cancelled ${ticketIds.length} ticket(s).` + ); + return paymentIds.length; +} + +let sweepTimer: ReturnType | null = null; + +/** + * Start a periodic sweep that auto-rejects unconfirmed payments for ended + * events. Each run is guarded by a distributed lock so that, across multiple + * replicas, only one instance does the work per interval. + */ +export function startEventEndSweep(): void { + const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min + + const run = () => { + getLock() + .withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () => + rejectUnconfirmedPaymentsForEndedEvents() + ) + .catch((err) => + console.error('[EventEndSweep] Run failed:', err?.message || err) + ); + }; + + // Run shortly after startup, then on the interval. + setTimeout(run, 60_000).unref?.(); + sweepTimer = setInterval(run, intervalMs); + sweepTimer.unref?.(); + console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`); +} + +export function stopEventEndSweep(): void { + if (sweepTimer) { + clearInterval(sweepTimer); + sweepTimer = null; + } +} diff --git a/backend/src/lib/holdRecovery.ts b/backend/src/lib/holdRecovery.ts new file mode 100644 index 0000000..ebdb5c5 --- /dev/null +++ b/backend/src/lib/holdRecovery.ts @@ -0,0 +1,116 @@ +// Shared capacity-checked recovery for on-hold bookings. +// +// When a booking is put on hold, its ticket(s) drop out of the capacity-counting +// statuses ('pending', 'confirmed', 'checked_in'), releasing the seat. Recovering +// an on-hold booking (user "I've paid" again, or an admin reactivating / marking it +// paid) must atomically re-check that the event still has room before re-reserving +// the seat, exactly like the original booking-creation flow in routes/tickets.ts. + +import { eq, and, inArray, sql } from 'drizzle-orm'; +import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js'; +import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js'; + +export class HoldCapacityError extends Error { + constructor(public available: number) { + super('EVENT_FULL'); + } +} + +interface ReserveOptions { + paidByAdminId?: string; + extraPaymentFields?: Record; +} + +/** + * Re-reserve seats for a group of on-hold tickets (e.g. all tickets sharing a + * bookingId), atomically re-checking capacity before flipping their status. + * Throws HoldCapacityError if the event no longer has room for ticketIds.length seats. + */ +export async function reserveOnHoldBooking( + eventId: string, + ticketIds: string[], + targetTicketStatus: 'pending' | 'confirmed', + targetPaymentStatus: 'pending_approval' | 'paid', + options: ReserveOptions = {} +): Promise { + if (ticketIds.length === 0) return; + + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, eventId)) + ); + if (!event) { + throw new Error('Event not found'); + } + + const now = getNow(); + const paymentUpdate: Record = { + status: targetPaymentStatus, + updatedAt: now, + ...options.extraPaymentFields, + }; + if (targetPaymentStatus === 'paid') { + paymentUpdate.paidAt = now; + if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId; + } + + const assertCapacity = (reserved: number) => { + if (isEventSoldOut(event.capacity, reserved)) { + throw new HoldCapacityError(0); + } + const seatsLeft = calculateAvailableSeats(event.capacity, reserved); + if (ticketIds.length > seatsLeft) { + throw new HoldCapacityError(seatsLeft); + } + }; + + if (isSqlite()) { + (db as any).transaction((tx: any) => { + const countRow = tx + .select({ count: sql`count(*)` }) + .from(tickets) + .where(and( + eq((tickets as any).eventId, eventId), + sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` + )) + .get(); + assertCapacity(Number(countRow?.count || 0)); + + tx.update(tickets) + .set({ status: targetTicketStatus }) + .where(and( + inArray((tickets as any).id, ticketIds), + eq((tickets as any).status, 'on_hold') + )) + .run(); + + tx.update(payments) + .set(paymentUpdate) + .where(inArray((payments as any).ticketId, ticketIds)) + .run(); + }); + } else { + await (db as any).transaction(async (tx: any) => { + const countRow = await dbGet( + tx + .select({ count: sql`count(*)` }) + .from(tickets) + .where(and( + eq((tickets as any).eventId, eventId), + sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` + )) + ); + assertCapacity(Number(countRow?.count || 0)); + + await tx.update(tickets) + .set({ status: targetTicketStatus }) + .where(and( + inArray((tickets as any).id, ticketIds), + eq((tickets as any).status, 'on_hold') + )); + + await tx.update(payments) + .set(paymentUpdate) + .where(inArray((payments as any).ticketId, ticketIds)); + }); + } +} diff --git a/backend/src/lib/holdSweep.ts b/backend/src/lib/holdSweep.ts new file mode 100644 index 0000000..ebc5905 --- /dev/null +++ b/backend/src/lib/holdSweep.ts @@ -0,0 +1,98 @@ +// Auto-hold stale pending-approval bookings. +// +// A payment enters 'pending_approval' when a user clicks "I've paid" on a manual +// payment method (bank transfer / TPago) and is waiting for an admin to review it. +// If no admin acts within HOLD_THRESHOLD_HOURS, this job silently moves the payment +// (and its ticket) to 'on_hold', which drops it out of the capacity-counting statuses +// ('pending', 'confirmed', 'checked_in') and so releases the seat back to the event. +// The user receives no notification — they can recover via "I've paid" again, and an +// admin can reactivate or mark it paid directly, both re-checking capacity. + +import { and, eq, lt, inArray } from 'drizzle-orm'; +import { db, dbAll, tickets, payments } from '../db/index.js'; +import { getNow, toDbDate } from './utils.js'; +import { getLock } from './stores/lock.js'; + +function getThresholdMs(): number { + const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10); + return (Number.isFinite(hours) && hours > 0 ? hours : 72) * 60 * 60 * 1000; +} + +/** + * Move stale pending-approval payments (and their tickets) to 'on_hold'. + * Returns the number of payments put on hold. + */ +export async function sweepStaleApprovals(): Promise { + const cutoff = toDbDate(new Date(Date.now() - getThresholdMs())); + + const stale = await dbAll<{ ticketId: string | null; paymentId: string }>( + (db as any) + .select({ + ticketId: (payments as any).ticketId, + paymentId: (payments as any).id, + }) + .from(payments) + .where(and( + eq((payments as any).status, 'pending_approval'), + lt((payments as any).createdAt, cutoff) + )) + ); + + if (stale.length === 0) return 0; + + const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id); + const paymentIds = stale.map((s) => s.paymentId); + const now = getNow(); + + await (db as any) + .update(payments) + .set({ status: 'on_hold', updatedAt: now }) + .where(inArray((payments as any).id, paymentIds)); + + if (ticketIds.length > 0) { + await (db as any) + .update(tickets) + .set({ status: 'on_hold' }) + .where(and( + inArray((tickets as any).id, ticketIds), + eq((tickets as any).status, 'pending') + )); + } + + console.log(`[HoldSweep] Put ${stale.length} stale pending-approval payment(s) on hold.`); + return stale.length; +} + +let sweepTimer: ReturnType | null = null; + +/** + * Start a periodic sweep of stale pending-approval payments. Each run is guarded by + * a distributed lock so that, across multiple replicas, only one instance does the + * work per interval. + */ +export function startHoldSweep(): void { + const intervalMs = parseInt(process.env.HOLD_SWEEP_INTERVAL_MS || '900000', 10); // 15 min + + const run = () => { + getLock() + .withLock('sweep-hold-stale-approvals', Math.min(intervalMs, 60_000), () => + sweepStaleApprovals() + ) + .catch((err) => + console.error('[HoldSweep] Run failed:', err?.message || err) + ); + }; + + // Run shortly after startup, then on the interval. + setTimeout(run, 45_000).unref?.(); + sweepTimer = setInterval(run, intervalMs); + sweepTimer.unref?.(); + console.log(`[HoldSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`); +} + +export function stopHoldSweep(): void { + if (sweepTimer) { + clearInterval(sweepTimer); + sweepTimer = null; + } +} diff --git a/backend/src/lib/lnbits.ts b/backend/src/lib/lnbits.ts index 66cc131..bd2bc87 100644 --- a/backend/src/lib/lnbits.ts +++ b/backend/src/lib/lnbits.ts @@ -36,6 +36,11 @@ export interface CreateInvoiceParams { extra?: Record; // Additional metadata } +// How long a booking's Lightning invoice is valid for, in seconds. Shared +// between initial booking creation and invoice regeneration so both produce +// invoices with the same lifetime. +export const LNBITS_INVOICE_EXPIRY_SECONDS = 900; // 15 minutes + /** * Check if LNbits is configured */ diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts index ac4e7da..103ded7 100644 --- a/backend/src/lib/utils.ts +++ b/backend/src/lib/utils.ts @@ -1,5 +1,6 @@ import { nanoid } from 'nanoid'; import { randomUUID } from 'crypto'; +import moment from 'moment-timezone'; /** * Get database type (reads env var each time to handle module loading order) @@ -59,17 +60,10 @@ export function parseEventDatetime( return new Date(datetime); } - // Treat the digits as UTC so we have a stable reference instant. - const fakeUTC = new Date(datetime + 'Z'); - - // Ask Intl what that UTC instant looks like in both UTC and the target tz. - const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' }); - const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone }); - - // The gap between the two tells us the tz offset at this point in time. - const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime(); - - return new Date(fakeUTC.getTime() + offsetMs); + // Interpret the wall-clock digits as local time in `timezone` using + // moment-timezone's bundled IANA data. This keeps the conversion correct + // regardless of the host Node runtime's (possibly stale) tz database. + return moment.tz(datetime, timezone).toDate(); } /** diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index c9327a8..c7a355f 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -76,7 +76,14 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => .from(payments) .where(eq((payments as any).status, 'pending')) ); - + + const onHoldPayments = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(payments) + .where(eq((payments as any).status, 'on_hold')) + ); + const revenueRow = await dbGet( (db as any) .select({ total: sql`COALESCE(SUM(${(payments as any).amount}), 0)` }) @@ -108,6 +115,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => totalTickets: totalTickets?.count || 0, confirmedTickets: confirmedTickets?.count || 0, pendingPayments: pendingPayments?.count || 0, + onHoldPayments: onHoldPayments?.count || 0, totalRevenue, newContacts: newContacts?.count || 0, totalSubscribers: totalSubscribers?.count || 0, @@ -213,6 +221,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => { userName: user?.name, userEmail: user?.email, userPhone: user?.phone, + attendeeRuc: ticket.attendeeRuc || user?.rucNumber || null, eventTitle: event?.title, eventDate: event?.startDatetime, paymentStatus: payment?.status, @@ -291,6 +300,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy 'Full Name': fullName, 'Email': ticket.attendeeEmail || '', 'Phone': ticket.attendeePhone || '', + 'RUC': ticket.attendeeRuc || '', 'Status': ticket.status, 'Checked In': isCheckedIn ? 'true' : 'false', 'Check-in Time': ticket.checkinAt || '', @@ -302,7 +312,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy ); const columns = [ - 'Ticket ID', 'Full Name', 'Email', 'Phone', + 'Ticket ID', 'Full Name', 'Email', 'Phone', 'RUC', 'Status', 'Checked In', 'Check-in Time', 'Payment Status', 'Booked At', 'Notes', ]; @@ -380,12 +390,13 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async }); } - const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At']; + const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'RUC', 'Status', 'Check-in Time', 'Booked At']; const rows = ticketList.map((ticket: any) => ({ 'Ticket ID': ticket.id, 'Booking ID': ticket.bookingId || '', 'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '), + 'RUC': ticket.attendeeRuc || '', 'Status': ticket.status, 'Check-in Time': ticket.checkinAt || '', 'Booked At': ticket.createdAt || '', @@ -458,6 +469,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => { attendeeFirstName: ticket.attendeeFirstName, attendeeLastName: ticket.attendeeLastName, attendeeEmail: ticket.attendeeEmail, + attendeeRuc: ticket.attendeeRuc || null, eventId: event?.id, eventTitle: event?.title, eventDate: event?.startDatetime, diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index 1493071..af34907 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -1,9 +1,15 @@ import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; -import { db, dbGet, dbAll, tickets, payments } from '../db/index.js'; -import { eq, and } from 'drizzle-orm'; -import { getNow } from '../lib/utils.js'; -import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js'; +import { db, dbGet, dbAll, tickets, payments, events } from '../db/index.js'; +import { eq, and, inArray } from 'drizzle-orm'; +import { getNow, toDbDate } from '../lib/utils.js'; +import { + verifyWebhookPayment, + getPaymentStatus, + createInvoice, + isLNbitsConfigured, + LNBITS_INVOICE_EXPIRY_SECONDS, +} from '../lib/lnbits.js'; import emailService from '../lib/email.js'; import { getPubSub } from '../lib/stores/pubsub.js'; import { getLock } from '../lib/stores/lock.js'; @@ -391,6 +397,145 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { }); }); +/** + * Get a Lightning invoice for a ticket to pay or re-pay. + * + * Reuses the stored invoice if it still has more than 5 minutes of validity + * left; otherwise generates a fresh one from LNbits. This is what lets a user + * come back to an unpaid Lightning booking later (e.g. from "Pay now" on the + * dashboard) instead of hitting a dead end. + */ +lnbitsRouter.post('/invoice/:ticketId', async (c) => { + const ticketId = c.req.param('ticketId'); + + const ticket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) + ); + if (!ticket) { + return c.json({ error: 'Ticket not found' }, 404); + } + + const payment = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId)) + ); + if (!payment) { + return c.json({ error: 'Payment not found' }, 404); + } + + if (payment.provider !== 'lightning') { + return c.json({ error: 'This booking is not a Lightning payment' }, 400); + } + + if (ticket.status === 'confirmed' || payment.status === 'paid') { + return c.json({ alreadyPaid: true }); + } + + if (ticket.status !== 'pending' || payment.status !== 'pending') { + return c.json({ + error: 'This booking is no longer active. Please make a new booking.', + }, 400); + } + + // Gather every ticket/payment in the booking group - a multi-ticket booking + // shares a single Lightning invoice for the combined total. + let groupTickets: any[] = [ticket]; + if (ticket.bookingId) { + groupTickets = await dbAll( + (db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + } + const groupPayments = await dbAll( + (db as any).select().from(payments).where(inArray((payments as any).ticketId, groupTickets.map((t: any) => t.id))) + ); + const invoiceHolder = groupPayments.find((p: any) => p.reference) || payment; + const totalAmount = groupPayments.reduce((sum: number, p: any) => sum + Number(p.amount), 0); + const currency = invoiceHolder.currency; + + const now = Date.now(); + const FIVE_MIN_MS = 5 * 60 * 1000; + const expiresAtMs = invoiceHolder.lnbitsExpiresAt ? new Date(invoiceHolder.lnbitsExpiresAt).getTime() : 0; + + if (invoiceHolder.reference && invoiceHolder.lnbitsInvoice && expiresAtMs - now > FIVE_MIN_MS) { + return c.json({ + invoice: { + paymentHash: invoiceHolder.reference, + paymentRequest: invoiceHolder.lnbitsInvoice, + amount: invoiceHolder.lnbitsAmountSats || 0, + fiatAmount: totalAmount, + fiatCurrency: currency, + expiresAt: invoiceHolder.lnbitsExpiresAt, + }, + reused: true, + }); + } + + // No usable stored invoice (never created, expired, or expiring soon) - get a fresh one. + if (!isLNbitsConfigured()) { + return c.json({ error: 'Bitcoin Lightning payments are not available at this time' }, 400); + } + + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)) + ); + + const apiUrl = process.env.API_URL || 'http://localhost:3001'; + const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || ''; + const webhookUrl = webhookSecret + ? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}` + : `${apiUrl}/api/lnbits/webhook`; + const attendeeName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); + + try { + const lnbitsInvoice = await createInvoice({ + amount: totalAmount, + unit: currency, + memo: `Spanglish: ${event?.title || 'Event'} - ${attendeeName}${groupTickets.length > 1 ? ` (${groupTickets.length} tickets)` : ''}`, + webhookUrl, + expiry: LNBITS_INVOICE_EXPIRY_SECONDS, + extra: { + ticketId: invoiceHolder.ticketId, + bookingId: ticket.bookingId || null, + ticketIds: groupTickets.map((t: any) => t.id), + eventId: ticket.eventId, + eventTitle: event?.title, + attendeeName, + attendeeEmail: ticket.attendeeEmail, + ticketCount: groupTickets.length, + }, + }); + + const lnbitsExpiresAt = toDbDate(new Date(now + LNBITS_INVOICE_EXPIRY_SECONDS * 1000)); + + await (db as any) + .update(payments) + .set({ + reference: lnbitsInvoice.paymentHash, + lnbitsInvoice: lnbitsInvoice.paymentRequest, + lnbitsExpiresAt, + lnbitsAmountSats: lnbitsInvoice.amount, + updatedAt: getNow(), + }) + .where(eq((payments as any).id, invoiceHolder.id)); + + return c.json({ + invoice: { + paymentHash: lnbitsInvoice.paymentHash, + paymentRequest: lnbitsInvoice.paymentRequest, + amount: lnbitsInvoice.amount, + fiatAmount: lnbitsInvoice.fiatAmount ?? totalAmount, + fiatCurrency: lnbitsInvoice.fiatCurrency ?? currency, + expiresAt: lnbitsExpiresAt, + }, + reused: false, + }); + } catch (error: any) { + console.error('Failed to create Lightning invoice:', error); + return c.json({ + error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`, + }, 500); + } +}); + /** * Get payment status for a ticket (fallback polling endpoint) */ diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index f480479..46dc1c1 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -2,15 +2,16 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js'; -import { eq, desc, and, or, sql } from 'drizzle-orm'; +import { eq, desc, and, or, sql, inArray } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { getNow } from '../lib/utils.js'; import emailService from '../lib/email.js'; +import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js'; const paymentsRouter = new Hono(); const updatePaymentSchema = z.object({ - status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']), + status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'on_hold']), reference: z.string().optional(), adminNote: z.string().optional(), }); @@ -85,6 +86,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => { attendeeLastName: ticket.attendeeLastName, attendeeEmail: ticket.attendeeEmail, attendeePhone: ticket.attendeePhone, + attendeeRuc: ticket.attendeeRuc, status: ticket.status, } : null, event: event ? { @@ -95,7 +97,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => { }; }) ); - + // Filter by event(s) if (eventId) { enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId); @@ -164,12 +166,13 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy // Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { - const [totalRow, pendingRow, paidRow, refundedRow, failedRow, revenueRow] = await Promise.all([ + const [totalRow, pendingRow, paidRow, refundedRow, failedRow, onHoldRow, revenueRow] = await Promise.all([ dbGet((db as any).select({ count: sql`count(*)` }).from(payments)), dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))), dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))), dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))), dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))), + dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'on_hold'))), dbGet((db as any).select({ total: sql`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))), ]); @@ -180,6 +183,7 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { paid: Number(paidRow?.count || 0), refunded: Number(refundedRow?.count || 0), failed: Number(failedRow?.count || 0), + onHold: Number(onHoldRow?.count || 0), totalRevenue: Number(revenueRow?.total || 0), }, }); @@ -317,13 +321,13 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida return c.json({ error: 'Payment not found' }, 404); } - // Can approve pending or pending_approval payments - if (!['pending', 'pending_approval'].includes(payment.status)) { + // Can approve pending, pending_approval, or on_hold payments + if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) { return c.json({ error: 'Payment cannot be approved in its current state' }, 400); } - + const now = getNow(); - + // Get the ticket associated with this payment const ticket = await dbGet( (db as any) @@ -331,10 +335,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida .from(tickets) .where(eq((tickets as any).id, payment.ticketId)) ); - + // Check if this is part of a multi-ticket booking let ticketsToConfirm: any[] = [ticket]; - + if (ticket?.bookingId) { // Get all tickets in this booking ticketsToConfirm = await dbAll( @@ -345,27 +349,54 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida ); 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)); + + if (payment.status === 'on_hold') { + // The seat was released when this booking went on hold - re-check capacity + // before confirming it directly. + try { + await reserveOnHoldBooking( + ticket.eventId, + ticketsToConfirm.map((t: any) => t.id), + 'confirmed', + 'paid', + { paidByAdminId: user.id } + ); + } catch (err) { + if (err instanceof HoldCapacityError) { + return c.json({ + error: 'This event is now full. Your spot was released after the payment deadline passed.', + }, 400); + } + throw err; + } + if (adminNote) { + await (db as any) + .update(payments) + .set({ adminNote }) + .where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id))); + } + } else { + // 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 (if sendEmail is true, which is the default) if (sendEmail !== false) { Promise.all([ @@ -405,7 +436,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat return c.json({ error: 'Payment not found' }, 404); } - if (!['pending', 'pending_approval'].includes(payment.status)) { + if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) { return c.json({ error: 'Payment cannot be rejected in its current state' }, 400); } @@ -464,6 +495,59 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' }); }); +// Reactivate an on-hold payment back to pending_approval (admin) - re-reserves the seat +paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), async (c) => { + const id = c.req.param('id'); + + const payment = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).id, id)) + ); + + if (!payment) { + return c.json({ error: 'Payment not found' }, 404); + } + + if (payment.status !== 'on_hold') { + return c.json({ error: 'Only on-hold payments can be reactivated' }, 400); + } + + const ticket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId)) + ); + if (!ticket) { + return c.json({ error: 'Ticket not found' }, 404); + } + + let ticketsToReactivate: any[] = [ticket]; + if (ticket.bookingId) { + ticketsToReactivate = await dbAll( + (db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + } + + try { + await reserveOnHoldBooking( + ticket.eventId, + ticketsToReactivate.map((t: any) => t.id), + 'pending', + 'pending_approval' + ); + } catch (err) { + if (err instanceof HoldCapacityError) { + return c.json({ + error: 'This event is now full. Your spot was released after the payment deadline passed.', + }, 400); + } + throw err; + } + + const updated = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).id, id)) + ); + + return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' }); +}); + // Send payment reminder email paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => { const id = c.req.param('id'); diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 007e6b4..08fcde1 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -4,11 +4,12 @@ import { z } from 'zod'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js'; import { eq, and, or, sql, inArray } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; -import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; +import { generateId, generateTicketCode, getNow, toDbDate, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; +import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; import emailService from '../lib/email.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; +import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js'; const ticketsRouter = new Hono(); @@ -53,7 +54,7 @@ function isPaymentMethodEnabled(method: string, merged: Record): bo } const updateTicketSchema = z.object({ - status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(), + status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold']).optional(), adminNote: z.string().optional(), }); @@ -167,12 +168,21 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { phone: data.phone || null, role: 'user', languagePreference: null, + rucNumber: data.ruc || null, createdAt: now, updatedAt: now, }; await (db as any).insert(users).values(user); + } else if (data.ruc) { + // Keep the user's saved RUC up to date for future bookings, but never blank + // out an existing value if this booking didn't include one. + await (db as any) + .update(users) + .set({ rucNumber: data.ruc, updatedAt: now }) + .where(eq((users as any).id, user.id)); + user.rucNumber = data.ruc; } - + // Check for duplicate booking (unless allowDuplicateBookings is enabled) const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false; @@ -408,7 +418,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc. memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`, webhookUrl, - expiry: 900, // 15 minutes expiry for faster UX + expiry: LNBITS_INVOICE_EXPIRY_SECONDS, // 15 minutes expiry for faster UX extra: { ticketId: primaryTicket.id, bookingId: ticketCount > 1 ? bookingId : null, @@ -420,13 +430,22 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { ticketCount, }, }); - - // Update primary payment with LNbits payment hash reference + + const lnbitsExpiresAt = toDbDate(new Date(Date.now() + LNBITS_INVOICE_EXPIRY_SECONDS * 1000)); + + // Update primary payment with the LNbits invoice - the BOLT11 string and + // expiry are persisted so the "Pay now" page can redisplay this same + // invoice later instead of erroring out on an unpaid Lightning booking. await (db as any) .update(payments) - .set({ reference: lnbitsInvoice.paymentHash }) + .set({ + reference: lnbitsInvoice.paymentHash, + lnbitsInvoice: lnbitsInvoice.paymentRequest, + lnbitsExpiresAt, + lnbitsAmountSats: lnbitsInvoice.amount, + }) .where(eq((payments as any).id, primaryPayment.id)); - + (primaryPayment as any).reference = lnbitsInvoice.paymentHash; } catch (error: any) { console.error('Failed to create Lightning invoice:', error); @@ -1104,26 +1123,47 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] ); } - // 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)); + if (ticket.status === 'on_hold') { + // The seat was released when this booking went on hold - re-check capacity + // before confirming it directly. + try { + await reserveOnHoldBooking( + ticket.eventId, + ticketsToConfirm.map((t: any) => t.id), + 'confirmed', + 'paid', + { paidByAdminId: user.id } + ); + } catch (err) { + if (err instanceof HoldCapacityError) { + return c.json({ + error: 'This event is now full. Your spot was released after the payment deadline passed.', + }, 400); + } + throw err; + } + } else { + // Confirm all tickets in the booking + for (const t of ticketsToConfirm) { + // Update ticket status + await (db as any) + .update(tickets) + .set({ status: 'confirmed' }) + .where(eq((tickets as any).id, t.id)); + + // Update payment status + await (db as any) + .update(payments) + .set({ + status: 'paid', + paidAt: now, + paidByAdminId: user.id, + updatedAt: now, + }) + .where(eq((payments as any).ticketId, t.id)); + } } - + // Get payment for sending receipt const payment = await dbGet( (db as any) @@ -1194,18 +1234,55 @@ ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, wind } if (payment.status === 'paid') { - return c.json({ - payment, + return c.json({ + payment, message: 'Payment has already been confirmed.', alreadyProcessed: true, }); } - + + // A booking that was auto-released after the hold threshold: recover it by + // re-reserving the seat(s) and moving back into the admin approval queue. + if (payment.status === 'on_hold') { + let ticketsToRecover: any[] = [ticket]; + if (ticket.bookingId) { + ticketsToRecover = await dbAll( + (db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId)) + ); + } + + try { + await reserveOnHoldBooking( + ticket.eventId, + ticketsToRecover.map((t: any) => t.id), + 'pending', + 'pending_approval', + { extraPaymentFields: { userMarkedPaidAt: getNow(), payerName: payerName?.trim() || null } } + ); + } catch (err) { + if (err instanceof HoldCapacityError) { + return c.json({ + error: 'This event is now full. Your spot was released after the payment deadline passed.', + }, 400); + } + throw err; + } + + const recoveredPayment = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).id, payment.id)) + ); + + return c.json({ + payment: recoveredPayment, + message: 'Payment marked as sent. Waiting for admin approval.', + }); + } + // Only allow if currently pending if (payment.status !== 'pending') { return c.json({ error: 'Payment has already been processed' }, 400); } - + const now = getNow(); // Update payment status to pending_approval for this ticket and any siblings diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 3773812..5c21728 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -2,9 +2,9 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js'; -import { eq, desc, sql } from 'drizzle-orm'; +import { eq, desc, sql, and, gte, lte } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; -import { getNow } from '../lib/utils.js'; +import { getNow, toDbDate } from '../lib/utils.js'; interface UserContext { id: string; @@ -27,7 +27,43 @@ const updateUserSchema = z.object({ // Get all users (admin only) usersRouter.get('/', requireAuth(['admin']), async (c) => { const role = c.req.query('role'); - + const search = c.req.query('search'); + const accountStatus = c.req.query('accountStatus'); + const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no' + const registeredAfter = c.req.query('registeredAfter'); + const registeredBefore = c.req.query('registeredBefore'); + const eventId = c.req.query('eventId'); + const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1); + const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200); + + const conditions: any[] = []; + if (role) conditions.push(eq((users as any).role, role)); + if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus)); + if (registeredAfter) conditions.push(gte((users as any).createdAt, toDbDate(registeredAfter))); + if (registeredBefore) conditions.push(lte((users as any).createdAt, toDbDate(registeredBefore))); + if (search) { + const like = `%${search.toLowerCase()}%`; + conditions.push(sql`( + LOWER(${(users as any).name}) LIKE ${like} + OR LOWER(${(users as any).email}) LIKE ${like} + OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like} + )`); + } + if (hasBookings === 'yes') { + conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`); + } else if (hasBookings === 'no') { + conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`); + } + if (eventId) { + conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`); + } + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const totalQuery = whereClause + ? (db as any).select({ count: sql`count(*)` }).from(users).where(whereClause) + : (db as any).select({ count: sql`count(*)` }).from(users); + const totalRow = await dbGet(totalQuery); + let query = (db as any).select({ id: (users as any).id, email: (users as any).email, @@ -40,14 +76,16 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => { accountStatus: (users as any).accountStatus, createdAt: (users as any).createdAt, }).from(users); - - if (role) { - query = query.where(eq((users as any).role, role)); + + if (whereClause) { + query = query.where(whereClause); } - - const result = await dbAll(query.orderBy(desc((users as any).createdAt))); - - return c.json({ users: result }); + + const result = await dbAll( + query.orderBy(desc((users as any).createdAt)).limit(pageSize).offset((page - 1) * pageSize) + ); + + return c.json({ users: result, total: Number(totalRow?.count || 0), page, pageSize }); }); // Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id diff --git a/deploy/front-end_nginx.conf b/deploy/front-end_nginx.conf index 30b7c88..9b96a40 100644 --- a/deploy/front-end_nginx.conf +++ b/deploy/front-end_nginx.conf @@ -18,11 +18,28 @@ server { } } +# Canonical host is the apex (non-www). Redirect the www HTTPS vhost to it with a +# 301 so only one host is served and indexed. server { listen 443 ssl; http2 on; - server_name spanglishcommunity.com www.spanglishcommunity.com; + server_name www.spanglishcommunity.com; + + ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem; + + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + return 301 https://spanglishcommunity.com$request_uri; +} + +server { + listen 443 ssl; + http2 on; + + server_name spanglishcommunity.com; # Upload size limit (covers same-origin /api uploads via this vhost) client_max_body_size 20m; diff --git a/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx b/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx index 46cb494..b495d7b 100644 --- a/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx +++ b/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx @@ -335,7 +335,7 @@ export function BookingFormStep({ + + + + ); + } + + if (!lightningInvoice) { + return ( +
+
+
+

+ {locale === 'es' ? 'Preparando tu factura...' : 'Preparing your invoice...'} +

+
+
+ ); + } + + return ; + } + // Manual payment step - show payment details and "I have paid" button if (step === 'manual_payment' && ticket && paymentConfig) { const isBankTransfer = ticket.payment?.provider === 'bank_transfer'; diff --git a/frontend/src/app/(public)/community/layout.tsx b/frontend/src/app/(public)/community/layout.tsx index 51dac59..4a731b5 100644 --- a/frontend/src/app/(public)/community/layout.tsx +++ b/frontend/src/app/(public)/community/layout.tsx @@ -3,6 +3,9 @@ import type { Metadata } from 'next'; export const metadata: Metadata = { title: 'Join Our Language Exchange Community', description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.', + alternates: { + canonical: '/community', + }, openGraph: { title: 'Join Our Language Exchange Community – Spanglish', description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.', diff --git a/frontend/src/app/(public)/components/HomepageFaqSection.tsx b/frontend/src/app/(public)/components/HomepageFaqSection.tsx index bf07678..a816d8f 100644 --- a/frontend/src/app/(public)/components/HomepageFaqSection.tsx +++ b/frontend/src/app/(public)/components/HomepageFaqSection.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; import { faqApi, FaqItem } from '@/lib/api'; +import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton'; import { ChevronDownIcon } from '@heroicons/react/24/outline'; import Link from 'next/link'; import clsx from 'clsx'; @@ -23,7 +24,20 @@ export default function HomepageFaqSection() { return () => { cancelled = true; }; }, []); - if (loading || faqs.length === 0) { + if (loading) { + return ( +
+
+
+ + +
+
+
+ ); + } + + if (faqs.length === 0) { return null; } diff --git a/frontend/src/app/(public)/components/NextEventSection.tsx b/frontend/src/app/(public)/components/NextEventSection.tsx index 534df66..4dff0be 100644 --- a/frontend/src/app/(public)/components/NextEventSection.tsx +++ b/frontend/src/app/(public)/components/NextEventSection.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; import { formatPrice, formatDateLong, formatTime } from '@/lib/utils'; +import { FeaturedEventSkeleton } from '@/components/ui/Skeleton'; import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline'; interface NextEventSectionProps { @@ -51,11 +52,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps : ''; if (loading) { - return ( -
-
-
- ); + return ; } if (!nextEvent) { diff --git a/frontend/src/app/(public)/contact/layout.tsx b/frontend/src/app/(public)/contact/layout.tsx index 91e100d..3d3e90e 100644 --- a/frontend/src/app/(public)/contact/layout.tsx +++ b/frontend/src/app/(public)/contact/layout.tsx @@ -3,6 +3,9 @@ import type { Metadata } from 'next'; export const metadata: Metadata = { title: 'Contact Us', description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.', + alternates: { + canonical: '/contact', + }, openGraph: { title: 'Contact Us – Spanglish', description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.', diff --git a/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx b/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx index 83dd86a..e07845e 100644 --- a/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx @@ -22,6 +22,8 @@ import { groupByBooking, isUnpaid, isAwaitingApproval, + isOnHold, + isActionableAttention, ticketAmount, shareTicket, isToday, @@ -57,11 +59,14 @@ export default function OverviewTab({ // awaiting approval), ordered by soonest event. const attentionTicket = useMemo(() => { const candidates = activeTickets.filter( - (t) => isUnpaid(t) || isAwaitingApproval(t) + (t) => + isActionableAttention(t) && + (isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t)) ); + const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2); candidates.sort((a, b) => { - const aUnpaid = isUnpaid(a) ? 0 : 1; - const bUnpaid = isUnpaid(b) ? 0 : 1; + const aUnpaid = priority(a); + const bUnpaid = priority(b); if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid; const aStart = a.event?.startDatetime ? parseDate(a.event.startDatetime).getTime() @@ -190,7 +195,19 @@ export default function OverviewTab({
- {isUnpaid(t) ? ( + {isOnHold(t) ? ( + + ) : isUnpaid(t) ? ( + ) : isUnpaid(ticket) ? (
@@ -131,7 +132,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
{/* Actions */}
- {isUnpaid(ticket) ? ( + {isOnHold(ticket) ? ( + + ) : isUnpaid(ticket) ? ( +
+ +
+

+ {locale === 'es' + ? `Tu lugar para ${eventTitle} fue liberado` + : `Your spot for ${eventTitle} was released`} +

+

+ {locale === 'es' + ? `El pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.` + : `Payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`} +

+
+
+
+ +
+
+ ); + } + if (isAwaitingApproval(ticket)) { return (
diff --git a/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx b/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx index 0729d3d..3e2ffdb 100644 --- a/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx +++ b/frontend/src/app/(public)/dashboard/components/_shared/PayActions.tsx @@ -23,6 +23,11 @@ interface PayActionsProps { /** Stack the two buttons full-width (cards) vs inline (rows). */ layout?: 'stack' | 'inline'; className?: string; + /** + * The booking's spot was released after the hold threshold passed. Hides the + * "Pay now" link (money was already sent) and labels the retry "Rebook". + */ + onHold?: boolean; } /** @@ -41,6 +46,7 @@ export default function PayActions({ size = 'sm', layout = 'stack', className, + onHold = false, }: PayActionsProps) { const { t } = useLanguage(); const [confirming, setConfirming] = useState(false); @@ -76,18 +82,22 @@ export default function PayActions({ return ( <>
- - - + {!onHold && ( + + + + )}
@@ -108,16 +118,24 @@ export default function PayActions({

- {locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?'} + {onHold + ? (locale === 'es' ? '¿Reservar de nuevo?' : 'Rebook your spot?') + : (locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?')}

- {locale === 'es' - ? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?` - : `Did you already send the ${pyg(amount, currency)} for ${destination}?`} + {onHold + ? (locale === 'es' + ? `Intentaremos reservar tu lugar de nuevo para ${destination}.` + : `We'll try to re-reserve your spot for ${destination}.`) + : (locale === 'es' + ? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?` + : `Did you already send the ${pyg(amount, currency)} for ${destination}?`)}

+ +
+ + {/* Events grid */} +
+ {displayedEvents.length === 0 ? ( +
+ +

{t('events.noEvents')}

+
+ ) : ( +
+ {displayedEvents.map((event) => ( + + + {/* Event banner */} + {event.bannerUrl ? ( + {`${event.title} + ) : ( +
+ +
+ )} + +
+
+

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

+ {getStatusBadge(event)} +
+ +
+
+ + {formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)} +
+
+ + {event.location} +
+ {!event.externalBookingEnabled && ( +
+ + + {Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')} + +
+ )} +
+ +
+ + {event.price === 0 + ? t('events.details.free') + : formatPrice(event.price, event.currency)} + + +
+
+
+ + ))} +
+ )} +
+ + + ); +} diff --git a/frontend/src/app/(public)/events/[id]/loading.tsx b/frontend/src/app/(public)/events/[id]/loading.tsx new file mode 100644 index 0000000..044ad82 --- /dev/null +++ b/frontend/src/app/(public)/events/[id]/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton, ArticleSkeleton } from '@/components/ui/Skeleton'; + +// Route-level skeleton for the event detail page: back link, banner, article +// body on the left and the booking card in the sidebar. +export default function EventDetailLoading() { + return ( +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index 1ef74ba..fd1246c 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -123,7 +123,9 @@ function generateEventJsonLd(event: Event) { url: `${siteUrl}/events/${event.slug}`, validFrom: new Date().toISOString(), }, - image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`, + image: event.bannerUrl + ? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`) + : `${siteUrl}/images/og-image.jpg`, url: `${siteUrl}/events/${event.slug}`, }; } diff --git a/frontend/src/app/(public)/events/layout.tsx b/frontend/src/app/(public)/events/layout.tsx index 76e27b1..d4fae38 100644 --- a/frontend/src/app/(public)/events/layout.tsx +++ b/frontend/src/app/(public)/events/layout.tsx @@ -1,8 +1,13 @@ import type { Metadata } from 'next'; +// Note: the page title for the listing lives on events/page.tsx, not here. A +// plain-string title in this layout would reset the root title template for the +// child /events/[id] route, stripping the brand suffix from event detail titles. export const metadata: Metadata = { - title: 'Upcoming Language Exchange Events in Asunción', description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.', + alternates: { + canonical: '/events', + }, openGraph: { title: 'Upcoming Language Exchange Events in Asunción – Spanglish', description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.', diff --git a/frontend/src/app/(public)/events/loading.tsx b/frontend/src/app/(public)/events/loading.tsx new file mode 100644 index 0000000..7bea981 --- /dev/null +++ b/frontend/src/app/(public)/events/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, EventGridSkeleton } from '@/components/ui/Skeleton'; + +// Route-level skeleton shown during navigation while the server fetches the +// event list. Mirrors the EventsClient shell: title, filter tabs, card grid. +export default function EventsLoading() { + return ( +
+
+ +
+ + +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/app/(public)/events/page.tsx b/frontend/src/app/(public)/events/page.tsx index a247a46..1fb9c7c 100644 --- a/frontend/src/app/(public)/events/page.tsx +++ b/frontend/src/app/(public)/events/page.tsx @@ -1,157 +1,29 @@ -'use client'; +import type { Metadata } from 'next'; +import { Event } from '@/lib/api'; +import EventsClient from './EventsClient'; -import { useState, useEffect } from 'react'; -import Link from 'next/link'; -import { useLanguage } from '@/context/LanguageContext'; -import { eventsApi, Event } from '@/lib/api'; -import { formatPrice, formatDateShort, formatTime } from '@/lib/utils'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline'; -import clsx from 'clsx'; +const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -export default function EventsPage() { - const { t, locale } = useLanguage(); - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming'); +// Listing title lives here (not in the layout) so the root title template still +// applies to the sibling /events/[id] detail route. Picks up "%s – Spanglish". +export const metadata: Metadata = { + title: 'Upcoming Language Exchange Events in Asunción', +}; - useEffect(() => { - eventsApi.getAll() - .then(({ events }) => setEvents(events)) - .catch(console.error) - .finally(() => setLoading(false)); - }, []); +// Fetch the public (published) event list on the server so event titles, dates, +// and locations appear in the initial HTML rather than only after JS runs. +async function getEvents(): Promise { + try { + const res = await fetch(`${apiUrl}/api/events`, { next: { revalidate: 60 } }); + if (!res.ok) return []; + const data = await res.json(); + return data.events || []; + } catch { + return []; + } +} - const now = new Date(); - const upcomingEvents = events.filter(e => - e.status === 'published' && new Date(e.startDatetime) >= now - ); - const pastEvents = events.filter(e => - e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now) - ); - - const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents; - - const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es'); - const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); - - const getStatusBadge = (event: Event) => { - if (event.status === 'cancelled') { - return {t('events.details.cancelled')}; - } - if (event.availableSeats === 0) { - return {t('events.details.soldOut')}; - } - return null; - }; - - return ( -
-
-

{t('events.title')}

- - {/* Filter tabs */} -
- - -
- - {/* Events grid */} -
- {loading ? ( -
-
-
- ) : displayedEvents.length === 0 ? ( -
- -

{t('events.noEvents')}

-
- ) : ( -
- {displayedEvents.map((event) => ( - - - {/* Event banner */} - {event.bannerUrl ? ( - {`${event.title} - ) : ( -
- -
- )} - -
-
-

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

- {getStatusBadge(event)} -
- -
-
- - {formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)} -
-
- - {event.location} -
- {!event.externalBookingEnabled && ( -
- - - {Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')} - -
- )} -
- -
- - {event.price === 0 - ? t('events.details.free') - : formatPrice(event.price, event.currency)} - - -
-
-
- - ))} -
- )} -
-
-
- ); +export default async function EventsPage() { + const events = await getEvents(); + return ; } diff --git a/frontend/src/app/(public)/faq/FaqClient.tsx b/frontend/src/app/(public)/faq/FaqClient.tsx new file mode 100644 index 0000000..1d242d1 --- /dev/null +++ b/frontend/src/app/(public)/faq/FaqClient.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState } from 'react'; +import { useLanguage } from '@/context/LanguageContext'; +import { FaqItem } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; + +// Receives the FAQ list already fetched on the server so the questions and +// answers are present in the initial HTML for crawlers. The accordion below is +// purely a visual toggle; the answer text stays in the DOM either way. +export default function FaqClient({ initialFaqs }: { initialFaqs: FaqItem[] }) { + const { locale } = useLanguage(); + const [openIndex, setOpenIndex] = useState(null); + + const toggleFAQ = (index: number) => { + setOpenIndex(openIndex === index ? null : index); + }; + + return ( +
+
+
+

+ {locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'} +

+

+ {locale === 'es' + ? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish' + : 'Find answers to the most common questions about Spanglish'} +

+
+ + {initialFaqs.length === 0 ? ( + +

+ {locale === 'es' + ? 'No hay preguntas frecuentes publicadas en este momento.' + : 'No FAQ questions are published at the moment.'} +

+
+ ) : ( +
+ {initialFaqs.map((faq, index) => ( + + +
+
+ {locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer} +
+
+
+ ))} +
+ )} + + +

+ {locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'} +

+

+ {locale === 'es' + ? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!' + : "Don't hesitate to reach out. We're here to help!"} +

+
+ {locale === 'es' ? 'Contáctanos' : 'Contact Us'} + + +
+
+ ); +} diff --git a/frontend/src/app/(public)/faq/layout.tsx b/frontend/src/app/(public)/faq/layout.tsx index bc95501..9fb94df 100644 --- a/frontend/src/app/(public)/faq/layout.tsx +++ b/frontend/src/app/(public)/faq/layout.tsx @@ -20,6 +20,9 @@ async function getFaqForSchema(): Promise<{ question: string; answer: string }[] export const metadata: Metadata = { title: 'Frequently Asked Questions', description: 'Find answers to common questions about Spanglish language exchange events in Asunción. Learn about how events work, who can attend, and more.', + alternates: { + canonical: '/faq', + }, openGraph: { title: 'Frequently Asked Questions – Spanglish', description: 'Find answers to common questions about Spanglish language exchange events in Asunción.', diff --git a/frontend/src/app/(public)/faq/loading.tsx b/frontend/src/app/(public)/faq/loading.tsx new file mode 100644 index 0000000..918f1f3 --- /dev/null +++ b/frontend/src/app/(public)/faq/loading.tsx @@ -0,0 +1,16 @@ +import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton'; + +// Route-level skeleton for the FAQ page: centered header + accordion rows. +export default function FaqLoading() { + return ( +
+
+
+ + +
+ +
+
+ ); +} diff --git a/frontend/src/app/(public)/faq/page.tsx b/frontend/src/app/(public)/faq/page.tsx index 33c11b5..3fae217 100644 --- a/frontend/src/app/(public)/faq/page.tsx +++ b/frontend/src/app/(public)/faq/page.tsx @@ -1,116 +1,22 @@ -'use client'; +import { FaqItem } from '@/lib/api'; +import FaqClient from './FaqClient'; -import { useState, useEffect } from 'react'; -import { useLanguage } from '@/context/LanguageContext'; -import { faqApi, FaqItem } from '@/lib/api'; -import Card from '@/components/ui/Card'; -import { ChevronDownIcon } from '@heroicons/react/24/outline'; -import clsx from 'clsx'; +const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -export default function FAQPage() { - const { locale } = useLanguage(); - const [faqs, setFaqs] = useState([]); - const [loading, setLoading] = useState(true); - const [openIndex, setOpenIndex] = useState(null); - - useEffect(() => { - let cancelled = false; - faqApi.getList().then((res) => { - if (!cancelled) { - setFaqs(res.faqs); - } - }).finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { cancelled = true; }; - }, []); - - const toggleFAQ = (index: number) => { - setOpenIndex(openIndex === index ? null : index); - }; - - if (loading) { - return ( -
-
-
-
-
- ); +// Fetch the published FAQ list on the server so the questions and answers are +// rendered into the initial HTML (crawlers see the content without running JS). +async function getFaqs(): Promise { + try { + const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } }); + if (!res.ok) return []; + const data = await res.json(); + return data.faqs || []; + } catch { + return []; } +} - return ( -
-
-
-

- {locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'} -

-

- {locale === 'es' - ? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish' - : 'Find answers to the most common questions about Spanglish'} -

-
- - {faqs.length === 0 ? ( - -

- {locale === 'es' - ? 'No hay preguntas frecuentes publicadas en este momento.' - : 'No FAQ questions are published at the moment.'} -

-
- ) : ( -
- {faqs.map((faq, index) => ( - - -
-
- {locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer} -
-
-
- ))} -
- )} - - -

- {locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'} -

-

- {locale === 'es' - ? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!' - : "Don't hesitate to reach out. We're here to help!"} -

- - {locale === 'es' ? 'Contáctanos' : 'Contact Us'} - -
-
-
- ); +export default async function FAQPage() { + const faqs = await getFaqs(); + return ; } diff --git a/frontend/src/app/(public)/legal/[slug]/loading.tsx b/frontend/src/app/(public)/legal/[slug]/loading.tsx new file mode 100644 index 0000000..12918ed --- /dev/null +++ b/frontend/src/app/(public)/legal/[slug]/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton, SkeletonGroup, SkeletonText } from '@/components/ui/Skeleton'; + +// Route-level skeleton for legal pages: back link, bordered header, prose body. +export default function LegalPageLoading() { + return ( +
+
+ + +
+ + +
+
+ + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/legal/[slug]/page.tsx b/frontend/src/app/(public)/legal/[slug]/page.tsx index e0e3d0c..5e2252e 100644 --- a/frontend/src/app/(public)/legal/[slug]/page.tsx +++ b/frontend/src/app/(public)/legal/[slug]/page.tsx @@ -40,7 +40,8 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro } return { - title: `${legalPage.title} – Spanglish`, + // The root layout's title template appends " – Spanglish"; do not repeat it here. + title: legalPage.title, description: `${legalPage.title} for Spanglish language exchange events in Asunción, Paraguay.`, robots: { index: true, diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index 3ddffab..33a13ad 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -59,7 +59,9 @@ export async function generateMetadata(): Promise { if (!event) { return { - title: 'Spanglish – Language Exchange Events in Asunción', + // Title already carries the brand, so bypass the "%s – Spanglish" + // template to avoid doubling it. + title: { absolute: 'Spanglish – Language Exchange Events in Asunción' }, description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.', }; @@ -76,7 +78,9 @@ export async function generateMetadata(): Promise { const description = `Next event: ${eventDate} – ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`; return { - title: 'Spanglish – Language Exchange Events in Asunción', + // Title already carries the brand, so bypass the "%s – Spanglish" + // template to avoid doubling it. + title: { absolute: 'Spanglish – Language Exchange Events in Asunción' }, description, openGraph: { title: 'Spanglish – Language Exchange Events in Asunción', @@ -142,7 +146,9 @@ function generateNextEventJsonLd(event: NextEvent) { : 'https://schema.org/SoldOut', url: `${siteUrl}/events/${event.slug}`, }, - image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`, + image: event.bannerUrl + ? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`) + : `${siteUrl}/images/og-image.jpg`, url: `${siteUrl}/events/${event.slug}`, }; } diff --git a/frontend/src/app/admin/[...notFound]/page.tsx b/frontend/src/app/admin/[...notFound]/page.tsx new file mode 100644 index 0000000..a3e1fd3 --- /dev/null +++ b/frontend/src/app/admin/[...notFound]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function AdminCatchAll() { + notFound(); +} diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index b6f1c8a..100aa7b 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -2,10 +2,11 @@ import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; -import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api'; +import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api'; import { parseDate } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; +import { AdminPageSkeleton } from '@/components/ui/Skeleton'; import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { TicketIcon, @@ -17,6 +18,7 @@ import { PhoneIcon, FunnelIcon, MagnifyingGlassIcon, + ArrowPathIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; @@ -101,6 +103,20 @@ export default function AdminBookingsPage() { } }; + const handleReactivate = async (ticket: TicketWithDetails) => { + if (!ticket.payment?.id) return; + setProcessing(ticket.id); + try { + await paymentsApi.reactivate(ticket.payment.id); + toast.success('Booking reactivated'); + loadData(); + } catch (error: any) { + toast.error(error.message || 'Failed to reactivate booking'); + } finally { + setProcessing(null); + } + }; + const handleCancel = async (ticketId: string) => { if (!confirm('Are you sure you want to cancel this booking?')) return; @@ -133,6 +149,7 @@ export default function AdminBookingsPage() { case 'pending': return 'bg-yellow-100 text-yellow-800'; case 'cancelled': return 'bg-red-100 text-red-800'; case 'checked_in': return 'bg-blue-100 text-blue-800'; + case 'on_hold': return 'bg-slate-100 text-slate-600'; default: return 'bg-gray-100 text-gray-800'; } }; @@ -144,6 +161,7 @@ export default function AdminBookingsPage() { case 'failed': case 'cancelled': return 'bg-red-100 text-red-800'; case 'refunded': return 'bg-purple-100 text-purple-800'; + case 'on_hold': return 'bg-slate-100 text-slate-600'; default: return 'bg-gray-100 text-gray-800'; } }; @@ -191,6 +209,7 @@ export default function AdminBookingsPage() { confirmed: tickets.filter(t => t.status === 'confirmed').length, checkedIn: tickets.filter(t => t.status === 'checked_in').length, cancelled: tickets.filter(t => t.status === 'cancelled').length, + onHold: tickets.filter(t => t.status === 'on_hold').length, pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length, }; @@ -218,6 +237,9 @@ export default function AdminBookingsPage() { if (ticket.status === 'pending' && ticket.payment?.status === 'pending') { return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' }; } + if (ticket.status === 'on_hold') { + return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' }; + } if (ticket.status === 'confirmed') { return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' }; } @@ -225,11 +247,7 @@ export default function AdminBookingsPage() { }; if (loading) { - return ( -
-
-
- ); + return ; } return ( @@ -239,7 +257,7 @@ export default function AdminBookingsPage() {
{/* Stats Cards */} -
+

{stats.total}

Total

@@ -260,6 +278,10 @@ export default function AdminBookingsPage() {

{stats.cancelled}

Cancelled

+ +

{stats.onHold}

+

On Hold

+

{stats.pendingPayment}

Pending Pay

@@ -305,6 +327,7 @@ export default function AdminBookingsPage() { +
@@ -316,6 +339,7 @@ export default function AdminBookingsPage() { +
@@ -368,6 +392,7 @@ export default function AdminBookingsPage() { Attendee + RUC Event Payment Status @@ -378,7 +403,7 @@ export default function AdminBookingsPage() { {sortedTickets.length === 0 ? ( - + No bookings found. @@ -392,6 +417,7 @@ export default function AdminBookingsPage() {

{ticket.attendeeEmail || 'N/A'}

{ticket.attendeePhone &&

{ticket.attendeePhone}

} + {ticket.attendeeRuc || '-'} {ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'} @@ -431,6 +457,18 @@ export default function AdminBookingsPage() { Check In )} + {ticket.status === 'on_hold' && ( + <> + + + + )} {(ticket.status === 'pending' || ticket.status === 'confirmed') && ( handleCancel(ticket.id)} className="text-red-600"> @@ -519,6 +557,13 @@ export default function AdminBookingsPage() { )} + {ticket.status === 'on_hold' && ( + + handleReactivate(ticket)}> + Reactivate + + + )} {ticket.status === 'checked_in' && ( Attended @@ -557,6 +602,7 @@ export default function AdminBookingsPage() { { value: 'confirmed', label: `Confirmed (${stats.confirmed})` }, { value: 'checked_in', label: `Checked In (${stats.checkedIn})` }, { value: 'cancelled', label: `Cancelled (${stats.cancelled})` }, + { value: 'on_hold', label: `On Hold (${stats.onHold})` }, ].map((opt) => ( )} + {ticket.status === 'on_hold' && ( + handleReactivate(ticket)}> + Reactivate + + )} handleOpenNoteModal(ticket)}> {ticket.adminNote ? 'Edit Note' : 'Add Note'} @@ -307,6 +319,7 @@ export function AttendeesTab({

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

{ticket.attendeeEmail}

{ticket.attendeePhone &&

{ticket.attendeePhone}

} + {ticket.attendeeRuc &&

RUC: {ticket.attendeeRuc}

}
@@ -328,6 +341,11 @@ export function AttendeesTab({ )} + {ticket.status === 'on_hold' && ( + handleReactivate(ticket)}> + Reactivate + + )} handleOpenNoteModal(ticket)}> {ticket.adminNote ? 'Edit Note' : 'Add Note'} diff --git a/frontend/src/app/admin/events/[id]/_types.ts b/frontend/src/app/admin/events/[id]/_types.ts index 0078e41..937b622 100644 --- a/frontend/src/app/admin/events/[id]/_types.ts +++ b/frontend/src/app/admin/events/[id]/_types.ts @@ -2,7 +2,7 @@ import type { ComponentType } from 'react'; export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments'; -export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'; +export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled' | 'on_hold'; export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in'; export type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in'; diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 4e5944d..8e70dae 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -1,13 +1,14 @@ 'use client'; import { useState, useEffect, useRef } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; -import { ticketsApi, emailsApi, adminApi, Ticket } from '@/lib/api'; +import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket } from '@/lib/api'; import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; +import { Skeleton, TableSkeleton } from '@/components/ui/Skeleton'; import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { ArrowLeftIcon, @@ -48,10 +49,10 @@ import { TicketsTab } from './_tabs/TicketsTab'; import { EmailTab } from './_tabs/EmailTab'; import { PaymentsTab } from './_tabs/PaymentsTab'; import { EventModals } from './_modals/EventModals'; +import EventFormModal from '../_components/EventFormModal'; export default function AdminEventDetailPage() { const params = useParams(); - const router = useRouter(); const eventId = params.id as string; const { locale } = useLanguage(); @@ -116,6 +117,17 @@ export default function AdminEventDetailPage() { // Payment options state + handlers const payments = usePaymentOverrides(eventId, locale); + // Edit event modal (opens in place instead of redirecting to the list page) + const [showEditForm, setShowEditForm] = useState(false); + const [featuredEventId, setFeaturedEventId] = useState(null); + + useEffect(() => { + siteSettingsApi + .get() + .then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null)) + .catch(() => {}); + }, []); + // Mobile-specific state const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false); const [mobileFilterOpen, setMobileFilterOpen] = useState(false); @@ -163,6 +175,17 @@ export default function AdminEventDetailPage() { } }; + const handleReactivate = async (ticket: Ticket) => { + if (!ticket.payment?.id) return; + try { + await paymentsApi.reactivate(ticket.payment.id); + toast.success('Booking reactivated'); + loadEventData(); + } catch (error: any) { + toast.error(error.message || 'Failed to reactivate booking'); + } + }; + const handleRemoveCheckin = async (ticketId: string) => { if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return; try { @@ -398,8 +421,12 @@ export default function AdminEventDetailPage() { if (loading) { return ( -
-
+
+
+ + +
+
); } @@ -421,6 +448,7 @@ export default function AdminEventDetailPage() { const pendingCount = getTicketsByStatus('pending').length; const checkedInCount = getTicketsByStatus('checked_in').length; const cancelledCount = getTicketsByStatus('cancelled').length; + const onHoldCount = getTicketsByStatus('on_hold').length; const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length; const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length; const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price; @@ -435,7 +463,7 @@ export default function AdminEventDetailPage() { // ========== Primary action for a ticket ========== const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => { - if (ticket.status === 'pending') { + if (ticket.status === 'pending' || ticket.status === 'on_hold') { return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' }; } if (ticket.status === 'confirmed') { @@ -472,12 +500,10 @@ export default function AdminEventDetailPage() { View Public - - - +
{/* Mobile header overflow menu */}
@@ -493,7 +519,7 @@ export default function AdminEventDetailPage() { { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}> View Public - { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}> + { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}> Edit Event { toggleStats(); setMobileHeaderMenuOpen(false); }}> @@ -671,6 +697,7 @@ export default function AdminEventDetailPage() { confirmedCount={confirmedCount} checkedInCount={checkedInCount} cancelledCount={cancelledCount} + onHoldCount={onHoldCount} exporting={exporting} showExportDropdown={showExportDropdown} setShowExportDropdown={setShowExportDropdown} @@ -685,6 +712,7 @@ export default function AdminEventDetailPage() { setShowAddTicketSheet={setShowAddTicketSheet} getPrimaryAction={getPrimaryAction} handleOpenNoteModal={handleOpenNoteModal} + handleReactivate={handleReactivate} /> )} @@ -742,6 +770,7 @@ export default function AdminEventDetailPage() { confirmedCount={confirmedCount} checkedInCount={checkedInCount} cancelledCount={cancelledCount} + onHoldCount={onHoldCount} statusFilter={statusFilter} setStatusFilter={setStatusFilter} mobileFilterOpen={mobileFilterOpen} @@ -781,6 +810,15 @@ export default function AdminEventDetailPage() { setPreviewHtml={setPreviewHtml} /> + setShowEditForm(false)} + onSaved={() => { setShowEditForm(false); loadEventData(); }} + /> +
); diff --git a/frontend/src/app/admin/events/_components/EventFormModal.tsx b/frontend/src/app/admin/events/_components/EventFormModal.tsx new file mode 100644 index 0000000..7af3652 --- /dev/null +++ b/frontend/src/app/admin/events/_components/EventFormModal.tsx @@ -0,0 +1,392 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { eventsApi, siteSettingsApi, Event } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import MediaPicker from '@/components/MediaPicker'; +import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; +import { useLanguage } from '@/context/LanguageContext'; +import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; + +interface EventFormData { + title: string; + titleEs: string; + slug: string; + description: string; + descriptionEs: string; + shortDescription: string; + shortDescriptionEs: string; + startDatetime: string; + endDatetime: string; + location: string; + locationUrl: string; + price: number; + currency: string; + capacity: number; + status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived'; + bannerUrl: string; + externalBookingEnabled: boolean; + externalBookingUrl: string; +} + +const EMPTY_FORM: EventFormData = { + title: '', titleEs: '', slug: '', description: '', descriptionEs: '', + shortDescription: '', shortDescriptionEs: '', + startDatetime: '', endDatetime: '', location: '', locationUrl: '', + price: 0, currency: 'PYG', capacity: 50, status: 'draft', + bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '', +}; + +function isoToLocalDatetime(isoString: string): string { + const date = parseDate(isoString); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: EVENT_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(date); + const get = (type: string) => parts.find(p => p.type === type)!.value; + const h = get('hour') === '24' ? '00' : get('hour'); + return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`; +} + +interface EventFormModalProps { + /** When true the modal is rendered. */ + open: boolean; + /** The event being edited, or null to create a new event. */ + event: Event | null; + /** Currently featured event id (owned by the parent page). */ + featuredEventId: string | null; + /** Notify the parent when the featured event changes. */ + onFeaturedChange: (id: string | null) => void; + /** Close the modal without saving. */ + onClose: () => void; + /** Called after a successful create/update so the parent can refresh. */ + onSaved: () => void; +} + +/** + * Shared create/edit event modal used by both the events list page and the + * single-event detail page. Owns its own form state so it can be dropped in + * anywhere; the parent only supplies the event to edit and refresh callbacks. + */ +export default function EventFormModal({ + open, + event, + featuredEventId, + onFeaturedChange, + onClose, + onSaved, +}: EventFormModalProps) { + const { t } = useLanguage(); + const [formData, setFormData] = useState(EMPTY_FORM); + const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]); + const [saving, setSaving] = useState(false); + const [settingFeatured, setSettingFeatured] = useState(false); + + useEffect(() => { + if (!open) return; + if (event) { + setFormData({ + title: event.title, titleEs: event.titleEs || '', slug: event.slug || '', + description: event.description, descriptionEs: event.descriptionEs || '', + shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '', + startDatetime: isoToLocalDatetime(event.startDatetime), + endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '', + location: event.location, locationUrl: event.locationUrl || '', + price: event.price, currency: event.currency, capacity: event.capacity, + status: event.status, bannerUrl: event.bannerUrl || '', + externalBookingEnabled: event.externalBookingEnabled || false, + externalBookingUrl: event.externalBookingUrl || '', + }); + loadSlugAliases(event.id); + } else { + setFormData(EMPTY_FORM); + setSlugAliases([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, event]); + + const loadSlugAliases = async (eventId: string) => { + try { + const { aliases } = await eventsApi.getSlugAliases(eventId); + setSlugAliases(aliases); + } catch (error) { + setSlugAliases([]); + } + }; + + const handleRemoveAlias = async (slug: string) => { + if (!event) return; + if (!confirm(`Remove alias "${slug}"? The old URL /events/${slug} will stop working.`)) return; + try { + await eventsApi.deleteSlugAlias(event.id, slug); + toast.success('Alias removed'); + setSlugAliases((prev) => prev.filter((a) => a.slug !== slug)); + } catch (error: any) { + toast.error(error.message || 'Failed to remove alias'); + } + }; + + const handleSetFeatured = async (eventId: string | null) => { + setSettingFeatured(true); + try { + await siteSettingsApi.setFeaturedEvent(eventId); + onFeaturedChange(eventId); + toast.success(eventId ? 'Event set as featured' : 'Featured event removed'); + } catch (error: any) { + toast.error(error.message || 'Failed to update featured event'); + } finally { + setSettingFeatured(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + if (formData.externalBookingEnabled && !formData.externalBookingUrl) { + toast.error('External booking URL is required when external booking is enabled'); + setSaving(false); + return; + } + if (formData.externalBookingEnabled && !formData.externalBookingUrl.startsWith('https://')) { + toast.error('External booking URL must be a valid HTTPS link'); + setSaving(false); + return; + } + const eventData: Partial = { + title: formData.title, titleEs: formData.titleEs || undefined, + description: formData.description, descriptionEs: formData.descriptionEs || undefined, + shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined, + startDatetime: formData.startDatetime, + endDatetime: formData.endDatetime || undefined, + location: formData.location, locationUrl: formData.locationUrl || undefined, + price: formData.price, currency: formData.currency, capacity: formData.capacity, + status: formData.status, bannerUrl: formData.bannerUrl || undefined, + externalBookingEnabled: formData.externalBookingEnabled, + externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, + }; + if (event) { + // Only send slug when editing so creates still auto-generate from title + eventData.slug = formData.slug || undefined; + await eventsApi.update(event.id, eventData); + toast.success('Event updated'); + } else { + await eventsApi.create(eventData); + toast.success('Event created'); + } + onSaved(); + } catch (error: any) { + toast.error(error.message || 'Failed to save event'); + } finally { + setSaving(false); + } + }; + + if (!open) return null; + + return ( +
+ +
+

+ {event ? t('admin.events.edit') : t('admin.events.create')} +

+ +
+ +
+
+ setFormData({ ...formData, title: e.target.value })} required /> + setFormData({ ...formData, titleEs: e.target.value })} /> +
+ + {event && ( +
+ setFormData({ ...formData, slug: e.target.value })} + placeholder="auto-generated from title" /> +

+ Public URL: /events/{formData.slug || '...'} + . Changing the slug keeps the old one as a redirecting alias. +

+ {slugAliases.length > 0 && ( +
+

URL aliases

+

+ Old URLs that still redirect to the current slug. Removing one breaks those links. +

+
    + {slugAliases.map((alias) => ( +
  • + /events/{alias.slug} + +
  • + ))} +
+
+ )} +
+ )} + +
+ +