Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a600b6d6 | ||
|
|
4772b85f3d | ||
|
|
0d47156071 | ||
|
|
75e317d73e | ||
|
|
d8c207c995 | ||
|
|
cb422332c8 | ||
|
|
8d5fbf18b4 | ||
|
|
78ffd0ae52 | ||
|
|
09bfb17f03 | ||
|
|
cacc52ec24 |
@@ -7,6 +7,7 @@ package-lock.json
|
||||
# Build outputs
|
||||
dist/
|
||||
.next/
|
||||
.next-build/
|
||||
out/
|
||||
build/
|
||||
|
||||
@@ -56,6 +57,10 @@ yarn-error.log*
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Go photo-api service
|
||||
photo-api/bin/
|
||||
photo-api/data/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,11 +5,20 @@
|
||||
// abandoned checkout would otherwise hold those seats forever. This job cancels
|
||||
// pending tickets whose payment is still 'pending' (i.e. never paid and not
|
||||
// awaiting admin approval) after a configurable TTL, freeing the seats.
|
||||
//
|
||||
// Two exclusions:
|
||||
// - Manual-verification providers (bank transfer / TPago / cash) are never
|
||||
// auto-failed; they are settled by an admin and instead follow the 72h on-hold
|
||||
// sweep. See holdSweep.ts and MANUAL_PAYMENT_PROVIDERS.
|
||||
// - Staleness is measured from `updatedAt`, not `createdAt`, so an admin action
|
||||
// (e.g. reopening a payment to 'pending' via /reopen) restarts the TTL rather
|
||||
// than being immediately re-failed on the next run.
|
||||
|
||||
import { and, eq, lt, inArray } from 'drizzle-orm';
|
||||
import { and, eq, lt, inArray, notInArray } from 'drizzle-orm';
|
||||
import { db, dbAll, tickets, payments } from '../db/index.js';
|
||||
import { getNow, toDbDate } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
|
||||
|
||||
function getTtlMs(): number {
|
||||
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
|
||||
@@ -20,8 +29,9 @@ function getTtlMs(): number {
|
||||
* Cancel stale pending bookings. Returns the number of tickets cancelled.
|
||||
*
|
||||
* A booking is considered stale when its payment is still 'pending' (not
|
||||
* 'pending_approval', which means an admin is reviewing a manual transfer) and
|
||||
* older than PENDING_BOOKING_TTL_MINUTES.
|
||||
* 'pending_approval', which means an admin is reviewing a manual transfer),
|
||||
* uses a non-manual provider, and has not been touched (updatedAt) for
|
||||
* PENDING_BOOKING_TTL_MINUTES.
|
||||
*/
|
||||
export async function cleanupStalePendingBookings(): Promise<number> {
|
||||
const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
|
||||
@@ -35,7 +45,8 @@ export async function cleanupStalePendingBookings(): Promise<number> {
|
||||
.from(payments)
|
||||
.where(and(
|
||||
eq((payments as any).status, 'pending'),
|
||||
lt((payments as any).createdAt, cutoff)
|
||||
notInArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
||||
lt((payments as any).updatedAt, cutoff)
|
||||
))
|
||||
);
|
||||
|
||||
|
||||
@@ -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,148 @@
|
||||
// Shared capacity-checked recovery for released bookings.
|
||||
//
|
||||
// When a booking is put on hold (or failed/cancelled), its ticket(s) drop out of the
|
||||
// capacity-counting statuses ('pending', 'confirmed', 'checked_in'), releasing the seat.
|
||||
// Recovering such a booking (user "I've paid" again, or an admin reactivating / marking
|
||||
// it paid / reopening a failed payment) 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.
|
||||
//
|
||||
// Callers choose which ticket statuses are eligible to be re-reserved via
|
||||
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list — and
|
||||
// tickets that already hold a seat — are left untouched and don't consume capacity.
|
||||
|
||||
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>;
|
||||
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
|
||||
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reserve seats for a group of released tickets (e.g. all tickets sharing a
|
||||
* bookingId), atomically re-checking capacity before flipping their status.
|
||||
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
|
||||
* Capacity is asserted against the number of those tickets that don't already
|
||||
* hold a seat, so re-reserving tickets that are already seated is a no-op.
|
||||
* Throws HoldCapacityError if the event no longer has room.
|
||||
*/
|
||||
export async function reserveOnHoldBooking(
|
||||
eventId: string,
|
||||
ticketIds: string[],
|
||||
targetTicketStatus: 'pending' | 'confirmed',
|
||||
targetPaymentStatus: 'pending_approval' | 'paid' | 'pending',
|
||||
options: ReserveOptions = {}
|
||||
): Promise<void> {
|
||||
if (ticketIds.length === 0) return;
|
||||
|
||||
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// `needed` is how many of these tickets don't currently hold a seat and so must
|
||||
// be found new capacity; tickets already in a seat-holding status cost nothing.
|
||||
const assertCapacity = (reserved: number, needed: number) => {
|
||||
if (needed <= 0) return;
|
||||
if (isEventSoldOut(event.capacity, reserved)) {
|
||||
throw new HoldCapacityError(0);
|
||||
}
|
||||
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
||||
if (needed > 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();
|
||||
const neededRow = tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')`
|
||||
))
|
||||
.get();
|
||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||
|
||||
tx.update(tickets)
|
||||
.set({ status: targetTicketStatus })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
inArray((tickets as any).status, fromTicketStatuses)
|
||||
))
|
||||
.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')`
|
||||
))
|
||||
);
|
||||
const neededRow = await dbGet<any>(
|
||||
tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')`
|
||||
))
|
||||
);
|
||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||
|
||||
await tx.update(tickets)
|
||||
.set({ status: targetTicketStatus })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
inArray((tickets as any).status, fromTicketStatuses)
|
||||
));
|
||||
|
||||
await tx.update(payments)
|
||||
.set(paymentUpdate)
|
||||
.where(inArray((payments as any).ticketId, ticketIds));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Auto-hold stale manual-payment bookings.
|
||||
//
|
||||
// This job releases the seat held by an abandoned manual-payment booking (bank
|
||||
// transfer / TPago / cash) after HOLD_THRESHOLD_HOURS. It covers two states, both of
|
||||
// which keep a seat reserved while awaiting a human:
|
||||
// - 'pending_approval': the user clicked "I've paid" and is waiting for an admin.
|
||||
// - 'pending' on a manual provider (see MANUAL_PAYMENT_PROVIDERS): the booking was
|
||||
// never settled (these are exempt from the 30-min auto-fail in bookingCleanup.ts,
|
||||
// so this is their only seat-release path).
|
||||
// In either case the payment (and its ticket) is silently moved 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, or, 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';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.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 awaiting-verification payments (and their tickets) to 'on_hold'.
|
||||
* Covers 'pending_approval' payments and 'pending' payments on manual providers.
|
||||
* 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(
|
||||
or(
|
||||
eq((payments as any).status, 'pending_approval'),
|
||||
and(
|
||||
eq((payments as any).status, 'pending'),
|
||||
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS])
|
||||
)
|
||||
),
|
||||
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 awaiting-verification 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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Payment providers that require a human to verify the money arrived.
|
||||
//
|
||||
// These methods (bank transfer / TPago / cash) are never auto-confirmed and —
|
||||
// crucially — are never auto-failed by the stale-booking cleanup: an admin settles
|
||||
// them by hand. Note this is broader than the set of methods that expose an online
|
||||
// "I've paid" step (bank transfer / TPago only); cash is settled at the door.
|
||||
export const MANUAL_PAYMENT_PROVIDERS = ['bank_transfer', 'tpago', 'cash'] as const;
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
+233
-33
@@ -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(),
|
||||
});
|
||||
@@ -25,6 +26,10 @@ const rejectPaymentSchema = z.object({
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
const reopenPaymentSchema = z.object({
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
|
||||
// Get all payments (admin) - with ticket and event details
|
||||
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const status = c.req.query('status');
|
||||
@@ -85,6 +90,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 +101,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Filter by event(s)
|
||||
if (eventId) {
|
||||
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
|
||||
@@ -164,12 +170,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 +187,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),
|
||||
},
|
||||
});
|
||||
@@ -228,10 +236,16 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Confirming a failed payment must go through /approve, which re-checks event
|
||||
// capacity before re-reserving the (previously released) seat. Block the raw path.
|
||||
if (data.status === 'paid' && existing.status === 'failed') {
|
||||
return c.json({ error: 'Use the approve action to confirm a failed payment' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
|
||||
const updateData: any = { ...data, updatedAt: now };
|
||||
|
||||
|
||||
// If marking as paid, record who approved it and when
|
||||
if (data.status === 'paid' && existing.status !== 'paid') {
|
||||
updateData.paidAt = now;
|
||||
@@ -317,13 +331,15 @@ 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, on_hold, or failed payments.
|
||||
// 'failed' covers an admin confirming a payment that was auto-failed or rejected
|
||||
// in error; its tickets are cancelled, so recovery re-checks capacity below.
|
||||
if (!['pending', 'pending_approval', 'on_hold', 'failed'].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 +347,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 +361,77 @@ 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));
|
||||
|
||||
// For a failed payment, only recover tickets whose own payment is also failed.
|
||||
// This protects mixed bookings (e.g. a sibling ticket was refunded) from being
|
||||
// resurrected or having its payment flipped to paid.
|
||||
if (payment.status === 'failed') {
|
||||
const bookingPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
||||
.from(payments)
|
||||
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)))
|
||||
);
|
||||
const failedTicketIds = new Set(
|
||||
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
||||
);
|
||||
ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id));
|
||||
if (ticketsToConfirm.length === 0) {
|
||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (payment.status === 'on_hold' || payment.status === 'failed') {
|
||||
// The seat was released when this booking went on hold or failed - re-check
|
||||
// capacity before confirming it directly.
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{
|
||||
paidByAdminId: user.id,
|
||||
fromTicketStatuses:
|
||||
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold'],
|
||||
}
|
||||
);
|
||||
} 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 +471,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 +530,140 @@ 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' });
|
||||
});
|
||||
|
||||
// Reopen a failed payment back to pending (admin) - re-reserves the seat.
|
||||
// For when a payment was failed in error (auto-fail or rejection) and should
|
||||
// return to the normal pending flow (reminders, "I've paid", approve/reject).
|
||||
paymentsRouter.post('/:id/reopen', requireAuth(['admin', 'organizer']), zValidator('json', reopenPaymentSchema), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { adminNote } = c.req.valid('json');
|
||||
|
||||
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 !== 'failed') {
|
||||
return c.json({ error: 'Only failed payments can be reopened' }, 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 ticketsToReopen: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToReopen = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
// Only reopen tickets whose own payment is also failed (protects mixed bookings).
|
||||
const bookingPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
||||
.from(payments)
|
||||
.where(inArray((payments as any).ticketId, ticketsToReopen.map((t: any) => t.id)))
|
||||
);
|
||||
const failedTicketIds = new Set(
|
||||
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
||||
);
|
||||
ticketsToReopen = ticketsToReopen.filter((t: any) => failedTicketIds.has(t.id));
|
||||
if (ticketsToReopen.length === 0) {
|
||||
return c.json({ error: 'Only failed payments can be reopened' }, 400);
|
||||
}
|
||||
|
||||
const reopenIds = ticketsToReopen.map((t: any) => t.id);
|
||||
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
reopenIds,
|
||||
'pending',
|
||||
'pending',
|
||||
{ fromTicketStatuses: ['cancelled', 'on_hold'] }
|
||||
);
|
||||
} 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, reopenIds));
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ payment: updated, message: 'Payment reopened and set to pending' });
|
||||
});
|
||||
|
||||
// Send payment reminder email
|
||||
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
+122
-36
@@ -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();
|
||||
|
||||
@@ -30,11 +31,19 @@ const createTicketSchema = z.object({
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
// 'bancard' intentionally excluded: no checkout integration exists for it
|
||||
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
|
||||
// Base + optional "-" + check digit; digits-only kept for older clients, normalized to dashed form on save
|
||||
ruc: z.string().regex(/^(\d{6,10}|\d{5,8}-\d)$/, 'Invalid RUC format').optional().or(z.literal('')),
|
||||
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
|
||||
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
|
||||
});
|
||||
|
||||
// Canonical stored RUC form is "base-checkdigit" (e.g. 1234567-9); older clients send digits only
|
||||
function normalizeRuc(ruc: string | undefined): string | null {
|
||||
if (!ruc) return null;
|
||||
if (ruc.includes('-')) return ruc;
|
||||
return `${ruc.slice(0, -1)}-${ruc.slice(-1)}`;
|
||||
}
|
||||
|
||||
// Maps a payment provider to the merged payment-option flag that enables it
|
||||
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
|
||||
const truthy = (v: any) => v === true || v === 1;
|
||||
@@ -53,7 +62,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(),
|
||||
});
|
||||
|
||||
@@ -75,7 +84,8 @@ const adminCreateTicketSchema = z.object({
|
||||
// Book a ticket (public) - supports single or multi-ticket bookings
|
||||
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const rucNumber = normalizeRuc(data.ruc);
|
||||
|
||||
// Determine attendees list (use attendees array if provided, otherwise single attendee from main fields)
|
||||
const attendeesList = data.attendees && data.attendees.length > 0
|
||||
? data.attendees
|
||||
@@ -167,12 +177,21 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
phone: data.phone || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
rucNumber,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await (db as any).insert(users).values(user);
|
||||
} else if (rucNumber) {
|
||||
// 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, updatedAt: now })
|
||||
.where(eq((users as any).id, user.id));
|
||||
user.rucNumber = rucNumber;
|
||||
}
|
||||
|
||||
|
||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||
const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false;
|
||||
|
||||
@@ -243,7 +262,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||
attendeeEmail: data.email,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
attendeeRuc: rucNumber,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'pending',
|
||||
qrCode,
|
||||
@@ -304,7 +323,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||
attendeeEmail: data.email,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
attendeeRuc: rucNumber,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'pending',
|
||||
qrCode,
|
||||
@@ -408,7 +427,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 +439,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 +1132,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 +1243,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
|
||||
|
||||
@@ -63,6 +63,29 @@ server {
|
||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||
}
|
||||
|
||||
# Photo gallery service (photo-api, port 3020). ^~ wins over the /
|
||||
# prefix below; bigger body cap and unbuffered uploads for photo batches.
|
||||
location ^~ /api/photos/ {
|
||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||
|
||||
proxy_pass http://spanglish_photos;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Strip CORS headers from the service (nginx handles CORS here)
|
||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
location / {
|
||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -62,6 +79,24 @@ server {
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
# Photo gallery service (photo-api, port 3020). ^~ wins over the /api
|
||||
# prefix below. Batch photo uploads are large and slow, so the body cap
|
||||
# is raised and request buffering is off for this location only.
|
||||
location ^~ /api/photos/ {
|
||||
proxy_pass http://spanglish_photos;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
# Proxy /api to backend
|
||||
location /api {
|
||||
proxy_pass http://spanglish_backend;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=Spanglish Photo Gallery API
|
||||
Documentation=https://git.azzamo.net/Michilis/Spanglish
|
||||
After=network.target spanglish-backend.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spanglish
|
||||
Group=spanglish
|
||||
WorkingDirectory=/home/spanglish/Spanglish/photo-api
|
||||
EnvironmentFile=/home/spanglish/Spanglish/photo-api/.env
|
||||
Environment=PORT=3020
|
||||
# Apply pending photos_* migrations before serving (idempotent, advisory-locked)
|
||||
ExecStartPre=/home/spanglish/Spanglish/photo-api/bin/photo-api migrate
|
||||
ExecStart=/home/spanglish/Spanglish/photo-api/bin/photo-api
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=spanglish-photos
|
||||
|
||||
# Security hardening (mirrors spanglish-backend.service)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/spanglish/Spanglish/photo-api/data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -4,4 +4,8 @@ upstream spanglish_frontend {
|
||||
|
||||
upstream spanglish_backend {
|
||||
server 127.0.0.1:3018;
|
||||
}
|
||||
|
||||
upstream spanglish_photos {
|
||||
server 127.0.0.1:3020;
|
||||
}
|
||||
@@ -7,6 +7,11 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com
|
||||
# API URL (leave empty for same-origin proxy)
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
# Photo gallery service (photo-api) origin, used by the /api/photos rewrite
|
||||
# and server-side rendering of /photos pages.
|
||||
# Dev default: http://localhost:3003 — production: http://127.0.0.1:3020
|
||||
PHOTO_API_URL=
|
||||
|
||||
# Google OAuth (optional - leave empty to hide Google Sign-In button)
|
||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||
# 1. Create a new OAuth 2.0 Client ID (Web application)
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
// being hardcoded to localhost.
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||
|
||||
// The standalone photo gallery service (photo-api/). Must be rewritten
|
||||
// before the generic /api rule below — Next rewrites are order-sensitive.
|
||||
const PHOTO_API_URL = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN).
|
||||
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
|
||||
.split(',')
|
||||
@@ -20,6 +24,9 @@ const securityHeaders = [
|
||||
];
|
||||
|
||||
const nextConfig = {
|
||||
// Overridable so a production build can run while `next dev` holds .next
|
||||
// (e.g. NEXT_DIST_DIR=.next-build npm run build).
|
||||
distDir: process.env.NEXT_DIST_DIR || '.next',
|
||||
images: {
|
||||
// Restrict remote image sources to a known allowlist instead of allowing any
|
||||
// https host (which let the Next image optimizer be used as an open proxy).
|
||||
@@ -39,6 +46,10 @@ const nextConfig = {
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/photos/:path*',
|
||||
destination: `${PHOTO_API_URL}/api/photos/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${BACKEND_URL}/api/:path*`,
|
||||
|
||||
@@ -8,11 +8,17 @@ import {
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { PaymentMethod, BookingResult } from '../_types';
|
||||
|
||||
export const rucPattern = /^\d{6,10}$/;
|
||||
// Paraguayan RUC: 5-8 digit base + "-" + 1 check digit (DV), e.g. 1234567-9 or 80012345-0
|
||||
export const rucPattern = /^\d{5,8}-\d$/;
|
||||
|
||||
/** Format RUC input: digits only, max 10. */
|
||||
/** Sanitize RUC input: digits and a single user-typed dash, max 10 chars. No dash is auto-inserted. */
|
||||
export function formatRuc(value: string): string {
|
||||
return value.replace(/\D/g, '').slice(0, 10);
|
||||
const cleaned = value.replace(/[^\d-]/g, '');
|
||||
const firstDash = cleaned.indexOf('-');
|
||||
const oneDash = firstDash === -1
|
||||
? cleaned
|
||||
: cleaned.slice(0, firstDash + 1) + cleaned.slice(firstDash + 1).replace(/-/g, '');
|
||||
return oneDash.slice(0, 10);
|
||||
}
|
||||
|
||||
/** Truncate a long invoice string for display. */
|
||||
|
||||
@@ -226,25 +226,10 @@ export function BookingFormStep({
|
||||
onBlur={handleRucBlur}
|
||||
placeholder={t('booking.form.rucPlaceholder')}
|
||||
error={errors.ruc}
|
||||
inputMode="numeric"
|
||||
maxLength={10}
|
||||
aria-label={t('booking.form.ruc')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('booking.form.preferredLanguage')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.preferredLanguage}
|
||||
onChange={(e) => setFormData({ ...formData, preferredLanguage: e.target.value as 'en' | 'es' })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -335,7 +320,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 +362,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 +418,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')
|
||||
|
||||
@@ -12,8 +12,8 @@ export interface BookingFormData {
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
preferredLanguage: 'en' | 'es';
|
||||
paymentMethod: PaymentMethod;
|
||||
// Empty until the user explicitly picks a method (no default selection).
|
||||
paymentMethod: PaymentMethod | '';
|
||||
ruc: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||
import { formatDateLong, formatTime, formatRucDisplay } from '@/lib/utils';
|
||||
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||
import toast from 'react-hot-toast';
|
||||
import type {
|
||||
@@ -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)
|
||||
@@ -57,8 +56,7 @@ export default function BookingPage() {
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
preferredLanguage: locale as 'en' | 'es',
|
||||
paymentMethod: 'cash',
|
||||
paymentMethod: '',
|
||||
ruc: '',
|
||||
});
|
||||
|
||||
@@ -79,11 +77,10 @@ export default function BookingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Validate RUC on blur (optional field: 6–10 digits)
|
||||
// Validate RUC on blur (optional field: base + "-" + check digit)
|
||||
const handleRucBlur = () => {
|
||||
if (!formData.ruc) return;
|
||||
const digits = formData.ruc.replace(/\D/g, '');
|
||||
if (digits.length > 0 && !rucPattern.test(digits)) {
|
||||
if (!rucPattern.test(formData.ruc)) {
|
||||
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
|
||||
}
|
||||
};
|
||||
@@ -131,18 +128,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));
|
||||
@@ -164,8 +150,7 @@ export default function BookingPage() {
|
||||
lastName: prev.lastName || lastName,
|
||||
email: prev.email || user.email || '',
|
||||
phone: prev.phone || user.phone || '',
|
||||
preferredLanguage: (user.languagePreference as 'en' | 'es') || prev.preferredLanguage,
|
||||
ruc: prev.ruc || user.rucNumber || '',
|
||||
ruc: prev.ruc || formatRucDisplay(user.rucNumber),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -209,12 +194,18 @@ export default function BookingPage() {
|
||||
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
||||
}
|
||||
|
||||
// RUC validation (optional field - 6–10 digits if filled)
|
||||
if (formData.ruc.trim()) {
|
||||
const digits = formData.ruc.replace(/\D/g, '');
|
||||
if (!/^\d{6,10}$/.test(digits)) {
|
||||
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
||||
}
|
||||
// RUC validation (optional field - base + "-" + check digit if filled)
|
||||
if (formData.ruc.trim() && !rucPattern.test(formData.ruc.trim())) {
|
||||
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -245,7 +236,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 () => {
|
||||
@@ -298,9 +295,9 @@ export default function BookingPage() {
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
preferredLanguage: formData.preferredLanguage,
|
||||
paymentMethod: formData.paymentMethod,
|
||||
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
|
||||
preferredLanguage: locale as 'en' | 'es',
|
||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||
...(formData.ruc.trim() && { ruc: formData.ruc.trim() }),
|
||||
// Include attendees array for multi-ticket bookings
|
||||
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
||||
});
|
||||
@@ -330,7 +327,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 +358,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,50 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
import type { PhotoGallery, Photo } from '@/lib/api';
|
||||
import GalleryClient from '@/app/(public)/photos/[slug]/GalleryClient';
|
||||
|
||||
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
interface PageProps {
|
||||
params: { id: string }; // event slug, same param name as the parent route
|
||||
}
|
||||
|
||||
// Public event galleries render server-side; link/ticket galleries return
|
||||
// null here and are fetched client-side with the viewer's token.
|
||||
async function getEventGallery(
|
||||
eventSlug: string
|
||||
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${photoApiUrl}/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery`,
|
||||
{ next: { revalidate: 300 } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const data = await getEventGallery(params.id);
|
||||
if (!data) {
|
||||
return { title: 'Event Photos', robots: { index: false } };
|
||||
}
|
||||
return {
|
||||
title: `${data.gallery.title} – Photos`,
|
||||
description:
|
||||
data.gallery.description ||
|
||||
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EventGalleryPage({ params }: PageProps) {
|
||||
const initial = await getEventGallery(params.id);
|
||||
return (
|
||||
// Suspense boundary required by useSearchParams() in the client child.
|
||||
<Suspense>
|
||||
<GalleryClient eventSlug={params.id} initial={initial} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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,72 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import type { PhotoGallery } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { CameraIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function PhotosIndexClient({ galleries }: { galleries: PhotoGallery[] }) {
|
||||
const { locale } = useLanguage();
|
||||
const es = locale === 'es';
|
||||
|
||||
const formatDate = (iso?: string) => {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString(es ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<h1 className="section-title text-center mb-2">
|
||||
{es ? 'Galerías de Fotos' : 'Photo Galleries'}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600 mb-10">
|
||||
{es
|
||||
? 'Recuerdos de nuestros intercambios de idiomas en Asunción'
|
||||
: 'Memories from our language exchanges in Asunción'}
|
||||
</p>
|
||||
|
||||
{galleries.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-600">
|
||||
{es ? 'Aún no hay galerías publicadas.' : 'No galleries published yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{galleries.map((g) => (
|
||||
// Event-linked galleries live under the event's URL.
|
||||
<Link key={g.id} href={g.event ? `/events/${g.event.slug}/gallery` : `/photos/${g.slug}`}>
|
||||
<Card variant="elevated" className="overflow-hidden hover:shadow-card-hover transition-shadow h-full">
|
||||
<div className="aspect-video bg-gray-100 flex items-center justify-center">
|
||||
{g.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<CameraIcon className="w-12 h-12 text-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h2 className="font-heading font-semibold text-lg text-primary-dark">
|
||||
{es && g.titleEs ? g.titleEs : g.title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{g.photoCount} {es ? 'fotos' : 'photos'}
|
||||
{g.event?.startDatetime && <> · {formatDate(g.event.startDatetime)}</>}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||
import Lightbox from '@/components/Lightbox';
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
CalendarIcon,
|
||||
CameraIcon,
|
||||
LockClosedIcon,
|
||||
TicketIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface GalleryClientProps {
|
||||
/** Fetch by gallery slug (standalone galleries at /photos/[slug]) … */
|
||||
slug?: string;
|
||||
/** … or by event slug (event galleries at /events/[slug]/gallery). */
|
||||
eventSlug?: string;
|
||||
initial: { gallery: PhotoGallery; photos: Photo[] } | null;
|
||||
}
|
||||
|
||||
type DeniedState = 'login' | 'ticket' | 'notfound' | null;
|
||||
|
||||
export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) {
|
||||
const { locale } = useLanguage();
|
||||
const es = locale === 'es';
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const shareToken = searchParams.get('token') || undefined;
|
||||
|
||||
const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null);
|
||||
const [photos, setPhotos] = useState<Photo[]>(initial?.photos ?? []);
|
||||
const [loading, setLoading] = useState(!initial);
|
||||
const [denied, setDenied] = useState<DeniedState>(null);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
// Server-rendered public galleries need no client fetch. Everything else
|
||||
// (link/ticket/private) is fetched here with the share token and/or the
|
||||
// viewer's own auth token attached.
|
||||
useEffect(() => {
|
||||
if (initial) return;
|
||||
if (authLoading) return; // wait so the Bearer token is available
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const fetcher = eventSlug
|
||||
? photosApi.getEventGallery(eventSlug, shareToken)
|
||||
: photosApi.getPublicGallery(slug || '', shareToken);
|
||||
fetcher
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setGallery(data.gallery);
|
||||
setPhotos(data.photos);
|
||||
setDenied(null);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return;
|
||||
const msg = err.message || '';
|
||||
if (msg.includes('Authentication required')) setDenied('login');
|
||||
else if (msg.includes('attendees')) setDenied('ticket');
|
||||
else setDenied('notfound');
|
||||
})
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
|
||||
|
||||
if (loading || (authLoading && !initial)) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<ImageGridSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'login') {
|
||||
const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`);
|
||||
return (
|
||||
<GateMessage
|
||||
icon={LockClosedIcon}
|
||||
title={es ? 'Inicia sesión para ver esta galería' : 'Log in to view this gallery'}
|
||||
body={
|
||||
es
|
||||
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
|
||||
: 'This gallery is for event attendees. Log in with the account you used to book.'
|
||||
}
|
||||
action={
|
||||
<Button onClick={() => router.push(`/login?redirect=${redirect}`)}>
|
||||
{es ? 'Iniciar sesión' : 'Log in'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'ticket') {
|
||||
return (
|
||||
<GateMessage
|
||||
icon={TicketIcon}
|
||||
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||||
body={
|
||||
es
|
||||
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada.'
|
||||
: 'This gallery is only available to people who attended the event with a confirmed ticket.'
|
||||
}
|
||||
action={
|
||||
eventSlug || gallery?.event ? (
|
||||
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
|
||||
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'notfound' || !gallery) {
|
||||
return (
|
||||
<GateMessage
|
||||
icon={CameraIcon}
|
||||
title={es ? 'Galería no encontrada' : 'Gallery not found'}
|
||||
body={
|
||||
es
|
||||
? 'Este enlace no existe o ya no está disponible.'
|
||||
: 'This link does not exist or is no longer available.'
|
||||
}
|
||||
action={
|
||||
<Link href="/photos">
|
||||
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
||||
const lightboxItems = readyPhotos.map((p) => ({
|
||||
id: p.id,
|
||||
previewUrl: p.urls.preview || p.urls.original,
|
||||
downloadUrl: p.urls.original,
|
||||
filename: p.originalFilename,
|
||||
}));
|
||||
|
||||
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
|
||||
const description = es && gallery.descriptionEs ? gallery.descriptionEs : gallery.description;
|
||||
const eventTitle = gallery.event
|
||||
? es && gallery.event.titleEs
|
||||
? gallery.event.titleEs
|
||||
: gallery.event.title
|
||||
: null;
|
||||
const eventDate = gallery.event?.startDatetime
|
||||
? new Date(gallery.event.startDatetime).toLocaleDateString(es ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
})
|
||||
: null;
|
||||
|
||||
// Hero backdrop: the cover photo's preview when available, else the
|
||||
// first photo. Falls back to a navy gradient with no image.
|
||||
const coverPhoto =
|
||||
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
|
||||
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<div className="relative bg-brand-navy overflow-hidden">
|
||||
{heroUrl && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={heroUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
|
||||
</>
|
||||
)}
|
||||
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">
|
||||
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
|
||||
<CameraIcon className="w-4 h-4" />
|
||||
{readyPhotos.length} {es ? 'fotos' : 'photos'}
|
||||
</span>
|
||||
{gallery.event && (
|
||||
<Link
|
||||
href={`/events/${gallery.event.slug}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
|
||||
>
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
{eventTitle}
|
||||
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Masonry grid */}
|
||||
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
|
||||
{readyPhotos.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-600">
|
||||
{es ? 'Las fotos estarán disponibles pronto.' : 'Photos will be available soon.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
|
||||
{readyPhotos.map((photo, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setLightboxIndex(i);
|
||||
}
|
||||
}}
|
||||
className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
style={
|
||||
photo.width && photo.height
|
||||
? { aspectRatio: `${photo.width} / ${photo.height}` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photo.urls.thumb}
|
||||
alt=""
|
||||
loading={i < 8 ? 'eager' : 'lazy'}
|
||||
className="w-full h-auto group-hover:scale-[1.03] transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
<a
|
||||
href={photo.urls.original}
|
||||
download={photo.originalFilename || true}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
|
||||
aria-label={es ? 'Descargar' : 'Download'}
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
items={lightboxItems}
|
||||
index={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onNavigate={setLightboxIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GateMessage({
|
||||
icon: Icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
}: {
|
||||
icon: typeof CameraIcon;
|
||||
title: string;
|
||||
body: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-lg mx-auto text-center py-16">
|
||||
<Icon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">{title}</h1>
|
||||
<p className="text-gray-600 mb-6">{body}</p>
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
import type { PhotoGallery, Photo } from '@/lib/api';
|
||||
import GalleryClient from './GalleryClient';
|
||||
|
||||
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
interface PageProps {
|
||||
params: { slug: string };
|
||||
}
|
||||
|
||||
// Public galleries render server-side (SEO + fast first paint); link and
|
||||
// ticket galleries return null here and are fetched client-side with the
|
||||
// viewer's token / share token.
|
||||
async function getPublicGallery(
|
||||
slug: string
|
||||
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${photoApiUrl}/api/photos/public/galleries/${encodeURIComponent(slug)}`,
|
||||
{ next: { revalidate: 300 } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const data = await getPublicGallery(params.slug);
|
||||
if (!data) {
|
||||
return { title: 'Photo Gallery', robots: { index: false } };
|
||||
}
|
||||
return {
|
||||
title: `${data.gallery.title} – Photos`,
|
||||
description:
|
||||
data.gallery.description ||
|
||||
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function GalleryPage({ params }: PageProps) {
|
||||
const initial = await getPublicGallery(params.slug);
|
||||
return (
|
||||
// Suspense boundary required by useSearchParams() in the client child.
|
||||
<Suspense>
|
||||
<GalleryClient slug={params.slug} initial={initial} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { PhotoGallery } from '@/lib/api';
|
||||
import PhotosIndexClient from './PhotosIndexClient';
|
||||
|
||||
// Server-side calls go straight to the photo-api service; the browser uses
|
||||
// the same-origin /api/photos path via the Next rewrite / nginx.
|
||||
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Event Photo Galleries',
|
||||
description: 'Photos from past Spanglish language exchange events in Asunción.',
|
||||
};
|
||||
|
||||
async function getGalleries(): Promise<PhotoGallery[]> {
|
||||
try {
|
||||
const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.galleries || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PhotosPage() {
|
||||
const galleries = await getGalleries();
|
||||
return <PhotosIndexClient galleries={galleries} />;
|
||||
}
|
||||
@@ -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 { parseDate } from '@/lib/utils';
|
||||
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
||||
import { parseDate, formatRucDisplay } 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">{formatRucDisplay(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
EnvelopeIcon,
|
||||
InboxIcon,
|
||||
PhotoIcon,
|
||||
CameraIcon,
|
||||
Cog6ToothIcon,
|
||||
ArrowLeftOnRectangleIcon,
|
||||
Bars3Icon,
|
||||
@@ -54,6 +55,7 @@ export default function AdminLayout({
|
||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin', 'organizer'] },
|
||||
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
|
||||
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
|
||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
||||
@@ -62,6 +64,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 +85,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>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import { parseDate, formatRucDisplay } 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,36 @@ 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 handleReopen = async (payment: PaymentWithDetails) => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
await paymentsApi.reopen(payment.id, noteText);
|
||||
toast.success(locale === 'es' ? 'Pago cambiado a pendiente' : 'Payment changed to pending');
|
||||
setSelectedPayment(null);
|
||||
setNoteText('');
|
||||
setSendEmail(true);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || (locale === 'es' ? 'No se pudo reabrir el pago' : 'Failed to reopen payment'));
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefund = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to process this refund?')) return;
|
||||
|
||||
@@ -178,7 +209,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 +221,7 @@ export default function AdminPaymentsPage() {
|
||||
p.createdAt,
|
||||
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
||||
p.attendeeEmail || '',
|
||||
formatRucDisplay(p.attendeeRuc) || '',
|
||||
p.eventTitle,
|
||||
p.eventDate,
|
||||
]);
|
||||
@@ -225,6 +257,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 +266,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 +377,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 +446,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: {formatRucDisplay(selectedPayment.ticket.attendeeRuc)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -475,24 +510,48 @@ export default function AdminPaymentsPage() {
|
||||
</div>
|
||||
</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" />
|
||||
{locale === 'es' ? 'Aprobar' : 'Approve'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
|
||||
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
|
||||
<XCircleIcon className="w-5 h-5 mr-2" />
|
||||
{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="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>
|
||||
)}
|
||||
|
||||
{selectedPayment.status === 'failed' ? (
|
||||
<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" />
|
||||
{locale === 'es' ? 'Confirmar pago' : 'Confirm payment'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleReopen(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
|
||||
<ArrowPathIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Cambiar a pendiente' : 'Change 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" />
|
||||
{locale === 'es' ? 'Aprobar' : 'Approve'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
|
||||
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
|
||||
<XCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Rechazar' : 'Reject'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!['on_hold', 'failed'].includes(selectedPayment.status) && (
|
||||
<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 +686,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 +701,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 +889,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 +971,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 +981,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 +995,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 +1009,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' || payment.status === 'failed') && (
|
||||
<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 +1071,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' || payment.status === 'failed') && (
|
||||
<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 +1126,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>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import type { GalleryVisibility } from '@/lib/api';
|
||||
import {
|
||||
GlobeAltIcon,
|
||||
LockClosedIcon,
|
||||
LinkIcon,
|
||||
TicketIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const visibilityMeta: Record<
|
||||
GalleryVisibility,
|
||||
{ icon: typeof GlobeAltIcon; className: string; en: string; es: string }
|
||||
> = {
|
||||
public: { icon: GlobeAltIcon, className: 'bg-green-100 text-green-700', en: 'Public', es: 'Pública' },
|
||||
private: { icon: LockClosedIcon, className: 'bg-gray-200 text-gray-700', en: 'Private', es: 'Privada' },
|
||||
link: { icon: LinkIcon, className: 'bg-blue-100 text-blue-700', en: 'Link only', es: 'Solo enlace' },
|
||||
ticket: { icon: TicketIcon, className: 'bg-yellow-100 text-yellow-800', en: 'Ticket holders', es: 'Con entrada' },
|
||||
};
|
||||
|
||||
export default function VisibilityBadge({ visibility }: { visibility: GalleryVisibility }) {
|
||||
const { locale } = useLanguage();
|
||||
const meta = visibilityMeta[visibility] || visibilityMeta.private;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded ${meta.className}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{locale === 'es' ? meta.es : meta.en}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { photosApi, eventsApi, PhotoGallery, Photo, Event, GalleryVisibility } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||
import Lightbox from '@/components/Lightbox';
|
||||
import VisibilityBadge from '../VisibilityBadge';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowUpTrayIcon,
|
||||
ArrowPathIcon,
|
||||
CheckIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
LinkIcon,
|
||||
PhotoIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface UploadItem {
|
||||
key: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
progress: number; // 0..1 while uploading
|
||||
status: 'queued' | 'uploading' | 'processing' | 'error';
|
||||
error?: string;
|
||||
photoId?: string;
|
||||
}
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
||||
if (n >= 1 << 10) return `${Math.round(n / (1 << 10))} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
export default function AdminGalleryDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const { locale } = useLanguage();
|
||||
const es = locale === 'es';
|
||||
|
||||
const [gallery, setGallery] = useState<PhotoGallery | null>(null);
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Uploader panel state (Google Drive style: one row per file).
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||
const uploadQueue = useRef<{ key: string; file: File }[]>([]);
|
||||
const pumpRunning = useRef(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [detail, ev] = await Promise.all([photosApi.getGallery(id), eventsApi.getAll()]);
|
||||
setGallery(detail.gallery);
|
||||
setPhotos(detail.photos);
|
||||
setEvents(ev.events);
|
||||
} catch {
|
||||
toast.error(es ? 'No se pudo cargar la galería' : 'Failed to load gallery');
|
||||
router.push('/admin/photos');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Poll while any photo is still processing so thumbnails (and the
|
||||
// uploader panel rows) update as the worker finishes them.
|
||||
const processingCount = photos.filter((p) => p.status === 'queued' || p.status === 'processing').length;
|
||||
useEffect(() => {
|
||||
if (processingCount === 0) return;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const detail = await photosApi.getGallery(id);
|
||||
setGallery(detail.gallery);
|
||||
setPhotos(detail.photos);
|
||||
} catch {
|
||||
/* transient; next tick retries */
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [processingCount, id]);
|
||||
|
||||
const patchUpload = (key: string, patch: Partial<UploadItem>) => {
|
||||
setUploads((prev) => prev.map((u) => (u.key === key ? { ...u, ...patch } : u)));
|
||||
};
|
||||
|
||||
// Sequential upload pump: one file at a time, byte progress per file.
|
||||
const pump = useCallback(async () => {
|
||||
if (pumpRunning.current) return;
|
||||
pumpRunning.current = true;
|
||||
while (uploadQueue.current.length > 0) {
|
||||
const { key, file } = uploadQueue.current.shift()!;
|
||||
patchUpload(key, { status: 'uploading', progress: 0 });
|
||||
try {
|
||||
const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) =>
|
||||
patchUpload(key, { progress: fraction })
|
||||
);
|
||||
setPhotos((prev) => [...prev, ...added]);
|
||||
patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id });
|
||||
} catch (err) {
|
||||
patchUpload(key, {
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Upload failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
pumpRunning.current = false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const handleUpload = (files: FileList | File[]) => {
|
||||
const list = Array.from(files);
|
||||
if (list.length === 0) return;
|
||||
const items: UploadItem[] = list.map((file) => ({
|
||||
key: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: file.name,
|
||||
sizeBytes: file.size,
|
||||
progress: 0,
|
||||
status: 'queued',
|
||||
}));
|
||||
uploadQueue.current.push(...items.map((item, i) => ({ key: item.key, file: list[i] })));
|
||||
setUploads((prev) => [...prev, ...items]);
|
||||
setPanelCollapsed(false);
|
||||
pump();
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
// A row is "done" once its photo finished processing; the panel derives
|
||||
// this from the photos list instead of tracking it separately.
|
||||
const displayStatus = (u: UploadItem): { state: string; error?: string } => {
|
||||
if (u.status === 'processing' && u.photoId) {
|
||||
const photo = photos.find((p) => p.id === u.photoId);
|
||||
if (photo?.status === 'ready') return { state: 'done' };
|
||||
if (photo?.status === 'failed') return { state: 'error', error: photo.lastError || 'Processing failed' };
|
||||
}
|
||||
return { state: u.status, error: u.error };
|
||||
};
|
||||
|
||||
const activeUploads = uploads.filter((u) => {
|
||||
const s = displayStatus(u).state;
|
||||
return s === 'queued' || s === 'uploading' || s === 'processing';
|
||||
}).length;
|
||||
|
||||
const saveField = async (patch: Parameters<typeof photosApi.updateGallery>[1], okMsg?: string) => {
|
||||
try {
|
||||
const { gallery: updated } = await photosApi.updateGallery(id, patch);
|
||||
setGallery(updated);
|
||||
if (okMsg) toast.success(okMsg);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const copyShareLink = async () => {
|
||||
if (!gallery?.shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(gallery.shareUrl);
|
||||
setCopied(true);
|
||||
toast.success(es ? 'Enlace copiado' : 'Share link copied');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error(es ? 'No se pudo copiar' : 'Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
const rotateToken = async () => {
|
||||
if (!confirm(es ? '¿Invalidar el enlace actual y generar uno nuevo?' : 'Invalidate the current link and generate a new one?'))
|
||||
return;
|
||||
try {
|
||||
const { gallery: updated } = await photosApi.rotateShareToken(id);
|
||||
setGallery(updated);
|
||||
toast.success(es ? 'Nuevo enlace generado' : 'New share link generated');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
const deletePhoto = async (photoId: string): Promise<boolean> => {
|
||||
if (!confirm(es ? '¿Eliminar esta foto?' : 'Delete this photo?')) return false;
|
||||
try {
|
||||
await photosApi.deletePhoto(photoId);
|
||||
setPhotos((prev) => prev.filter((p) => p.id !== photoId));
|
||||
toast.success(es ? 'Foto eliminada' : 'Photo deleted');
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const retryPhoto = async (photoId: string) => {
|
||||
try {
|
||||
const { photo } = await photosApi.retryPhoto(photoId);
|
||||
setPhotos((prev) => prev.map((p) => (p.id === photoId ? photo : p)));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to retry');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGallery = async () => {
|
||||
if (!confirm(es ? '¿Eliminar toda la galería y sus fotos?' : 'Delete the whole gallery and its photos?')) return;
|
||||
try {
|
||||
await photosApi.deleteGallery(id);
|
||||
toast.success(es ? 'Galería eliminada' : 'Gallery deleted');
|
||||
router.push('/admin/photos');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete');
|
||||
}
|
||||
};
|
||||
|
||||
// HTML5 drag-and-drop reorder; persisted on drop.
|
||||
const onDropReorder = async (targetId: string) => {
|
||||
if (!dragId || dragId === targetId) return;
|
||||
const ids = photos.map((p) => p.id);
|
||||
const from = ids.indexOf(dragId);
|
||||
const to = ids.indexOf(targetId);
|
||||
if (from < 0 || to < 0) return;
|
||||
ids.splice(to, 0, ids.splice(from, 1)[0]);
|
||||
const reordered = ids
|
||||
.map((pid) => photos.find((p) => p.id === pid))
|
||||
.filter((p): p is Photo => !!p);
|
||||
setPhotos(reordered);
|
||||
setDragId(null);
|
||||
try {
|
||||
await photosApi.reorderPhotos(id, ids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save order');
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !gallery) {
|
||||
return (
|
||||
<div>
|
||||
<Skeleton className="h-8 w-64 mb-6" />
|
||||
<ImageGridSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
||||
const lightboxItems = readyPhotos.map((p) => ({
|
||||
id: p.id,
|
||||
previewUrl: p.urls.preview || p.urls.original,
|
||||
downloadUrl: p.urls.original,
|
||||
filename: p.originalFilename,
|
||||
}));
|
||||
const openLightboxFor = (photoId: string) => {
|
||||
const idx = readyPhotos.findIndex((p) => p.id === photoId);
|
||||
if (idx >= 0) setLightboxIndex(idx);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes('Files')) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
e.preventDefault();
|
||||
handleUpload(e.dataTransfer.files);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Link href="/admin/photos" className="text-gray-400 hover:text-gray-600">
|
||||
<ArrowLeftIcon className="w-6 h-6" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-primary-dark truncate">
|
||||
{es && gallery.titleEs ? gallery.titleEs : gallery.title}
|
||||
</h1>
|
||||
<VisibilityBadge visibility={gallery.visibility} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => fileInputRef.current?.click()}>
|
||||
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
|
||||
{es ? 'Subir fotos' : 'Upload photos'}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/heic,.heic"
|
||||
multiple
|
||||
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<Card className="p-4 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{es ? 'Visibilidad' : 'Visibility'}</label>
|
||||
<select
|
||||
value={gallery.visibility}
|
||||
onChange={(e) =>
|
||||
saveField(
|
||||
{ visibility: e.target.value as GalleryVisibility },
|
||||
es ? 'Visibilidad actualizada' : 'Visibility updated'
|
||||
)
|
||||
}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="private">{es ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
|
||||
<option value="public">{es ? 'Pública (listada)' : 'Public (listed)'}</option>
|
||||
<option value="link">{es ? 'Solo con enlace' : 'Link only'}</option>
|
||||
<option value="ticket">{es ? 'Con entrada del evento' : 'Ticket holders of the event'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{es ? 'Evento vinculado' : 'Linked event'}</label>
|
||||
<select
|
||||
value={gallery.eventId || ''}
|
||||
onChange={(e) =>
|
||||
saveField({ eventId: e.target.value }, es ? 'Evento actualizado' : 'Event updated')
|
||||
}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">{es ? 'Ninguno' : 'None'}</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{gallery.visibility === 'ticket' && !gallery.eventId && (
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
{es
|
||||
? 'El modo "con entrada" necesita un evento vinculado'
|
||||
: 'Ticket mode needs a linked event'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{es ? 'Enlace para compartir' : 'Share link'}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={gallery.shareUrl || ''}
|
||||
className="flex-1 min-w-0 px-3 py-2 text-sm border rounded-btn bg-gray-50"
|
||||
/>
|
||||
<Button size="sm" onClick={copyShareLink} title={es ? 'Copiar' : 'Copy'}>
|
||||
{copied ? <CheckIcon className="w-4 h-4" /> : <LinkIcon className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={rotateToken} title={es ? 'Regenerar' : 'Regenerate'}>
|
||||
<ArrowPathIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{gallery.event && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{es
|
||||
? 'Publicada en la página del evento: '
|
||||
: 'Published on the event page: '}
|
||||
<span className="font-mono">/events/{gallery.event.slug}/gallery</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{processingCount > 0 && (
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{es
|
||||
? `Procesando ${processingCount} foto(s)…`
|
||||
: `Processing ${processingCount} photo(s)…`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Photo grid */}
|
||||
{photos.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<ArrowUpTrayIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-500">
|
||||
{es
|
||||
? 'Arrastra fotos aquí o usa el botón de subir'
|
||||
: 'Drag photos here or use the upload button'}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{photos.map((photo) => (
|
||||
<Card
|
||||
key={photo.id}
|
||||
draggable
|
||||
onDragStart={() => setDragId(photo.id)}
|
||||
onDragOver={(e) => {
|
||||
if (dragId) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (dragId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDropReorder(photo.id);
|
||||
}
|
||||
}}
|
||||
className={`group relative overflow-hidden aspect-square cursor-grab ${
|
||||
dragId === photo.id ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{photo.status === 'ready' && photo.urls.thumb ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={photo.urls.thumb}
|
||||
alt=""
|
||||
className="w-full h-full object-cover cursor-pointer"
|
||||
draggable={false}
|
||||
onClick={() => openLightboxFor(photo.id)}
|
||||
/>
|
||||
) : photo.status === 'failed' ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 text-red-600 p-2 text-center">
|
||||
<ExclamationTriangleIcon className="w-8 h-8 mb-1" />
|
||||
<span className="text-xs line-clamp-3">{photo.lastError || 'Failed'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gallery.coverPhotoId === photo.id && (
|
||||
<StarIconSolid className="absolute top-2 left-2 w-5 h-5 text-primary-yellow drop-shadow" />
|
||||
)}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 py-1.5">
|
||||
{photo.status === 'ready' && (
|
||||
<button
|
||||
onClick={() =>
|
||||
saveField({ coverPhotoId: photo.id }, es ? 'Portada actualizada' : 'Cover updated')
|
||||
}
|
||||
className="p-1.5 text-white hover:text-primary-yellow"
|
||||
title={es ? 'Usar como portada' : 'Set as cover'}
|
||||
>
|
||||
<StarIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{photo.status === 'failed' && (
|
||||
<button
|
||||
onClick={() => retryPhoto(photo.id)}
|
||||
className="p-1.5 text-white hover:text-primary-yellow"
|
||||
title={es ? 'Reintentar' : 'Retry'}
|
||||
>
|
||||
<ArrowPathIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deletePhoto(photo.id)}
|
||||
className="p-1.5 text-white hover:text-red-400"
|
||||
title={es ? 'Eliminar' : 'Delete'}
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="mt-10 pt-6 border-t border-secondary-light-gray flex justify-end">
|
||||
<Button variant="danger" onClick={deleteGallery}>
|
||||
<TrashIcon className="w-5 h-5 mr-2" />
|
||||
{es ? 'Eliminar galería' : 'Delete gallery'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Lightbox with admin actions */}
|
||||
{lightboxIndex !== null && lightboxItems.length > 0 && (
|
||||
<Lightbox
|
||||
items={lightboxItems}
|
||||
index={Math.min(lightboxIndex, lightboxItems.length - 1)}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onNavigate={setLightboxIndex}
|
||||
renderActions={(item) => (
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
saveField({ coverPhotoId: item.id }, es ? 'Portada actualizada' : 'Cover updated')
|
||||
}
|
||||
className="text-white hover:text-primary-yellow"
|
||||
title={es ? 'Usar como portada' : 'Set as cover'}
|
||||
>
|
||||
{gallery.coverPhotoId === item.id ? (
|
||||
<StarIconSolid className="w-7 h-7 text-primary-yellow" />
|
||||
) : (
|
||||
<StarIcon className="w-7 h-7" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const before = readyPhotos.length;
|
||||
const deleted = await deletePhoto(item.id);
|
||||
if (!deleted) return;
|
||||
if (before <= 1) setLightboxIndex(null);
|
||||
else setLightboxIndex((i) => (i === null ? null : Math.max(0, Math.min(i, before - 2))));
|
||||
}}
|
||||
className="text-white hover:text-red-400"
|
||||
title={es ? 'Eliminar' : 'Delete'}
|
||||
>
|
||||
<TrashIcon className="w-7 h-7" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Uploader panel (Google Drive style) */}
|
||||
{uploads.length > 0 && (
|
||||
<div className="fixed bottom-4 right-4 z-40 w-96 max-w-[calc(100vw-2rem)]">
|
||||
<div className="bg-white rounded-card shadow-card-hover border border-secondary-light-gray overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-primary-dark text-white">
|
||||
<p className="text-sm font-medium">
|
||||
{activeUploads > 0
|
||||
? es
|
||||
? `Subiendo ${uploads.length - activeUploads}/${uploads.length}…`
|
||||
: `Uploading ${uploads.length - activeUploads}/${uploads.length}…`
|
||||
: es
|
||||
? `${uploads.length} subida(s) completada(s)`
|
||||
: `${uploads.length} upload(s) complete`}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPanelCollapsed((c) => !c)}
|
||||
className="p-1 hover:text-primary-yellow"
|
||||
aria-label={panelCollapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{panelCollapsed ? <ChevronUpIcon className="w-5 h-5" /> : <ChevronDownIcon className="w-5 h-5" />}
|
||||
</button>
|
||||
{activeUploads === 0 && (
|
||||
<button
|
||||
onClick={() => setUploads([])}
|
||||
className="p-1 hover:text-primary-yellow"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!panelCollapsed && (
|
||||
<ul className="max-h-72 overflow-y-auto divide-y divide-secondary-light-gray">
|
||||
{uploads.map((u) => {
|
||||
const ds = displayStatus(u);
|
||||
return (
|
||||
<li key={u.key} className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<PhotoIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-primary-dark truncate" title={u.name}>
|
||||
{u.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{ds.state === 'uploading' && `${Math.round(u.progress * 100)}% · ${formatBytes(u.sizeBytes)}`}
|
||||
{ds.state === 'queued' && (es ? 'En cola' : 'Queued')}
|
||||
{ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')}
|
||||
{ds.state === 'done' && formatBytes(u.sizeBytes)}
|
||||
{ds.state === 'error' && (
|
||||
<span className="text-red-600" title={ds.error}>
|
||||
{ds.error}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0">
|
||||
{ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />}
|
||||
{ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />}
|
||||
{ds.state === 'processing' && (
|
||||
<div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" />
|
||||
)}
|
||||
{ds.state === 'uploading' && (
|
||||
<span className="text-xs text-gray-600 tabular-nums">{Math.round(u.progress * 100)}%</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{ds.state === 'uploading' && (
|
||||
<div className="mt-1.5 h-1 rounded-full bg-secondary-gray overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-yellow transition-[width] duration-200"
|
||||
style={{ width: `${Math.round(u.progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { photosApi, eventsApi, PhotoGallery, Event, GalleryVisibility } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||
import VisibilityBadge from './VisibilityBadge';
|
||||
import {
|
||||
CameraIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function AdminPhotosPage() {
|
||||
const { locale } = useLanguage();
|
||||
const [galleries, setGalleries] = useState<PhotoGallery[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
titleEs: '',
|
||||
eventId: '',
|
||||
visibility: 'private' as GalleryVisibility,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([photosApi.getGalleries(), eventsApi.getAll()])
|
||||
.then(([g, e]) => {
|
||||
setGalleries(g.galleries);
|
||||
setEvents(e.events);
|
||||
})
|
||||
.catch(() => toast.error(locale === 'es' ? 'Error al cargar galerías' : 'Failed to load galleries'))
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.title.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const { gallery } = await photosApi.createGallery({
|
||||
title: form.title.trim(),
|
||||
titleEs: form.titleEs.trim() || undefined,
|
||||
eventId: form.eventId || undefined,
|
||||
visibility: form.visibility,
|
||||
});
|
||||
setGalleries((prev) => [gallery, ...prev]);
|
||||
setShowCreate(false);
|
||||
setForm({ title: '', titleEs: '', eventId: '', visibility: 'private' });
|
||||
toast.success(locale === 'es' ? 'Galería creada' : 'Gallery created');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create gallery');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
<ImageGridSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">
|
||||
{locale === 'es' ? 'Fotos de Eventos' : 'Event Photos'}
|
||||
</h1>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{galleries.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-600 mb-2">
|
||||
{locale === 'es' ? 'Sin galerías todavía' : 'No galleries yet'}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-4">
|
||||
{locale === 'es'
|
||||
? 'Crea una galería para compartir las fotos de un evento'
|
||||
: 'Create a gallery to share photos from a past event'}
|
||||
</p>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Crear la primera' : 'Create the first one'}
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{galleries.map((g) => (
|
||||
<Link key={g.id} href={`/admin/photos/${g.id}`}>
|
||||
<Card className="overflow-hidden hover:shadow-card-hover transition-shadow cursor-pointer h-full">
|
||||
<div className="aspect-video bg-gray-100 flex items-center justify-center">
|
||||
{g.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<CameraIcon className="w-12 h-12 text-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-primary-dark truncate">
|
||||
{locale === 'es' && g.titleEs ? g.titleEs : g.title}
|
||||
</h3>
|
||||
<VisibilityBadge visibility={g.visibility} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{g.photoCount} {locale === 'es' ? 'fotos' : 'photos'}
|
||||
{g.event && <> · {locale === 'es' && g.event.titleEs ? g.event.titleEs : g.event.title}</>}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-primary-dark">
|
||||
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
|
||||
</h2>
|
||||
<button onClick={() => setShowCreate(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{locale === 'es' ? 'Título (inglés)' : 'Title (English)'} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||
placeholder="June Language Exchange"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{locale === 'es' ? 'Título (español)' : 'Title (Spanish)'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.titleEs}
|
||||
onChange={(e) => setForm({ ...form, titleEs: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||
placeholder="Intercambio de Junio"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{locale === 'es' ? 'Evento vinculado' : 'Linked event'}
|
||||
</label>
|
||||
<select
|
||||
value={form.eventId}
|
||||
onChange={(e) => setForm({ ...form, eventId: e.target.value })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">{locale === 'es' ? 'Ninguno' : 'None'}</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{locale === 'es' ? 'Visibilidad' : 'Visibility'}
|
||||
</label>
|
||||
<select
|
||||
value={form.visibility}
|
||||
onChange={(e) => setForm({ ...form, visibility: e.target.value as GalleryVisibility })}
|
||||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="private">{locale === 'es' ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
|
||||
<option value="public">{locale === 'es' ? 'Pública (listada)' : 'Public (listed)'}</option>
|
||||
<option value="link">{locale === 'es' ? 'Solo con enlace' : 'Link only'}</option>
|
||||
<option value="ticket">
|
||||
{locale === 'es' ? 'Con entrada del evento' : 'Ticket holders of the event'}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>
|
||||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={creating}>
|
||||
{locale === 'es' ? 'Crear' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</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 { parseDate } from '@/lib/utils';
|
||||
import { usersApi, eventsApi, User, Event } from '@/lib/api';
|
||||
import { parseDate, formatRucDisplay } 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">{formatRucDisplay(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: {formatRucDisplay(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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,26 @@ import { MetadataRoute } from 'next';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
interface SitemapPhotoGallery {
|
||||
slug: string;
|
||||
updatedAt: string;
|
||||
event?: { slug: string };
|
||||
}
|
||||
|
||||
/** Public photo galleries from the photo-api service (public visibility only). */
|
||||
async function getPublicPhotoGalleries(): Promise<SitemapPhotoGallery[]> {
|
||||
try {
|
||||
const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
return ((await res.json()).galleries as SitemapPhotoGallery[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface SitemapEvent {
|
||||
id: string;
|
||||
@@ -41,7 +61,10 @@ async function getIndexableEvents(): Promise<SitemapEvent[]> {
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const events = await getIndexableEvents();
|
||||
const [events, photoGalleries] = await Promise.all([
|
||||
getIndexableEvents(),
|
||||
getPublicPhotoGalleries(),
|
||||
]);
|
||||
const now = new Date();
|
||||
|
||||
// Static pages
|
||||
@@ -88,6 +111,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/photos`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.6,
|
||||
},
|
||||
// Legal pages
|
||||
{
|
||||
url: `${siteUrl}/legal/terms-policy`,
|
||||
@@ -120,5 +149,13 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
};
|
||||
});
|
||||
|
||||
return [...staticPages, ...eventPages];
|
||||
const photoPages: MetadataRoute.Sitemap = photoGalleries.map((g) => ({
|
||||
// Event-linked galleries live under the event's URL.
|
||||
url: g.event ? `${siteUrl}/events/${g.event.slug}/gallery` : `${siteUrl}/photos/${g.slug}`,
|
||||
lastModified: g.updatedAt ? new Date(g.updatedAt) : now,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.5,
|
||||
}));
|
||||
|
||||
return [...staticPages, ...eventPages, ...photoPages];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import {
|
||||
XMarkIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export interface LightboxItem {
|
||||
id: string;
|
||||
previewUrl: string;
|
||||
downloadUrl: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
interface LightboxProps {
|
||||
items: LightboxItem[];
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onNavigate: (index: number) => void;
|
||||
/** Extra per-item action buttons rendered in the top bar (admin use). */
|
||||
renderActions?: (item: LightboxItem, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen photo lightbox with keyboard and swipe navigation, in the
|
||||
* style of the admin gallery preview modal (fixed inset-0 bg-black/90).
|
||||
*/
|
||||
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) {
|
||||
const [touchStartX, setTouchStartX] = useState<number | null>(null);
|
||||
const item = items[index];
|
||||
|
||||
const prev = useCallback(() => {
|
||||
onNavigate(index > 0 ? index - 1 : items.length - 1);
|
||||
}, [index, items.length, onNavigate]);
|
||||
const next = useCallback(() => {
|
||||
onNavigate(index < items.length - 1 ? index + 1 : 0);
|
||||
}, [index, items.length, onNavigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') prev();
|
||||
if (e.key === 'ArrowRight') next();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [onClose, prev, next]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
onTouchStart={(e) => setTouchStartX(e.touches[0].clientX)}
|
||||
onTouchEnd={(e) => {
|
||||
if (touchStartX === null) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX;
|
||||
if (dx > 60) prev();
|
||||
if (dx < -60) next();
|
||||
setTouchStartX(null);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white hover:text-gray-300 z-10 p-2 -m-2"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="absolute top-4 left-4 z-10 flex items-center gap-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<a
|
||||
href={item.downloadUrl}
|
||||
download={item.filename || true}
|
||||
className="text-white hover:text-gray-300"
|
||||
aria-label="Download"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-7 h-7" />
|
||||
</a>
|
||||
{renderActions?.(item, index)}
|
||||
</div>
|
||||
|
||||
{items.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
prev();
|
||||
}}
|
||||
aria-label="Previous"
|
||||
>
|
||||
<ChevronLeftIcon className="w-9 h-9" />
|
||||
</button>
|
||||
<button
|
||||
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
next();
|
||||
}}
|
||||
aria-label="Next"
|
||||
>
|
||||
<ChevronRightIcon className="w-9 h-9" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt=""
|
||||
className="max-w-[95vw] max-h-[90vh] object-contain select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm">
|
||||
{index + 1} / {items.length}
|
||||
</div>
|
||||
|
||||
{/* Preload neighbours so prev/next feels instant. */}
|
||||
<div className="hidden" aria-hidden>
|
||||
{items.length > 1 && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={items[(index + 1) % items.length].previewUrl} alt="" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={items[(index - 1 + items.length) % items.length].previewUrl} alt="" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export function NotFoundMessage() {
|
||||
return (
|
||||
<div className="text-center px-4 py-16">
|
||||
<h1 className="text-6xl font-bold text-primary-dark mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/events"
|
||||
className="inline-flex items-center justify-center px-6 py-3 border-2 border-primary-dark text-primary-dark font-semibold rounded-btn hover:bg-primary-dark hover:text-white transition-colors"
|
||||
>
|
||||
View Events
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
@@ -274,6 +275,7 @@
|
||||
"contacts": "Messages",
|
||||
"emails": "Emails",
|
||||
"gallery": "Gallery",
|
||||
"photos": "Photos",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"events": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
@@ -274,6 +275,7 @@
|
||||
"contacts": "Mensajes",
|
||||
"emails": "Emails",
|
||||
"gallery": "Galería",
|
||||
"photos": "Fotos",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"events": {
|
||||
|
||||
@@ -17,3 +17,11 @@ export { siteSettingsApi } from './siteSettings';
|
||||
export { legalSettingsApi } from './legalSettings';
|
||||
export { legalPagesApi } from './legalPages';
|
||||
export { faqApi } from './faq';
|
||||
export { photosApi } from './photos';
|
||||
export type {
|
||||
Photo,
|
||||
PhotoGallery,
|
||||
PhotoGalleryEvent,
|
||||
GalleryVisibility,
|
||||
CreateGalleryInput,
|
||||
} from './photos';
|
||||
|
||||
@@ -46,4 +46,15 @@ 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',
|
||||
}),
|
||||
|
||||
reopen: (id: string, adminNote?: string) =>
|
||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reopen`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminNote }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { fetchApi, API_BASE, getToken } from './client';
|
||||
|
||||
// Client for the standalone photo-api Go service (photo-api/), reachable
|
||||
// under /api/photos via the Next rewrite (dev) or nginx (prod).
|
||||
|
||||
export type GalleryVisibility = 'public' | 'private' | 'link' | 'ticket';
|
||||
|
||||
export interface PhotoGalleryEvent {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
startDatetime: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PhotoGallery {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description?: string;
|
||||
descriptionEs?: string;
|
||||
eventId?: string;
|
||||
visibility: GalleryVisibility;
|
||||
coverPhotoId?: string;
|
||||
photoCount: number;
|
||||
coverUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
event?: PhotoGalleryEvent;
|
||||
// Present on admin responses only:
|
||||
shareToken?: string;
|
||||
shareUrl?: string;
|
||||
}
|
||||
|
||||
export interface Photo {
|
||||
id: string;
|
||||
galleryId: string;
|
||||
position: number;
|
||||
originalFilename?: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
takenAt?: string;
|
||||
status: 'queued' | 'processing' | 'ready' | 'failed';
|
||||
lastError?: string;
|
||||
createdAt: string;
|
||||
urls: {
|
||||
thumb?: string;
|
||||
preview?: string;
|
||||
original: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateGalleryInput {
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description?: string;
|
||||
descriptionEs?: string;
|
||||
eventId?: string;
|
||||
visibility?: GalleryVisibility;
|
||||
}
|
||||
|
||||
export const photosApi = {
|
||||
// Admin (requires admin/organizer token)
|
||||
createGallery: (data: CreateGalleryInput) =>
|
||||
fetchApi<{ gallery: PhotoGallery }>('/api/photos/galleries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
getGalleries: (eventId?: string) => {
|
||||
const query = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
|
||||
return fetchApi<{ galleries: PhotoGallery[] }>(`/api/photos/galleries${query}`);
|
||||
},
|
||||
|
||||
getGallery: (id: string) =>
|
||||
fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(`/api/photos/galleries/${id}`),
|
||||
|
||||
updateGallery: (id: string, data: Partial<CreateGalleryInput> & { coverPhotoId?: string }) =>
|
||||
fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
deleteGallery: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/photos/galleries/${id}`, { method: 'DELETE' }),
|
||||
|
||||
rotateShareToken: (id: string) =>
|
||||
fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}/share-token`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
uploadPhoto: async (galleryId: string, file: File) => {
|
||||
const token = getToken();
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ error: 'Upload failed' }));
|
||||
throw new Error(errorData.error || 'Upload failed');
|
||||
}
|
||||
return res.json() as Promise<{ photos: Photo[] }>;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload with byte-level progress (0..1). fetch() cannot report upload
|
||||
* progress, so this uses XMLHttpRequest; used by the admin uploader panel.
|
||||
*/
|
||||
uploadPhotoWithProgress: (
|
||||
galleryId: string,
|
||||
file: File,
|
||||
onProgress: (fraction: number) => void
|
||||
) =>
|
||||
new Promise<{ photos: Photo[] }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`);
|
||||
const token = getToken();
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
|
||||
else reject(new Error(body.error || `Upload failed (${xhr.status})`));
|
||||
} catch {
|
||||
reject(new Error(`Upload failed (${xhr.status})`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Network error during upload'));
|
||||
xhr.onabort = () => reject(new Error('Upload cancelled'));
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
xhr.send(formData);
|
||||
}),
|
||||
|
||||
reorderPhotos: (galleryId: string, photoIds: string[]) =>
|
||||
fetchApi<{ message: string }>(`/api/photos/galleries/${galleryId}/order`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ photoIds }),
|
||||
}),
|
||||
|
||||
deletePhoto: (photoId: string) =>
|
||||
fetchApi<{ message: string }>(`/api/photos/photos/${photoId}`, { method: 'DELETE' }),
|
||||
|
||||
retryPhoto: (photoId: string) =>
|
||||
fetchApi<{ photo: Photo }>(`/api/photos/photos/${photoId}/retry`, { method: 'POST' }),
|
||||
|
||||
// Viewer (anonymous or member token; share token via query param)
|
||||
getPublicGalleries: () =>
|
||||
fetchApi<{ galleries: PhotoGallery[] }>('/api/photos/public/galleries'),
|
||||
|
||||
getPublicGallery: (slug: string, token?: string) => {
|
||||
const query = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(
|
||||
`/api/photos/public/galleries/${encodeURIComponent(slug)}${query}`
|
||||
);
|
||||
},
|
||||
|
||||
/** Newest gallery linked to an event; backs /events/[slug]/gallery. */
|
||||
getEventGallery: (eventSlug: string, token?: string) => {
|
||||
const query = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(
|
||||
`/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery${query}`
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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}`),
|
||||
|
||||
@@ -166,6 +166,18 @@ export function formatCurrency(amount: number, currency: string = 'PYG'): string
|
||||
return formatPrice(amount, currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Paraguayan RUC for display as "base-checkdigit" (e.g. 1234567-9).
|
||||
* Legacy records stored digits only; insert the dash before the check digit.
|
||||
*/
|
||||
export function formatRucDisplay(ruc: string | null | undefined): string {
|
||||
if (!ruc) return '';
|
||||
if (ruc.includes('-')) return ruc;
|
||||
const digits = ruc.replace(/\D/g, '');
|
||||
if (digits.length < 2) return ruc;
|
||||
return `${digits.slice(0, -1)}-${digits.slice(-1)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payment helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user