Author SHA1 Message Date
MichilisandCursor c9a600b6d6 Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 17:32:23 +00:00
MichilisandCursor 4772b85f3d Stop auto-failing manual payments (TPago/bank/cash) after the pending TTL.
Exclude manual providers from booking cleanup, hold them via the 72h sweep instead, and let admins reopen failed payments to pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 03:53:51 +00:00
MichilisandCursor 0d47156071 Normalize RUC formatting to base-checkdigit across booking and admin views.
Accept dashed or digits-only RUC input on the backend, store a canonical dashed form, and display it consistently in booking and admin UIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 03:47:35 +00:00
MichilisandCursor 75e317d73e Replace spinners with skeleton loading states for faster perceived UX.
Add reusable Skeleton components and route-level loading files across public pages and admin lists so layouts stay stable while data loads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 23:14:11 +00:00
MichilisandCursor d8c207c995 Add Next.js as a root dependency for the monorepo workspace.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 22:05:22 +00:00
MichilisandCursor cb422332c8 Fix booking defaults, dashboard alerts, admin edit flow, and ended-event payments.
Require an explicit payment method on booking, hide stale release banners for past or inactive events, open event editing in place on the detail page, and auto-reject unconfirmed payments after events end without sending email.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 22:04:22 +00:00
MichilisandCursor 8d5fbf18b4 Fix event time save off-by-one using bundled tz data.
Use moment-timezone in parseEventDatetime so naive America/Asuncion wall-clock times convert correctly regardless of the host Node tz database. Also normalize user registration date filters with toDbDate for Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 01:51:18 +00:00
MichilisandCursor 78ffd0ae52 Allow Lightning invoice reuse and re-payment from the dashboard.
Store LNbits invoice data on payments, add an invoice endpoint that reuses valid invoices or regenerates expired ones, and wire the booking payment page to fetch and display invoices via a shared watcher hook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:48:28 +00:00
MichilisandClaude Sonnet 5 09bfb17f03 Add custom error and global-error pages matching site design
Adds app/error.tsx (client error boundary, reuses Header/Footer/Button)
and app/global-error.tsx (root layout crash fallback with self-contained
html/body and manually replicated styling), both bilingual (ES/EN) and
matching the brand's colors, fonts, and tone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 06:06:22 +00:00
Michilis cacc52ec24 Security recovery: hold sweep, dashboard updates, and admin fixes. 2026-07-01 05:51:38 +00:00
139 changed files with 10129 additions and 1022 deletions
+5
View File
@@ -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
+13
View File
@@ -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
+1
View File
@@ -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",
+18
View File
@@ -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`
+8 -2
View File
@@ -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'),
+8
View File
@@ -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.
+15 -4
View File
@@ -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)
))
);
+124
View File
@@ -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;
}
}
+148
View File
@@ -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));
});
}
}
+111
View File
@@ -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;
}
}
+5
View File
@@ -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
*/
+7
View File
@@ -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;
+5 -11
View File
@@ -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();
}
/**
+15 -3
View File
@@ -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,
+149 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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;
+36 -1
View File
@@ -18,11 +18,28 @@ server {
}
}
# Canonical host is the apex (non-www). Redirect the www HTTPS vhost to it with a
# 301 so only one host is served and indexed.
server {
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;
+30
View File
@@ -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
View File
@@ -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;
}
+5
View File
@@ -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)
+11
View File
@@ -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: 610 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 - 610 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({
+2 -3
View File
@@ -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}`,
};
}
+6 -1
View File
@@ -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>
);
}
+24 -152
View File
@@ -1,157 +1,29 @@
'use client';
import type { Metadata } from 'next';
import { Event } from '@/lib/api';
import EventsClient from './EventsClient';
import { useState, useEffect } from 'react';
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>
);
}
+3
View File
@@ -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.',
+16
View File
@@ -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>
);
}
+17 -111
View File
@@ -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,
+9 -3
View File
@@ -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>
);
}
+30
View File
@@ -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();
}
+56 -9
View File
@@ -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}
+16 -2
View File
@@ -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>
);
}
+2 -5
View File
@@ -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'}
+1 -1
View File
@@ -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';
+51 -13
View File
@@ -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>
);
}
+25 -358
View File
@@ -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>
+2 -5
View File
@@ -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 (
+7 -2
View File
@@ -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>
);
}
+13 -4
View File
@@ -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]);
+2 -5
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
import { NotFoundMessage } from '@/components/NotFoundMessage';
export default function AdminNotFound() {
return (
<div className="flex-1 flex items-center justify-center">
<NotFoundMessage />
</div>
);
}
+116 -29
View File
@@ -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>
);
}
+617
View File
@@ -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>
);
}
+221
View File
@@ -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>
);
}
+2 -5
View File
@@ -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 (
+207 -49
View File
@@ -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>
+54
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+3 -19
View File
@@ -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',
+16 -2
View File
@@ -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">
+9 -25
View File
@@ -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>
);
}
+39 -2
View File
@@ -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];
}
+145
View File
@@ -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>
);
}
+227
View File
@@ -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>
);
}
@@ -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]);
}
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+8
View File
@@ -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';
+11
View File
@@ -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 }),
}),
};
+176
View File
@@ -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}`
);
},
};
+9
View File
@@ -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`,
};
+13 -2
View File
@@ -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;
+27 -3
View File
@@ -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}`),
+12
View File
@@ -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