Security recovery: hold sweep, dashboard updates, and admin fixes.

This commit is contained in:
Michilis
2026-07-01 05:51:38 +00:00
parent 38526f17b5
commit cacc52ec24
45 changed files with 1452 additions and 474 deletions
+7
View File
@@ -114,3 +114,10 @@ PENDING_BOOKING_TTL_MINUTES=30
# How often the cleanup job runs, in milliseconds (default: 300000 = 5 min) # How often the cleanup job runs, in milliseconds (default: 300000 = 5 min)
PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000 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
+2 -2
View File
@@ -104,7 +104,7 @@ export const sqliteTickets = sqliteTable('tickets', {
attendeePhone: text('attendee_phone'), attendeePhone: text('attendee_phone'),
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
preferredLanguage: text('preferred_language'), 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'), checkinAt: text('checkin_at'),
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
qrCode: text('qr_code'), qrCode: text('qr_code'),
@@ -119,7 +119,7 @@ export const sqlitePayments = sqliteTable('payments', {
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(), provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
amount: real('amount').notNull(), amount: real('amount').notNull(),
currency: text('currency').notNull().default('PYG'), 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'), reference: text('reference'),
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid" userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
payerName: text('payer_name'), // Name of payer if different from attendee payerName: text('payer_name'), // Name of payer if different from attendee
+4
View File
@@ -26,6 +26,7 @@ import faqRoutes from './routes/faq.js';
import emailService from './lib/email.js'; import emailService from './lib/email.js';
import { initEmailQueue } from './lib/emailQueue.js'; import { initEmailQueue } from './lib/emailQueue.js';
import { startBookingCleanup } from './lib/bookingCleanup.js'; import { startBookingCleanup } from './lib/bookingCleanup.js';
import { startHoldSweep } from './lib/holdSweep.js';
import { getLock } from './lib/stores/lock.js'; import { getLock } from './lib/stores/lock.js';
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.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. // Periodically expire abandoned pending bookings so they stop holding seats.
startBookingCleanup(); startBookingCleanup();
// Periodically put stale pending-approval payments on hold, releasing their seats.
startHoldSweep();
// Initialize email templates on startup. // Initialize email templates on startup.
// Guarded by a distributed lock so that, when running multiple replicas, only // 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. // one instance seeds/updates templates per boot instead of all of them racing.
+116
View File
@@ -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<string, any>;
}
/**
* 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<void> {
if (ticketIds.length === 0) return;
const event = await dbGet<any>(
(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<string, any> = {
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<number>`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<any>(
tx
.select({ count: sql<number>`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));
});
}
}
+98
View File
@@ -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<number> {
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<typeof setInterval> | 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;
}
}
+15 -3
View File
@@ -76,7 +76,14 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.from(payments) .from(payments)
.where(eq((payments as any).status, 'pending')) .where(eq((payments as any).status, 'pending'))
); );
const onHoldPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'on_hold'))
);
const revenueRow = await dbGet<any>( const revenueRow = await dbGet<any>(
(db as any) (db as any)
.select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }) .select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` })
@@ -108,6 +115,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
totalTickets: totalTickets?.count || 0, totalTickets: totalTickets?.count || 0,
confirmedTickets: confirmedTickets?.count || 0, confirmedTickets: confirmedTickets?.count || 0,
pendingPayments: pendingPayments?.count || 0, pendingPayments: pendingPayments?.count || 0,
onHoldPayments: onHoldPayments?.count || 0,
totalRevenue, totalRevenue,
newContacts: newContacts?.count || 0, newContacts: newContacts?.count || 0,
totalSubscribers: totalSubscribers?.count || 0, totalSubscribers: totalSubscribers?.count || 0,
@@ -213,6 +221,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
userName: user?.name, userName: user?.name,
userEmail: user?.email, userEmail: user?.email,
userPhone: user?.phone, userPhone: user?.phone,
attendeeRuc: ticket.attendeeRuc || user?.rucNumber || null,
eventTitle: event?.title, eventTitle: event?.title,
eventDate: event?.startDatetime, eventDate: event?.startDatetime,
paymentStatus: payment?.status, paymentStatus: payment?.status,
@@ -291,6 +300,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
'Full Name': fullName, 'Full Name': fullName,
'Email': ticket.attendeeEmail || '', 'Email': ticket.attendeeEmail || '',
'Phone': ticket.attendeePhone || '', 'Phone': ticket.attendeePhone || '',
'RUC': ticket.attendeeRuc || '',
'Status': ticket.status, 'Status': ticket.status,
'Checked In': isCheckedIn ? 'true' : 'false', 'Checked In': isCheckedIn ? 'true' : 'false',
'Check-in Time': ticket.checkinAt || '', 'Check-in Time': ticket.checkinAt || '',
@@ -302,7 +312,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
); );
const columns = [ const columns = [
'Ticket ID', 'Full Name', 'Email', 'Phone', 'Ticket ID', 'Full Name', 'Email', 'Phone', 'RUC',
'Status', 'Checked In', 'Check-in Time', 'Payment Status', 'Status', 'Checked In', 'Check-in Time', 'Payment Status',
'Booked At', 'Notes', '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) => ({ const rows = ticketList.map((ticket: any) => ({
'Ticket ID': ticket.id, 'Ticket ID': ticket.id,
'Booking ID': ticket.bookingId || '', 'Booking ID': ticket.bookingId || '',
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '), 'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
'RUC': ticket.attendeeRuc || '',
'Status': ticket.status, 'Status': ticket.status,
'Check-in Time': ticket.checkinAt || '', 'Check-in Time': ticket.checkinAt || '',
'Booked At': ticket.createdAt || '', 'Booked At': ticket.createdAt || '',
@@ -458,6 +469,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
attendeeFirstName: ticket.attendeeFirstName, attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName, attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
attendeeRuc: ticket.attendeeRuc || null,
eventId: event?.id, eventId: event?.id,
eventTitle: event?.title, eventTitle: event?.title,
eventDate: event?.startDatetime, eventDate: event?.startDatetime,
+115 -31
View File
@@ -2,15 +2,16 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js'; 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 { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
const paymentsRouter = new Hono(); const paymentsRouter = new Hono();
const updatePaymentSchema = z.object({ 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(), reference: z.string().optional(),
adminNote: z.string().optional(), adminNote: z.string().optional(),
}); });
@@ -85,6 +86,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
attendeeLastName: ticket.attendeeLastName, attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone, attendeePhone: ticket.attendeePhone,
attendeeRuc: ticket.attendeeRuc,
status: ticket.status, status: ticket.status,
} : null, } : null,
event: event ? { event: event ? {
@@ -95,7 +97,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
}; };
}) })
); );
// Filter by event(s) // Filter by event(s)
if (eventId) { if (eventId) {
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === 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 // Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { 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<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments)), dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments)),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))), dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))), dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))), dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))), dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'on_hold'))),
dbGet<any>((db as any).select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))), dbGet<any>((db as any).select({ total: sql<number>`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), paid: Number(paidRow?.count || 0),
refunded: Number(refundedRow?.count || 0), refunded: Number(refundedRow?.count || 0),
failed: Number(failedRow?.count || 0), failed: Number(failedRow?.count || 0),
onHold: Number(onHoldRow?.count || 0),
totalRevenue: Number(revenueRow?.total || 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); return c.json({ error: 'Payment not found' }, 404);
} }
// Can approve pending or pending_approval payments // Can approve pending, pending_approval, or on_hold payments
if (!['pending', 'pending_approval'].includes(payment.status)) { if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400); return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
} }
const now = getNow(); const now = getNow();
// Get the ticket associated with this payment // Get the ticket associated with this payment
const ticket = await dbGet<any>( const ticket = await dbGet<any>(
(db as any) (db as any)
@@ -331,10 +335,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, payment.ticketId)) .where(eq((tickets as any).id, payment.ticketId))
); );
// Check if this is part of a multi-ticket booking // Check if this is part of a multi-ticket booking
let ticketsToConfirm: any[] = [ticket]; let ticketsToConfirm: any[] = [ticket];
if (ticket?.bookingId) { if (ticket?.bookingId) {
// Get all tickets in this booking // Get all tickets in this booking
ticketsToConfirm = await dbAll( 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`); console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
} }
// Update all payments in the booking to paid if (payment.status === 'on_hold') {
for (const t of ticketsToConfirm) { // The seat was released when this booking went on hold - re-check capacity
await (db as any) // before confirming it directly.
.update(payments) try {
.set({ await reserveOnHoldBooking(
status: 'paid', ticket.eventId,
paidAt: now, ticketsToConfirm.map((t: any) => t.id),
paidByAdminId: user.id, 'confirmed',
adminNote: adminNote || payment.adminNote, 'paid',
updatedAt: now, { paidByAdminId: user.id }
}) );
.where(eq((payments as any).ticketId, (t as any).id)); } catch (err) {
if (err instanceof HoldCapacityError) {
// Update ticket status to confirmed return c.json({
await (db as any) error: 'This event is now full. Your spot was released after the payment deadline passed.',
.update(tickets) }, 400);
.set({ status: 'confirmed' }) }
.where(eq((tickets as any).id, (t as any).id)); 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) // Send confirmation emails asynchronously (if sendEmail is true, which is the default)
if (sendEmail !== false) { if (sendEmail !== false) {
Promise.all([ Promise.all([
@@ -405,7 +436,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
return c.json({ error: 'Payment not found' }, 404); 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); 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' }); 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<any>(
(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<any>(
(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<any>(
(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 // Send payment reminder email
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => { paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
+93 -25
View File
@@ -9,6 +9,7 @@ import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
const ticketsRouter = new Hono(); const ticketsRouter = new Hono();
@@ -53,7 +54,7 @@ function isPaymentMethodEnabled(method: string, merged: Record<string, any>): bo
} }
const updateTicketSchema = z.object({ 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(), adminNote: z.string().optional(),
}); });
@@ -167,12 +168,21 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
phone: data.phone || null, phone: data.phone || null,
role: 'user', role: 'user',
languagePreference: null, languagePreference: null,
rucNumber: data.ruc || null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
await (db as any).insert(users).values(user); 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) // Check for duplicate booking (unless allowDuplicateBookings is enabled)
const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false; const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false;
@@ -1104,26 +1114,47 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
); );
} }
// Confirm all tickets in the booking if (ticket.status === 'on_hold') {
for (const t of ticketsToConfirm) { // The seat was released when this booking went on hold - re-check capacity
// Update ticket status // before confirming it directly.
await (db as any) try {
.update(tickets) await reserveOnHoldBooking(
.set({ status: 'confirmed' }) ticket.eventId,
.where(eq((tickets as any).id, t.id)); ticketsToConfirm.map((t: any) => t.id),
'confirmed',
// Update payment status 'paid',
await (db as any) { paidByAdminId: user.id }
.update(payments) );
.set({ } catch (err) {
status: 'paid', if (err instanceof HoldCapacityError) {
paidAt: now, return c.json({
paidByAdminId: user.id, error: 'This event is now full. Your spot was released after the payment deadline passed.',
updatedAt: now, }, 400);
}) }
.where(eq((payments as any).ticketId, t.id)); 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 // Get payment for sending receipt
const payment = await dbGet<any>( const payment = await dbGet<any>(
(db as any) (db as any)
@@ -1194,18 +1225,55 @@ ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, wind
} }
if (payment.status === 'paid') { if (payment.status === 'paid') {
return c.json({ return c.json({
payment, payment,
message: 'Payment has already been confirmed.', message: 'Payment has already been confirmed.',
alreadyProcessed: true, 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<any>(
(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 // Only allow if currently pending
if (payment.status !== 'pending') { if (payment.status !== 'pending') {
return c.json({ error: 'Payment has already been processed' }, 400); return c.json({ error: 'Payment has already been processed' }, 400);
} }
const now = getNow(); const now = getNow();
// Update payment status to pending_approval for this ticket and any siblings // Update payment status to pending_approval for this ticket and any siblings
+47 -9
View File
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; 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 { 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 { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
@@ -27,7 +27,43 @@ const updateUserSchema = z.object({
// Get all users (admin only) // Get all users (admin only)
usersRouter.get('/', requireAuth(['admin']), async (c) => { usersRouter.get('/', requireAuth(['admin']), async (c) => {
const role = c.req.query('role'); 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<number>`count(*)` }).from(users).where(whereClause)
: (db as any).select({ count: sql<number>`count(*)` }).from(users);
const totalRow = await dbGet<any>(totalQuery);
let query = (db as any).select({ let query = (db as any).select({
id: (users as any).id, id: (users as any).id,
email: (users as any).email, email: (users as any).email,
@@ -40,14 +76,16 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
accountStatus: (users as any).accountStatus, accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt, createdAt: (users as any).createdAt,
}).from(users); }).from(users);
if (role) { if (whereClause) {
query = query.where(eq((users as any).role, role)); query = query.where(whereClause);
} }
const result = await dbAll(query.orderBy(desc((users as any).createdAt))); const result = await dbAll(
query.orderBy(desc((users as any).createdAt)).limit(pageSize).offset((page - 1) * pageSize)
return c.json({ users: result }); );
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 // Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id
+18 -1
View File
@@ -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 { server {
listen 443 ssl; listen 443 ssl;
http2 on; 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) # Upload size limit (covers same-origin /api uploads via this vhost)
client_max_body_size 20m; client_max_body_size 20m;
@@ -3,6 +3,9 @@ import type { Metadata } from 'next';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Join Our Language Exchange Community', 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.', 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: { openGraph: {
title: 'Join Our Language Exchange Community Spanglish', 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.', 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.',
@@ -3,6 +3,9 @@ import type { Metadata } from 'next';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Contact Us', title: 'Contact Us',
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.', description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
alternates: {
canonical: '/contact',
},
openGraph: { openGraph: {
title: 'Contact Us Spanglish', title: 'Contact Us Spanglish',
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.', description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
@@ -22,6 +22,7 @@ import {
groupByBooking, groupByBooking,
isUnpaid, isUnpaid,
isAwaitingApproval, isAwaitingApproval,
isOnHold,
ticketAmount, ticketAmount,
shareTicket, shareTicket,
isToday, isToday,
@@ -57,11 +58,12 @@ export default function OverviewTab({
// awaiting approval), ordered by soonest event. // awaiting approval), ordered by soonest event.
const attentionTicket = useMemo(() => { const attentionTicket = useMemo(() => {
const candidates = activeTickets.filter( 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) => { candidates.sort((a, b) => {
const aUnpaid = isUnpaid(a) ? 0 : 1; const aUnpaid = priority(a);
const bUnpaid = isUnpaid(b) ? 0 : 1; const bUnpaid = priority(b);
if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid; if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid;
const aStart = a.event?.startDatetime const aStart = a.event?.startDatetime
? parseDate(a.event.startDatetime).getTime() ? parseDate(a.event.startDatetime).getTime()
@@ -190,7 +192,19 @@ export default function OverviewTab({
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<StatusPill status={status} locale={locale} /> <StatusPill status={status} locale={locale} />
{isUnpaid(t) ? ( {isOnHold(t) ? (
<PayActions
ticketId={t.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
onHold
/>
) : isUnpaid(t) ? (
<PayActions <PayActions
ticketId={t.id} ticketId={t.id}
amount={amount} amount={amount}
@@ -346,7 +360,19 @@ function HeroCard({
)} )}
{/* Smart primary action. */} {/* Smart primary action. */}
{isUnpaid(ticket) ? ( {isOnHold(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="md"
onHold
/>
) : isUnpaid(ticket) ? (
<PayActions <PayActions
ticketId={ticket.id} ticketId={ticket.id}
amount={amount} amount={amount}
@@ -106,6 +106,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
: payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event'); : payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event');
const canMarkPaid = const canMarkPaid =
payment.status === 'pending' && isManualProvider(payment.provider); payment.status === 'pending' && isManualProvider(payment.provider);
const canRebook = payment.status === 'on_hold';
return ( return (
<Card key={payment.id} className="p-4"> <Card key={payment.id} className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -131,7 +132,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
</div> </div>
<div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end"> <div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end">
{canMarkPaid && ( {canRebook ? (
<PayActions <PayActions
ticketId={payment.ticketId} ticketId={payment.ticketId}
amount={Number(payment.amount)} amount={Number(payment.amount)}
@@ -141,7 +142,21 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
onPaid={onChange} onPaid={onChange}
layout="inline" layout="inline"
size="sm" size="sm"
onHold
/> />
) : (
canMarkPaid && (
<PayActions
ticketId={payment.ticketId}
amount={Number(payment.amount)}
currency={payment.currency}
destination={eventTitle}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
/>
)
)} )}
{payment.invoice && ( {payment.invoice && (
<a <a
@@ -16,9 +16,11 @@ import { StatusPill, deriveTicketStatus } from './_shared/status';
import { import {
groupByBooking, groupByBooking,
isUnpaid, isUnpaid,
isOnHold,
ticketAmount, ticketAmount,
ticketPdfUrl, ticketPdfUrl,
pyg, pyg,
HOLD_THRESHOLD_HOURS,
type BookingGroup, type BookingGroup,
} from './_shared/helpers'; } from './_shared/helpers';
import PayActions from './_shared/PayActions'; import PayActions from './_shared/PayActions';
@@ -176,12 +178,31 @@ function BookingCard({
<p className="text-gray-500"> <p className="text-gray-500">
{pyg(amount, currency)} {pyg(amount, currency)}
</p> </p>
{isOnHold(ticket) && (
<p className="text-slate-600">
{locale === 'es'
? `Tu lugar fue liberado porque el pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.`
: `Your spot has been released because payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`}
</p>
)}
</div> </div>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex flex-col gap-2 sm:w-44"> <div className="flex flex-col gap-2 sm:w-44">
{isUnpaid(ticket) ? ( {isOnHold(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="stack"
size="sm"
onHold
/>
) : isUnpaid(ticket) ? (
<PayActions <PayActions
ticketId={ticket.id} ticketId={ticket.id}
amount={amount} amount={amount}
@@ -13,6 +13,8 @@ import {
ticketAmount, ticketAmount,
pyg, pyg,
isAwaitingApproval, isAwaitingApproval,
isOnHold,
HOLD_THRESHOLD_HOURS,
} from './helpers'; } from './helpers';
/** /**
@@ -38,6 +40,41 @@ export default function AttentionBanner({
: ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event'); : ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event');
const { amount, currency } = ticketAmount(ticket); const { amount, currency } = ticketAmount(ticket);
if (isOnHold(ticket)) {
return (
<div className="rounded-card border border-slate-200 bg-slate-50 p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-slate-500" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-slate-800">
{locale === 'es'
? `Tu lugar para ${eventTitle} fue liberado`
: `Your spot for ${eventTitle} was released`}
</p>
<p className="mt-0.5 text-sm text-slate-600">
{locale === 'es'
? `El pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.`
: `Payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`}
</p>
</div>
</div>
<div className="mt-3 sm:pl-9">
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={eventTitle}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
onHold
/>
</div>
</div>
);
}
if (isAwaitingApproval(ticket)) { if (isAwaitingApproval(ticket)) {
return ( return (
<div className="flex items-start gap-3 rounded-card border border-amber-200 bg-amber-50 p-4"> <div className="flex items-start gap-3 rounded-card border border-amber-200 bg-amber-50 p-4">
@@ -23,6 +23,11 @@ interface PayActionsProps {
/** Stack the two buttons full-width (cards) vs inline (rows). */ /** Stack the two buttons full-width (cards) vs inline (rows). */
layout?: 'stack' | 'inline'; layout?: 'stack' | 'inline';
className?: string; 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', size = 'sm',
layout = 'stack', layout = 'stack',
className, className,
onHold = false,
}: PayActionsProps) { }: PayActionsProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
@@ -76,18 +82,22 @@ export default function PayActions({
return ( return (
<> <>
<div className={`${containerClass} ${className || ''}`}> <div className={`${containerClass} ${className || ''}`}>
<Link href={`/booking/${ticketId}`} className={btnWidth}> {!onHold && (
<Button size={size} className={btnWidth}> <Link href={`/booking/${ticketId}`} className={btnWidth}>
{locale === 'es' ? 'Pagar ahora' : 'Pay now'} <Button size={size} className={btnWidth}>
</Button> {locale === 'es' ? 'Pagar ahora' : 'Pay now'}
</Link> </Button>
</Link>
)}
<Button <Button
variant="outline" variant={onHold ? 'primary' : 'outline'}
size={size} size={size}
className={btnWidth} className={btnWidth}
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
> >
{locale === 'es' ? 'Ya pagué' : "I've paid"} {onHold
? (locale === 'es' ? 'Reservar de nuevo' : 'Rebook')
: (locale === 'es' ? 'Ya pagué' : "I've paid")}
</Button> </Button>
</div> </div>
@@ -108,16 +118,24 @@ export default function PayActions({
</div> </div>
</div> </div>
<h3 className="mb-2 text-center text-lg font-semibold text-primary-dark"> <h3 className="mb-2 text-center text-lg font-semibold text-primary-dark">
{locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?'} {onHold
? (locale === 'es' ? '¿Reservar de nuevo?' : 'Rebook your spot?')
: (locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?')}
</h3> </h3>
<p className="mb-6 text-center text-sm text-gray-600"> <p className="mb-6 text-center text-sm text-gray-600">
{locale === 'es' {onHold
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?` ? (locale === 'es'
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`} ? `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}?`)}
</p> </p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button isLoading={marking} className="w-full" onClick={handleConfirm}> <Button isLoading={marking} className="w-full" onClick={handleConfirm}>
{locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid"} {onHold
? (locale === 'es' ? 'Sí, reservar de nuevo' : 'Yes, rebook')
: (locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -6,6 +6,16 @@ import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
// ticket was created, capped at the event start time. // ticket was created, capped at the event start time.
export const PAYMENT_HOLD_HOURS = 24; export const PAYMENT_HOLD_HOURS = 24;
// Hours a pending-approval booking (payment already marked as sent) can wait
// for admin review before the auto-hold sweep releases the spot. Mirrors the
// backend's HOLD_THRESHOLD_HOURS env default - keep these in sync.
export const HOLD_THRESHOLD_HOURS = 72;
/** True once a booking has been auto-released after the approval hold window. */
export function isOnHold(ticket: Pick<UserTicket, 'status'> & { payment?: { status?: string } | null }): boolean {
return ticket.status === 'on_hold' || ticket.payment?.status === 'on_hold';
}
/** Currency is Guarani with no decimals, e.g. "21 PYG". */ /** Currency is Guarani with no decimals, e.g. "21 PYG". */
export function pyg(amount: number, currency: string = 'PYG'): string { export function pyg(amount: number, currency: string = 'PYG'): string {
return formatPrice(Number(amount) || 0, currency || 'PYG'); return formatPrice(Number(amount) || 0, currency || 'PYG');
@@ -9,12 +9,14 @@ import type { UserTicket, Payment } from '@/lib/api';
// unpaid -> pale red // unpaid -> pale red
// attended -> pale blue (replaces the raw "checked_in" value) // attended -> pale blue (replaces the raw "checked_in" value)
// cancelled -> pale gray // cancelled -> pale gray
// onHold -> pale slate (spot released after the payment deadline passed)
export type DashStatus = export type DashStatus =
| 'confirmed' | 'confirmed'
| 'awaiting' | 'awaiting'
| 'unpaid' | 'unpaid'
| 'attended' | 'attended'
| 'cancelled'; | 'cancelled'
| 'onHold';
/** /**
* Collapse a ticket status + payment status into a single user-facing status. * Collapse a ticket status + payment status into a single user-facing status.
@@ -26,6 +28,7 @@ export function deriveTicketStatus(
): DashStatus { ): DashStatus {
if (ticketStatus === 'checked_in') return 'attended'; if (ticketStatus === 'checked_in') return 'attended';
if (ticketStatus === 'cancelled') return 'cancelled'; if (ticketStatus === 'cancelled') return 'cancelled';
if (ticketStatus === 'on_hold' || paymentStatus === 'on_hold') return 'onHold';
if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed'; if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed';
if (paymentStatus === 'pending_approval') return 'awaiting'; if (paymentStatus === 'pending_approval') return 'awaiting';
return 'unpaid'; return 'unpaid';
@@ -35,6 +38,7 @@ export function deriveTicketStatus(
export function derivePaymentStatus(paymentStatus?: string): DashStatus { export function derivePaymentStatus(paymentStatus?: string): DashStatus {
if (paymentStatus === 'paid') return 'confirmed'; if (paymentStatus === 'paid') return 'confirmed';
if (paymentStatus === 'pending_approval') return 'awaiting'; if (paymentStatus === 'pending_approval') return 'awaiting';
if (paymentStatus === 'on_hold') return 'onHold';
if (paymentStatus === 'refunded') return 'cancelled'; if (paymentStatus === 'refunded') return 'cancelled';
return 'unpaid'; return 'unpaid';
} }
@@ -46,6 +50,7 @@ export function statusLabel(status: DashStatus, locale: string): string {
unpaid: { en: 'Unpaid', es: 'No pagado' }, unpaid: { en: 'Unpaid', es: 'No pagado' },
attended: { en: 'Attended', es: 'Asistió' }, attended: { en: 'Attended', es: 'Asistió' },
cancelled: { en: 'Cancelled', es: 'Cancelado' }, cancelled: { en: 'Cancelled', es: 'Cancelado' },
onHold: { en: 'On Hold', es: 'En Espera' },
}; };
return locale === 'es' ? labels[status].es : labels[status].en; return locale === 'es' ? labels[status].es : labels[status].en;
} }
@@ -56,6 +61,7 @@ const PILL_STYLES: Record<DashStatus, string> = {
unpaid: 'bg-red-100 text-red-700', unpaid: 'bg-red-100 text-red-700',
attended: 'bg-blue-100 text-blue-800', attended: 'bg-blue-100 text-blue-800',
cancelled: 'bg-gray-100 text-gray-600', cancelled: 'bg-gray-100 text-gray-600',
onHold: 'bg-slate-100 text-slate-600',
}; };
export function StatusPill({ export function StatusPill({
@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { 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';
// Receives the event list already fetched on the server so event titles, dates,
// and locations are present in the initial HTML. The upcoming/past filter below
// is client-side interactivity layered on top of the server-rendered data.
export default function EventsClient({ initialEvents }: { initialEvents: Event[] }) {
const { t, locale } = useLanguage();
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
const now = new Date();
const upcomingEvents = initialEvents.filter(e =>
e.status === 'published' && new Date(e.startDatetime) >= now
);
const pastEvents = initialEvents.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 <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
}
if (event.availableSeats === 0) {
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
}
return null;
};
return (
<div className="section-padding">
<div className="container-page">
<h1 className="section-title">{t('events.title')}</h1>
{/* Filter tabs */}
<div className="mt-8 flex gap-2">
<button
onClick={() => setFilter('upcoming')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'upcoming'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.upcoming')} ({upcomingEvents.length})
</button>
<button
onClick={() => setFilter('past')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'past'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.past')} ({pastEvents.length})
</button>
</div>
{/* Events grid */}
<div className="mt-8">
{displayedEvents.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg">{t('events.noEvents')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedEvents.map((event) => (
<Link key={event.id} href={`/events/${event.slug}`} className="block">
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
{/* Event banner */}
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
className="h-40 w-full object-cover"
loading="lazy"
/>
) : (
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
</div>
)}
<div className="p-6">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-lg text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h3>
{getStatusBadge(event)}
</div>
<div className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-2">
<MapPinIcon className="w-4 h-4" />
<span className="truncate">{event.location}</span>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" />
<span>
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
</span>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<span className="font-bold text-xl text-primary-dark">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</span>
<Button size="sm">
{t('common.moreInfo')}
</Button>
</div>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -123,7 +123,9 @@ function generateEventJsonLd(event: Event) {
url: `${siteUrl}/events/${event.slug}`, url: `${siteUrl}/events/${event.slug}`,
validFrom: new Date().toISOString(), 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}`, url: `${siteUrl}/events/${event.slug}`,
}; };
} }
+6 -1
View File
@@ -1,8 +1,13 @@
import type { Metadata } from 'next'; 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 = { 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.', description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
alternates: {
canonical: '/events',
},
openGraph: { openGraph: {
title: 'Upcoming Language Exchange Events in Asunción Spanglish', 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.', description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
+24 -152
View File
@@ -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'; const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
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';
export default function EventsPage() { // Listing title lives here (not in the layout) so the root title template still
const { t, locale } = useLanguage(); // applies to the sibling /events/[id] detail route. Picks up "%s Spanglish".
const [events, setEvents] = useState<Event[]>([]); export const metadata: Metadata = {
const [loading, setLoading] = useState(true); title: 'Upcoming Language Exchange Events in Asunción',
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming'); };
useEffect(() => { // Fetch the public (published) event list on the server so event titles, dates,
eventsApi.getAll() // and locations appear in the initial HTML rather than only after JS runs.
.then(({ events }) => setEvents(events)) async function getEvents(): Promise<Event[]> {
.catch(console.error) try {
.finally(() => setLoading(false)); 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(); export default async function EventsPage() {
const upcomingEvents = events.filter(e => const events = await getEvents();
e.status === 'published' && new Date(e.startDatetime) >= now return <EventsClient initialEvents={events} />;
);
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 <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
}
if (event.availableSeats === 0) {
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
}
return null;
};
return (
<div className="section-padding">
<div className="container-page">
<h1 className="section-title">{t('events.title')}</h1>
{/* Filter tabs */}
<div className="mt-8 flex gap-2">
<button
onClick={() => setFilter('upcoming')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'upcoming'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.upcoming')} ({upcomingEvents.length})
</button>
<button
onClick={() => setFilter('past')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'past'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.past')} ({pastEvents.length})
</button>
</div>
{/* Events grid */}
<div className="mt-8">
{loading ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div>
) : displayedEvents.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg">{t('events.noEvents')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedEvents.map((event) => (
<Link key={event.id} href={`/events/${event.slug}`} className="block">
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
{/* Event banner */}
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
className="h-40 w-full object-cover"
loading="lazy"
/>
) : (
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
</div>
)}
<div className="p-6">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-lg text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h3>
{getStatusBadge(event)}
</div>
<div className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-2">
<MapPinIcon className="w-4 h-4" />
<span className="truncate">{event.location}</span>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" />
<span>
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
</span>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<span className="font-bold text-xl text-primary-dark">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</span>
<Button size="sm">
{t('common.moreInfo')}
</Button>
</div>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
} }
@@ -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<number | null>(null);
const toggleFAQ = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<div className="section-padding">
<div className="container-page max-w-3xl">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-primary-dark mb-4">
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
: 'Find answers to the most common questions about Spanglish'}
</p>
</div>
{initialFaqs.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{locale === 'es'
? 'No hay preguntas frecuentes publicadas en este momento.'
: 'No FAQ questions are published at the moment.'}
</p>
</Card>
) : (
<div className="space-y-4">
{initialFaqs.map((faq, index) => (
<Card key={faq.id} className="overflow-hidden">
<button
onClick={() => toggleFAQ(index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-primary-dark pr-4">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</span>
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
openIndex === index && 'transform rotate-180'
)}
/>
</button>
<div
className={clsx(
'overflow-hidden transition-all duration-200',
openIndex === index ? 'max-h-96' : 'max-h-0'
)}
>
<div className="px-6 pb-4 text-gray-600">
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
</div>
</div>
</Card>
))}
</div>
)}
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
<h2 className="text-xl font-semibold text-primary-dark mb-2">
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
</h2>
<p className="text-gray-600 mb-4">
{locale === 'es'
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
: "Don't hesitate to reach out. We're here to help!"}
</p>
<a
href="/contact"
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
{locale === 'es' ? 'Contáctanos' : 'Contact Us'}
</a>
</Card>
</div>
</div>
);
}
+3
View File
@@ -20,6 +20,9 @@ async function getFaqForSchema(): Promise<{ question: string; answer: string }[]
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Frequently Asked Questions', 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.', 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: { openGraph: {
title: 'Frequently Asked Questions Spanglish', title: 'Frequently Asked Questions Spanglish',
description: 'Find answers to common questions about Spanglish language exchange events in Asunción.', description: 'Find answers to common questions about Spanglish language exchange events in Asunción.',
+17 -111
View File
@@ -1,116 +1,22 @@
'use client'; import { FaqItem } from '@/lib/api';
import FaqClient from './FaqClient';
import { useState, useEffect } from 'react'; const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
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';
export default function FAQPage() { // Fetch the published FAQ list on the server so the questions and answers are
const { locale } = useLanguage(); // rendered into the initial HTML (crawlers see the content without running JS).
const [faqs, setFaqs] = useState<FaqItem[]>([]); async function getFaqs(): Promise<FaqItem[]> {
const [loading, setLoading] = useState(true); try {
const [openIndex, setOpenIndex] = useState<number | null>(null); const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } });
if (!res.ok) return [];
useEffect(() => { const data = await res.json();
let cancelled = false; return data.faqs || [];
faqApi.getList().then((res) => { } catch {
if (!cancelled) { return [];
setFaqs(res.faqs);
}
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const toggleFAQ = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
if (loading) {
return (
<div className="section-padding">
<div className="container-page max-w-3xl flex justify-center py-20">
<div className="animate-spin w-10 h-10 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
</div>
);
} }
}
return ( export default async function FAQPage() {
<div className="section-padding"> const faqs = await getFaqs();
<div className="container-page max-w-3xl"> return <FaqClient initialFaqs={faqs} />;
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-primary-dark mb-4">
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
: 'Find answers to the most common questions about Spanglish'}
</p>
</div>
{faqs.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{locale === 'es'
? 'No hay preguntas frecuentes publicadas en este momento.'
: 'No FAQ questions are published at the moment.'}
</p>
</Card>
) : (
<div className="space-y-4">
{faqs.map((faq, index) => (
<Card key={faq.id} className="overflow-hidden">
<button
onClick={() => toggleFAQ(index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-primary-dark pr-4">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</span>
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
openIndex === index && 'transform rotate-180'
)}
/>
</button>
<div
className={clsx(
'overflow-hidden transition-all duration-200',
openIndex === index ? 'max-h-96' : 'max-h-0'
)}
>
<div className="px-6 pb-4 text-gray-600">
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
</div>
</div>
</Card>
))}
</div>
)}
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
<h2 className="text-xl font-semibold text-primary-dark mb-2">
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
</h2>
<p className="text-gray-600 mb-4">
{locale === 'es'
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
: "Don't hesitate to reach out. We're here to help!"}
</p>
<a
href="/contact"
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
{locale === 'es' ? 'Contáctanos' : 'Contact Us'}
</a>
</Card>
</div>
</div>
);
} }
@@ -40,7 +40,8 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro
} }
return { 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.`, description: `${legalPage.title} for Spanglish language exchange events in Asunción, Paraguay.`,
robots: { robots: {
index: true, index: true,
+9 -3
View File
@@ -59,7 +59,9 @@ export async function generateMetadata(): Promise<Metadata> {
if (!event) { if (!event) {
return { 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: description:
'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.', '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<Metadata> {
const description = `Next event: ${eventDate} ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`; const description = `Next event: ${eventDate} ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
return { 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, description,
openGraph: { openGraph: {
title: 'Spanglish Language Exchange Events in Asunción', title: 'Spanglish Language Exchange Events in Asunción',
@@ -142,7 +146,9 @@ function generateNextEventJsonLd(event: NextEvent) {
: 'https://schema.org/SoldOut', : 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.slug}`, 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}`, url: `${siteUrl}/events/${event.slug}`,
}; };
} }
@@ -0,0 +1,5 @@
import { notFound } from 'next/navigation';
export default function AdminCatchAll() {
notFound();
}
+53 -3
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; 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 { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
@@ -17,6 +17,7 @@ import {
PhoneIcon, PhoneIcon,
FunnelIcon, FunnelIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; 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) => { const handleCancel = async (ticketId: string) => {
if (!confirm('Are you sure you want to cancel this booking?')) return; 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 'pending': return 'bg-yellow-100 text-yellow-800';
case 'cancelled': return 'bg-red-100 text-red-800'; case 'cancelled': return 'bg-red-100 text-red-800';
case 'checked_in': return 'bg-blue-100 text-blue-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'; default: return 'bg-gray-100 text-gray-800';
} }
}; };
@@ -144,6 +160,7 @@ export default function AdminBookingsPage() {
case 'failed': case 'failed':
case 'cancelled': return 'bg-red-100 text-red-800'; case 'cancelled': return 'bg-red-100 text-red-800';
case 'refunded': return 'bg-purple-100 text-purple-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'; default: return 'bg-gray-100 text-gray-800';
} }
}; };
@@ -191,6 +208,7 @@ export default function AdminBookingsPage() {
confirmed: tickets.filter(t => t.status === 'confirmed').length, confirmed: tickets.filter(t => t.status === 'confirmed').length,
checkedIn: tickets.filter(t => t.status === 'checked_in').length, checkedIn: tickets.filter(t => t.status === 'checked_in').length,
cancelled: tickets.filter(t => t.status === 'cancelled').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, 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') { if (ticket.status === 'pending' && ticket.payment?.status === 'pending') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' }; 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') { if (ticket.status === 'confirmed') {
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' }; return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' };
} }
@@ -239,7 +260,7 @@ export default function AdminBookingsPage() {
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-6 gap-2 md:gap-4 mb-6"> <div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-2 md:gap-4 mb-6">
<Card className="p-3 md:p-4 text-center"> <Card className="p-3 md:p-4 text-center">
<p className="text-xl md:text-2xl font-bold text-primary-dark">{stats.total}</p> <p className="text-xl md:text-2xl font-bold text-primary-dark">{stats.total}</p>
<p className="text-xs md:text-sm text-gray-500">Total</p> <p className="text-xs md:text-sm text-gray-500">Total</p>
@@ -260,6 +281,10 @@ export default function AdminBookingsPage() {
<p className="text-xl md:text-2xl font-bold text-red-600">{stats.cancelled}</p> <p className="text-xl md:text-2xl font-bold text-red-600">{stats.cancelled}</p>
<p className="text-xs md:text-sm text-gray-500">Cancelled</p> <p className="text-xs md:text-sm text-gray-500">Cancelled</p>
</Card> </Card>
<Card className="p-3 md:p-4 text-center border-l-4 border-slate-400">
<p className="text-xl md:text-2xl font-bold text-slate-600">{stats.onHold}</p>
<p className="text-xs md:text-sm text-gray-500">On Hold</p>
</Card>
<Card className="p-3 md:p-4 text-center border-l-4 border-orange-400"> <Card className="p-3 md:p-4 text-center border-l-4 border-orange-400">
<p className="text-xl md:text-2xl font-bold text-orange-600">{stats.pendingPayment}</p> <p className="text-xl md:text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
<p className="text-xs md:text-sm text-gray-500">Pending Pay</p> <p className="text-xs md:text-sm text-gray-500">Pending Pay</p>
@@ -305,6 +330,7 @@ export default function AdminBookingsPage() {
<option value="confirmed">Confirmed</option> <option value="confirmed">Confirmed</option>
<option value="checked_in">Checked In</option> <option value="checked_in">Checked In</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
<option value="on_hold">On Hold</option>
</select> </select>
</div> </div>
<div> <div>
@@ -316,6 +342,7 @@ export default function AdminBookingsPage() {
<option value="paid">Paid</option> <option value="paid">Paid</option>
<option value="refunded">Refunded</option> <option value="refunded">Refunded</option>
<option value="failed">Failed</option> <option value="failed">Failed</option>
<option value="on_hold">On Hold</option>
</select> </select>
</div> </div>
</div> </div>
@@ -368,6 +395,7 @@ export default function AdminBookingsPage() {
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">RUC</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Payment</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Payment</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
@@ -378,7 +406,7 @@ export default function AdminBookingsPage() {
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{sortedTickets.length === 0 ? ( {sortedTickets.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm"> <td colSpan={7} className="px-4 py-12 text-center text-gray-500 text-sm">
No bookings found. No bookings found.
</td> </td>
</tr> </tr>
@@ -392,6 +420,7 @@ export default function AdminBookingsPage() {
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p> <p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>} {ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-600">{ticket.attendeeRuc || '-'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="text-sm truncate max-w-[150px] block"> <span className="text-sm truncate max-w-[150px] block">
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'} {ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
@@ -431,6 +460,18 @@ export default function AdminBookingsPage() {
Check In Check In
</Button> </Button>
)} )}
{ticket.status === 'on_hold' && (
<>
<Button size="sm" variant="outline" onClick={() => handleMarkPaid(ticket.id)}
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
Mark Paid
</Button>
<Button size="sm" variant="outline" onClick={() => handleReactivate(ticket)}
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
Reactivate
</Button>
</>
)}
{(ticket.status === 'pending' || ticket.status === 'confirmed') && ( {(ticket.status === 'pending' || ticket.status === 'confirmed') && (
<MoreMenu> <MoreMenu>
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600"> <DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
@@ -519,6 +560,13 @@ export default function AdminBookingsPage() {
</DropdownItem> </DropdownItem>
</MoreMenu> </MoreMenu>
)} )}
{ticket.status === 'on_hold' && (
<MoreMenu>
<DropdownItem onClick={() => handleReactivate(ticket)}>
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
</MoreMenu>
)}
{ticket.status === 'checked_in' && ( {ticket.status === 'checked_in' && (
<span className="text-[10px] text-green-600 flex items-center gap-1"> <span className="text-[10px] text-green-600 flex items-center gap-1">
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended <CheckCircleIcon className="w-3.5 h-3.5" /> Attended
@@ -557,6 +605,7 @@ export default function AdminBookingsPage() {
{ value: 'confirmed', label: `Confirmed (${stats.confirmed})` }, { value: 'confirmed', label: `Confirmed (${stats.confirmed})` },
{ value: 'checked_in', label: `Checked In (${stats.checkedIn})` }, { value: 'checked_in', label: `Checked In (${stats.checkedIn})` },
{ value: 'cancelled', label: `Cancelled (${stats.cancelled})` }, { value: 'cancelled', label: `Cancelled (${stats.cancelled})` },
{ value: 'on_hold', label: `On Hold (${stats.onHold})` },
].map((opt) => ( ].map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -581,6 +630,7 @@ export default function AdminBookingsPage() {
{ value: 'paid', label: 'Paid' }, { value: 'paid', label: 'Paid' },
{ value: 'refunded', label: 'Refunded' }, { value: 'refunded', label: 'Refunded' },
{ value: 'failed', label: 'Failed' }, { value: 'failed', label: 'Failed' },
{ value: 'on_hold', label: 'On Hold' },
].map((opt) => ( ].map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -6,6 +6,7 @@ export function StatusBadge({ status, compact = false }: { status: string; compa
confirmed: 'bg-green-100 text-green-800', confirmed: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800', cancelled: 'bg-red-100 text-red-800',
checked_in: 'bg-blue-100 text-blue-800', checked_in: 'bg-blue-100 text-blue-800',
on_hold: 'bg-slate-100 text-slate-600',
}; };
return ( return (
<span className={clsx( <span className={clsx(
@@ -20,6 +20,7 @@ interface EventModalsProps {
confirmedCount: number; confirmedCount: number;
checkedInCount: number; checkedInCount: number;
cancelledCount: number; cancelledCount: number;
onHoldCount: number;
statusFilter: AttendeeStatusFilter; statusFilter: AttendeeStatusFilter;
setStatusFilter: (value: AttendeeStatusFilter) => void; setStatusFilter: (value: AttendeeStatusFilter) => void;
// mobile filter sheet // mobile filter sheet
@@ -75,6 +76,7 @@ export function EventModals(props: EventModalsProps) {
confirmedCount, confirmedCount,
checkedInCount, checkedInCount,
cancelledCount, cancelledCount,
onHoldCount,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
mobileFilterOpen, mobileFilterOpen,
@@ -129,6 +131,7 @@ export function EventModals(props: EventModalsProps) {
{ value: 'confirmed', label: `Confirmed (${confirmedCount})` }, { value: 'confirmed', label: `Confirmed (${confirmedCount})` },
{ value: 'checked_in', label: `Checked In (${checkedInCount})` }, { value: 'checked_in', label: `Checked In (${checkedInCount})` },
{ value: 'cancelled', label: `Cancelled (${cancelledCount})` }, { value: 'cancelled', label: `Cancelled (${cancelledCount})` },
{ value: 'on_hold', label: `On Hold (${onHoldCount})` },
].map((option) => ( ].map((option) => (
<button <button
key={option.value} key={option.value}
@@ -13,6 +13,7 @@ import {
StarIcon, StarIcon,
FunnelIcon, FunnelIcon,
ChatBubbleLeftIcon, ChatBubbleLeftIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { StatusBadge } from '../_components/StatusBadge'; import { StatusBadge } from '../_components/StatusBadge';
import type { AttendeeStatusFilter, PrimaryAction } from '../_types'; import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
@@ -29,6 +30,7 @@ interface AttendeesTabProps {
confirmedCount: number; confirmedCount: number;
checkedInCount: number; checkedInCount: number;
cancelledCount: number; cancelledCount: number;
onHoldCount: number;
exporting: boolean; exporting: boolean;
showExportDropdown: boolean; showExportDropdown: boolean;
setShowExportDropdown: (value: boolean) => void; setShowExportDropdown: (value: boolean) => void;
@@ -43,6 +45,7 @@ interface AttendeesTabProps {
setShowAddTicketSheet: (value: boolean) => void; setShowAddTicketSheet: (value: boolean) => void;
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null; getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
handleOpenNoteModal: (ticket: Ticket) => void; handleOpenNoteModal: (ticket: Ticket) => void;
handleReactivate: (ticket: Ticket) => void;
} }
export function AttendeesTab({ export function AttendeesTab({
@@ -57,6 +60,7 @@ export function AttendeesTab({
confirmedCount, confirmedCount,
checkedInCount, checkedInCount,
cancelledCount, cancelledCount,
onHoldCount,
exporting, exporting,
showExportDropdown, showExportDropdown,
setShowExportDropdown, setShowExportDropdown,
@@ -71,6 +75,7 @@ export function AttendeesTab({
setShowAddTicketSheet, setShowAddTicketSheet,
getPrimaryAction, getPrimaryAction,
handleOpenNoteModal, handleOpenNoteModal,
handleReactivate,
}: AttendeesTabProps) { }: AttendeesTabProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -98,6 +103,7 @@ export function AttendeesTab({
<option value="confirmed">Confirmed ({confirmedCount})</option> <option value="confirmed">Confirmed ({confirmedCount})</option>
<option value="checked_in">Checked In ({checkedInCount})</option> <option value="checked_in">Checked In ({checkedInCount})</option>
<option value="cancelled">Cancelled ({cancelledCount})</option> <option value="cancelled">Cancelled ({cancelledCount})</option>
<option value="on_hold">On Hold ({onHoldCount})</option>
</select> </select>
<div className="flex-1" /> <div className="flex-1" />
@@ -241,6 +247,7 @@ export function AttendeesTab({
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<p className="text-sm text-gray-600 truncate max-w-[200px]">{ticket.attendeeEmail}</p> <p className="text-sm text-gray-600 truncate max-w-[200px]">{ticket.attendeeEmail}</p>
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>} {ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
{ticket.attendeeRuc && <p className="text-xs text-gray-400">RUC: {ticket.attendeeRuc}</p>}
</td> </td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
@@ -267,6 +274,11 @@ export function AttendeesTab({
</Button> </Button>
)} )}
<MoreMenu> <MoreMenu>
{ticket.status === 'on_hold' && (
<DropdownItem onClick={() => handleReactivate(ticket)}>
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
)}
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}> <DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" /> <ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'} {ticket.adminNote ? 'Edit Note' : 'Add Note'}
@@ -307,6 +319,7 @@ export function AttendeesTab({
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p> <p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p> <p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>} {ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
{ticket.attendeeRuc && <p className="text-[10px] text-gray-400">RUC: {ticket.attendeeRuc}</p>}
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end"> <div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
<StatusBadge status={ticket.status} compact /> <StatusBadge status={ticket.status} compact />
@@ -328,6 +341,11 @@ export function AttendeesTab({
</Button> </Button>
)} )}
<MoreMenu> <MoreMenu>
{ticket.status === 'on_hold' && (
<DropdownItem onClick={() => handleReactivate(ticket)}>
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
)}
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}> <DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" /> <ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'} {ticket.adminNote ? 'Edit Note' : 'Add Note'}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ComponentType } from 'react';
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments'; 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 TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
export type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in'; export type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
+17 -2
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; 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 { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; 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) => { const handleRemoveCheckin = async (ticketId: string) => {
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return; if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
try { try {
@@ -421,6 +432,7 @@ export default function AdminEventDetailPage() {
const pendingCount = getTicketsByStatus('pending').length; const pendingCount = getTicketsByStatus('pending').length;
const checkedInCount = getTicketsByStatus('checked_in').length; const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length; const cancelledCount = getTicketsByStatus('cancelled').length;
const onHoldCount = getTicketsByStatus('on_hold').length;
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length; const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length; const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price; const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
@@ -435,7 +447,7 @@ export default function AdminEventDetailPage() {
// ========== Primary action for a ticket ========== // ========== Primary action for a ticket ==========
const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => { 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' }; return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
} }
if (ticket.status === 'confirmed') { if (ticket.status === 'confirmed') {
@@ -671,6 +683,7 @@ export default function AdminEventDetailPage() {
confirmedCount={confirmedCount} confirmedCount={confirmedCount}
checkedInCount={checkedInCount} checkedInCount={checkedInCount}
cancelledCount={cancelledCount} cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
exporting={exporting} exporting={exporting}
showExportDropdown={showExportDropdown} showExportDropdown={showExportDropdown}
setShowExportDropdown={setShowExportDropdown} setShowExportDropdown={setShowExportDropdown}
@@ -685,6 +698,7 @@ export default function AdminEventDetailPage() {
setShowAddTicketSheet={setShowAddTicketSheet} setShowAddTicketSheet={setShowAddTicketSheet}
getPrimaryAction={getPrimaryAction} getPrimaryAction={getPrimaryAction}
handleOpenNoteModal={handleOpenNoteModal} handleOpenNoteModal={handleOpenNoteModal}
handleReactivate={handleReactivate}
/> />
)} )}
@@ -742,6 +756,7 @@ export default function AdminEventDetailPage() {
confirmedCount={confirmedCount} confirmedCount={confirmedCount}
checkedInCount={checkedInCount} checkedInCount={checkedInCount}
cancelledCount={cancelledCount} cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
statusFilter={statusFilter} statusFilter={statusFilter}
setStatusFilter={setStatusFilter} setStatusFilter={setStatusFilter}
mobileFilterOpen={mobileFilterOpen} mobileFilterOpen={mobileFilterOpen}
+11 -4
View File
@@ -62,6 +62,10 @@ export default function AdminLayout({
const allowedPathsForRole = new Set( const allowedPathsForRole = new Set(
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href) 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 = const defaultAdminRoute =
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin'; userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
@@ -79,11 +83,14 @@ export default function AdminLayout({
router.replace(defaultAdminRoute); router.replace(defaultAdminRoute);
return; return;
} }
const isPathAllowed = (path: string) => { const matchesHrefSet = (path: string, hrefs: Set<string>) => {
if (allowedPathsForRole.has(path)) return true; if (hrefs.has(path)) return true;
return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/')); 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); router.replace(defaultAdminRoute);
} }
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]); }, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
+9
View File
@@ -0,0 +1,9 @@
import { NotFoundMessage } from '@/components/NotFoundMessage';
export default function AdminNotFound() {
return (
<div className="flex-1 flex items-center justify-center">
<NotFoundMessage />
</div>
);
}
+73 -12
View File
@@ -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) => { const handleRefund = async (id: string) => {
if (!confirm('Are you sure you want to process this refund?')) return; if (!confirm('Are you sure you want to process this refund?')) return;
@@ -178,7 +192,7 @@ export default function AdminPaymentsPage() {
const downloadCSV = () => { const downloadCSV = () => {
if (!exportData) return; 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 => [ const rows = exportData.payments.map(p => [
p.paymentId, p.paymentId,
p.amount, p.amount,
@@ -190,6 +204,7 @@ export default function AdminPaymentsPage() {
p.createdAt, p.createdAt,
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(), `${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
p.attendeeEmail || '', p.attendeeEmail || '',
p.attendeeRuc || '',
p.eventTitle, p.eventTitle,
p.eventDate, p.eventDate,
]); ]);
@@ -225,6 +240,7 @@ export default function AdminPaymentsPage() {
refunded: 'bg-blue-100 text-blue-700', refunded: 'bg-blue-100 text-blue-700',
failed: 'bg-red-100 text-red-700', failed: 'bg-red-100 text-red-700',
cancelled: 'bg-gray-100 text-gray-700', cancelled: 'bg-gray-100 text-gray-700',
on_hold: 'bg-slate-100 text-slate-600',
}; };
const labels: Record<string, string> = { const labels: Record<string, string> = {
pending: locale === 'es' ? 'Pendiente' : 'Pending', pending: locale === 'es' ? 'Pendiente' : 'Pending',
@@ -233,6 +249,7 @@ export default function AdminPaymentsPage() {
refunded: locale === 'es' ? 'Reembolsado' : 'Refunded', refunded: locale === 'es' ? 'Reembolsado' : 'Refunded',
failed: locale === 'es' ? 'Fallido' : 'Failed', failed: locale === 'es' ? 'Fallido' : 'Failed',
cancelled: locale === 'es' ? 'Cancelado' : 'Cancelled', cancelled: locale === 'es' ? 'Cancelado' : 'Cancelled',
on_hold: locale === 'es' ? 'En Espera' : 'On Hold',
}; };
return ( return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || 'bg-gray-100 text-gray-700'}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || 'bg-gray-100 text-gray-700'}`}>
@@ -343,6 +360,8 @@ export default function AdminPaymentsPage() {
payments.filter(p => p.status === 'paid') payments.filter(p => p.status === 'paid')
); );
const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments); const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments);
const onHoldPayments = payments.filter(p => p.status === 'on_hold');
const onHoldBookingsCount = getUniqueBookingsCount(onHoldPayments);
if (loading) { if (loading) {
return ( return (
@@ -414,6 +433,9 @@ export default function AdminPaymentsPage() {
{selectedPayment.ticket.attendeePhone && ( {selectedPayment.ticket.attendeePhone && (
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p> <p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
)} )}
{selectedPayment.ticket.attendeeRuc && (
<p className="text-sm text-gray-600">RUC: {selectedPayment.ticket.attendeeRuc}</p>
)}
</div> </div>
)} )}
@@ -475,6 +497,15 @@ export default function AdminPaymentsPage() {
</div> </div>
</div> </div>
{selectedPayment.status === 'on_hold' && (
<div className="mb-3">
<Button variant="outline" onClick={() => handleReactivate(selectedPayment)} isLoading={processing} className="w-full min-h-[44px]">
<ArrowPathIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Reactivar (volver a pendiente)' : 'Reactivate (back to pending)'}
</Button>
</div>
)}
<div className="flex gap-3"> <div className="flex gap-3">
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]"> <Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
<CheckCircleIcon className="w-5 h-5 mr-2" /> <CheckCircleIcon className="w-5 h-5 mr-2" />
@@ -486,13 +517,15 @@ export default function AdminPaymentsPage() {
{locale === 'es' ? 'Rechazar' : 'Reject'} {locale === 'es' ? 'Rechazar' : 'Reject'}
</Button> </Button>
</div> </div>
<div className="pt-2 border-t"> {selectedPayment.status !== 'on_hold' && (
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]"> <div className="pt-2 border-t">
<EnvelopeIcon className="w-5 h-5 mr-2" /> <Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
{locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'} <EnvelopeIcon className="w-5 h-5 mr-2" />
</Button> {locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'}
</div> </Button>
</div>
)}
</div> </div>
</Card> </Card>
</div> </div>
@@ -627,7 +660,7 @@ export default function AdminPaymentsPage() {
)} )}
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
@@ -642,6 +675,20 @@ export default function AdminPaymentsPage() {
</div> </div>
</div> </div>
</Card> </Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center">
<ClockIcon className="w-5 h-5 text-slate-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'En Espera' : 'On Hold'}</p>
<p className="text-xl font-bold text-slate-600">{onHoldBookingsCount}</p>
{onHoldPayments.length !== onHoldBookingsCount && (
<p className="text-xs text-gray-400">({onHoldPayments.length} tickets)</p>
)}
</div>
</div>
</Card>
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
@@ -816,6 +863,7 @@ export default function AdminPaymentsPage() {
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option> <option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option> <option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option> <option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
<option value="on_hold">{locale === 'es' ? 'En Espera' : 'On Hold'}</option>
</select> </select>
</div> </div>
<div> <div>
@@ -897,6 +945,7 @@ export default function AdminPaymentsPage() {
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Asistente' : 'Attendee'}</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">RUC</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Evento' : 'Event'}</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Evento' : 'Event'}</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Monto' : 'Amount'}</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Monto' : 'Amount'}</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Método' : 'Method'}</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Método' : 'Method'}</th>
@@ -906,7 +955,7 @@ export default function AdminPaymentsPage() {
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{filteredPayments.length === 0 ? ( {filteredPayments.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr> <tr><td colSpan={7} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr>
) : ( ) : (
filteredPayments.map((payment) => { filteredPayments.map((payment) => {
const bookingInfo = getBookingInfo(payment); const bookingInfo = getBookingInfo(payment);
@@ -920,6 +969,7 @@ export default function AdminPaymentsPage() {
</div> </div>
) : <span className="text-gray-400 text-sm">-</span>} ) : <span className="text-gray-400 text-sm">-</span>}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-600">{payment.ticket?.attendeeRuc || '-'}</td>
<td className="px-4 py-3 text-sm truncate max-w-[150px]">{payment.event?.title || '-'}</td> <td className="px-4 py-3 text-sm truncate max-w-[150px]">{payment.event?.title || '-'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="font-medium text-sm">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p> <p className="font-medium text-sm">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
@@ -933,11 +983,16 @@ export default function AdminPaymentsPage() {
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td> <td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{(payment.status === 'pending' || payment.status === 'pending_approval') && ( {(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1"> <Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1">
{locale === 'es' ? 'Revisar' : 'Review'} {locale === 'es' ? 'Revisar' : 'Review'}
</Button> </Button>
)} )}
{payment.status === 'on_hold' && (
<Button size="sm" variant="outline" onClick={() => handleReactivate(payment)} className="text-xs px-2 py-1">
{locale === 'es' ? 'Reactivar' : 'Reactivate'}
</Button>
)}
{payment.status === 'paid' && ( {payment.status === 'paid' && (
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2 py-1"> <Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2 py-1">
{t('admin.payments.refund')} {t('admin.payments.refund')}
@@ -990,11 +1045,16 @@ export default function AdminPaymentsPage() {
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100"> <div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p> <p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{(payment.status === 'pending' || payment.status === 'pending_approval') && ( {(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]"> <Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{locale === 'es' ? 'Revisar' : 'Review'} {locale === 'es' ? 'Revisar' : 'Review'}
</Button> </Button>
)} )}
{payment.status === 'on_hold' && (
<Button size="sm" variant="outline" onClick={() => handleReactivate(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{locale === 'es' ? 'Reactivar' : 'Reactivate'}
</Button>
)}
{payment.status === 'paid' && ( {payment.status === 'paid' && (
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]"> <Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{t('admin.payments.refund')} {t('admin.payments.refund')}
@@ -1040,6 +1100,7 @@ export default function AdminPaymentsPage() {
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option> <option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option> <option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option> <option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
<option value="on_hold">{locale === 'es' ? 'En Espera' : 'On Hold'}</option>
</select> </select>
</div> </div>
<div> <div>
+204 -43
View File
@@ -2,22 +2,38 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; 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 { parseDate } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { CheckCircleIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; 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() { export default function AdminUsersPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [roleFilter, setRoleFilter] = useState<string>(''); const [roleFilter, setRoleFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [hasBookingsFilter, setHasBookingsFilter] = useState<'' | 'yes' | 'no'>('');
const [registeredRange, setRegisteredRange] = useState<RegisteredRange>('');
const [eventFilter, setEventFilter] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [editingUser, setEditingUser] = useState<User | null>(null); const [editingUser, setEditingUser] = useState<User | null>(null);
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
name: '', name: '',
@@ -30,14 +46,45 @@ export default function AdminUsersPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = 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(() => { useEffect(() => {
loadUsers(); 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 () => { const loadUsers = async () => {
try { 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); setUsers(users);
setTotal(total);
} catch (error) { } catch (error) {
toast.error('Failed to load users'); toast.error('Failed to load users');
} finally { } finally {
@@ -129,16 +176,29 @@ export default function AdminUsersPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1> <h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.users.title')} ({total})</h1>
</div> </div>
{/* Desktop Filters */} {/* Desktop Filters */}
<Card className="p-4 mb-6 hidden md:block"> <Card className="p-4 mb-6 hidden md:block">
<div className="flex flex-wrap gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Search</label>
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Name, email, phone..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label> <label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)} <select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm"> className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All Roles</option> <option value="">All Roles</option>
<option value="admin">{t('admin.users.roles.admin')}</option> <option value="admin">{t('admin.users.roles.admin')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option> <option value="organizer">{t('admin.users.roles.organizer')}</option>
@@ -147,25 +207,82 @@ export default function AdminUsersPage() {
<option value="user">{t('admin.users.roles.user')}</option> <option value="user">{t('admin.users.roles.user')}</option>
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="unclaimed">Unclaimed</option>
<option value="suspended">Suspended</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Has Bookings</label>
<select value={hasBookingsFilter} onChange={(e) => setHasBookingsFilter(e.target.value as '' | 'yes' | 'no')}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Registered</label>
<select value={registeredRange} onChange={(e) => setRegisteredRange(e.target.value as RegisteredRange)}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All Time</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Event</label>
<select value={eventFilter} onChange={(e) => setEventFilter(e.target.value)}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray text-sm">
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
</div> </div>
{hasActiveFilters && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span>Showing {users.length} of {total}</span>
<button onClick={clearFilters} className="text-primary-yellow hover:underline">Clear</button>
</div>
)}
</Card> </Card>
{/* Mobile Toolbar */} {/* Mobile Toolbar */}
<div className="md:hidden mb-4 flex items-center gap-2"> <div className="md:hidden mb-4 space-y-2">
<button onClick={() => setMobileFilterOpen(true)} <div className="relative">
className={clsx( <MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]', <input
roleFilter ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600' type="text"
)}> placeholder="Name, email, phone..."
<FunnelIcon className="w-4 h-4" /> value={searchQuery}
{roleFilter ? t(`admin.users.roles.${roleFilter}`) : 'Filter by Role'} onChange={(e) => setSearchQuery(e.target.value)}
</button> className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
{roleFilter && ( />
<button onClick={() => setRoleFilter('')} className="text-xs text-primary-yellow min-h-[44px] flex items-center"> </div>
Clear <div className="flex items-center gap-2">
<button onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
hasActiveFilters ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
)}>
<FunnelIcon className="w-4 h-4" />
Filters
</button> </button>
)} {hasActiveFilters && (
<span className="text-xs text-gray-500 ml-auto">{users.length} users</span> <button onClick={clearFilters} className="text-xs text-primary-yellow min-h-[44px] flex items-center">
Clear
</button>
)}
<span className="text-xs text-gray-500 ml-auto">{total} users</span>
</div>
</div> </div>
{/* Desktop: Table */} {/* Desktop: Table */}
@@ -176,6 +293,7 @@ export default function AdminUsersPage() {
<tr> <tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">User</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">RUC</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
@@ -183,7 +301,7 @@ export default function AdminUsersPage() {
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{users.length === 0 ? ( {users.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr> <tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr>
) : ( ) : (
users.map((user) => ( users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50"> <tr key={user.id} className="hover:bg-gray-50">
@@ -199,6 +317,7 @@ export default function AdminUsersPage() {
</div> </div>
</td> </td>
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td> <td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{user.rucNumber || '-'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)} <select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
className="px-2 py-1 rounded border border-secondary-light-gray text-sm"> className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
@@ -247,6 +366,7 @@ export default function AdminUsersPage() {
<p className="font-medium text-sm truncate">{user.name}</p> <p className="font-medium text-sm truncate">{user.name}</p>
<p className="text-xs text-gray-500 truncate">{user.email}</p> <p className="text-xs text-gray-500 truncate">{user.email}</p>
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>} {user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {user.rucNumber}</p>}
</div> </div>
{getRoleBadge(user.role)} {getRoleBadge(user.role)}
</div> </div>
@@ -269,26 +389,67 @@ export default function AdminUsersPage() {
</div> </div>
{/* Mobile Filter BottomSheet */} {/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filter by Role"> <BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-1"> <div className="space-y-4">
{[ <div>
{ value: '', label: 'All Roles' }, <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.users.role')}</label>
{ value: 'admin', label: t('admin.users.roles.admin') }, <select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)}
{ value: 'organizer', label: t('admin.users.roles.organizer') }, className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
{ value: 'staff', label: t('admin.users.roles.staff') }, <option value="">All Roles</option>
{ value: 'marketing', label: t('admin.users.roles.marketing') }, <option value="admin">{t('admin.users.roles.admin')}</option>
{ value: 'user', label: t('admin.users.roles.user') }, <option value="organizer">{t('admin.users.roles.organizer')}</option>
].map((opt) => ( <option value="staff">{t('admin.users.roles.staff')}</option>
<button key={opt.value} <option value="marketing">{t('admin.users.roles.marketing')}</option>
onClick={() => { setRoleFilter(opt.value); setMobileFilterOpen(false); }} <option value="user">{t('admin.users.roles.user')}</option>
className={clsx( </select>
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between', </div>
roleFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50' <div>
)}> <label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
{opt.label} <select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
{roleFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />} className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
</button> <option value="">All Statuses</option>
))} <option value="active">Active</option>
<option value="unclaimed">Unclaimed</option>
<option value="suspended">Suspended</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Has Bookings</label>
<select value={hasBookingsFilter} onChange={(e) => setHasBookingsFilter(e.target.value as '' | 'yes' | 'no')}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Registered</label>
<select value={registeredRange} onChange={(e) => setRegisteredRange(e.target.value as RegisteredRange)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All Time</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select value={eventFilter} onChange={(e) => setEventFilter(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => { clearFilters(); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">
Clear All
</Button>
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">
Apply
</Button>
</div>
</div> </div>
</BottomSheet> </BottomSheet>
+3 -19
View File
@@ -26,20 +26,6 @@ export const metadata: Metadata = {
template: '%s Spanglish', template: '%s Spanglish',
}, },
description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.', 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' }], authors: [{ name: 'Spanglish' }],
creator: 'Spanglish', creator: 'Spanglish',
publisher: 'Spanglish', publisher: 'Spanglish',
@@ -82,12 +68,10 @@ export const metadata: Metadata = {
'max-snippet': -1, 'max-snippet': -1,
}, },
}, },
// Each route overrides this with its own path so the canonical is
// self-referential. Resolved against metadataBase above.
alternates: { alternates: {
canonical: siteUrl, canonical: '/',
languages: {
'en': siteUrl,
'es': `${siteUrl}/es`,
},
}, },
category: 'events', category: 'events',
manifest: '/manifest.json', manifest: '/manifest.json',
+9 -25
View File
@@ -1,5 +1,7 @@
import type { Metadata } from 'next'; 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 = { export const metadata: Metadata = {
title: 'Page Not Found Spanglish', title: 'Page Not Found Spanglish',
@@ -12,30 +14,12 @@ export const metadata: Metadata = {
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-secondary-gray"> <div className="min-h-screen flex flex-col">
<div className="text-center px-4"> <Header />
<h1 className="text-6xl font-bold text-primary-dark mb-4">404</h1> <main className="flex-1 flex items-center justify-center bg-secondary-gray">
<h2 className="text-2xl font-semibold text-gray-700 mb-4"> <NotFoundMessage />
Page Not Found </main>
</h2> <Footer />
<p className="text-gray-600 mb-8 max-w-md mx-auto">
The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/"
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
Go Home
</Link>
<Link
href="/events"
className="inline-flex items-center justify-center px-6 py-3 border-2 border-primary-dark text-primary-dark font-semibold rounded-btn hover:bg-primary-dark hover:text-white transition-colors"
>
View Events
</Link>
</div>
</div>
</div> </div>
); );
} }
@@ -0,0 +1,29 @@
import Link from 'next/link';
export function NotFoundMessage() {
return (
<div className="text-center px-4 py-16">
<h1 className="text-6xl font-bold text-primary-dark mb-4">404</h1>
<h2 className="text-2xl font-semibold text-gray-700 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/"
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
Go Home
</Link>
<Link
href="/events"
className="inline-flex items-center justify-center px-6 py-3 border-2 border-primary-dark text-primary-dark font-semibold rounded-btn hover:bg-primary-dark hover:text-white transition-colors"
>
View Events
</Link>
</div>
</div>
);
}
+5
View File
@@ -46,4 +46,9 @@ export const paymentsApi = {
refund: (id: string) => refund: (id: string) =>
fetchApi<{ message: string }>(`/api/payments/${id}/refund`, { method: 'POST' }), fetchApi<{ message: string }>(`/api/payments/${id}/refund`, { method: 'POST' }),
reactivate: (id: string) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, {
method: 'POST',
}),
}; };
+4 -2
View File
@@ -37,7 +37,7 @@ export interface Ticket {
attendeePhone?: string; attendeePhone?: string;
attendeeRuc?: string; attendeeRuc?: string;
preferredLanguage?: string; preferredLanguage?: string;
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in'; status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in' | 'on_hold';
checkinAt?: string; checkinAt?: string;
checkedInByAdminId?: string; checkedInByAdminId?: string;
qrCode: string; qrCode: string;
@@ -111,7 +111,7 @@ export interface Payment {
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago'; provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
amount: number; amount: number;
currency: string; currency: string;
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed'; status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold';
reference?: string; reference?: string;
userMarkedPaidAt?: string; userMarkedPaidAt?: string;
payerName?: string; // Name of payer if different from attendee payerName?: string; // Name of payer if different from attendee
@@ -131,6 +131,7 @@ export interface PaymentWithDetails extends Payment {
attendeeLastName?: string; attendeeLastName?: string;
attendeeEmail?: string; attendeeEmail?: string;
attendeePhone?: string; attendeePhone?: string;
attendeeRuc?: string;
status: string; status: string;
} | null; } | null;
event: { event: {
@@ -325,6 +326,7 @@ export interface ExportedPayment {
attendeeFirstName: string; attendeeFirstName: string;
attendeeLastName?: string; attendeeLastName?: string;
attendeeEmail?: string; attendeeEmail?: string;
attendeeRuc?: string;
eventId: string; eventId: string;
eventTitle: string; eventTitle: string;
eventDate: string; eventDate: string;
+27 -3
View File
@@ -1,10 +1,34 @@
import { fetchApi } from './client'; import { fetchApi } from './client';
import type { User } from './types'; 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 = { export const usersApi = {
getAll: (role?: string) => { getAll: (params?: UsersListParams | string) => {
const query = role ? `?role=${role}` : ''; // Back-compat: allow the old `getAll(role)` call shape.
return fetchApi<{ users: User[] }>(`/api/users${query}`); 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}`), getById: (id: string) => fetchApi<{ user: User }>(`/api/users/${id}`),