Security recovery: hold sweep, dashboard updates, and admin fixes.
This commit is contained in:
@@ -114,3 +114,10 @@ PENDING_BOOKING_TTL_MINUTES=30
|
||||
# How often the cleanup job runs, in milliseconds (default: 300000 = 5 min)
|
||||
PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000
|
||||
|
||||
# Auto-Hold Sweep
|
||||
# Bookings awaiting admin payment approval are put on hold (seat released) after
|
||||
# this many hours with no confirmation (default: 72)
|
||||
HOLD_THRESHOLD_HOURS=72
|
||||
# How often the hold sweep job runs, in milliseconds (default: 900000 = 15 min)
|
||||
HOLD_SWEEP_INTERVAL_MS=900000
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ export const sqliteTickets = sqliteTable('tickets', {
|
||||
attendeePhone: text('attendee_phone'),
|
||||
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
|
||||
preferredLanguage: text('preferred_language'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold'] }).notNull().default('pending'),
|
||||
checkinAt: text('checkin_at'),
|
||||
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||
qrCode: text('qr_code'),
|
||||
@@ -119,7 +119,7 @@ export const sqlitePayments = sqliteTable('payments', {
|
||||
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
|
||||
amount: real('amount').notNull(),
|
||||
currency: text('currency').notNull().default('PYG'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
|
||||
reference: text('reference'),
|
||||
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
|
||||
payerName: text('payer_name'), // Name of payer if different from attendee
|
||||
|
||||
@@ -26,6 +26,7 @@ import faqRoutes from './routes/faq.js';
|
||||
import emailService from './lib/email.js';
|
||||
import { initEmailQueue } from './lib/emailQueue.js';
|
||||
import { startBookingCleanup } from './lib/bookingCleanup.js';
|
||||
import { startHoldSweep } from './lib/holdSweep.js';
|
||||
import { getLock } from './lib/stores/lock.js';
|
||||
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
||||
|
||||
@@ -1932,6 +1933,9 @@ initEmailQueue(emailService);
|
||||
// Periodically expire abandoned pending bookings so they stop holding seats.
|
||||
startBookingCleanup();
|
||||
|
||||
// Periodically put stale pending-approval payments on hold, releasing their seats.
|
||||
startHoldSweep();
|
||||
|
||||
// Initialize email templates on startup.
|
||||
// Guarded by a distributed lock so that, when running multiple replicas, only
|
||||
// one instance seeds/updates templates per boot instead of all of them racing.
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,14 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending'))
|
||||
);
|
||||
|
||||
|
||||
const onHoldPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'on_hold'))
|
||||
);
|
||||
|
||||
const revenueRow = await dbGet<any>(
|
||||
(db as any)
|
||||
.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,
|
||||
confirmedTickets: confirmedTickets?.count || 0,
|
||||
pendingPayments: pendingPayments?.count || 0,
|
||||
onHoldPayments: onHoldPayments?.count || 0,
|
||||
totalRevenue,
|
||||
newContacts: newContacts?.count || 0,
|
||||
totalSubscribers: totalSubscribers?.count || 0,
|
||||
@@ -213,6 +221,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
userName: user?.name,
|
||||
userEmail: user?.email,
|
||||
userPhone: user?.phone,
|
||||
attendeeRuc: ticket.attendeeRuc || user?.rucNumber || null,
|
||||
eventTitle: event?.title,
|
||||
eventDate: event?.startDatetime,
|
||||
paymentStatus: payment?.status,
|
||||
@@ -291,6 +300,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
||||
'Full Name': fullName,
|
||||
'Email': ticket.attendeeEmail || '',
|
||||
'Phone': ticket.attendeePhone || '',
|
||||
'RUC': ticket.attendeeRuc || '',
|
||||
'Status': ticket.status,
|
||||
'Checked In': isCheckedIn ? 'true' : 'false',
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
@@ -302,7 +312,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
||||
);
|
||||
|
||||
const columns = [
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone', 'RUC',
|
||||
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
||||
'Booked At', 'Notes',
|
||||
];
|
||||
@@ -380,12 +390,13 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async
|
||||
});
|
||||
}
|
||||
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'RUC', 'Status', 'Check-in Time', 'Booked At'];
|
||||
|
||||
const rows = ticketList.map((ticket: any) => ({
|
||||
'Ticket ID': ticket.id,
|
||||
'Booking ID': ticket.bookingId || '',
|
||||
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
|
||||
'RUC': ticket.attendeeRuc || '',
|
||||
'Status': ticket.status,
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
@@ -458,6 +469,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
attendeeFirstName: ticket.attendeeFirstName,
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeeRuc: ticket.attendeeRuc || null,
|
||||
eventId: event?.id,
|
||||
eventTitle: event?.title,
|
||||
eventDate: event?.startDatetime,
|
||||
|
||||
+115
-31
@@ -2,15 +2,16 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
|
||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||
import { eq, desc, and, or, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
|
||||
const paymentsRouter = new Hono();
|
||||
|
||||
const updatePaymentSchema = z.object({
|
||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
|
||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'on_hold']),
|
||||
reference: z.string().optional(),
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
@@ -85,6 +86,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeePhone: ticket.attendeePhone,
|
||||
attendeeRuc: ticket.attendeeRuc,
|
||||
status: ticket.status,
|
||||
} : null,
|
||||
event: event ? {
|
||||
@@ -95,7 +97,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Filter by event(s)
|
||||
if (eventId) {
|
||||
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
|
||||
@@ -164,12 +166,13 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
|
||||
|
||||
// Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id
|
||||
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, revenueRow] = await Promise.all([
|
||||
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, onHoldRow, revenueRow] = await Promise.all([
|
||||
dbGet<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, '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, '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'))),
|
||||
]);
|
||||
|
||||
@@ -180,6 +183,7 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
paid: Number(paidRow?.count || 0),
|
||||
refunded: Number(refundedRow?.count || 0),
|
||||
failed: Number(failedRow?.count || 0),
|
||||
onHold: Number(onHoldRow?.count || 0),
|
||||
totalRevenue: Number(revenueRow?.total || 0),
|
||||
},
|
||||
});
|
||||
@@ -317,13 +321,13 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Can approve pending or pending_approval payments
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
// Can approve pending, pending_approval, or on_hold payments
|
||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||
}
|
||||
|
||||
|
||||
const now = getNow();
|
||||
|
||||
|
||||
// Get the ticket associated with this payment
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -331,10 +335,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
|
||||
// Check if this is part of a multi-ticket booking
|
||||
let ticketsToConfirm: any[] = [ticket];
|
||||
|
||||
|
||||
if (ticket?.bookingId) {
|
||||
// Get all tickets in this booking
|
||||
ticketsToConfirm = await dbAll(
|
||||
@@ -345,27 +349,54 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
);
|
||||
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||
}
|
||||
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
|
||||
if (payment.status === 'on_hold') {
|
||||
// The seat was released when this booking went on hold - re-check capacity
|
||||
// before confirming it directly.
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{ paidByAdminId: user.id }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (adminNote) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ adminNote })
|
||||
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)));
|
||||
}
|
||||
} else {
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||
if (sendEmail !== false) {
|
||||
Promise.all([
|
||||
@@ -405,7 +436,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
||||
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
|
||||
}
|
||||
|
||||
@@ -464,6 +495,59 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
|
||||
});
|
||||
|
||||
// Reactivate an on-hold payment back to pending_approval (admin) - re-reserves the seat
|
||||
paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const payment = await dbGet<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
|
||||
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
|
||||
const ticketsRouter = new Hono();
|
||||
|
||||
@@ -53,7 +54,7 @@ function isPaymentMethodEnabled(method: string, merged: Record<string, any>): bo
|
||||
}
|
||||
|
||||
const updateTicketSchema = z.object({
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold']).optional(),
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -167,12 +168,21 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
phone: data.phone || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
rucNumber: data.ruc || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await (db as any).insert(users).values(user);
|
||||
} else if (data.ruc) {
|
||||
// Keep the user's saved RUC up to date for future bookings, but never blank
|
||||
// out an existing value if this booking didn't include one.
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({ rucNumber: data.ruc, updatedAt: now })
|
||||
.where(eq((users as any).id, user.id));
|
||||
user.rucNumber = data.ruc;
|
||||
}
|
||||
|
||||
|
||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||
const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false;
|
||||
|
||||
@@ -1104,26 +1114,47 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
||||
);
|
||||
}
|
||||
|
||||
// Confirm all tickets in the booking
|
||||
for (const t of ticketsToConfirm) {
|
||||
// Update ticket status
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, t.id));
|
||||
|
||||
// Update payment status
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, t.id));
|
||||
if (ticket.status === 'on_hold') {
|
||||
// The seat was released when this booking went on hold - re-check capacity
|
||||
// before confirming it directly.
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{ paidByAdminId: user.id }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
// Confirm all tickets in the booking
|
||||
for (const t of ticketsToConfirm) {
|
||||
// Update ticket status
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, t.id));
|
||||
|
||||
// Update payment status
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, t.id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get payment for sending receipt
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -1194,18 +1225,55 @@ ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, wind
|
||||
}
|
||||
|
||||
if (payment.status === 'paid') {
|
||||
return c.json({
|
||||
payment,
|
||||
return c.json({
|
||||
payment,
|
||||
message: 'Payment has already been confirmed.',
|
||||
alreadyProcessed: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// A booking that was auto-released after the hold threshold: recover it by
|
||||
// re-reserving the seat(s) and moving back into the admin approval queue.
|
||||
if (payment.status === 'on_hold') {
|
||||
let ticketsToRecover: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToRecover = await dbAll<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
|
||||
if (payment.status !== 'pending') {
|
||||
return c.json({ error: 'Payment has already been processed' }, 400);
|
||||
}
|
||||
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Update payment status to pending_approval for this ticket and any siblings
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
|
||||
import { eq, desc, sql } from 'drizzle-orm';
|
||||
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
|
||||
@@ -27,7 +27,43 @@ const updateUserSchema = z.object({
|
||||
// Get all users (admin only)
|
||||
usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const role = c.req.query('role');
|
||||
|
||||
const search = c.req.query('search');
|
||||
const accountStatus = c.req.query('accountStatus');
|
||||
const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no'
|
||||
const registeredAfter = c.req.query('registeredAfter');
|
||||
const registeredBefore = c.req.query('registeredBefore');
|
||||
const eventId = c.req.query('eventId');
|
||||
const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1);
|
||||
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200);
|
||||
|
||||
const conditions: any[] = [];
|
||||
if (role) conditions.push(eq((users as any).role, role));
|
||||
if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus));
|
||||
if (registeredAfter) conditions.push(gte((users as any).createdAt, registeredAfter));
|
||||
if (registeredBefore) conditions.push(lte((users as any).createdAt, registeredBefore));
|
||||
if (search) {
|
||||
const like = `%${search.toLowerCase()}%`;
|
||||
conditions.push(sql`(
|
||||
LOWER(${(users as any).name}) LIKE ${like}
|
||||
OR LOWER(${(users as any).email}) LIKE ${like}
|
||||
OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like}
|
||||
)`);
|
||||
}
|
||||
if (hasBookings === 'yes') {
|
||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
||||
} else if (hasBookings === 'no') {
|
||||
conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
||||
}
|
||||
if (eventId) {
|
||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`);
|
||||
}
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const totalQuery = whereClause
|
||||
? (db as any).select({ count: sql<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({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
@@ -40,14 +76,16 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
accountStatus: (users as any).accountStatus,
|
||||
createdAt: (users as any).createdAt,
|
||||
}).from(users);
|
||||
|
||||
if (role) {
|
||||
query = query.where(eq((users as any).role, role));
|
||||
|
||||
if (whereClause) {
|
||||
query = query.where(whereClause);
|
||||
}
|
||||
|
||||
const result = await dbAll(query.orderBy(desc((users as any).createdAt)));
|
||||
|
||||
return c.json({ users: result });
|
||||
|
||||
const result = await dbAll(
|
||||
query.orderBy(desc((users as any).createdAt)).limit(pageSize).offset((page - 1) * pageSize)
|
||||
);
|
||||
|
||||
return c.json({ users: result, total: Number(totalRow?.count || 0), page, pageSize });
|
||||
});
|
||||
|
||||
// Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id
|
||||
|
||||
@@ -18,11 +18,28 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Canonical host is the apex (non-www). Redirect the www HTTPS vhost to it with a
|
||||
# 301 so only one host is served and indexed.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
||||
server_name www.spanglishcommunity.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
||||
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
return 301 https://spanglishcommunity.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name spanglishcommunity.com;
|
||||
|
||||
# Upload size limit (covers same-origin /api uploads via this vhost)
|
||||
client_max_body_size 20m;
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { Metadata } from 'next';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Join Our Language Exchange Community',
|
||||
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
||||
alternates: {
|
||||
canonical: '/community',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Join Our Language Exchange Community – Spanglish',
|
||||
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { Metadata } from 'next';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contact Us',
|
||||
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
||||
alternates: {
|
||||
canonical: '/contact',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Contact Us – Spanglish',
|
||||
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
groupByBooking,
|
||||
isUnpaid,
|
||||
isAwaitingApproval,
|
||||
isOnHold,
|
||||
ticketAmount,
|
||||
shareTicket,
|
||||
isToday,
|
||||
@@ -57,11 +58,12 @@ export default function OverviewTab({
|
||||
// awaiting approval), ordered by soonest event.
|
||||
const attentionTicket = useMemo(() => {
|
||||
const candidates = activeTickets.filter(
|
||||
(t) => isUnpaid(t) || isAwaitingApproval(t)
|
||||
(t) => isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t)
|
||||
);
|
||||
const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2);
|
||||
candidates.sort((a, b) => {
|
||||
const aUnpaid = isUnpaid(a) ? 0 : 1;
|
||||
const bUnpaid = isUnpaid(b) ? 0 : 1;
|
||||
const aUnpaid = priority(a);
|
||||
const bUnpaid = priority(b);
|
||||
if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid;
|
||||
const aStart = a.event?.startDatetime
|
||||
? parseDate(a.event.startDatetime).getTime()
|
||||
@@ -190,7 +192,19 @@ export default function OverviewTab({
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<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
|
||||
ticketId={t.id}
|
||||
amount={amount}
|
||||
@@ -346,7 +360,19 @@ function HeroCard({
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
|
||||
: payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event');
|
||||
const canMarkPaid =
|
||||
payment.status === 'pending' && isManualProvider(payment.provider);
|
||||
const canRebook = payment.status === 'on_hold';
|
||||
return (
|
||||
<Card key={payment.id} className="p-4">
|
||||
<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 className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end">
|
||||
{canMarkPaid && (
|
||||
{canRebook ? (
|
||||
<PayActions
|
||||
ticketId={payment.ticketId}
|
||||
amount={Number(payment.amount)}
|
||||
@@ -141,7 +142,21 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
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 && (
|
||||
<a
|
||||
|
||||
@@ -16,9 +16,11 @@ import { StatusPill, deriveTicketStatus } from './_shared/status';
|
||||
import {
|
||||
groupByBooking,
|
||||
isUnpaid,
|
||||
isOnHold,
|
||||
ticketAmount,
|
||||
ticketPdfUrl,
|
||||
pyg,
|
||||
HOLD_THRESHOLD_HOURS,
|
||||
type BookingGroup,
|
||||
} from './_shared/helpers';
|
||||
import PayActions from './_shared/PayActions';
|
||||
@@ -176,12 +178,31 @@ function BookingCard({
|
||||
<p className="text-gray-500">
|
||||
{pyg(amount, currency)}
|
||||
</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>
|
||||
|
||||
{/* Actions */}
|
||||
<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
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
ticketAmount,
|
||||
pyg,
|
||||
isAwaitingApproval,
|
||||
isOnHold,
|
||||
HOLD_THRESHOLD_HOURS,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
@@ -38,6 +40,41 @@ export default function AttentionBanner({
|
||||
: ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event');
|
||||
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)) {
|
||||
return (
|
||||
<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). */
|
||||
layout?: 'stack' | 'inline';
|
||||
className?: string;
|
||||
/**
|
||||
* The booking's spot was released after the hold threshold passed. Hides the
|
||||
* "Pay now" link (money was already sent) and labels the retry "Rebook".
|
||||
*/
|
||||
onHold?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +46,7 @@ export default function PayActions({
|
||||
size = 'sm',
|
||||
layout = 'stack',
|
||||
className,
|
||||
onHold = false,
|
||||
}: PayActionsProps) {
|
||||
const { t } = useLanguage();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
@@ -76,18 +82,22 @@ export default function PayActions({
|
||||
return (
|
||||
<>
|
||||
<div className={`${containerClass} ${className || ''}`}>
|
||||
<Link href={`/booking/${ticketId}`} className={btnWidth}>
|
||||
<Button size={size} className={btnWidth}>
|
||||
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
|
||||
</Button>
|
||||
</Link>
|
||||
{!onHold && (
|
||||
<Link href={`/booking/${ticketId}`} className={btnWidth}>
|
||||
<Button size={size} className={btnWidth}>
|
||||
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={onHold ? 'primary' : 'outline'}
|
||||
size={size}
|
||||
className={btnWidth}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -108,16 +118,24 @@ export default function PayActions({
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<p className="mb-6 text-center text-sm text-gray-600">
|
||||
{locale === 'es'
|
||||
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
|
||||
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`}
|
||||
{onHold
|
||||
? (locale === 'es'
|
||||
? `Intentaremos reservar tu lugar de nuevo para ${destination}.`
|
||||
: `We'll try to re-reserve your spot for ${destination}.`)
|
||||
: (locale === 'es'
|
||||
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
|
||||
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`)}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<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
|
||||
variant="ghost"
|
||||
|
||||
@@ -6,6 +6,16 @@ import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
// ticket was created, capped at the event start time.
|
||||
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". */
|
||||
export function pyg(amount: number, currency: string = 'PYG'): string {
|
||||
return formatPrice(Number(amount) || 0, currency || 'PYG');
|
||||
|
||||
@@ -9,12 +9,14 @@ import type { UserTicket, Payment } from '@/lib/api';
|
||||
// unpaid -> pale red
|
||||
// attended -> pale blue (replaces the raw "checked_in" value)
|
||||
// cancelled -> pale gray
|
||||
// onHold -> pale slate (spot released after the payment deadline passed)
|
||||
export type DashStatus =
|
||||
| 'confirmed'
|
||||
| 'awaiting'
|
||||
| 'unpaid'
|
||||
| 'attended'
|
||||
| 'cancelled';
|
||||
| 'cancelled'
|
||||
| 'onHold';
|
||||
|
||||
/**
|
||||
* Collapse a ticket status + payment status into a single user-facing status.
|
||||
@@ -26,6 +28,7 @@ export function deriveTicketStatus(
|
||||
): DashStatus {
|
||||
if (ticketStatus === 'checked_in') return 'attended';
|
||||
if (ticketStatus === 'cancelled') return 'cancelled';
|
||||
if (ticketStatus === 'on_hold' || paymentStatus === 'on_hold') return 'onHold';
|
||||
if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed';
|
||||
if (paymentStatus === 'pending_approval') return 'awaiting';
|
||||
return 'unpaid';
|
||||
@@ -35,6 +38,7 @@ export function deriveTicketStatus(
|
||||
export function derivePaymentStatus(paymentStatus?: string): DashStatus {
|
||||
if (paymentStatus === 'paid') return 'confirmed';
|
||||
if (paymentStatus === 'pending_approval') return 'awaiting';
|
||||
if (paymentStatus === 'on_hold') return 'onHold';
|
||||
if (paymentStatus === 'refunded') return 'cancelled';
|
||||
return 'unpaid';
|
||||
}
|
||||
@@ -46,6 +50,7 @@ export function statusLabel(status: DashStatus, locale: string): string {
|
||||
unpaid: { en: 'Unpaid', es: 'No pagado' },
|
||||
attended: { en: 'Attended', es: 'Asistió' },
|
||||
cancelled: { en: 'Cancelled', es: 'Cancelado' },
|
||||
onHold: { en: 'On Hold', es: 'En Espera' },
|
||||
};
|
||||
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',
|
||||
attended: 'bg-blue-100 text-blue-800',
|
||||
cancelled: 'bg-gray-100 text-gray-600',
|
||||
onHold: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
|
||||
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}`,
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
// Note: the page title for the listing lives on events/page.tsx, not here. A
|
||||
// plain-string title in this layout would reset the root title template for the
|
||||
// child /events/[id] route, stripping the brand suffix from event detail titles.
|
||||
export const metadata: Metadata = {
|
||||
title: 'Upcoming Language Exchange Events in Asunción',
|
||||
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
|
||||
alternates: {
|
||||
canonical: '/events',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Upcoming Language Exchange Events in Asunción – Spanglish',
|
||||
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
|
||||
|
||||
@@ -1,157 +1,29 @@
|
||||
'use client';
|
||||
import type { Metadata } from 'next';
|
||||
import { Event } from '@/lib/api';
|
||||
import EventsClient from './EventsClient';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function EventsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
|
||||
// Listing title lives here (not in the layout) so the root title template still
|
||||
// applies to the sibling /events/[id] detail route. Picks up "%s – Spanglish".
|
||||
export const metadata: Metadata = {
|
||||
title: 'Upcoming Language Exchange Events in Asunción',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
eventsApi.getAll()
|
||||
.then(({ events }) => setEvents(events))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
// Fetch the public (published) event list on the server so event titles, dates,
|
||||
// and locations appear in the initial HTML rather than only after JS runs.
|
||||
async function getEvents(): Promise<Event[]> {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/events`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.events || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const upcomingEvents = events.filter(e =>
|
||||
e.status === 'published' && new Date(e.startDatetime) >= now
|
||||
);
|
||||
const pastEvents = events.filter(e =>
|
||||
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
|
||||
);
|
||||
|
||||
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
||||
|
||||
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const getStatusBadge = (event: Event) => {
|
||||
if (event.status === 'cancelled') {
|
||||
return <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>
|
||||
);
|
||||
export default async function EventsPage() {
|
||||
const events = await getEvents();
|
||||
return <EventsClient initialEvents={events} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,9 @@ async function getFaqForSchema(): Promise<{ question: string; answer: string }[]
|
||||
export const metadata: Metadata = {
|
||||
title: 'Frequently Asked Questions',
|
||||
description: 'Find answers to common questions about Spanglish language exchange events in Asunción. Learn about how events work, who can attend, and more.',
|
||||
alternates: {
|
||||
canonical: '/faq',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Frequently Asked Questions – Spanglish',
|
||||
description: 'Find answers to common questions about Spanglish language exchange events in Asunción.',
|
||||
|
||||
@@ -1,116 +1,22 @@
|
||||
'use client';
|
||||
import { FaqItem } from '@/lib/api';
|
||||
import FaqClient from './FaqClient';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { faqApi, FaqItem } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function FAQPage() {
|
||||
const { locale } = useLanguage();
|
||||
const [faqs, setFaqs] = useState<FaqItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
faqApi.getList().then((res) => {
|
||||
if (!cancelled) {
|
||||
setFaqs(res.faqs);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const toggleFAQ = (index: number) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
// Fetch the published FAQ list on the server so the questions and answers are
|
||||
// rendered into the initial HTML (crawlers see the content without running JS).
|
||||
async function getFaqs(): Promise<FaqItem[]> {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.faqs || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{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>
|
||||
);
|
||||
export default async function FAQPage() {
|
||||
const faqs = await getFaqs();
|
||||
return <FaqClient initialFaqs={faqs} />;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${legalPage.title} – Spanglish`,
|
||||
// The root layout's title template appends " – Spanglish"; do not repeat it here.
|
||||
title: legalPage.title,
|
||||
description: `${legalPage.title} for Spanglish language exchange events in Asunción, Paraguay.`,
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
@@ -59,7 +59,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
|
||||
if (!event) {
|
||||
return {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
// Title already carries the brand, so bypass the "%s – Spanglish"
|
||||
// template to avoid doubling it.
|
||||
title: { absolute: 'Spanglish – Language Exchange Events in Asunción' },
|
||||
description:
|
||||
'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
|
||||
};
|
||||
@@ -76,7 +78,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const description = `Next event: ${eventDate} – ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
|
||||
|
||||
return {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
// Title already carries the brand, so bypass the "%s – Spanglish"
|
||||
// template to avoid doubling it.
|
||||
title: { absolute: 'Spanglish – Language Exchange Events in Asunción' },
|
||||
description,
|
||||
openGraph: {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
@@ -142,7 +146,9 @@ function generateNextEventJsonLd(event: NextEvent) {
|
||||
: 'https://schema.org/SoldOut',
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
},
|
||||
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
||||
image: event.bannerUrl
|
||||
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
|
||||
: `${siteUrl}/images/og-image.jpg`,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default function AdminCatchAll() {
|
||||
notFound();
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PhoneIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -101,6 +102,20 @@ export default function AdminBookingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivate = async (ticket: TicketWithDetails) => {
|
||||
if (!ticket.payment?.id) return;
|
||||
setProcessing(ticket.id);
|
||||
try {
|
||||
await paymentsApi.reactivate(ticket.payment.id);
|
||||
toast.success('Booking reactivated');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to reactivate booking');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (ticketId: string) => {
|
||||
if (!confirm('Are you sure you want to cancel this booking?')) return;
|
||||
|
||||
@@ -133,6 +148,7 @@ export default function AdminBookingsPage() {
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||
case 'checked_in': return 'bg-blue-100 text-blue-800';
|
||||
case 'on_hold': return 'bg-slate-100 text-slate-600';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
@@ -144,6 +160,7 @@ export default function AdminBookingsPage() {
|
||||
case 'failed':
|
||||
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||
case 'refunded': return 'bg-purple-100 text-purple-800';
|
||||
case 'on_hold': return 'bg-slate-100 text-slate-600';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
@@ -191,6 +208,7 @@ export default function AdminBookingsPage() {
|
||||
confirmed: tickets.filter(t => t.status === 'confirmed').length,
|
||||
checkedIn: tickets.filter(t => t.status === 'checked_in').length,
|
||||
cancelled: tickets.filter(t => t.status === 'cancelled').length,
|
||||
onHold: tickets.filter(t => t.status === 'on_hold').length,
|
||||
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
|
||||
};
|
||||
|
||||
@@ -218,6 +236,9 @@ export default function AdminBookingsPage() {
|
||||
if (ticket.status === 'pending' && ticket.payment?.status === 'pending') {
|
||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
|
||||
}
|
||||
if (ticket.status === 'on_hold') {
|
||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
|
||||
}
|
||||
if (ticket.status === 'confirmed') {
|
||||
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' };
|
||||
}
|
||||
@@ -239,7 +260,7 @@ export default function AdminBookingsPage() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
@@ -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-xs md:text-sm text-gray-500">Cancelled</p>
|
||||
</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">
|
||||
<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>
|
||||
@@ -305,6 +330,7 @@ export default function AdminBookingsPage() {
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="checked_in">Checked In</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="on_hold">On Hold</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -316,6 +342,7 @@ export default function AdminBookingsPage() {
|
||||
<option value="paid">Paid</option>
|
||||
<option value="refunded">Refunded</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="on_hold">On Hold</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,6 +395,7 @@ export default function AdminBookingsPage() {
|
||||
<thead className="bg-secondary-gray">
|
||||
<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">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">Payment</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">
|
||||
{sortedTickets.length === 0 ? (
|
||||
<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.
|
||||
</td>
|
||||
</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>
|
||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{ticket.attendeeRuc || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm truncate max-w-[150px] block">
|
||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||
@@ -431,6 +460,18 @@ export default function AdminBookingsPage() {
|
||||
Check In
|
||||
</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') && (
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
||||
@@ -519,6 +560,13 @@ export default function AdminBookingsPage() {
|
||||
</DropdownItem>
|
||||
</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' && (
|
||||
<span className="text-[10px] text-green-600 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
|
||||
@@ -557,6 +605,7 @@ export default function AdminBookingsPage() {
|
||||
{ value: 'confirmed', label: `Confirmed (${stats.confirmed})` },
|
||||
{ value: 'checked_in', label: `Checked In (${stats.checkedIn})` },
|
||||
{ value: 'cancelled', label: `Cancelled (${stats.cancelled})` },
|
||||
{ value: 'on_hold', label: `On Hold (${stats.onHold})` },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -581,6 +630,7 @@ export default function AdminBookingsPage() {
|
||||
{ value: 'paid', label: 'Paid' },
|
||||
{ value: 'refunded', label: 'Refunded' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'on_hold', label: 'On Hold' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
|
||||
@@ -6,6 +6,7 @@ export function StatusBadge({ status, compact = false }: { status: string; compa
|
||||
confirmed: 'bg-green-100 text-green-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
checked_in: 'bg-blue-100 text-blue-800',
|
||||
on_hold: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
return (
|
||||
<span className={clsx(
|
||||
|
||||
@@ -20,6 +20,7 @@ interface EventModalsProps {
|
||||
confirmedCount: number;
|
||||
checkedInCount: number;
|
||||
cancelledCount: number;
|
||||
onHoldCount: number;
|
||||
statusFilter: AttendeeStatusFilter;
|
||||
setStatusFilter: (value: AttendeeStatusFilter) => void;
|
||||
// mobile filter sheet
|
||||
@@ -75,6 +76,7 @@ export function EventModals(props: EventModalsProps) {
|
||||
confirmedCount,
|
||||
checkedInCount,
|
||||
cancelledCount,
|
||||
onHoldCount,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
mobileFilterOpen,
|
||||
@@ -129,6 +131,7 @@ export function EventModals(props: EventModalsProps) {
|
||||
{ value: 'confirmed', label: `Confirmed (${confirmedCount})` },
|
||||
{ value: 'checked_in', label: `Checked In (${checkedInCount})` },
|
||||
{ value: 'cancelled', label: `Cancelled (${cancelledCount})` },
|
||||
{ value: 'on_hold', label: `On Hold (${onHoldCount})` },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
StarIcon,
|
||||
FunnelIcon,
|
||||
ChatBubbleLeftIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { StatusBadge } from '../_components/StatusBadge';
|
||||
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
|
||||
@@ -29,6 +30,7 @@ interface AttendeesTabProps {
|
||||
confirmedCount: number;
|
||||
checkedInCount: number;
|
||||
cancelledCount: number;
|
||||
onHoldCount: number;
|
||||
exporting: boolean;
|
||||
showExportDropdown: boolean;
|
||||
setShowExportDropdown: (value: boolean) => void;
|
||||
@@ -43,6 +45,7 @@ interface AttendeesTabProps {
|
||||
setShowAddTicketSheet: (value: boolean) => void;
|
||||
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
|
||||
handleOpenNoteModal: (ticket: Ticket) => void;
|
||||
handleReactivate: (ticket: Ticket) => void;
|
||||
}
|
||||
|
||||
export function AttendeesTab({
|
||||
@@ -57,6 +60,7 @@ export function AttendeesTab({
|
||||
confirmedCount,
|
||||
checkedInCount,
|
||||
cancelledCount,
|
||||
onHoldCount,
|
||||
exporting,
|
||||
showExportDropdown,
|
||||
setShowExportDropdown,
|
||||
@@ -71,6 +75,7 @@ export function AttendeesTab({
|
||||
setShowAddTicketSheet,
|
||||
getPrimaryAction,
|
||||
handleOpenNoteModal,
|
||||
handleReactivate,
|
||||
}: AttendeesTabProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -98,6 +103,7 @@ export function AttendeesTab({
|
||||
<option value="confirmed">Confirmed ({confirmedCount})</option>
|
||||
<option value="checked_in">Checked In ({checkedInCount})</option>
|
||||
<option value="cancelled">Cancelled ({cancelledCount})</option>
|
||||
<option value="on_hold">On Hold ({onHoldCount})</option>
|
||||
</select>
|
||||
|
||||
<div className="flex-1" />
|
||||
@@ -241,6 +247,7 @@ export function AttendeesTab({
|
||||
<td className="px-4 py-2.5">
|
||||
<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.attendeeRuc && <p className="text-xs text-gray-400">RUC: {ticket.attendeeRuc}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
@@ -267,6 +274,11 @@ export function AttendeesTab({
|
||||
</Button>
|
||||
)}
|
||||
<MoreMenu>
|
||||
{ticket.status === 'on_hold' && (
|
||||
<DropdownItem onClick={() => handleReactivate(ticket)}>
|
||||
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
||||
{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="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</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 className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
||||
<StatusBadge status={ticket.status} compact />
|
||||
@@ -328,6 +341,11 @@ export function AttendeesTab({
|
||||
</Button>
|
||||
)}
|
||||
<MoreMenu>
|
||||
{ticket.status === 'on_hold' && (
|
||||
<DropdownItem onClick={() => handleReactivate(ticket)}>
|
||||
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ComponentType } from 'react';
|
||||
|
||||
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
||||
|
||||
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled';
|
||||
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled' | 'on_hold';
|
||||
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
|
||||
export type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, emailsApi, adminApi, Ticket } from '@/lib/api';
|
||||
import { ticketsApi, emailsApi, adminApi, paymentsApi, Ticket } from '@/lib/api';
|
||||
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
@@ -163,6 +163,17 @@ export default function AdminEventDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivate = async (ticket: Ticket) => {
|
||||
if (!ticket.payment?.id) return;
|
||||
try {
|
||||
await paymentsApi.reactivate(ticket.payment.id);
|
||||
toast.success('Booking reactivated');
|
||||
loadEventData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to reactivate booking');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCheckin = async (ticketId: string) => {
|
||||
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
|
||||
try {
|
||||
@@ -421,6 +432,7 @@ export default function AdminEventDetailPage() {
|
||||
const pendingCount = getTicketsByStatus('pending').length;
|
||||
const checkedInCount = getTicketsByStatus('checked_in').length;
|
||||
const cancelledCount = getTicketsByStatus('cancelled').length;
|
||||
const onHoldCount = getTicketsByStatus('on_hold').length;
|
||||
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
|
||||
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
|
||||
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
|
||||
@@ -435,7 +447,7 @@ export default function AdminEventDetailPage() {
|
||||
|
||||
// ========== Primary action for a ticket ==========
|
||||
const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => {
|
||||
if (ticket.status === 'pending') {
|
||||
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
|
||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||||
}
|
||||
if (ticket.status === 'confirmed') {
|
||||
@@ -671,6 +683,7 @@ export default function AdminEventDetailPage() {
|
||||
confirmedCount={confirmedCount}
|
||||
checkedInCount={checkedInCount}
|
||||
cancelledCount={cancelledCount}
|
||||
onHoldCount={onHoldCount}
|
||||
exporting={exporting}
|
||||
showExportDropdown={showExportDropdown}
|
||||
setShowExportDropdown={setShowExportDropdown}
|
||||
@@ -685,6 +698,7 @@ export default function AdminEventDetailPage() {
|
||||
setShowAddTicketSheet={setShowAddTicketSheet}
|
||||
getPrimaryAction={getPrimaryAction}
|
||||
handleOpenNoteModal={handleOpenNoteModal}
|
||||
handleReactivate={handleReactivate}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -742,6 +756,7 @@ export default function AdminEventDetailPage() {
|
||||
confirmedCount={confirmedCount}
|
||||
checkedInCount={checkedInCount}
|
||||
cancelledCount={cancelledCount}
|
||||
onHoldCount={onHoldCount}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
mobileFilterOpen={mobileFilterOpen}
|
||||
|
||||
@@ -62,6 +62,10 @@ export default function AdminLayout({
|
||||
const allowedPathsForRole = new Set(
|
||||
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href)
|
||||
);
|
||||
// All known admin routes regardless of role, used only to tell "not allowed
|
||||
// for this role" apart from "doesn't exist" - the latter should render the
|
||||
// 404 page instead of bouncing to the default route.
|
||||
const allAdminHrefs = new Set(navigationWithRoles.map((item) => item.href));
|
||||
const defaultAdminRoute =
|
||||
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
|
||||
|
||||
@@ -79,11 +83,14 @@ export default function AdminLayout({
|
||||
router.replace(defaultAdminRoute);
|
||||
return;
|
||||
}
|
||||
const isPathAllowed = (path: string) => {
|
||||
if (allowedPathsForRole.has(path)) return true;
|
||||
return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/'));
|
||||
const matchesHrefSet = (path: string, hrefs: Set<string>) => {
|
||||
if (hrefs.has(path)) return true;
|
||||
return Array.from(hrefs).some((href) => path.startsWith(href + '/'));
|
||||
};
|
||||
if (!isPathAllowed(pathname)) {
|
||||
// Unknown route entirely (e.g. a typo'd URL) - let it fall through to the
|
||||
// admin 404 page instead of silently redirecting away.
|
||||
if (!matchesHrefSet(pathname, allAdminHrefs)) return;
|
||||
if (!matchesHrefSet(pathname, allowedPathsForRole)) {
|
||||
router.replace(defaultAdminRoute);
|
||||
}
|
||||
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -147,6 +147,20 @@ export default function AdminPaymentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivate = async (payment: PaymentWithDetails) => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
await paymentsApi.reactivate(payment.id);
|
||||
toast.success(locale === 'es' ? 'Reserva reactivada' : 'Booking reactivated');
|
||||
setSelectedPayment(null);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to reactivate booking');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefund = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to process this refund?')) return;
|
||||
|
||||
@@ -178,7 +192,7 @@ export default function AdminPaymentsPage() {
|
||||
const downloadCSV = () => {
|
||||
if (!exportData) return;
|
||||
|
||||
const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'Event Title', 'Event Date'];
|
||||
const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'RUC', 'Event Title', 'Event Date'];
|
||||
const rows = exportData.payments.map(p => [
|
||||
p.paymentId,
|
||||
p.amount,
|
||||
@@ -190,6 +204,7 @@ export default function AdminPaymentsPage() {
|
||||
p.createdAt,
|
||||
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
||||
p.attendeeEmail || '',
|
||||
p.attendeeRuc || '',
|
||||
p.eventTitle,
|
||||
p.eventDate,
|
||||
]);
|
||||
@@ -225,6 +240,7 @@ export default function AdminPaymentsPage() {
|
||||
refunded: 'bg-blue-100 text-blue-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
cancelled: 'bg-gray-100 text-gray-700',
|
||||
on_hold: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
pending: locale === 'es' ? 'Pendiente' : 'Pending',
|
||||
@@ -233,6 +249,7 @@ export default function AdminPaymentsPage() {
|
||||
refunded: locale === 'es' ? 'Reembolsado' : 'Refunded',
|
||||
failed: locale === 'es' ? 'Fallido' : 'Failed',
|
||||
cancelled: locale === 'es' ? 'Cancelado' : 'Cancelled',
|
||||
on_hold: locale === 'es' ? 'En Espera' : 'On Hold',
|
||||
};
|
||||
return (
|
||||
<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')
|
||||
);
|
||||
const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments);
|
||||
const onHoldPayments = payments.filter(p => p.status === 'on_hold');
|
||||
const onHoldBookingsCount = getUniqueBookingsCount(onHoldPayments);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -414,6 +433,9 @@ export default function AdminPaymentsPage() {
|
||||
{selectedPayment.ticket.attendeePhone && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -475,6 +497,15 @@ export default function AdminPaymentsPage() {
|
||||
</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">
|
||||
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
@@ -486,13 +517,15 @@ export default function AdminPaymentsPage() {
|
||||
{locale === 'es' ? 'Rechazar' : 'Reject'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
|
||||
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedPayment.status !== 'on_hold' && (
|
||||
<div className="pt-2 border-t">
|
||||
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
|
||||
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -627,7 +660,7 @@ export default function AdminPaymentsPage() {
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
</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">
|
||||
<div className="flex items-center gap-3">
|
||||
<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="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
|
||||
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
|
||||
<option value="on_hold">{locale === 'es' ? 'En Espera' : 'On Hold'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -897,6 +945,7 @@ export default function AdminPaymentsPage() {
|
||||
<thead className="bg-secondary-gray">
|
||||
<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">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' ? '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>
|
||||
@@ -906,7 +955,7 @@ export default function AdminPaymentsPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{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) => {
|
||||
const bookingInfo = getBookingInfo(payment);
|
||||
@@ -920,6 +969,7 @@ export default function AdminPaymentsPage() {
|
||||
</div>
|
||||
) : <span className="text-gray-400 text-sm">-</span>}
|
||||
</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">
|
||||
<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">
|
||||
<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">
|
||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||
</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' && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2 py-1">
|
||||
{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">
|
||||
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
|
||||
<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]">
|
||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||
</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' && (
|
||||
<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')}
|
||||
@@ -1040,6 +1100,7 @@ export default function AdminPaymentsPage() {
|
||||
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
|
||||
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
|
||||
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
|
||||
<option value="on_hold">{locale === 'es' ? 'En Espera' : 'On Hold'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -2,22 +2,38 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { usersApi, User } from '@/lib/api';
|
||||
import { usersApi, eventsApi, User, Event } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type RegisteredRange = '' | '7d' | '30d' | '90d';
|
||||
|
||||
function registeredAfterFromRange(range: RegisteredRange): string | undefined {
|
||||
if (!range) return undefined;
|
||||
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
|
||||
const date = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
@@ -30,14 +46,45 @@ export default function AdminUsersPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
// Debounce the search box like the Emails page does (300ms).
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebouncedSearch(searchQuery), 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
eventsApi.getAll().then((res) => setEvents(res.events)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [roleFilter]);
|
||||
}, [roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
|
||||
|
||||
const hasActiveFilters =
|
||||
roleFilter || statusFilter || hasBookingsFilter || registeredRange || eventFilter || searchQuery;
|
||||
|
||||
const clearFilters = () => {
|
||||
setRoleFilter('');
|
||||
setStatusFilter('');
|
||||
setHasBookingsFilter('');
|
||||
setRegisteredRange('');
|
||||
setEventFilter('');
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const { users } = await usersApi.getAll(roleFilter || undefined);
|
||||
const { users, total } = await usersApi.getAll({
|
||||
role: roleFilter || undefined,
|
||||
accountStatus: statusFilter || undefined,
|
||||
hasBookings: hasBookingsFilter || undefined,
|
||||
registeredAfter: registeredAfterFromRange(registeredRange),
|
||||
eventId: eventFilter || undefined,
|
||||
search: debouncedSearch.trim() || undefined,
|
||||
pageSize: 200,
|
||||
});
|
||||
setUsers(users);
|
||||
setTotal(total);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load users');
|
||||
} finally {
|
||||
@@ -129,16 +176,29 @@ export default function AdminUsersPage() {
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Desktop Filters */}
|
||||
<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>
|
||||
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
|
||||
<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="admin">{t('admin.users.roles.admin')}</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>
|
||||
</select>
|
||||
</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>
|
||||
{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>
|
||||
|
||||
{/* Mobile Toolbar */}
|
||||
<div className="md:hidden mb-4 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]',
|
||||
roleFilter ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
|
||||
)}>
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
{roleFilter ? t(`admin.users.roles.${roleFilter}`) : 'Filter by Role'}
|
||||
</button>
|
||||
{roleFilter && (
|
||||
<button onClick={() => setRoleFilter('')} className="text-xs text-primary-yellow min-h-[44px] flex items-center">
|
||||
Clear
|
||||
<div className="md:hidden mb-4 space-y-2">
|
||||
<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.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 ml-auto">{users.length} users</span>
|
||||
{hasActiveFilters && (
|
||||
<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>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
@@ -176,6 +293,7 @@ export default function AdminUsersPage() {
|
||||
<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">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">Joined</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>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{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) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
@@ -199,6 +317,7 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
@@ -247,6 +366,7 @@ export default function AdminUsersPage() {
|
||||
<p className="font-medium text-sm truncate">{user.name}</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.rucNumber && <p className="text-[10px] text-gray-400">RUC: {user.rucNumber}</p>}
|
||||
</div>
|
||||
{getRoleBadge(user.role)}
|
||||
</div>
|
||||
@@ -269,26 +389,67 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
|
||||
{/* Mobile Filter BottomSheet */}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filter by Role">
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ value: '', label: 'All Roles' },
|
||||
{ value: 'admin', label: t('admin.users.roles.admin') },
|
||||
{ value: 'organizer', label: t('admin.users.roles.organizer') },
|
||||
{ value: 'staff', label: t('admin.users.roles.staff') },
|
||||
{ value: 'marketing', label: t('admin.users.roles.marketing') },
|
||||
{ value: 'user', label: t('admin.users.roles.user') },
|
||||
].map((opt) => (
|
||||
<button key={opt.value}
|
||||
onClick={() => { setRoleFilter(opt.value); setMobileFilterOpen(false); }}
|
||||
className={clsx(
|
||||
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||
roleFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||
)}>
|
||||
{opt.label}
|
||||
{roleFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||
</button>
|
||||
))}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.users.role')}</label>
|
||||
<select value={roleFilter} onChange={(e) => setRoleFilter(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 Roles</option>
|
||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||
<option value="marketing">{t('admin.users.roles.marketing')}</option>
|
||||
<option value="user">{t('admin.users.roles.user')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(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 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>
|
||||
</BottomSheet>
|
||||
|
||||
|
||||
@@ -26,20 +26,6 @@ export const metadata: Metadata = {
|
||||
template: '%s – Spanglish',
|
||||
},
|
||||
description: 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
|
||||
keywords: [
|
||||
'language exchange',
|
||||
'Spanglish',
|
||||
'Spanglish social',
|
||||
'English Spanish meetup',
|
||||
'language exchange Asunción',
|
||||
'practice English Asunción',
|
||||
'intercambio de idiomas',
|
||||
'intercambio de idiomas Asunción',
|
||||
'English Spanish Paraguay',
|
||||
'language events Paraguay',
|
||||
'Asunción',
|
||||
'Paraguay',
|
||||
],
|
||||
authors: [{ name: 'Spanglish' }],
|
||||
creator: 'Spanglish',
|
||||
publisher: 'Spanglish',
|
||||
@@ -82,12 +68,10 @@ export const metadata: Metadata = {
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
// Each route overrides this with its own path so the canonical is
|
||||
// self-referential. Resolved against metadataBase above.
|
||||
alternates: {
|
||||
canonical: siteUrl,
|
||||
languages: {
|
||||
'en': siteUrl,
|
||||
'es': `${siteUrl}/es`,
|
||||
},
|
||||
canonical: '/',
|
||||
},
|
||||
category: 'events',
|
||||
manifest: '/manifest.json',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
import { NotFoundMessage } from '@/components/NotFoundMessage';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Not Found – Spanglish',
|
||||
@@ -12,30 +14,12 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-gray">
|
||||
<div className="text-center px-4">
|
||||
<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>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 flex items-center justify-center bg-secondary-gray">
|
||||
<NotFoundMessage />
|
||||
</main>
|
||||
<Footer />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -46,4 +46,9 @@ export const paymentsApi = {
|
||||
|
||||
refund: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/payments/${id}/refund`, { method: 'POST' }),
|
||||
|
||||
reactivate: (id: string) =>
|
||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface Ticket {
|
||||
attendeePhone?: string;
|
||||
attendeeRuc?: string;
|
||||
preferredLanguage?: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
|
||||
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in' | 'on_hold';
|
||||
checkinAt?: string;
|
||||
checkedInByAdminId?: string;
|
||||
qrCode: string;
|
||||
@@ -111,7 +111,7 @@ export interface Payment {
|
||||
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed';
|
||||
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed' | 'on_hold';
|
||||
reference?: string;
|
||||
userMarkedPaidAt?: string;
|
||||
payerName?: string; // Name of payer if different from attendee
|
||||
@@ -131,6 +131,7 @@ export interface PaymentWithDetails extends Payment {
|
||||
attendeeLastName?: string;
|
||||
attendeeEmail?: string;
|
||||
attendeePhone?: string;
|
||||
attendeeRuc?: string;
|
||||
status: string;
|
||||
} | null;
|
||||
event: {
|
||||
@@ -325,6 +326,7 @@ export interface ExportedPayment {
|
||||
attendeeFirstName: string;
|
||||
attendeeLastName?: string;
|
||||
attendeeEmail?: string;
|
||||
attendeeRuc?: string;
|
||||
eventId: string;
|
||||
eventTitle: string;
|
||||
eventDate: string;
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
import { fetchApi } from './client';
|
||||
import type { User } from './types';
|
||||
|
||||
export interface UsersListParams {
|
||||
role?: string;
|
||||
search?: string;
|
||||
accountStatus?: string;
|
||||
hasBookings?: 'yes' | 'no';
|
||||
registeredAfter?: string;
|
||||
registeredBefore?: string;
|
||||
eventId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
getAll: (role?: string) => {
|
||||
const query = role ? `?role=${role}` : '';
|
||||
return fetchApi<{ users: User[] }>(`/api/users${query}`);
|
||||
getAll: (params?: UsersListParams | string) => {
|
||||
// Back-compat: allow the old `getAll(role)` call shape.
|
||||
const p: UsersListParams = typeof params === 'string' ? { role: params } : params || {};
|
||||
const query = new URLSearchParams();
|
||||
if (p.role) query.set('role', p.role);
|
||||
if (p.search) query.set('search', p.search);
|
||||
if (p.accountStatus) query.set('accountStatus', p.accountStatus);
|
||||
if (p.hasBookings) query.set('hasBookings', p.hasBookings);
|
||||
if (p.registeredAfter) query.set('registeredAfter', p.registeredAfter);
|
||||
if (p.registeredBefore) query.set('registeredBefore', p.registeredBefore);
|
||||
if (p.eventId) query.set('eventId', p.eventId);
|
||||
if (p.page) query.set('page', String(p.page));
|
||||
if (p.pageSize) query.set('pageSize', String(p.pageSize));
|
||||
const qs = query.toString();
|
||||
return fetchApi<{ users: User[]; total: number; page: number; pageSize: number }>(`/api/users${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
|
||||
getById: (id: string) => fetchApi<{ user: User }>(`/api/users/${id}`),
|
||||
|
||||
Reference in New Issue
Block a user