From cacc52ec240fbf7e3e0a0c55edde102b1b870b28 Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 1 Jul 2026 05:51:38 +0000 Subject: [PATCH] Security recovery: hold sweep, dashboard updates, and admin fixes. --- backend/.env.example | 7 + backend/src/db/schema.ts | 4 +- backend/src/index.ts | 4 + backend/src/lib/holdRecovery.ts | 116 ++++++++ backend/src/lib/holdSweep.ts | 98 +++++++ backend/src/routes/admin.ts | 18 +- backend/src/routes/payments.ts | 146 ++++++++--- backend/src/routes/tickets.ts | 118 +++++++-- backend/src/routes/users.ts | 56 +++- deploy/front-end_nginx.conf | 19 +- .../src/app/(public)/community/layout.tsx | 3 + frontend/src/app/(public)/contact/layout.tsx | 3 + .../dashboard/components/OverviewTab.tsx | 36 ++- .../dashboard/components/PaymentsTab.tsx | 17 +- .../dashboard/components/TicketsTab.tsx | 23 +- .../components/_shared/AttentionBanner.tsx | 37 +++ .../components/_shared/PayActions.tsx | 42 ++- .../dashboard/components/_shared/helpers.ts | 10 + .../dashboard/components/_shared/status.tsx | 8 +- .../src/app/(public)/events/EventsClient.tsx | 147 +++++++++++ .../src/app/(public)/events/[id]/page.tsx | 4 +- frontend/src/app/(public)/events/layout.tsx | 7 +- frontend/src/app/(public)/events/page.tsx | 176 ++----------- frontend/src/app/(public)/faq/FaqClient.tsx | 95 +++++++ frontend/src/app/(public)/faq/layout.tsx | 3 + frontend/src/app/(public)/faq/page.tsx | 128 ++------- .../src/app/(public)/legal/[slug]/page.tsx | 3 +- frontend/src/app/(public)/page.tsx | 12 +- frontend/src/app/admin/[...notFound]/page.tsx | 5 + frontend/src/app/admin/bookings/page.tsx | 56 +++- .../events/[id]/_components/StatusBadge.tsx | 1 + .../admin/events/[id]/_modals/EventModals.tsx | 3 + .../admin/events/[id]/_tabs/AttendeesTab.tsx | 18 ++ frontend/src/app/admin/events/[id]/_types.ts | 2 +- frontend/src/app/admin/events/[id]/page.tsx | 19 +- frontend/src/app/admin/layout.tsx | 15 +- frontend/src/app/admin/not-found.tsx | 9 + frontend/src/app/admin/payments/page.tsx | 85 +++++- frontend/src/app/admin/users/page.tsx | 247 +++++++++++++++--- frontend/src/app/layout.tsx | 22 +- frontend/src/app/not-found.tsx | 34 +-- frontend/src/components/NotFoundMessage.tsx | 29 ++ frontend/src/lib/api/payments.ts | 5 + frontend/src/lib/api/types.ts | 6 +- frontend/src/lib/api/users.ts | 30 ++- 45 files changed, 1452 insertions(+), 474 deletions(-) create mode 100644 backend/src/lib/holdRecovery.ts create mode 100644 backend/src/lib/holdSweep.ts create mode 100644 frontend/src/app/(public)/events/EventsClient.tsx create mode 100644 frontend/src/app/(public)/faq/FaqClient.tsx create mode 100644 frontend/src/app/admin/[...notFound]/page.tsx create mode 100644 frontend/src/app/admin/not-found.tsx create mode 100644 frontend/src/components/NotFoundMessage.tsx diff --git a/backend/.env.example b/backend/.env.example index 71c348b..8e0af46 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -114,3 +114,10 @@ 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 + diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index addab36..a79e2df 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,7 +119,7 @@ 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'), userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid" payerName: text('payer_name'), // Name of payer if different from attendee diff --git a/backend/src/index.ts b/backend/src/index.ts index 9532ae7..69115fe 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,7 @@ 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 { getLock } from './lib/stores/lock.js'; import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js'; @@ -1932,6 +1933,9 @@ 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(); + // 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/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/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/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..8e923c7 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -9,6 +9,7 @@ import { createInvoice, isLNbitsConfigured } 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; @@ -1104,26 +1114,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 +1225,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..03dcada 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -2,7 +2,7 @@ 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'; @@ -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, registeredAfter)); + if (registeredBefore) conditions.push(lte((users as any).createdAt, 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)/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)/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..27586b1 100644 --- a/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/OverviewTab.tsx @@ -22,6 +22,7 @@ import { groupByBooking, isUnpaid, isAwaitingApproval, + isOnHold, ticketAmount, shareTicket, isToday, @@ -57,11 +58,12 @@ export default function OverviewTab({ // awaiting approval), ordered by soonest event. const attentionTicket = useMemo(() => { const candidates = activeTickets.filter( - (t) => isUnpaid(t) || isAwaitingApproval(t) + (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 +192,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]/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/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/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]/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..77a902c 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -2,7 +2,7 @@ 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'; @@ -17,6 +17,7 @@ import { PhoneIcon, FunnelIcon, MagnifyingGlassIcon, + ArrowPathIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; @@ -101,6 +102,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 +148,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 +160,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 +208,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 +236,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' }; } @@ -239,7 +260,7 @@ export default function AdminBookingsPage() {
{/* Stats Cards */} -
+

{stats.total}

Total

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

{stats.cancelled}

Cancelled

+ +

{stats.onHold}

+

On Hold

+

{stats.pendingPayment}

Pending Pay

@@ -305,6 +330,7 @@ export default function AdminBookingsPage() { +
@@ -316,6 +342,7 @@ export default function AdminBookingsPage() { +
@@ -368,6 +395,7 @@ export default function AdminBookingsPage() { Attendee + RUC Event Payment Status @@ -378,7 +406,7 @@ export default function AdminBookingsPage() { {sortedTickets.length === 0 ? ( - + No bookings found. @@ -392,6 +420,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 +460,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 +560,13 @@ export default function AdminBookingsPage() { )} + {ticket.status === 'on_hold' && ( + + handleReactivate(ticket)}> + Reactivate + + + )} {ticket.status === 'checked_in' && ( Attended @@ -557,6 +605,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..d5271d8 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react'; import { useParams, useRouter } 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, Ticket } from '@/lib/api'; import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; @@ -163,6 +163,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 { @@ -421,6 +432,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 +447,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') { @@ -671,6 +683,7 @@ export default function AdminEventDetailPage() { confirmedCount={confirmedCount} checkedInCount={checkedInCount} cancelledCount={cancelledCount} + onHoldCount={onHoldCount} exporting={exporting} showExportDropdown={showExportDropdown} setShowExportDropdown={setShowExportDropdown} @@ -685,6 +698,7 @@ export default function AdminEventDetailPage() { setShowAddTicketSheet={setShowAddTicketSheet} getPrimaryAction={getPrimaryAction} handleOpenNoteModal={handleOpenNoteModal} + handleReactivate={handleReactivate} /> )} @@ -742,6 +756,7 @@ export default function AdminEventDetailPage() { confirmedCount={confirmedCount} checkedInCount={checkedInCount} cancelledCount={cancelledCount} + onHoldCount={onHoldCount} statusFilter={statusFilter} setStatusFilter={setStatusFilter} mobileFilterOpen={mobileFilterOpen} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index 679a960..d4cdced 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -62,6 +62,10 @@ export default function AdminLayout({ const allowedPathsForRole = new Set( navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href) ); + // All known admin routes regardless of role, used only to tell "not allowed + // for this role" apart from "doesn't exist" - the latter should render the + // 404 page instead of bouncing to the default route. + const allAdminHrefs = new Set(navigationWithRoles.map((item) => item.href)); const defaultAdminRoute = userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin'; @@ -79,11 +83,14 @@ export default function AdminLayout({ router.replace(defaultAdminRoute); return; } - const isPathAllowed = (path: string) => { - if (allowedPathsForRole.has(path)) return true; - return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/')); + const matchesHrefSet = (path: string, hrefs: Set) => { + if (hrefs.has(path)) return true; + return Array.from(hrefs).some((href) => path.startsWith(href + '/')); }; - if (!isPathAllowed(pathname)) { + // Unknown route entirely (e.g. a typo'd URL) - let it fall through to the + // admin 404 page instead of silently redirecting away. + if (!matchesHrefSet(pathname, allAdminHrefs)) return; + if (!matchesHrefSet(pathname, allowedPathsForRole)) { router.replace(defaultAdminRoute); } }, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]); diff --git a/frontend/src/app/admin/not-found.tsx b/frontend/src/app/admin/not-found.tsx new file mode 100644 index 0000000..2430064 --- /dev/null +++ b/frontend/src/app/admin/not-found.tsx @@ -0,0 +1,9 @@ +import { NotFoundMessage } from '@/components/NotFoundMessage'; + +export default function AdminNotFound() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index c12c28d..255d7ac 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -147,6 +147,20 @@ export default function AdminPaymentsPage() { } }; + const handleReactivate = async (payment: PaymentWithDetails) => { + setProcessing(true); + try { + await paymentsApi.reactivate(payment.id); + toast.success(locale === 'es' ? 'Reserva reactivada' : 'Booking reactivated'); + setSelectedPayment(null); + loadData(); + } catch (error: any) { + toast.error(error.message || 'Failed to reactivate booking'); + } finally { + setProcessing(false); + } + }; + const handleRefund = async (id: string) => { if (!confirm('Are you sure you want to process this refund?')) return; @@ -178,7 +192,7 @@ export default function AdminPaymentsPage() { const downloadCSV = () => { if (!exportData) return; - const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'Event Title', 'Event Date']; + const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'RUC', 'Event Title', 'Event Date']; const rows = exportData.payments.map(p => [ p.paymentId, p.amount, @@ -190,6 +204,7 @@ export default function AdminPaymentsPage() { p.createdAt, `${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(), p.attendeeEmail || '', + p.attendeeRuc || '', p.eventTitle, p.eventDate, ]); @@ -225,6 +240,7 @@ export default function AdminPaymentsPage() { refunded: 'bg-blue-100 text-blue-700', failed: 'bg-red-100 text-red-700', cancelled: 'bg-gray-100 text-gray-700', + on_hold: 'bg-slate-100 text-slate-600', }; const labels: Record = { pending: locale === 'es' ? 'Pendiente' : 'Pending', @@ -233,6 +249,7 @@ export default function AdminPaymentsPage() { refunded: locale === 'es' ? 'Reembolsado' : 'Refunded', failed: locale === 'es' ? 'Fallido' : 'Failed', cancelled: locale === 'es' ? 'Cancelado' : 'Cancelled', + on_hold: locale === 'es' ? 'En Espera' : 'On Hold', }; return ( @@ -343,6 +360,8 @@ export default function AdminPaymentsPage() { payments.filter(p => p.status === 'paid') ); const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments); + const onHoldPayments = payments.filter(p => p.status === 'on_hold'); + const onHoldBookingsCount = getUniqueBookingsCount(onHoldPayments); if (loading) { return ( @@ -414,6 +433,9 @@ export default function AdminPaymentsPage() { {selectedPayment.ticket.attendeePhone && (

{selectedPayment.ticket.attendeePhone}

)} + {selectedPayment.ticket.attendeeRuc && ( +

RUC: {selectedPayment.ticket.attendeeRuc}

+ )}
)} @@ -475,6 +497,15 @@ export default function AdminPaymentsPage() { + {selectedPayment.status === 'on_hold' && ( +
+ +
+ )} +
- -
- -
+ + {selectedPayment.status !== 'on_hold' && ( +
+ +
+ )} @@ -627,7 +660,7 @@ export default function AdminPaymentsPage() { )} {/* Summary Cards */} -
+
@@ -642,6 +675,20 @@ export default function AdminPaymentsPage() {
+ +
+
+ +
+
+

{locale === 'es' ? 'En Espera' : 'On Hold'}

+

{onHoldBookingsCount}

+ {onHoldPayments.length !== onHoldBookingsCount && ( +

({onHoldPayments.length} tickets)

+ )} +
+
+
@@ -816,6 +863,7 @@ export default function AdminPaymentsPage() { +
@@ -897,6 +945,7 @@ export default function AdminPaymentsPage() { {locale === 'es' ? 'Asistente' : 'Attendee'} + RUC {locale === 'es' ? 'Evento' : 'Event'} {locale === 'es' ? 'Monto' : 'Amount'} {locale === 'es' ? 'Método' : 'Method'} @@ -906,7 +955,7 @@ export default function AdminPaymentsPage() { {filteredPayments.length === 0 ? ( - {locale === 'es' ? 'No se encontraron pagos' : 'No payments found'} + {locale === 'es' ? 'No se encontraron pagos' : 'No payments found'} ) : ( filteredPayments.map((payment) => { const bookingInfo = getBookingInfo(payment); @@ -920,6 +969,7 @@ export default function AdminPaymentsPage() {
) : -} + {payment.ticket?.attendeeRuc || '-'} {payment.event?.title || '-'}

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

@@ -933,11 +983,16 @@ export default function AdminPaymentsPage() { {getStatusBadge(payment.status)}
- {(payment.status === 'pending' || payment.status === 'pending_approval') && ( + {(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && ( )} + {payment.status === 'on_hold' && ( + + )} {payment.status === 'paid' && ( )} + {payment.status === 'on_hold' && ( + + )} {payment.status === 'paid' && (
diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx index 07f789f..c8ef8e5 100644 --- a/frontend/src/app/admin/users/page.tsx +++ b/frontend/src/app/admin/users/page.tsx @@ -2,22 +2,38 @@ import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; -import { usersApi, User } from '@/lib/api'; +import { usersApi, eventsApi, User, Event } from '@/lib/api'; import { parseDate } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents'; -import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline'; -import { CheckCircleIcon } from '@heroicons/react/24/outline'; +import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; +type RegisteredRange = '' | '7d' | '30d' | '90d'; + +function registeredAfterFromRange(range: RegisteredRange): string | undefined { + if (!range) return undefined; + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90; + const date = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + return date.toISOString(); +} + export default function AdminUsersPage() { const { t, locale } = useLanguage(); const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [roleFilter, setRoleFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [hasBookingsFilter, setHasBookingsFilter] = useState<'' | 'yes' | 'no'>(''); + const [registeredRange, setRegisteredRange] = useState(''); + const [eventFilter, setEventFilter] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); const [editingUser, setEditingUser] = useState(null); const [editForm, setEditForm] = useState({ name: '', @@ -30,14 +46,45 @@ export default function AdminUsersPage() { const [saving, setSaving] = useState(false); const [mobileFilterOpen, setMobileFilterOpen] = useState(false); + // Debounce the search box like the Emails page does (300ms). + useEffect(() => { + const handle = setTimeout(() => setDebouncedSearch(searchQuery), 300); + return () => clearTimeout(handle); + }, [searchQuery]); + + useEffect(() => { + eventsApi.getAll().then((res) => setEvents(res.events)).catch(() => {}); + }, []); + useEffect(() => { loadUsers(); - }, [roleFilter]); + }, [roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]); + + const hasActiveFilters = + roleFilter || statusFilter || hasBookingsFilter || registeredRange || eventFilter || searchQuery; + + const clearFilters = () => { + setRoleFilter(''); + setStatusFilter(''); + setHasBookingsFilter(''); + setRegisteredRange(''); + setEventFilter(''); + setSearchQuery(''); + }; const loadUsers = async () => { try { - const { users } = await usersApi.getAll(roleFilter || undefined); + const { users, total } = await usersApi.getAll({ + role: roleFilter || undefined, + accountStatus: statusFilter || undefined, + hasBookings: hasBookingsFilter || undefined, + registeredAfter: registeredAfterFromRange(registeredRange), + eventId: eventFilter || undefined, + search: debouncedSearch.trim() || undefined, + pageSize: 200, + }); setUsers(users); + setTotal(total); } catch (error) { toast.error('Failed to load users'); } finally { @@ -129,16 +176,29 @@ export default function AdminUsersPage() { return (
-

{t('admin.users.title')}

+

{t('admin.users.title')} ({total})

{/* Desktop Filters */} -
+
+
+ +
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow" + /> +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {hasActiveFilters && ( +
+ Showing {users.length} of {total} + +
+ )} {/* Mobile Toolbar */} -
- - {roleFilter && ( - - )} - {users.length} users + {hasActiveFilters && ( + + )} + {total} users +
{/* Desktop: Table */} @@ -176,6 +293,7 @@ export default function AdminUsersPage() { User Contact + RUC Role Joined Actions @@ -183,7 +301,7 @@ export default function AdminUsersPage() { {users.length === 0 ? ( - No users found + No users found ) : ( users.map((user) => ( @@ -199,6 +317,7 @@ export default function AdminUsersPage() {
{user.phone || '-'} + {user.rucNumber || '-'} setRoleFilter(e.target.value)} + className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]"> + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 1c7250c..b793263 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -26,20 +26,6 @@ export const metadata: Metadata = { template: '%s – Spanglish', }, description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.', - keywords: [ - 'language exchange', - 'Spanglish', - 'Spanglish social', - 'English Spanish meetup', - 'language exchange Asunción', - 'practice English Asunción', - 'intercambio de idiomas', - 'intercambio de idiomas Asunción', - 'English Spanish Paraguay', - 'language events Paraguay', - 'Asunción', - 'Paraguay', - ], authors: [{ name: 'Spanglish' }], creator: 'Spanglish', publisher: 'Spanglish', @@ -82,12 +68,10 @@ export const metadata: Metadata = { 'max-snippet': -1, }, }, + // Each route overrides this with its own path so the canonical is + // self-referential. Resolved against metadataBase above. alternates: { - canonical: siteUrl, - languages: { - 'en': siteUrl, - 'es': `${siteUrl}/es`, - }, + canonical: '/', }, category: 'events', manifest: '/manifest.json', diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx index 2436093..a3e28db 100644 --- a/frontend/src/app/not-found.tsx +++ b/frontend/src/app/not-found.tsx @@ -1,5 +1,7 @@ import type { Metadata } from 'next'; -import Link from 'next/link'; +import Header from '@/components/layout/Header'; +import Footer from '@/components/layout/Footer'; +import { NotFoundMessage } from '@/components/NotFoundMessage'; export const metadata: Metadata = { title: 'Page Not Found – Spanglish', @@ -12,30 +14,12 @@ export const metadata: Metadata = { export default function NotFound() { return ( -
-
-

404

-

- Page Not Found -

-

- The page you are looking for might have been removed, had its name changed, or is temporarily unavailable. -

-
- - Go Home - - - View Events - -
-
+
+
+
+ +
+
); } diff --git a/frontend/src/components/NotFoundMessage.tsx b/frontend/src/components/NotFoundMessage.tsx new file mode 100644 index 0000000..9d3dbb3 --- /dev/null +++ b/frontend/src/components/NotFoundMessage.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; + +export function NotFoundMessage() { + return ( +
+

404

+

+ Page Not Found +

+

+ The page you are looking for might have been removed, had its name changed, or is temporarily unavailable. +

+
+ + Go Home + + + View Events + +
+
+ ); +} diff --git a/frontend/src/lib/api/payments.ts b/frontend/src/lib/api/payments.ts index 53bef31..2a86e6a 100644 --- a/frontend/src/lib/api/payments.ts +++ b/frontend/src/lib/api/payments.ts @@ -46,4 +46,9 @@ export const paymentsApi = { refund: (id: string) => fetchApi<{ message: string }>(`/api/payments/${id}/refund`, { method: 'POST' }), + + reactivate: (id: string) => + fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, { + method: 'POST', + }), }; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 59e2ab9..fd0d6e6 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -37,7 +37,7 @@ export interface Ticket { attendeePhone?: string; attendeeRuc?: string; preferredLanguage?: string; - status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in'; + status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in' | 'on_hold'; checkinAt?: string; checkedInByAdminId?: string; qrCode: string; @@ -111,7 +111,7 @@ export interface Payment { provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago'; amount: number; currency: string; - status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed'; + status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold'; reference?: string; userMarkedPaidAt?: string; payerName?: string; // Name of payer if different from attendee @@ -131,6 +131,7 @@ export interface PaymentWithDetails extends Payment { attendeeLastName?: string; attendeeEmail?: string; attendeePhone?: string; + attendeeRuc?: string; status: string; } | null; event: { @@ -325,6 +326,7 @@ export interface ExportedPayment { attendeeFirstName: string; attendeeLastName?: string; attendeeEmail?: string; + attendeeRuc?: string; eventId: string; eventTitle: string; eventDate: string; diff --git a/frontend/src/lib/api/users.ts b/frontend/src/lib/api/users.ts index 25f60ea..f4d35c7 100644 --- a/frontend/src/lib/api/users.ts +++ b/frontend/src/lib/api/users.ts @@ -1,10 +1,34 @@ import { fetchApi } from './client'; import type { User } from './types'; +export interface UsersListParams { + role?: string; + search?: string; + accountStatus?: string; + hasBookings?: 'yes' | 'no'; + registeredAfter?: string; + registeredBefore?: string; + eventId?: string; + page?: number; + pageSize?: number; +} + export const usersApi = { - getAll: (role?: string) => { - const query = role ? `?role=${role}` : ''; - return fetchApi<{ users: User[] }>(`/api/users${query}`); + getAll: (params?: UsersListParams | string) => { + // Back-compat: allow the old `getAll(role)` call shape. + const p: UsersListParams = typeof params === 'string' ? { role: params } : params || {}; + const query = new URLSearchParams(); + if (p.role) query.set('role', p.role); + if (p.search) query.set('search', p.search); + if (p.accountStatus) query.set('accountStatus', p.accountStatus); + if (p.hasBookings) query.set('hasBookings', p.hasBookings); + if (p.registeredAfter) query.set('registeredAfter', p.registeredAfter); + if (p.registeredBefore) query.set('registeredBefore', p.registeredBefore); + if (p.eventId) query.set('eventId', p.eventId); + if (p.page) query.set('page', String(p.page)); + if (p.pageSize) query.set('pageSize', String(p.pageSize)); + const qs = query.toString(); + return fetchApi<{ users: User[]; total: number; page: number; pageSize: number }>(`/api/users${qs ? `?${qs}` : ''}`); }, getById: (id: string) => fetchApi<{ user: User }>(`/api/users/${id}`),