Merge pull request 'speed' (#25) from speed into main
Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
@@ -114,3 +114,16 @@ PENDING_BOOKING_TTL_MINUTES=30
|
||||
# How often the cleanup job runs, in milliseconds (default: 300000 = 5 min)
|
||||
PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000
|
||||
|
||||
# Auto-Hold Sweep
|
||||
# Bookings awaiting admin payment approval are put on hold (seat released) after
|
||||
# this many hours with no confirmation (default: 72)
|
||||
HOLD_THRESHOLD_HOURS=72
|
||||
# How often the hold sweep job runs, in milliseconds (default: 900000 = 15 min)
|
||||
HOLD_SWEEP_INTERVAL_MS=900000
|
||||
|
||||
# Ended-Event Payment Sweep
|
||||
# Once an event is over, any unconfirmed payment (pending / pending_approval /
|
||||
# on_hold) is auto-rejected and its ticket cancelled. No email is sent.
|
||||
# How often this sweep runs, in milliseconds (default: 900000 = 15 min)
|
||||
EVENT_END_SWEEP_INTERVAL_MS=900000
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"hono": "^4.4.7",
|
||||
"ioredis": "^5.11.1",
|
||||
"jose": "^5.4.0",
|
||||
"moment-timezone": "^0.6.2",
|
||||
"nanoid": "^5.0.7",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pdfkit": "^0.17.2",
|
||||
|
||||
@@ -238,6 +238,15 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).run(sql`
|
||||
@@ -719,6 +728,15 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).execute(sql`
|
||||
|
||||
@@ -104,7 +104,7 @@ export const sqliteTickets = sqliteTable('tickets', {
|
||||
attendeePhone: text('attendee_phone'),
|
||||
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
|
||||
preferredLanguage: text('preferred_language'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold'] }).notNull().default('pending'),
|
||||
checkinAt: text('checkin_at'),
|
||||
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||
qrCode: text('qr_code'),
|
||||
@@ -119,8 +119,11 @@ export const sqlitePayments = sqliteTable('payments', {
|
||||
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
|
||||
amount: real('amount').notNull(),
|
||||
currency: text('currency').notNull().default('PYG'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
|
||||
reference: text('reference'),
|
||||
lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
|
||||
lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires
|
||||
lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
|
||||
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
|
||||
payerName: text('payer_name'), // Name of payer if different from attendee
|
||||
paidAt: text('paid_at'),
|
||||
@@ -476,6 +479,9 @@ export const pgPayments = pgTable('payments', {
|
||||
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||
reference: varchar('reference', { length: 255 }),
|
||||
lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
|
||||
lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires
|
||||
lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
|
||||
userMarkedPaidAt: timestamp('user_marked_paid_at'),
|
||||
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
|
||||
paidAt: timestamp('paid_at'),
|
||||
|
||||
@@ -26,6 +26,8 @@ import faqRoutes from './routes/faq.js';
|
||||
import emailService from './lib/email.js';
|
||||
import { initEmailQueue } from './lib/emailQueue.js';
|
||||
import { startBookingCleanup } from './lib/bookingCleanup.js';
|
||||
import { startHoldSweep } from './lib/holdSweep.js';
|
||||
import { startEventEndSweep } from './lib/eventEndSweep.js';
|
||||
import { getLock } from './lib/stores/lock.js';
|
||||
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
||||
|
||||
@@ -1932,6 +1934,12 @@ initEmailQueue(emailService);
|
||||
// Periodically expire abandoned pending bookings so they stop holding seats.
|
||||
startBookingCleanup();
|
||||
|
||||
// Periodically put stale pending-approval payments on hold, releasing their seats.
|
||||
startHoldSweep();
|
||||
|
||||
// Periodically auto-reject unconfirmed payments once their event is over (no email).
|
||||
startEventEndSweep();
|
||||
|
||||
// Initialize email templates on startup.
|
||||
// Guarded by a distributed lock so that, when running multiple replicas, only
|
||||
// one instance seeds/updates templates per boot instead of all of them racing.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Auto-reject unconfirmed payments once their event is over.
|
||||
//
|
||||
// After an event ends, any booking whose payment was never confirmed
|
||||
// (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored.
|
||||
// This job silently fails those payments and cancels their tickets so they stop
|
||||
// lingering as "pending" forever. It deliberately sends NO email — unlike the
|
||||
// admin reject route, this is a housekeeping sweep and users are not notified.
|
||||
//
|
||||
// An event is considered over when COALESCE(end_datetime, start_datetime) is in
|
||||
// the past. Updates are guarded by the current status so re-running is a no-op.
|
||||
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db, dbAll, tickets, payments, events } from '../db/index.js';
|
||||
import { getNow } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
|
||||
// Payment statuses that represent an unconfirmed booking.
|
||||
const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold'];
|
||||
// Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled).
|
||||
const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold'];
|
||||
|
||||
/**
|
||||
* Fail unconfirmed payments (and cancel their tickets) for events that have
|
||||
* already ended. Returns the number of payments rejected.
|
||||
*/
|
||||
export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise<number> {
|
||||
// Pull candidate rows first, then decide "ended" in JS so the comparison works
|
||||
// identically for SQLite (ISO text) and Postgres (timestamp) datetime columns.
|
||||
const rows = await dbAll<{
|
||||
paymentId: string;
|
||||
ticketId: string;
|
||||
endDatetime: string | Date | null;
|
||||
startDatetime: string | Date | null;
|
||||
}>(
|
||||
(db as any)
|
||||
.select({
|
||||
paymentId: (payments as any).id,
|
||||
ticketId: (tickets as any).id,
|
||||
endDatetime: (events as any).endDatetime,
|
||||
startDatetime: (events as any).startDatetime,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.innerJoin(events, eq((tickets as any).eventId, (events as any).id))
|
||||
.where(and(
|
||||
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
|
||||
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
|
||||
))
|
||||
);
|
||||
|
||||
const nowMs = Date.now();
|
||||
const ended = rows.filter((r) => {
|
||||
const ref = r.endDatetime || r.startDatetime;
|
||||
if (!ref) return false;
|
||||
return new Date(ref as any).getTime() < nowMs;
|
||||
});
|
||||
|
||||
if (ended.length === 0) return 0;
|
||||
|
||||
const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId)));
|
||||
const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id)));
|
||||
const now = getNow();
|
||||
|
||||
// Fail the payments. The status guard keeps this idempotent and avoids
|
||||
// clobbering anything that changed since we read the candidates.
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now })
|
||||
.where(and(
|
||||
inArray((payments as any).id, paymentIds),
|
||||
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
|
||||
));
|
||||
|
||||
// Cancel the associated tickets, freeing any seats they still hold.
|
||||
if (ticketIds.length > 0) {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
|
||||
));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` +
|
||||
`cancelled ${ticketIds.length} ticket(s).`
|
||||
);
|
||||
return paymentIds.length;
|
||||
}
|
||||
|
||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start a periodic sweep that auto-rejects unconfirmed payments for ended
|
||||
* events. Each run is guarded by a distributed lock so that, across multiple
|
||||
* replicas, only one instance does the work per interval.
|
||||
*/
|
||||
export function startEventEndSweep(): void {
|
||||
const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
|
||||
|
||||
const run = () => {
|
||||
getLock()
|
||||
.withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () =>
|
||||
rejectUnconfirmedPaymentsForEndedEvents()
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error('[EventEndSweep] Run failed:', err?.message || err)
|
||||
);
|
||||
};
|
||||
|
||||
// Run shortly after startup, then on the interval.
|
||||
setTimeout(run, 60_000).unref?.();
|
||||
sweepTimer = setInterval(run, intervalMs);
|
||||
sweepTimer.unref?.();
|
||||
console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
|
||||
}
|
||||
|
||||
export function stopEventEndSweep(): void {
|
||||
if (sweepTimer) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,11 @@ export interface CreateInvoiceParams {
|
||||
extra?: Record<string, any>; // Additional metadata
|
||||
}
|
||||
|
||||
// How long a booking's Lightning invoice is valid for, in seconds. Shared
|
||||
// between initial booking creation and invoice regeneration so both produce
|
||||
// invoices with the same lifetime.
|
||||
export const LNBITS_INVOICE_EXPIRY_SECONDS = 900; // 15 minutes
|
||||
|
||||
/**
|
||||
* Check if LNbits is configured
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { randomUUID } from 'crypto';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
/**
|
||||
* Get database type (reads env var each time to handle module loading order)
|
||||
@@ -59,17 +60,10 @@ export function parseEventDatetime(
|
||||
return new Date(datetime);
|
||||
}
|
||||
|
||||
// Treat the digits as UTC so we have a stable reference instant.
|
||||
const fakeUTC = new Date(datetime + 'Z');
|
||||
|
||||
// Ask Intl what that UTC instant looks like in both UTC and the target tz.
|
||||
const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' });
|
||||
const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone });
|
||||
|
||||
// The gap between the two tells us the tz offset at this point in time.
|
||||
const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
|
||||
|
||||
return new Date(fakeUTC.getTime() + offsetMs);
|
||||
// Interpret the wall-clock digits as local time in `timezone` using
|
||||
// moment-timezone's bundled IANA data. This keeps the conversion correct
|
||||
// regardless of the host Node runtime's (possibly stale) tz database.
|
||||
return moment.tz(datetime, timezone).toDate();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
|
||||
import { db, dbGet, dbAll, tickets, payments, events } from '../db/index.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getNow, toDbDate } from '../lib/utils.js';
|
||||
import {
|
||||
verifyWebhookPayment,
|
||||
getPaymentStatus,
|
||||
createInvoice,
|
||||
isLNbitsConfigured,
|
||||
LNBITS_INVOICE_EXPIRY_SECONDS,
|
||||
} from '../lib/lnbits.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { getPubSub } from '../lib/stores/pubsub.js';
|
||||
import { getLock } from '../lib/stores/lock.js';
|
||||
@@ -391,6 +397,145 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a Lightning invoice for a ticket to pay or re-pay.
|
||||
*
|
||||
* Reuses the stored invoice if it still has more than 5 minutes of validity
|
||||
* left; otherwise generates a fresh one from LNbits. This is what lets a user
|
||||
* come back to an unpaid Lightning booking later (e.g. from "Pay now" on the
|
||||
* dashboard) instead of hitting a dead end.
|
||||
*/
|
||||
lnbitsRouter.post('/invoice/:ticketId', async (c) => {
|
||||
const ticketId = c.req.param('ticketId');
|
||||
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (payment.provider !== 'lightning') {
|
||||
return c.json({ error: 'This booking is not a Lightning payment' }, 400);
|
||||
}
|
||||
|
||||
if (ticket.status === 'confirmed' || payment.status === 'paid') {
|
||||
return c.json({ alreadyPaid: true });
|
||||
}
|
||||
|
||||
if (ticket.status !== 'pending' || payment.status !== 'pending') {
|
||||
return c.json({
|
||||
error: 'This booking is no longer active. Please make a new booking.',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Gather every ticket/payment in the booking group - a multi-ticket booking
|
||||
// shares a single Lightning invoice for the combined total.
|
||||
let groupTickets: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
groupTickets = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
const groupPayments = await dbAll<any>(
|
||||
(db as any).select().from(payments).where(inArray((payments as any).ticketId, groupTickets.map((t: any) => t.id)))
|
||||
);
|
||||
const invoiceHolder = groupPayments.find((p: any) => p.reference) || payment;
|
||||
const totalAmount = groupPayments.reduce((sum: number, p: any) => sum + Number(p.amount), 0);
|
||||
const currency = invoiceHolder.currency;
|
||||
|
||||
const now = Date.now();
|
||||
const FIVE_MIN_MS = 5 * 60 * 1000;
|
||||
const expiresAtMs = invoiceHolder.lnbitsExpiresAt ? new Date(invoiceHolder.lnbitsExpiresAt).getTime() : 0;
|
||||
|
||||
if (invoiceHolder.reference && invoiceHolder.lnbitsInvoice && expiresAtMs - now > FIVE_MIN_MS) {
|
||||
return c.json({
|
||||
invoice: {
|
||||
paymentHash: invoiceHolder.reference,
|
||||
paymentRequest: invoiceHolder.lnbitsInvoice,
|
||||
amount: invoiceHolder.lnbitsAmountSats || 0,
|
||||
fiatAmount: totalAmount,
|
||||
fiatCurrency: currency,
|
||||
expiresAt: invoiceHolder.lnbitsExpiresAt,
|
||||
},
|
||||
reused: true,
|
||||
});
|
||||
}
|
||||
|
||||
// No usable stored invoice (never created, expired, or expiring soon) - get a fresh one.
|
||||
if (!isLNbitsConfigured()) {
|
||||
return c.json({ error: 'Bitcoin Lightning payments are not available at this time' }, 400);
|
||||
}
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
|
||||
const webhookUrl = webhookSecret
|
||||
? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}`
|
||||
: `${apiUrl}/api/lnbits/webhook`;
|
||||
const attendeeName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
try {
|
||||
const lnbitsInvoice = await createInvoice({
|
||||
amount: totalAmount,
|
||||
unit: currency,
|
||||
memo: `Spanglish: ${event?.title || 'Event'} - ${attendeeName}${groupTickets.length > 1 ? ` (${groupTickets.length} tickets)` : ''}`,
|
||||
webhookUrl,
|
||||
expiry: LNBITS_INVOICE_EXPIRY_SECONDS,
|
||||
extra: {
|
||||
ticketId: invoiceHolder.ticketId,
|
||||
bookingId: ticket.bookingId || null,
|
||||
ticketIds: groupTickets.map((t: any) => t.id),
|
||||
eventId: ticket.eventId,
|
||||
eventTitle: event?.title,
|
||||
attendeeName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketCount: groupTickets.length,
|
||||
},
|
||||
});
|
||||
|
||||
const lnbitsExpiresAt = toDbDate(new Date(now + LNBITS_INVOICE_EXPIRY_SECONDS * 1000));
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
reference: lnbitsInvoice.paymentHash,
|
||||
lnbitsInvoice: lnbitsInvoice.paymentRequest,
|
||||
lnbitsExpiresAt,
|
||||
lnbitsAmountSats: lnbitsInvoice.amount,
|
||||
updatedAt: getNow(),
|
||||
})
|
||||
.where(eq((payments as any).id, invoiceHolder.id));
|
||||
|
||||
return c.json({
|
||||
invoice: {
|
||||
paymentHash: lnbitsInvoice.paymentHash,
|
||||
paymentRequest: lnbitsInvoice.paymentRequest,
|
||||
amount: lnbitsInvoice.amount,
|
||||
fiatAmount: lnbitsInvoice.fiatAmount ?? totalAmount,
|
||||
fiatCurrency: lnbitsInvoice.fiatCurrency ?? currency,
|
||||
expiresAt: lnbitsExpiresAt,
|
||||
},
|
||||
reused: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create Lightning invoice:', error);
|
||||
return c.json({
|
||||
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`,
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get payment status for a ticket (fallback polling endpoint)
|
||||
*/
|
||||
|
||||
+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');
|
||||
|
||||
+109
-32
@@ -4,11 +4,12 @@ import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
|
||||
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||
import { generateId, generateTicketCode, getNow, toDbDate, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
|
||||
const ticketsRouter = new Hono();
|
||||
|
||||
@@ -53,7 +54,7 @@ function isPaymentMethodEnabled(method: string, merged: Record<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;
|
||||
|
||||
@@ -408,7 +418,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
|
||||
memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`,
|
||||
webhookUrl,
|
||||
expiry: 900, // 15 minutes expiry for faster UX
|
||||
expiry: LNBITS_INVOICE_EXPIRY_SECONDS, // 15 minutes expiry for faster UX
|
||||
extra: {
|
||||
ticketId: primaryTicket.id,
|
||||
bookingId: ticketCount > 1 ? bookingId : null,
|
||||
@@ -420,13 +430,22 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
ticketCount,
|
||||
},
|
||||
});
|
||||
|
||||
// Update primary payment with LNbits payment hash reference
|
||||
|
||||
const lnbitsExpiresAt = toDbDate(new Date(Date.now() + LNBITS_INVOICE_EXPIRY_SECONDS * 1000));
|
||||
|
||||
// Update primary payment with the LNbits invoice - the BOLT11 string and
|
||||
// expiry are persisted so the "Pay now" page can redisplay this same
|
||||
// invoice later instead of erroring out on an unpaid Lightning booking.
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ reference: lnbitsInvoice.paymentHash })
|
||||
.set({
|
||||
reference: lnbitsInvoice.paymentHash,
|
||||
lnbitsInvoice: lnbitsInvoice.paymentRequest,
|
||||
lnbitsExpiresAt,
|
||||
lnbitsAmountSats: lnbitsInvoice.amount,
|
||||
})
|
||||
.where(eq((payments as any).id, primaryPayment.id));
|
||||
|
||||
|
||||
(primaryPayment as any).reference = lnbitsInvoice.paymentHash;
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create Lightning invoice:', error);
|
||||
@@ -1104,26 +1123,47 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
||||
);
|
||||
}
|
||||
|
||||
// Confirm all tickets in the booking
|
||||
for (const t of ticketsToConfirm) {
|
||||
// Update ticket status
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, t.id));
|
||||
|
||||
// Update payment status
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, t.id));
|
||||
if (ticket.status === 'on_hold') {
|
||||
// The seat was released when this booking went on hold - re-check capacity
|
||||
// before confirming it directly.
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{ paidByAdminId: user.id }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
// Confirm all tickets in the booking
|
||||
for (const t of ticketsToConfirm) {
|
||||
// Update ticket status
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, t.id));
|
||||
|
||||
// Update payment status
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, t.id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get payment for sending receipt
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -1194,18 +1234,55 @@ ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, wind
|
||||
}
|
||||
|
||||
if (payment.status === 'paid') {
|
||||
return c.json({
|
||||
payment,
|
||||
return c.json({
|
||||
payment,
|
||||
message: 'Payment has already been confirmed.',
|
||||
alreadyProcessed: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// A booking that was auto-released after the hold threshold: recover it by
|
||||
// re-reserving the seat(s) and moving back into the admin approval queue.
|
||||
if (payment.status === 'on_hold') {
|
||||
let ticketsToRecover: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToRecover = await dbAll<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
|
||||
|
||||
+48
-10
@@ -2,9 +2,9 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
|
||||
import { eq, desc, sql } from 'drizzle-orm';
|
||||
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { getNow, toDbDate } from '../lib/utils.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -27,7 +27,43 @@ const updateUserSchema = z.object({
|
||||
// Get all users (admin only)
|
||||
usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const role = c.req.query('role');
|
||||
|
||||
const search = c.req.query('search');
|
||||
const accountStatus = c.req.query('accountStatus');
|
||||
const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no'
|
||||
const registeredAfter = c.req.query('registeredAfter');
|
||||
const registeredBefore = c.req.query('registeredBefore');
|
||||
const eventId = c.req.query('eventId');
|
||||
const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1);
|
||||
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200);
|
||||
|
||||
const conditions: any[] = [];
|
||||
if (role) conditions.push(eq((users as any).role, role));
|
||||
if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus));
|
||||
if (registeredAfter) conditions.push(gte((users as any).createdAt, toDbDate(registeredAfter)));
|
||||
if (registeredBefore) conditions.push(lte((users as any).createdAt, toDbDate(registeredBefore)));
|
||||
if (search) {
|
||||
const like = `%${search.toLowerCase()}%`;
|
||||
conditions.push(sql`(
|
||||
LOWER(${(users as any).name}) LIKE ${like}
|
||||
OR LOWER(${(users as any).email}) LIKE ${like}
|
||||
OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like}
|
||||
)`);
|
||||
}
|
||||
if (hasBookings === 'yes') {
|
||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
||||
} else if (hasBookings === 'no') {
|
||||
conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
||||
}
|
||||
if (eventId) {
|
||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`);
|
||||
}
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const totalQuery = whereClause
|
||||
? (db as any).select({ count: sql<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;
|
||||
|
||||
@@ -335,7 +335,7 @@ export function BookingFormStep({
|
||||
<button
|
||||
key={method.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, paymentMethod: method.id })}
|
||||
onClick={() => setFormData((prev) => ({ ...prev, paymentMethod: method.id }))}
|
||||
className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${
|
||||
formData.paymentMethod === method.id
|
||||
? 'border-primary-yellow bg-primary-yellow/10'
|
||||
@@ -377,6 +377,9 @@ export function BookingFormStep({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{errors.paymentMethod && (
|
||||
<p className="mt-3 text-sm text-red-600">{errors.paymentMethod}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Terms & Privacy agreement */}
|
||||
@@ -430,7 +433,7 @@ export function BookingFormStep({
|
||||
size="lg"
|
||||
className="w-full"
|
||||
isLoading={submitting}
|
||||
disabled={paymentMethods.length === 0 || !agreedToTerms}
|
||||
disabled={paymentMethods.length === 0 || !formData.paymentMethod || !agreedToTerms}
|
||||
>
|
||||
{formData.paymentMethod === 'cash'
|
||||
? t('booking.form.reserveSpot')
|
||||
|
||||
@@ -13,7 +13,8 @@ export interface BookingFormData {
|
||||
email: string;
|
||||
phone: string;
|
||||
preferredLanguage: 'en' | 'es';
|
||||
paymentMethod: PaymentMethod;
|
||||
// Empty until the user explicitly picks a method (no default selection).
|
||||
paymentMethod: PaymentMethod | '';
|
||||
ruc: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
PaymentMethod,
|
||||
} from './_types';
|
||||
import { buildPaymentMethods, formatRuc, rucPattern } from './_logic/booking';
|
||||
import { useLightningWatcher } from './_hooks/useLightningWatcher';
|
||||
import { useLightningWatcher } from '@/hooks/useLightningWatcher';
|
||||
import { PayingStep } from './_steps/PayingStep';
|
||||
import { ManualPaymentStep } from './_steps/ManualPaymentStep';
|
||||
import { PendingApprovalStep } from './_steps/PendingApprovalStep';
|
||||
@@ -35,7 +35,6 @@ export default function BookingPage() {
|
||||
const [step, setStep] = useState<BookingStep>('form');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [bookingResult, setBookingResult] = useState<BookingResult | null>(null);
|
||||
const [, setPaymentPending] = useState(false);
|
||||
const [markingPaid, setMarkingPaid] = useState(false);
|
||||
|
||||
// State for payer name (when paid under different name)
|
||||
@@ -58,7 +57,7 @@ export default function BookingPage() {
|
||||
email: '',
|
||||
phone: '',
|
||||
preferredLanguage: locale as 'en' | 'es',
|
||||
paymentMethod: 'cash',
|
||||
paymentMethod: '',
|
||||
ruc: '',
|
||||
});
|
||||
|
||||
@@ -131,18 +130,7 @@ export default function BookingPage() {
|
||||
return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' });
|
||||
});
|
||||
setPaymentConfig(paymentRes.paymentOptions);
|
||||
|
||||
// Set default payment method based on what's enabled
|
||||
const config = paymentRes.paymentOptions;
|
||||
if (config.lightningEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'lightning' }));
|
||||
} else if (config.cashEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'cash' }));
|
||||
} else if (config.bankTransferEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'bank_transfer' }));
|
||||
} else if (config.tpagoEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'tpago' }));
|
||||
}
|
||||
// No payment method is pre-selected; the user must choose one.
|
||||
})
|
||||
.catch(() => router.push('/events'))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -217,6 +205,15 @@ export default function BookingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Payment method must be explicitly chosen and currently enabled
|
||||
const availableMethods = buildPaymentMethods(paymentConfig, locale);
|
||||
if (
|
||||
!formData.paymentMethod ||
|
||||
!availableMethods.some((m) => m.id === formData.paymentMethod)
|
||||
) {
|
||||
newErrors.paymentMethod = t('booking.form.errors.paymentMethodRequired');
|
||||
}
|
||||
|
||||
// Validate additional attendees (if multi-ticket)
|
||||
attendees.forEach((attendee, index) => {
|
||||
if (!attendee.firstName.trim() || attendee.firstName.length < 2) {
|
||||
@@ -245,7 +242,13 @@ export default function BookingPage() {
|
||||
};
|
||||
|
||||
// Watch for Lightning payment confirmation while on the paying step.
|
||||
useLightningWatcher(step, bookingResult?.ticketId, locale, setPaymentPending, setStep);
|
||||
useLightningWatcher(
|
||||
step === 'paying',
|
||||
bookingResult?.ticketId,
|
||||
locale,
|
||||
() => setStep('success'),
|
||||
() => {}
|
||||
);
|
||||
|
||||
// Handle "I Have Paid" button click
|
||||
const handleMarkPaymentSent = async () => {
|
||||
@@ -299,7 +302,7 @@ export default function BookingPage() {
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
preferredLanguage: formData.preferredLanguage,
|
||||
paymentMethod: formData.paymentMethod,
|
||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
|
||||
// Include attendees array for multi-ticket bookings
|
||||
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
||||
@@ -330,7 +333,6 @@ export default function BookingPage() {
|
||||
};
|
||||
setBookingResult(result);
|
||||
setStep('paying');
|
||||
setPaymentPending(true);
|
||||
// Payment confirmation is handled by the paying-step watcher effect.
|
||||
} else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') {
|
||||
// Manual payment methods - show payment details
|
||||
@@ -362,7 +364,7 @@ export default function BookingPage() {
|
||||
bookingId,
|
||||
qrCode: primaryTicket.qrCode,
|
||||
qrCodes: ticketsList?.map((t: any) => t.qrCode),
|
||||
paymentMethod: formData.paymentMethod,
|
||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||
ticketCount,
|
||||
});
|
||||
setStep('success');
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig, LightningInvoice } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
|
||||
import { useLightningWatcher } from '@/hooks/useLightningWatcher';
|
||||
import { PayingStep } from '@/app/(public)/book/[eventId]/_steps/PayingStep';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
TicketIcon,
|
||||
@@ -22,7 +24,7 @@ import {
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error';
|
||||
type PaymentStep = 'loading' | 'manual_payment' | 'lightning_payment' | 'pending_approval' | 'confirmed' | 'error';
|
||||
|
||||
export default function BookingPaymentPage() {
|
||||
const params = useParams();
|
||||
@@ -33,10 +35,36 @@ export default function BookingPaymentPage() {
|
||||
const [step, setStep] = useState<PaymentStep>('loading');
|
||||
const [markingPaid, setMarkingPaid] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightningInvoice, setLightningInvoice] = useState<LightningInvoice | null>(null);
|
||||
const [invoiceExpired, setInvoiceExpired] = useState(false);
|
||||
const [fetchingInvoice, setFetchingInvoice] = useState(false);
|
||||
|
||||
const ticketId = params.ticketId as string;
|
||||
const requestedStep = searchParams.get('step');
|
||||
|
||||
// Get (or refresh) the Lightning invoice for this ticket - reuses the
|
||||
// stored invoice if still valid, otherwise the backend generates a new one.
|
||||
const fetchLightningInvoice = useCallback(async () => {
|
||||
setFetchingInvoice(true);
|
||||
try {
|
||||
const result = await ticketsApi.getLightningInvoice(ticketId);
|
||||
if (result.alreadyPaid) {
|
||||
setStep('confirmed');
|
||||
return;
|
||||
}
|
||||
if (result.invoice) {
|
||||
setLightningInvoice(result.invoice);
|
||||
setInvoiceExpired(false);
|
||||
setStep('lightning_payment');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load Lightning invoice');
|
||||
setStep('error');
|
||||
} finally {
|
||||
setFetchingInvoice(false);
|
||||
}
|
||||
}, [ticketId]);
|
||||
|
||||
// Fetch ticket and payment config
|
||||
useEffect(() => {
|
||||
if (!ticketId) return;
|
||||
@@ -45,7 +73,7 @@ export default function BookingPaymentPage() {
|
||||
try {
|
||||
// Get ticket with event and payment info
|
||||
const { ticket: ticketData } = await ticketsApi.getById(ticketId);
|
||||
|
||||
|
||||
if (!ticketData) {
|
||||
setError('Booking not found');
|
||||
setStep('error');
|
||||
@@ -54,10 +82,24 @@ export default function BookingPaymentPage() {
|
||||
|
||||
setTicket(ticketData);
|
||||
|
||||
// Only proceed for manual payment methods
|
||||
const paymentMethod = ticketData.payment?.provider;
|
||||
|
||||
if (paymentMethod === 'lightning') {
|
||||
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
|
||||
setStep('confirmed');
|
||||
} else if (ticketData.status === 'cancelled') {
|
||||
setError(locale === 'es'
|
||||
? 'Esta reserva ya no está activa. Por favor realiza una nueva reserva.'
|
||||
: 'This booking is no longer active. Please make a new booking.');
|
||||
setStep('error');
|
||||
} else {
|
||||
await fetchLightningInvoice();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) {
|
||||
// Not a manual payment method, redirect to success page or show appropriate state
|
||||
// Not a manual or Lightning payment method, show appropriate state
|
||||
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
|
||||
setStep('confirmed');
|
||||
} else {
|
||||
@@ -75,7 +117,7 @@ export default function BookingPaymentPage() {
|
||||
|
||||
// Determine which step to show based on payment status
|
||||
const paymentStatus = ticketData.payment?.status;
|
||||
|
||||
|
||||
if (paymentStatus === 'paid' || ticketData.status === 'confirmed') {
|
||||
setStep('confirmed');
|
||||
} else if (paymentStatus === 'pending_approval') {
|
||||
@@ -96,6 +138,15 @@ export default function BookingPaymentPage() {
|
||||
loadBookingData();
|
||||
}, [ticketId]);
|
||||
|
||||
// Watch for Lightning payment confirmation while the invoice is on screen.
|
||||
useLightningWatcher(
|
||||
step === 'lightning_payment' && !invoiceExpired,
|
||||
ticketId,
|
||||
locale,
|
||||
() => setStep('confirmed'),
|
||||
() => setInvoiceExpired(true)
|
||||
);
|
||||
|
||||
// Handle "I Have Paid" button click
|
||||
const handleMarkPaymentSent = async () => {
|
||||
if (!ticket) return;
|
||||
@@ -301,6 +352,49 @@ export default function BookingPaymentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Lightning payment step - show the (reused or freshly generated) invoice
|
||||
if (step === 'lightning_payment' && ticket) {
|
||||
if (invoiceExpired) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-orange-100 flex items-center justify-center mx-auto mb-6">
|
||||
<ClockIcon className="w-10 h-10 text-orange-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? 'La factura expiró' : 'Invoice expired'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{locale === 'es'
|
||||
? 'Genera una nueva factura Lightning para continuar con el pago.'
|
||||
: 'Generate a new Lightning invoice to continue with payment.'}
|
||||
</p>
|
||||
<Button onClick={fetchLightningInvoice} isLoading={fetchingInvoice} size="lg">
|
||||
{locale === 'es' ? 'Generar Nueva Factura' : 'Generate New Invoice'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!lightningInvoice) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
<p className="mt-4 text-gray-600">
|
||||
{locale === 'es' ? 'Preparando tu factura...' : 'Preparing your invoice...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PayingStep invoice={lightningInvoice} qrCode={ticket.qrCode} locale={locale} />;
|
||||
}
|
||||
|
||||
// Manual payment step - show payment details and "I have paid" button
|
||||
if (step === 'manual_payment' && ticket && paymentConfig) {
|
||||
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
||||
|
||||
@@ -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,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { faqApi, FaqItem } from '@/lib/api';
|
||||
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import clsx from 'clsx';
|
||||
@@ -23,7 +24,20 @@ export default function HomepageFaqSection() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading || faqs.length === 0) {
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="section-padding bg-secondary-gray" aria-labelledby="homepage-faq-title">
|
||||
<div className="container-page">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Skeleton className="h-9 w-72 max-w-full mx-auto mb-8" />
|
||||
<FaqListSkeleton count={4} compact />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (faqs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||
import { FeaturedEventSkeleton } from '@/components/ui/Skeleton';
|
||||
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface NextEventSectionProps {
|
||||
@@ -51,11 +52,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
||||
: '';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <FeaturedEventSkeleton />;
|
||||
}
|
||||
|
||||
if (!nextEvent) {
|
||||
|
||||
@@ -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,8 @@ import {
|
||||
groupByBooking,
|
||||
isUnpaid,
|
||||
isAwaitingApproval,
|
||||
isOnHold,
|
||||
isActionableAttention,
|
||||
ticketAmount,
|
||||
shareTicket,
|
||||
isToday,
|
||||
@@ -57,11 +59,14 @@ export default function OverviewTab({
|
||||
// awaiting approval), ordered by soonest event.
|
||||
const attentionTicket = useMemo(() => {
|
||||
const candidates = activeTickets.filter(
|
||||
(t) => isUnpaid(t) || isAwaitingApproval(t)
|
||||
(t) =>
|
||||
isActionableAttention(t) &&
|
||||
(isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t))
|
||||
);
|
||||
const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2);
|
||||
candidates.sort((a, b) => {
|
||||
const aUnpaid = isUnpaid(a) ? 0 : 1;
|
||||
const bUnpaid = isUnpaid(b) ? 0 : 1;
|
||||
const aUnpaid = priority(a);
|
||||
const bUnpaid = priority(b);
|
||||
if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid;
|
||||
const aStart = a.event?.startDatetime
|
||||
? parseDate(a.event.startDatetime).getTime()
|
||||
@@ -190,7 +195,19 @@ export default function OverviewTab({
|
||||
</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 +363,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');
|
||||
@@ -25,6 +35,22 @@ export function isAwaitingApproval(ticket: { payment?: { status?: string } | nul
|
||||
return ticket.payment?.status === 'pending_approval';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an attention banner (e.g. "your spot was released", "payment pending")
|
||||
* is still worth showing to the user. It only makes sense to nudge the user when
|
||||
* the event still exists, is still bookable (published/unlisted), and has not
|
||||
* already ended. This suppresses stale banners for deleted, unpublished,
|
||||
* cancelled/completed/archived, or past events.
|
||||
*/
|
||||
export function isActionableAttention(ticket: UserTicket): boolean {
|
||||
const event = ticket.event;
|
||||
if (!event) return false;
|
||||
if (event.status !== 'published' && event.status !== 'unlisted') return false;
|
||||
const refDate = event.endDatetime || event.startDatetime;
|
||||
if (!refDate) return false;
|
||||
return parseDate(refDate).getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment the seat hold expires. null when paid/awaiting/cancelled or when
|
||||
* the data needed to compute it is missing.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UserPayment,
|
||||
} from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { CardListSkeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
import OverviewTab from './components/OverviewTab';
|
||||
import TicketsTab from './components/TicketsTab';
|
||||
@@ -104,9 +105,7 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Tab content */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-yellow" />
|
||||
</div>
|
||||
<CardListSkeleton count={3} />
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'overview' && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Skeleton, ArticleSkeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
// Route-level skeleton for the event detail page: back link, banner, article
|
||||
// body on the left and the booking card in the sidebar.
|
||||
export default function EventDetailLoading() {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<ArticleSkeleton withBanner />
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-white rounded-card shadow-card p-6" aria-hidden="true">
|
||||
<div className="text-center">
|
||||
<Skeleton className="h-4 w-16 mx-auto" />
|
||||
<Skeleton className="mt-3 h-10 w-32 mx-auto" />
|
||||
</div>
|
||||
<Skeleton className="mt-6 h-12 w-full rounded-btn" />
|
||||
<Skeleton className="mt-3 h-4 w-3/4 mx-auto" />
|
||||
</div>
|
||||
</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.',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Skeleton, EventGridSkeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
// Route-level skeleton shown during navigation while the server fetches the
|
||||
// event list. Mirrors the EventsClient shell: title, filter tabs, card grid.
|
||||
export default function EventsLoading() {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<div className="mt-8 flex gap-2">
|
||||
<Skeleton className="h-10 w-32 rounded-btn" />
|
||||
<Skeleton className="h-10 w-28 rounded-btn" />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<EventGridSkeleton count={6} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
// Route-level skeleton for the FAQ page: centered header + accordion rows.
|
||||
export default function FaqLoading() {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-3xl">
|
||||
<div className="text-center mb-12">
|
||||
<Skeleton className="h-10 w-80 max-w-full mx-auto" />
|
||||
<Skeleton className="mt-4 h-4 w-96 max-w-full mx-auto" />
|
||||
</div>
|
||||
<FaqListSkeleton count={6} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Skeleton, SkeletonGroup, SkeletonText } from '@/components/ui/Skeleton';
|
||||
|
||||
// Route-level skeleton for legal pages: back link, bordered header, prose body.
|
||||
export default function LegalPageLoading() {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-4xl">
|
||||
<SkeletonGroup>
|
||||
<Skeleton className="h-5 w-32 mb-8" />
|
||||
<div className="mb-8 pb-6 border-b border-gray-200">
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
<Skeleton className="mt-3 h-4 w-44" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<SkeletonText lines={4} />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<SkeletonText lines={5} />
|
||||
<Skeleton className="h-6 w-2/5" />
|
||||
<SkeletonText lines={4} />
|
||||
</div>
|
||||
</SkeletonGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,10 +2,11 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
TicketIcon,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
PhoneIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -101,6 +103,20 @@ export default function AdminBookingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivate = async (ticket: TicketWithDetails) => {
|
||||
if (!ticket.payment?.id) return;
|
||||
setProcessing(ticket.id);
|
||||
try {
|
||||
await paymentsApi.reactivate(ticket.payment.id);
|
||||
toast.success('Booking reactivated');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to reactivate booking');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (ticketId: string) => {
|
||||
if (!confirm('Are you sure you want to cancel this booking?')) return;
|
||||
|
||||
@@ -133,6 +149,7 @@ export default function AdminBookingsPage() {
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||
case 'checked_in': return 'bg-blue-100 text-blue-800';
|
||||
case 'on_hold': return 'bg-slate-100 text-slate-600';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
@@ -144,6 +161,7 @@ export default function AdminBookingsPage() {
|
||||
case 'failed':
|
||||
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||
case 'refunded': return 'bg-purple-100 text-purple-800';
|
||||
case 'on_hold': return 'bg-slate-100 text-slate-600';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
@@ -191,6 +209,7 @@ export default function AdminBookingsPage() {
|
||||
confirmed: tickets.filter(t => t.status === 'confirmed').length,
|
||||
checkedIn: tickets.filter(t => t.status === 'checked_in').length,
|
||||
cancelled: tickets.filter(t => t.status === 'cancelled').length,
|
||||
onHold: tickets.filter(t => t.status === 'on_hold').length,
|
||||
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
|
||||
};
|
||||
|
||||
@@ -218,6 +237,9 @@ export default function AdminBookingsPage() {
|
||||
if (ticket.status === 'pending' && ticket.payment?.status === 'pending') {
|
||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
|
||||
}
|
||||
if (ticket.status === 'on_hold') {
|
||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
|
||||
}
|
||||
if (ticket.status === 'confirmed') {
|
||||
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' };
|
||||
}
|
||||
@@ -225,11 +247,7 @@ export default function AdminBookingsPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
return <AdminPageSkeleton cols={7} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -239,7 +257,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 +278,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 +327,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 +339,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 +392,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 +403,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 +417,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 +457,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 +557,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 +602,7 @@ export default function AdminBookingsPage() {
|
||||
{ value: 'confirmed', label: `Confirmed (${stats.confirmed})` },
|
||||
{ value: 'checked_in', label: `Checked In (${stats.checkedIn})` },
|
||||
{ value: 'cancelled', label: `Cancelled (${stats.cancelled})` },
|
||||
{ value: 'on_hold', label: `On Hold (${stats.onHold})` },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -581,6 +627,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}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
import { contactsApi, Contact } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Skeleton, SkeletonText, CardListSkeleton } from '@/components/ui/Skeleton';
|
||||
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
@@ -65,8 +66,21 @@ export default function AdminContactsPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<CardListSkeleton count={5} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-card shadow-card p-6">
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<SkeletonText lines={4} className="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
@@ -418,11 +419,7 @@ export default function AdminEmailsPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
return <AdminPageSkeleton cols={5} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, emailsApi, adminApi, Ticket } from '@/lib/api';
|
||||
import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket } from '@/lib/api';
|
||||
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Skeleton, TableSkeleton } from '@/components/ui/Skeleton';
|
||||
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
@@ -48,10 +49,10 @@ import { TicketsTab } from './_tabs/TicketsTab';
|
||||
import { EmailTab } from './_tabs/EmailTab';
|
||||
import { PaymentsTab } from './_tabs/PaymentsTab';
|
||||
import { EventModals } from './_modals/EventModals';
|
||||
import EventFormModal from '../_components/EventFormModal';
|
||||
|
||||
export default function AdminEventDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const eventId = params.id as string;
|
||||
const { locale } = useLanguage();
|
||||
|
||||
@@ -116,6 +117,17 @@ export default function AdminEventDetailPage() {
|
||||
// Payment options state + handlers
|
||||
const payments = usePaymentOverrides(eventId, locale);
|
||||
|
||||
// Edit event modal (opens in place instead of redirecting to the list page)
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
siteSettingsApi
|
||||
.get()
|
||||
.then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Mobile-specific state
|
||||
const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false);
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
@@ -163,6 +175,17 @@ export default function AdminEventDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivate = async (ticket: Ticket) => {
|
||||
if (!ticket.payment?.id) return;
|
||||
try {
|
||||
await paymentsApi.reactivate(ticket.payment.id);
|
||||
toast.success('Booking reactivated');
|
||||
loadEventData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to reactivate booking');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCheckin = async (ticketId: string) => {
|
||||
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
|
||||
try {
|
||||
@@ -398,8 +421,12 @@ export default function AdminEventDetailPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||||
</div>
|
||||
<TableSkeleton rows={5} cols={4} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -421,6 +448,7 @@ export default function AdminEventDetailPage() {
|
||||
const pendingCount = getTicketsByStatus('pending').length;
|
||||
const checkedInCount = getTicketsByStatus('checked_in').length;
|
||||
const cancelledCount = getTicketsByStatus('cancelled').length;
|
||||
const onHoldCount = getTicketsByStatus('on_hold').length;
|
||||
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
|
||||
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
|
||||
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
|
||||
@@ -435,7 +463,7 @@ export default function AdminEventDetailPage() {
|
||||
|
||||
// ========== Primary action for a ticket ==========
|
||||
const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => {
|
||||
if (ticket.status === 'pending') {
|
||||
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
|
||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||||
}
|
||||
if (ticket.status === 'confirmed') {
|
||||
@@ -472,12 +500,10 @@ export default function AdminEventDetailPage() {
|
||||
View Public
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/events?edit=${event.id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<PencilIcon className="w-4 h-4 mr-1.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowEditForm(true)}>
|
||||
<PencilIcon className="w-4 h-4 mr-1.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{/* Mobile header overflow menu */}
|
||||
<div className="md:hidden flex-shrink-0">
|
||||
@@ -493,7 +519,7 @@ export default function AdminEventDetailPage() {
|
||||
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
|
||||
<EyeIcon className="w-4 h-4 mr-2" /> View Public
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
|
||||
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
|
||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { toggleStats(); setMobileHeaderMenuOpen(false); }}>
|
||||
@@ -671,6 +697,7 @@ export default function AdminEventDetailPage() {
|
||||
confirmedCount={confirmedCount}
|
||||
checkedInCount={checkedInCount}
|
||||
cancelledCount={cancelledCount}
|
||||
onHoldCount={onHoldCount}
|
||||
exporting={exporting}
|
||||
showExportDropdown={showExportDropdown}
|
||||
setShowExportDropdown={setShowExportDropdown}
|
||||
@@ -685,6 +712,7 @@ export default function AdminEventDetailPage() {
|
||||
setShowAddTicketSheet={setShowAddTicketSheet}
|
||||
getPrimaryAction={getPrimaryAction}
|
||||
handleOpenNoteModal={handleOpenNoteModal}
|
||||
handleReactivate={handleReactivate}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -742,6 +770,7 @@ export default function AdminEventDetailPage() {
|
||||
confirmedCount={confirmedCount}
|
||||
checkedInCount={checkedInCount}
|
||||
cancelledCount={cancelledCount}
|
||||
onHoldCount={onHoldCount}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
mobileFilterOpen={mobileFilterOpen}
|
||||
@@ -781,6 +810,15 @@ export default function AdminEventDetailPage() {
|
||||
setPreviewHtml={setPreviewHtml}
|
||||
/>
|
||||
|
||||
<EventFormModal
|
||||
open={showEditForm}
|
||||
event={event}
|
||||
featuredEventId={featuredEventId}
|
||||
onFeaturedChange={setFeaturedEventId}
|
||||
onClose={() => setShowEditForm(false)}
|
||||
onSaved={() => { setShowEditForm(false); loadEventData(); }}
|
||||
/>
|
||||
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import MediaPicker from '@/components/MediaPicker';
|
||||
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
|
||||
interface EventFormData {
|
||||
title: string;
|
||||
titleEs: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
descriptionEs: string;
|
||||
shortDescription: string;
|
||||
shortDescriptionEs: string;
|
||||
startDatetime: string;
|
||||
endDatetime: string;
|
||||
location: string;
|
||||
locationUrl: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||
bannerUrl: string;
|
||||
externalBookingEnabled: boolean;
|
||||
externalBookingUrl: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: EventFormData = {
|
||||
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
||||
shortDescription: '', shortDescriptionEs: '',
|
||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft',
|
||||
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||
};
|
||||
|
||||
function isoToLocalDatetime(isoString: string): string {
|
||||
const date = parseDate(isoString);
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: EVENT_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const get = (type: string) => parts.find(p => p.type === type)!.value;
|
||||
const h = get('hour') === '24' ? '00' : get('hour');
|
||||
return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`;
|
||||
}
|
||||
|
||||
interface EventFormModalProps {
|
||||
/** When true the modal is rendered. */
|
||||
open: boolean;
|
||||
/** The event being edited, or null to create a new event. */
|
||||
event: Event | null;
|
||||
/** Currently featured event id (owned by the parent page). */
|
||||
featuredEventId: string | null;
|
||||
/** Notify the parent when the featured event changes. */
|
||||
onFeaturedChange: (id: string | null) => void;
|
||||
/** Close the modal without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful create/update so the parent can refresh. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared create/edit event modal used by both the events list page and the
|
||||
* single-event detail page. Owns its own form state so it can be dropped in
|
||||
* anywhere; the parent only supplies the event to edit and refresh callbacks.
|
||||
*/
|
||||
export default function EventFormModal({
|
||||
open,
|
||||
event,
|
||||
featuredEventId,
|
||||
onFeaturedChange,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EventFormModalProps) {
|
||||
const { t } = useLanguage();
|
||||
const [formData, setFormData] = useState<EventFormData>(EMPTY_FORM);
|
||||
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [settingFeatured, setSettingFeatured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (event) {
|
||||
setFormData({
|
||||
title: event.title, titleEs: event.titleEs || '', slug: event.slug || '',
|
||||
description: event.description, descriptionEs: event.descriptionEs || '',
|
||||
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
|
||||
startDatetime: isoToLocalDatetime(event.startDatetime),
|
||||
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||
location: event.location, locationUrl: event.locationUrl || '',
|
||||
price: event.price, currency: event.currency, capacity: event.capacity,
|
||||
status: event.status, bannerUrl: event.bannerUrl || '',
|
||||
externalBookingEnabled: event.externalBookingEnabled || false,
|
||||
externalBookingUrl: event.externalBookingUrl || '',
|
||||
});
|
||||
loadSlugAliases(event.id);
|
||||
} else {
|
||||
setFormData(EMPTY_FORM);
|
||||
setSlugAliases([]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, event]);
|
||||
|
||||
const loadSlugAliases = async (eventId: string) => {
|
||||
try {
|
||||
const { aliases } = await eventsApi.getSlugAliases(eventId);
|
||||
setSlugAliases(aliases);
|
||||
} catch (error) {
|
||||
setSlugAliases([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAlias = async (slug: string) => {
|
||||
if (!event) return;
|
||||
if (!confirm(`Remove alias "${slug}"? The old URL /events/${slug} will stop working.`)) return;
|
||||
try {
|
||||
await eventsApi.deleteSlugAlias(event.id, slug);
|
||||
toast.success('Alias removed');
|
||||
setSlugAliases((prev) => prev.filter((a) => a.slug !== slug));
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to remove alias');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetFeatured = async (eventId: string | null) => {
|
||||
setSettingFeatured(true);
|
||||
try {
|
||||
await siteSettingsApi.setFeaturedEvent(eventId);
|
||||
onFeaturedChange(eventId);
|
||||
toast.success(eventId ? 'Event set as featured' : 'Featured event removed');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update featured event');
|
||||
} finally {
|
||||
setSettingFeatured(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
|
||||
toast.error('External booking URL is required when external booking is enabled');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
if (formData.externalBookingEnabled && !formData.externalBookingUrl.startsWith('https://')) {
|
||||
toast.error('External booking URL must be a valid HTTPS link');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const eventData: Partial<Event> = {
|
||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||
startDatetime: formData.startDatetime,
|
||||
endDatetime: formData.endDatetime || undefined,
|
||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||
externalBookingEnabled: formData.externalBookingEnabled,
|
||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||
};
|
||||
if (event) {
|
||||
// Only send slug when editing so creates still auto-generate from title
|
||||
eventData.slug = formData.slug || undefined;
|
||||
await eventsApi.update(event.id, eventData);
|
||||
toast.success('Event updated');
|
||||
} else {
|
||||
await eventsApi.create(eventData);
|
||||
toast.success('Event created');
|
||||
}
|
||||
onSaved();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save event');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-lg md:text-xl font-bold">
|
||||
{event ? t('admin.events.edit') : t('admin.events.create')}
|
||||
</h2>
|
||||
<button onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 md:p-6 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input label="Title (English)" value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })} required />
|
||||
<Input label="Title (Spanish)" value={formData.titleEs}
|
||||
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{event && (
|
||||
<div>
|
||||
<Input label="URL Slug" value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder="auto-generated from title" />
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Public URL: <span className="font-mono">/events/{formData.slug || '...'}</span>
|
||||
. Changing the slug keeps the old one as a redirecting alias.
|
||||
</p>
|
||||
{slugAliases.length > 0 && (
|
||||
<div className="mt-3 rounded-btn border border-secondary-light-gray p-3">
|
||||
<p className="text-sm font-medium mb-2">URL aliases</p>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Old URLs that still redirect to the current slug. Removing one breaks those links.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{slugAliases.map((alias) => (
|
||||
<li key={alias.slug} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-mono truncate">/events/{alias.slug}</span>
|
||||
<button type="button" onClick={() => handleRemoveAlias(alias.slug)}
|
||||
className="p-1.5 hover:bg-red-50 text-red-600 rounded-btn flex-shrink-0"
|
||||
title="Remove alias">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (English)</label>
|
||||
<textarea value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={3} required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
|
||||
<textarea value={formData.descriptionEs}
|
||||
onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={3} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Short Description (English)</label>
|
||||
<textarea value={formData.shortDescription}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} maxLength={300} placeholder="Brief summary for SEO and cards (max 300 chars)" />
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
|
||||
<textarea value={formData.shortDescriptionEs}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} maxLength={300} placeholder="Resumen breve (máx 300 caracteres)" />
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input label="Start Date & Time" type="datetime-local" value={formData.startDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })} required />
|
||||
<Input label="End Date & Time" type="datetime-local" value={formData.endDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<Input label="Location" value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
|
||||
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
|
||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input label="Price" type="number" min="0" value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="PYG">PYG</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input label="Capacity" type="number" min="1" value={formData.capacity}
|
||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">External Booking</label>
|
||||
<p className="text-xs text-gray-500">Redirect users to an external platform</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||
formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
|
||||
}`}>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{formData.externalBookingEnabled && (
|
||||
<div>
|
||||
<Input label="External Booking URL" type="url" value={formData.externalBookingUrl}
|
||||
onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })}
|
||||
placeholder="https://example.com/book" required />
|
||||
<p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MediaPicker value={formData.bannerUrl}
|
||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||
relatedId={event?.id} relatedType="event" />
|
||||
|
||||
{event && event.status === 'published' && (
|
||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<StarIcon className="w-5 h-5 text-amber-500" /> Featured Event
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Prominently displayed on homepage</p>
|
||||
</div>
|
||||
<button type="button" disabled={settingFeatured}
|
||||
onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
|
||||
featuredEventId === event.id ? 'bg-amber-500' : 'bg-gray-200'
|
||||
}`}>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
featuredEventId === event.id ? 'translate-x-5' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{featuredEventId && featuredEventId !== event.id && (
|
||||
<p className="text-xs text-amber-700 bg-amber-100 p-2 rounded">
|
||||
Note: Another event is currently featured. Setting this event as featured will replace it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">
|
||||
{event ? 'Update Event' : 'Create Event'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,14 +7,14 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import MediaPicker from '@/components/MediaPicker';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, XMarkIcon, LinkIcon } from '@heroicons/react/24/outline';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
|
||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import EventFormModal from './_components/EventFormModal';
|
||||
|
||||
export default function AdminEventsPage() {
|
||||
const router = useRouter();
|
||||
@@ -24,50 +24,8 @@ export default function AdminEventsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||||
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
||||
|
||||
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
titleEs: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
descriptionEs: string;
|
||||
shortDescription: string;
|
||||
shortDescriptionEs: string;
|
||||
startDatetime: string;
|
||||
endDatetime: string;
|
||||
location: string;
|
||||
locationUrl: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||
bannerUrl: string;
|
||||
externalBookingEnabled: boolean;
|
||||
externalBookingUrl: string;
|
||||
}>({
|
||||
title: '',
|
||||
titleEs: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
descriptionEs: '',
|
||||
shortDescription: '',
|
||||
shortDescriptionEs: '',
|
||||
startDatetime: '',
|
||||
endDatetime: '',
|
||||
location: '',
|
||||
locationUrl: '',
|
||||
price: 0,
|
||||
currency: 'PYG',
|
||||
capacity: 50,
|
||||
status: 'draft',
|
||||
bannerUrl: '',
|
||||
externalBookingEnabled: false,
|
||||
externalBookingUrl: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
@@ -115,116 +73,19 @@ export default function AdminEventsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSlugAliases([]);
|
||||
setFormData({
|
||||
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
||||
shortDescription: '', shortDescriptionEs: '',
|
||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
|
||||
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||
});
|
||||
const handleEdit = (event: Event) => {
|
||||
setEditingEvent(event);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingEvent(null);
|
||||
};
|
||||
|
||||
const isoToLocalDatetime = (isoString: string): string => {
|
||||
const date = parseDate(isoString);
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: EVENT_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const get = (type: string) => parts.find(p => p.type === type)!.value;
|
||||
const h = get('hour') === '24' ? '00' : get('hour');
|
||||
return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`;
|
||||
};
|
||||
|
||||
const loadSlugAliases = async (eventId: string) => {
|
||||
try {
|
||||
const { aliases } = await eventsApi.getSlugAliases(eventId);
|
||||
setSlugAliases(aliases);
|
||||
} catch (error) {
|
||||
setSlugAliases([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAlias = async (slug: string) => {
|
||||
if (!editingEvent) return;
|
||||
if (!confirm(`Remove alias "${slug}"? The old URL /events/${slug} will stop working.`)) return;
|
||||
try {
|
||||
await eventsApi.deleteSlugAlias(editingEvent.id, slug);
|
||||
toast.success('Alias removed');
|
||||
setSlugAliases((prev) => prev.filter((a) => a.slug !== slug));
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to remove alias');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (event: Event) => {
|
||||
setFormData({
|
||||
title: event.title, titleEs: event.titleEs || '', slug: event.slug || '',
|
||||
description: event.description, descriptionEs: event.descriptionEs || '',
|
||||
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
|
||||
startDatetime: isoToLocalDatetime(event.startDatetime),
|
||||
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||
location: event.location, locationUrl: event.locationUrl || '',
|
||||
price: event.price, currency: event.currency, capacity: event.capacity,
|
||||
status: event.status, bannerUrl: event.bannerUrl || '',
|
||||
externalBookingEnabled: event.externalBookingEnabled || false,
|
||||
externalBookingUrl: event.externalBookingUrl || '',
|
||||
});
|
||||
setEditingEvent(event);
|
||||
setShowForm(true);
|
||||
loadSlugAliases(event.id);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
|
||||
toast.error('External booking URL is required when external booking is enabled');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
if (formData.externalBookingEnabled && !formData.externalBookingUrl.startsWith('https://')) {
|
||||
toast.error('External booking URL must be a valid HTTPS link');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const eventData: Partial<Event> = {
|
||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||
startDatetime: formData.startDatetime,
|
||||
endDatetime: formData.endDatetime || undefined,
|
||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||
externalBookingEnabled: formData.externalBookingEnabled,
|
||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||
};
|
||||
if (editingEvent) {
|
||||
// Only send slug when editing so creates still auto-generate from title
|
||||
eventData.slug = formData.slug || undefined;
|
||||
await eventsApi.update(editingEvent.id, eventData);
|
||||
toast.success('Event updated');
|
||||
} else {
|
||||
await eventsApi.create(eventData);
|
||||
toast.success('Event created');
|
||||
}
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
loadEvents();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save event');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
const handleFormSaved = () => {
|
||||
handleCloseForm();
|
||||
loadEvents();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
@@ -288,221 +149,27 @@ export default function AdminEventsPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
return <AdminPageSkeleton cols={5} />;
|
||||
}
|
||||
|
||||
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.events.title')}</h1>
|
||||
<Button onClick={() => { resetForm(); setShowForm(true); }} className="hidden md:flex">
|
||||
<Button onClick={() => { setEditingEvent(null); setShowForm(true); }} className="hidden md:flex">
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
{t('admin.events.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Event Form Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-lg md:text-xl font-bold">
|
||||
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
||||
</h2>
|
||||
<button onClick={() => { setShowForm(false); resetForm(); }}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 md:p-6 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input label="Title (English)" value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })} required />
|
||||
<Input label="Title (Spanish)" value={formData.titleEs}
|
||||
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{editingEvent && (
|
||||
<div>
|
||||
<Input label="URL Slug" value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder="auto-generated from title" />
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Public URL: <span className="font-mono">/events/{formData.slug || '...'}</span>
|
||||
. Changing the slug keeps the old one as a redirecting alias.
|
||||
</p>
|
||||
{slugAliases.length > 0 && (
|
||||
<div className="mt-3 rounded-btn border border-secondary-light-gray p-3">
|
||||
<p className="text-sm font-medium mb-2">URL aliases</p>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Old URLs that still redirect to the current slug. Removing one breaks those links.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{slugAliases.map((alias) => (
|
||||
<li key={alias.slug} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-mono truncate">/events/{alias.slug}</span>
|
||||
<button type="button" onClick={() => handleRemoveAlias(alias.slug)}
|
||||
className="p-1.5 hover:bg-red-50 text-red-600 rounded-btn flex-shrink-0"
|
||||
title="Remove alias">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (English)</label>
|
||||
<textarea value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={3} required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
|
||||
<textarea value={formData.descriptionEs}
|
||||
onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={3} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Short Description (English)</label>
|
||||
<textarea value={formData.shortDescription}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} maxLength={300} placeholder="Brief summary for SEO and cards (max 300 chars)" />
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
|
||||
<textarea value={formData.shortDescriptionEs}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} maxLength={300} placeholder="Resumen breve (máx 300 caracteres)" />
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input label="Start Date & Time" type="datetime-local" value={formData.startDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })} required />
|
||||
<Input label="End Date & Time" type="datetime-local" value={formData.endDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<Input label="Location" value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
|
||||
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
|
||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input label="Price" type="number" min="0" value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="PYG">PYG</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input label="Capacity" type="number" min="1" value={formData.capacity}
|
||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">External Booking</label>
|
||||
<p className="text-xs text-gray-500">Redirect users to an external platform</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||
formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
|
||||
}`}>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{formData.externalBookingEnabled && (
|
||||
<div>
|
||||
<Input label="External Booking URL" type="url" value={formData.externalBookingUrl}
|
||||
onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })}
|
||||
placeholder="https://example.com/book" required />
|
||||
<p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MediaPicker value={formData.bannerUrl}
|
||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||
relatedId={editingEvent?.id} relatedType="event" />
|
||||
|
||||
{editingEvent && editingEvent.status === 'published' && (
|
||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<StarIcon className="w-5 h-5 text-amber-500" /> Featured Event
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Prominently displayed on homepage</p>
|
||||
</div>
|
||||
<button type="button" disabled={settingFeatured !== null}
|
||||
onClick={() => handleSetFeatured(featuredEventId === editingEvent.id ? null : editingEvent.id)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
|
||||
featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200'
|
||||
}`}>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{featuredEventId && featuredEventId !== editingEvent.id && (
|
||||
<p className="text-xs text-amber-700 bg-amber-100 p-2 rounded">
|
||||
Note: Another event is currently featured. Setting this event as featured will replace it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">
|
||||
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => { setShowForm(false); resetForm(); }} className="flex-1 min-h-[44px]">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<EventFormModal
|
||||
open={showForm}
|
||||
event={editingEvent}
|
||||
featuredEventId={featuredEventId}
|
||||
onFeaturedChange={setFeaturedEventId}
|
||||
onClose={handleCloseForm}
|
||||
onSaved={handleFormSaved}
|
||||
/>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
@@ -724,7 +391,7 @@ export default function AdminEventsPage() {
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||
<button onClick={() => { resetForm(); setShowForm(true); }}
|
||||
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
|
||||
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
|
||||
<PlusIcon className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
import { faqApi, FaqItemAdmin } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -185,11 +186,7 @@ export default function AdminFaqPage() {
|
||||
const handleDragEnd = () => { setDraggedId(null); setDragOverId(null); };
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
return <AdminPageSkeleton cols={5} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { mediaApi, Media } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||
import {
|
||||
PhotoIcon,
|
||||
TrashIcon,
|
||||
@@ -126,8 +127,12 @@ export default function AdminGalleryPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||||
</div>
|
||||
<ImageGridSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { legalPagesApi, LegalPage } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
import Input from '@/components/ui/Input';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -173,11 +174,7 @@ export default function AdminLegalPagesPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
return <AdminPageSkeleton cols={5} />;
|
||||
}
|
||||
|
||||
// Editor view
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPa
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
@@ -147,6 +148,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 +193,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 +205,7 @@ export default function AdminPaymentsPage() {
|
||||
p.createdAt,
|
||||
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
||||
p.attendeeEmail || '',
|
||||
p.attendeeRuc || '',
|
||||
p.eventTitle,
|
||||
p.eventDate,
|
||||
]);
|
||||
@@ -225,6 +241,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 +250,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,13 +361,11 @@ 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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
return <AdminPageSkeleton cols={7} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -414,6 +430,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 +494,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 +514,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 +657,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 +672,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 +860,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 +942,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 +952,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 +966,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 +980,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 +1042,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 +1097,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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { CheckCircleIcon, XCircleIcon, PlusIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
@@ -135,11 +136,7 @@ export default function AdminTicketsPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
return <AdminPageSkeleton cols={5} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,22 +2,39 @@
|
||||
|
||||
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 { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
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 +47,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 {
|
||||
@@ -119,26 +167,35 @@ export default function AdminUsersPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
return <AdminPageSkeleton cols={6} />;
|
||||
}
|
||||
|
||||
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 +204,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 +290,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 +298,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 +314,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 +363,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 +386,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>
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 flex items-center justify-center bg-secondary-gray px-4 py-16">
|
||||
<div className="text-center max-w-md mx-auto">
|
||||
<div className="text-6xl mb-4">😅</div>
|
||||
<h1 className="text-3xl font-semibold text-primary-dark mb-2">
|
||||
¡Ups! Algo salió mal
|
||||
</h1>
|
||||
<p className="text-lg text-gray-700 mb-6">
|
||||
Oops, something went wrong
|
||||
</p>
|
||||
<p className="text-gray-600 mb-8">
|
||||
No fue tu culpa, fue un error de nuestro lado. Prueba de nuevo o vuelve al inicio mientras lo revisamos.
|
||||
<br />
|
||||
It was not your fault, something broke on our end. Try again or head back home while we take a look.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button onClick={reset} size="lg">
|
||||
Intentar de nuevo · Try again
|
||||
</Button>
|
||||
<Link href="/">
|
||||
<Button variant="outline" size="lg" className="w-full sm:w-auto">
|
||||
Ir al inicio · Go home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Poppins } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
const poppins = Poppins({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-poppins',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en" className={poppins.variable}>
|
||||
<body className={poppins.className}>
|
||||
<div className="min-h-screen flex flex-col bg-secondary-gray">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center">
|
||||
<span className="text-xl font-semibold" style={{ color: '#002F44' }}>
|
||||
Spanglish
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex items-center justify-center px-4 py-16">
|
||||
<div className="text-center max-w-md mx-auto bg-white rounded-card shadow-card p-8">
|
||||
<div className="text-6xl mb-4">😵</div>
|
||||
<h1 className="text-2xl font-semibold text-primary-dark mb-2">
|
||||
¡Ups! Algo salió mal
|
||||
</h1>
|
||||
<p className="text-lg text-gray-700 mb-4">
|
||||
Oops, something went wrong
|
||||
</p>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Toda la página tuvo un problema al cargar. Prueba de nuevo o vuelve al inicio.
|
||||
<br />
|
||||
The whole page had trouble loading. Try again or head back home.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="inline-flex items-center justify-center px-7 py-3 text-lg font-medium rounded-btn bg-primary-yellow text-primary-dark hover:bg-yellow-400 transition-colors"
|
||||
>
|
||||
Intentar de nuevo · Try again
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-7 py-3 text-lg font-medium rounded-btn border-2 border-primary-dark text-primary-dark hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Ir al inicio · Go home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-secondary-light-gray py-6">
|
||||
<p className="text-center text-sm" style={{ color: '#002F44' }}>
|
||||
© {new Date().getFullYear()} Spanglish
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -73,8 +73,22 @@ export default function LinktreePage() {
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 text-center">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 animate-pulse"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
>
|
||||
<span className="sr-only">Loading…</span>
|
||||
<div className="h-6 w-2/3 rounded-lg bg-white/15" />
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="h-4 w-3/4 rounded-lg bg-white/15" />
|
||||
<div className="h-4 w-1/2 rounded-lg bg-white/15" />
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="h-5 w-20 rounded-lg bg-white/15" />
|
||||
<div className="h-4 w-24 rounded-lg bg-white/15" />
|
||||
</div>
|
||||
<div className="mt-4 h-12 rounded-xl bg-white/15" />
|
||||
</div>
|
||||
) : nextEvent ? (
|
||||
<Link href={`/events/${nextEvent.slug}`} className="block group">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
// Skeleton loading previews. The base block uses Tailwind's animate-pulse and
|
||||
// the light-gray surface tokens so placeholders match real card/table styling.
|
||||
// Composites below mirror the layouts they stand in for (event cards, admin
|
||||
// tables, FAQ accordions, article bodies) to avoid layout shift when data lands.
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Skeleton({ className }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={clsx('animate-pulse rounded-lg bg-secondary-light-gray/70', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Screen-reader announcement wrapper for a group of skeleton blocks.
|
||||
export function SkeletonGroup({ className, children }: SkeletonProps & { children: React.ReactNode }) {
|
||||
return (
|
||||
<div role="status" aria-busy="true" className={className}>
|
||||
<span className="sr-only">Loading…</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Paragraph-style stack of text lines with a shorter last line.
|
||||
export function SkeletonText({ lines = 3, className }: SkeletonProps & { lines?: number }) {
|
||||
return (
|
||||
<div className={clsx('space-y-2.5', className)} aria-hidden="true">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton key={i} className={clsx('h-4', i === lines - 1 ? 'w-2/3' : 'w-full')} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors the event card in EventsClient: banner, title, meta rows, price + button.
|
||||
export function EventCardSkeleton() {
|
||||
return (
|
||||
<div className="bg-white rounded-card overflow-hidden shadow-card h-full" aria-hidden="true">
|
||||
<Skeleton className="h-40 w-full rounded-none" />
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<div className="mt-4 space-y-2">
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/5" />
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Skeleton className="h-7 w-24" />
|
||||
<Skeleton className="h-9 w-24 rounded-btn" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventGridSkeleton({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<SkeletonGroup className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<EventCardSkeleton key={i} />
|
||||
))}
|
||||
</SkeletonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors the featured "next event" hero card on the homepage: side banner + info column.
|
||||
export function FeaturedEventSkeleton() {
|
||||
return (
|
||||
<SkeletonGroup>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<Skeleton className="w-full md:w-2/5 flex-shrink-0 h-48 md:h-auto md:min-h-[280px] rounded-none" />
|
||||
<div className="flex-1 p-5 md:p-8">
|
||||
<Skeleton className="h-7 w-2/3" />
|
||||
<Skeleton className="mt-3 h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-5/6" />
|
||||
<div className="mt-4 md:mt-5 space-y-2.5">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</div>
|
||||
<div className="mt-5 md:mt-6 flex items-center justify-between gap-4">
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-10 w-28 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// Accordion-style rows for FAQ lists. `compact` matches the homepage variant
|
||||
// (rounded-btn, px-5); the default matches the /faq page cards (px-6, more spacing).
|
||||
export function FaqListSkeleton({ count = 5, compact = false }: { count?: number; compact?: boolean }) {
|
||||
return (
|
||||
<SkeletonGroup className={compact ? 'space-y-3' : 'space-y-4'}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
'bg-white border border-gray-200 flex items-center justify-between',
|
||||
compact ? 'rounded-btn px-5 py-4' : 'rounded-card px-6 py-4 shadow-card border-0'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Skeleton className={clsx('h-5', i % 2 === 0 ? 'w-3/5' : 'w-2/5')} />
|
||||
<Skeleton className="h-5 w-5 rounded-full flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</SkeletonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// Long-form content: heading, meta line, then paragraph blocks.
|
||||
export function ArticleSkeleton({ withBanner = false }: { withBanner?: boolean }) {
|
||||
return (
|
||||
<SkeletonGroup>
|
||||
{withBanner && <Skeleton className="h-56 md:h-80 w-full rounded-card mb-8" />}
|
||||
<Skeleton className="h-9 w-3/4 md:w-1/2" />
|
||||
<Skeleton className="mt-3 h-4 w-40" />
|
||||
<div className="mt-8 space-y-6">
|
||||
<SkeletonText lines={4} />
|
||||
<SkeletonText lines={3} />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<SkeletonText lines={4} />
|
||||
</div>
|
||||
</SkeletonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// Stacked list of card rows (dashboard tickets/payments, contact lists).
|
||||
export function CardListSkeleton({ count = 3 }: { count?: number }) {
|
||||
return (
|
||||
<SkeletonGroup className="space-y-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-card shadow-card p-5" aria-hidden="true">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-5 w-1/3" />
|
||||
<Skeleton className="mt-2.5 h-4 w-1/2" />
|
||||
<Skeleton className="mt-2 h-4 w-2/5" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</SkeletonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin data tables: desktop table inside a Card, mobile stacked cards —
|
||||
// mirrors the two-breakpoint layout every admin list page renders.
|
||||
export function TableSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<SkeletonGroup>
|
||||
{/* Desktop: table */}
|
||||
<div className="bg-white rounded-card overflow-hidden shadow-card hidden md:block" aria-hidden="true">
|
||||
<div className="bg-secondary-gray px-4 py-3 flex gap-4">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-3.5 flex-1 bg-secondary-light-gray" />
|
||||
))}
|
||||
</div>
|
||||
<div className="divide-y divide-secondary-light-gray">
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div key={r} className="px-4 py-3.5 flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Skeleton className="w-10 h-10 rounded-lg flex-shrink-0" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
{Array.from({ length: cols - 1 }).map((_, c) => (
|
||||
<Skeleton key={c} className={clsx('h-4 flex-1', c === cols - 2 && 'max-w-[80px]')} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: card list */}
|
||||
<div className="md:hidden space-y-3" aria-hidden="true">
|
||||
{Array.from({ length: Math.min(rows, 4) }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-card shadow-card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="w-12 h-12 rounded-lg flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="mt-2 h-3.5 w-1/2" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SkeletonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// Full admin page placeholder: title bar + table. Used by the `if (loading)`
|
||||
// gates that replace the entire page, so it includes the header row.
|
||||
export function AdminPageSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||||
</div>
|
||||
<TableSkeleton rows={rows} cols={cols} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Thumbnail grid (admin gallery/media pages).
|
||||
export function ImageGridSkeleton({ count = 12 }: { count?: number }) {
|
||||
return (
|
||||
<SkeletonGroup className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square w-full rounded-card" />
|
||||
))}
|
||||
</SkeletonGroup>
|
||||
);
|
||||
}
|
||||
+8
-10
@@ -1,22 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ticketsApi } from '@/lib/api';
|
||||
import type { BookingStep } from '../_types';
|
||||
|
||||
/**
|
||||
* Watch for Lightning payment confirmation while on the paying step.
|
||||
* Watch for Lightning payment confirmation while an invoice is on screen.
|
||||
* SSE gives instant updates; a 3s poll runs in parallel as a safety net so a
|
||||
* buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI.
|
||||
*/
|
||||
export function useLightningWatcher(
|
||||
step: BookingStep,
|
||||
active: boolean,
|
||||
ticketId: string | undefined,
|
||||
locale: string,
|
||||
setPaymentPending: (value: boolean) => void,
|
||||
setStep: (value: BookingStep) => void
|
||||
onPaid: () => void,
|
||||
onExpired: () => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (step !== 'paying' || !ticketId) return;
|
||||
if (!active || !ticketId) return;
|
||||
|
||||
let settled = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -25,15 +24,14 @@ export function useLightningWatcher(
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
|
||||
setPaymentPending(false);
|
||||
setStep('success');
|
||||
onPaid();
|
||||
};
|
||||
|
||||
const expire = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired');
|
||||
setPaymentPending(false);
|
||||
onExpired();
|
||||
};
|
||||
|
||||
// Always same-origin so the streaming proxy route handler is used (it
|
||||
@@ -79,5 +77,5 @@ export function useLightningWatcher(
|
||||
eventSource.close();
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
};
|
||||
}, [step, ticketId, locale]);
|
||||
}, [active, ticketId, locale]);
|
||||
}
|
||||
@@ -134,6 +134,7 @@
|
||||
"bookingFailed": "Booking failed. Please try again.",
|
||||
"rucInvalidFormat": "Invalid format. Example: 12345678-9",
|
||||
"rucInvalidCheckDigit": "Invalid RUC. Please verify the number.",
|
||||
"paymentMethodRequired": "Please select a payment method.",
|
||||
"termsRequired": "You must agree to the Terms of Service and Privacy Policy to continue."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
|
||||
"rucInvalidFormat": "Formato inválido. Ej: 12345678-9",
|
||||
"rucInvalidCheckDigit": "RUC inválido. Verifique el número.",
|
||||
"paymentMethodRequired": "Por favor selecciona un método de pago.",
|
||||
"termsRequired": "Debes aceptar los Términos de Servicio y la Política de Privacidad para continuar."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
TicketValidationResult,
|
||||
TicketSearchResult,
|
||||
LiveSearchResult,
|
||||
LightningInvoice,
|
||||
} from './types';
|
||||
|
||||
export const ticketsApi = {
|
||||
@@ -137,6 +138,14 @@ export const ticketsApi = {
|
||||
`/api/lnbits/status/${ticketId}`
|
||||
),
|
||||
|
||||
// Get a Lightning invoice to pay/re-pay a ticket - reuses the stored invoice
|
||||
// if it's still valid, otherwise generates a fresh one.
|
||||
getLightningInvoice: (ticketId: string) =>
|
||||
fetchApi<{ invoice?: LightningInvoice; reused?: boolean; alreadyPaid?: boolean }>(
|
||||
`/api/lnbits/invoice/${ticketId}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// Get PDF download URL (returns the URL, not the PDF itself)
|
||||
getPdfUrl: (id: string) => `${API_BASE}/api/tickets/${id}/pdf`,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,15 @@ export interface Event {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LightningInvoice {
|
||||
paymentHash: string;
|
||||
paymentRequest: string; // BOLT11 invoice
|
||||
amount: number; // Amount in satoshis
|
||||
fiatAmount?: number; // Original fiat amount
|
||||
fiatCurrency?: string; // Original fiat currency
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
bookingId?: string; // Groups multiple tickets from same booking
|
||||
@@ -37,7 +46,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 +120,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 +140,7 @@ export interface PaymentWithDetails extends Payment {
|
||||
attendeeLastName?: string;
|
||||
attendeeEmail?: string;
|
||||
attendeePhone?: string;
|
||||
attendeeRuc?: string;
|
||||
status: string;
|
||||
} | null;
|
||||
event: {
|
||||
@@ -325,6 +335,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}`),
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^16.2.10"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user