Author SHA1 Message Date
MichilisandClaude Fable 5 9b2668f498 Unify admin ticket creation into one modal with first-class payment status.
- Replace the three Attendees-tab modals (Manual Ticket / Add at Door /
  Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented
  control, shared fields, "Check in now" for all types, and a live
  "what happens" preview, backed by one POST /api/tickets/admin/add.
- Add tickets.payment_status (paid | unpaid | comp) with a backfill
  migration; keep it in sync on every payment-settlement path (mark-paid,
  admin approval, Lightning, free bookings, hold recovery).
- Show Paid/Unpaid/Comp badges in the attendee list, count only paid
  tickets toward revenue, let unpaid tickets be resolved via Mark Paid,
  and flag unpaid tickets with their balance due in the door scanner.
- Replace the per-page useStatsPrivacy hook with an admin-wide
  PrivacyContext + SensitiveValue mask, toggled from the admin layout.
- Add server-side pagination with page-size options to the users page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 05:25:00 +00:00
MichilisandClaude Fable 5 e38d14970d Harden the Redis layer for production.
- Locks no longer fail open: an unreachable backend throws
  LockUnavailableError and withLock skips the run (or opts into running
  unlocked, as template seeding does). The lnbits payment poller keeps
  polling through an outage, made safe by only sending confirmation
  emails when a row actually transitioned.
- Rate limiter INCR+PEXPIRE now runs as one Lua script, self-healing
  counters stranded without a TTL.
- Per-email login lockout moves to a Redis-backed store shared across
  replicas (in-memory fallback preserved), case-insensitive on email.
- Graceful shutdown on SIGTERM/SIGINT: stop periodic jobs, close the
  server, force-close lingering SSE sockets, close Redis.
- Active PING probe backs the health flag; /health reports last ping.
  SSE payment streams also poll the DB as a pub/sub-gap fallback.
- Scale compose gains requirepass, maxmemory 256mb with noeviction, and
  an authenticated healthcheck; .env.example documents passwords, TLS
  (rediss://), and DB-index selection.
- First tests in the repo: vitest + ioredis-mock covering the lock,
  rate limiter, and login lockout stores (27 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:58:52 +00:00
MichilisandClaude Fable 5 71c277045b Centralize seat capacity accounting and the payment provider registry.
Replace manualProviders.ts with a paymentProviders.ts registry
(automatic vs manual settlement) and move all seat counting into
capacity.ts as the single source of truth: only paid/checked-in tickets
and pending_approval payments hold a seat, so abandoned checkouts never
block sales. Admins can now knowingly approve a payment over capacity
(allowOverCapacity), with the booking and admin UIs updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:58:40 +00:00
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
MichilisandCursor 38526f17b5 Redesign user dashboard with overview tab and i18n payment copy.
Consolidate profile and security into AccountTab, add shared dashboard components, and move awaiting-approval payment messages to translations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 07:39:52 +00:00
167 changed files with 14060 additions and 3160 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
+24 -3
View File
@@ -22,10 +22,18 @@ DATABASE_URL=./data/spanglish.db
# Note: running more than one instance requires DB_TYPE=postgres. SQLite is a
# single local file and cannot be shared safely across instances.
# Redis connection URL. When set, the cache, rate limiter, pub/sub (real-time
# payment events), distributed locks, and the email hourly cap are shared across
# all instances. When unset, each instance uses in-memory equivalents.
# Redis connection URL. When set, the cache, rate limiter, login lockout,
# pub/sub (real-time payment events), distributed locks, and the email hourly
# cap are shared across all instances. When unset, each instance uses in-memory
# equivalents.
#
# In production always set a password (requirepass on the server) and put it in
# the URL. Use the rediss:// scheme for TLS (handled natively by the client),
# and an optional /N path to select a DB index when sharing a Redis instance
# with other applications.
# REDIS_URL=redis://localhost:6379
# REDIS_URL=redis://:your-redis-password@redis.internal:6379
# REDIS_URL=rediss://:your-redis-password@redis.example.com:6380/1
# Optional S3-compatible object storage for media uploads (e.g. Garage, MinIO,
# AWS S3). When S3_ENDPOINT and S3_BUCKET are set, uploads go to the bucket and
@@ -114,3 +122,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
+5 -1
View File
@@ -5,6 +5,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"test": "vitest run",
"start": "NODE_ENV=production node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
@@ -25,6 +26,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",
@@ -41,7 +43,9 @@
"@types/pg": "^8.11.6",
"@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.22.8",
"ioredis-mock": "^8.13.1",
"tsx": "^4.15.7",
"typescript": "^5.5.2"
"typescript": "^5.5.2",
"vitest": "^4.1.10"
}
}
+43 -1
View File
@@ -199,7 +199,19 @@ async function migrate() {
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
// backfilled from is_guest and the payments table on first run
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'unpaid'`);
await (db as any).run(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
await (db as any).run(sql`
UPDATE tickets SET payment_status = 'paid'
WHERE is_guest = 0
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
`);
} catch (e) { /* column may already exist */ }
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
@@ -238,6 +250,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`
@@ -693,6 +714,18 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
// backfilled from is_guest and the payments table on first run
try {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN payment_status VARCHAR(10) NOT NULL DEFAULT 'unpaid'`);
await (db as any).execute(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
await (db as any).execute(sql`
UPDATE tickets SET payment_status = 'paid'
WHERE is_guest = 0
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY,
@@ -719,6 +752,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`
+12 -2
View File
@@ -104,12 +104,14 @@ 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'),
adminNote: text('admin_note'),
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
createdAt: text('created_at').notNull(),
});
@@ -119,8 +121,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'),
@@ -465,6 +470,8 @@ export const pgTickets = pgTable('tickets', {
qrCode: varchar('qr_code', { length: 255 }),
adminNote: pgText('admin_note'),
isGuest: pgInteger('is_guest').notNull().default(0),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
createdAt: timestamp('created_at').notNull(),
});
@@ -476,6 +483,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'),
+52 -4
View File
@@ -24,8 +24,11 @@ import legalPagesRoutes from './routes/legal-pages.js';
import legalSettingsRoutes from './routes/legal-settings.js';
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 { initEmailQueue, stopQueue } from './lib/emailQueue.js';
import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js';
import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js';
import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js';
import { closeRedis } from './lib/redis.js';
import { getLock } from './lib/stores/lock.js';
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
@@ -1932,11 +1935,20 @@ 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.
// onUnavailable 'run': at boot the Redis connection may not be ready yet, and
// seeding is upsert-idempotent, so racing replicas are safe — never skipping
// beats never seeding on a first boot during a Redis blip.
getLock()
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates())
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates(), { onUnavailable: 'run' })
.then((result) => {
if (result === null) {
console.log('[Email] Template seeding skipped (another instance holds the lock)');
@@ -1953,7 +1965,43 @@ console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
// Log which backend (memory/redis, local/s3) each subsystem selected.
logSelectedBackends();
serve({
const server = serve({
fetch: app.fetch,
port,
});
// Graceful shutdown: stop the periodic jobs, stop accepting connections, then
// close Redis and exit. Open SSE payment streams hold sockets forever, so
// server.close() alone never completes — force-close remaining connections
// after a short grace period, with a hard exit as the final backstop.
let shuttingDown = false;
function shutdown(signal: string): void {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[shutdown] ${signal} received, draining...`);
stopBookingCleanup();
stopHoldSweep();
stopEventEndSweep();
stopQueue();
server.close(() => {
console.log('[shutdown] server closed, closing redis');
closeRedis().finally(() => process.exit(0));
});
const forceClose = setTimeout(() => {
console.warn('[shutdown] force-closing remaining connections (SSE streams)');
(server as any).closeAllConnections?.();
}, 5_000);
forceClose.unref();
const forceExit = setTimeout(() => {
console.warn('[shutdown] drain timed out, forcing exit');
process.exit(1);
}, 10_000);
forceExit.unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+5 -2
View File
@@ -1,11 +1,12 @@
// Reports which backend each scalable subsystem is using, for the health
// endpoint and startup logging.
import { isRedisEnabled, isRedisHealthy } from './redis.js';
import { isRedisEnabled, isRedisHealthy, getRedisHealthDetail } from './redis.js';
import { getRateLimiter } from './stores/rateLimiter.js';
import { getPubSub } from './stores/pubsub.js';
import { getCache } from './stores/cache.js';
import { getLock } from './stores/lock.js';
import { getLoginLockout } from './stores/loginLockout.js';
import { getStorage } from './storage.js';
export function describeBackends() {
@@ -14,12 +15,13 @@ export function describeBackends() {
rateLimiter: getRateLimiter().backend,
pubsub: getPubSub().backend,
lock: getLock().backend,
loginLockout: getLoginLockout().backend,
storage: getStorage().backend,
};
}
export function describeRedis() {
return { enabled: isRedisEnabled(), healthy: isRedisHealthy() };
return { enabled: isRedisEnabled(), healthy: isRedisHealthy(), ...getRedisHealthDetail() };
}
/** Log one line per subsystem at startup so the active backend is obvious. */
@@ -32,5 +34,6 @@ export function logSelectedBackends(): void {
console.log(` rate limiter: ${b.rateLimiter}`);
console.log(` pub/sub: ${b.pubsub}`);
console.log(` lock: ${b.lock}`);
console.log(` login lockout:${b.loginLockout}`);
console.log(` storage: ${b.storage}`);
}
+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 './paymentProviders.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)
))
);
+70
View File
@@ -0,0 +1,70 @@
// Single source of truth for event seat accounting.
//
// A seat is held by a booking the moment the money is real or claimed to be:
// - ticket 'confirmed' or 'checked_in' (paid), or
// - ticket 'pending' whose payment is 'pending_approval' (customer clicked
// "I've paid" and is waiting for admin verification).
//
// A bare 'pending' payment — an opened checkout that was never paid nor claimed,
// on any provider — holds NO seat, so abandoned bookings can never block sales.
// The accepted trade-off is a small oversell window when several people book the
// last seats and all later pay/claim; admin approval is the backstop and may
// knowingly approve over capacity (see routes/payments.ts).
//
// 'pending_approval' is a payment status (the ticket row stays 'pending'), so
// every capacity count joins tickets to payments. There is exactly one payment
// row per ticket (created together in routes/tickets.ts).
//
// Every place that counts seats — booking creation, public availability,
// hold recovery, admin dashboards — must go through these builders so the
// formula cannot diverge between surfaces.
import { sql, and, eq, inArray } from 'drizzle-orm';
import { tickets, payments } from '../db/index.js';
// COALESCE keeps the predicate two-valued under the LEFT JOIN (a ticket with no
// payment row must count as "not holding" — NULL would poison NOT ...).
export const seatHoldingSql = sql`(${(tickets as any).status} IN ('confirmed', 'checked_in') OR (${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval'))`;
/**
* Query: number of seats currently held for an event.
* `executor` is the db, or a transaction (sync sqlite tx: finish with `.get()`;
* async pg tx / plain db: await via dbGet).
*/
export function seatHolderCountQuery(executor: any, eventId: string) {
return executor
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
.from(tickets)
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
.where(and(eq((tickets as any).eventId, eventId), seatHoldingSql));
}
/**
* Query: how many of the given tickets do NOT currently hold a seat (and so
* would need fresh capacity if promoted to a seat-holding state).
*/
export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) {
return executor
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
.from(tickets)
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
.where(and(inArray((tickets as any).id, ticketIds), sql`NOT ${seatHoldingSql}`));
}
/**
* Query: per-event breakdown of paid vs claimed seats, grouped by event.
* paidCount = confirmed + checked_in; claimedCount = pending_approval-held.
* Pass `eventId` to restrict to one event (still returns a grouped row).
*/
export function eventSeatBreakdownQuery(executor: any, eventId?: string) {
const query = executor
.select({
eventId: (tickets as any).eventId,
paidCount: sql<number>`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`,
claimedCount: sql<number>`sum(case when ${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval' then 1 else 0 end)`,
})
.from(tickets)
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id));
return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query)
.groupBy((tickets as any).eventId);
}
+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;
}
}
+138
View File
@@ -0,0 +1,138 @@
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
//
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
// states — user clicking "I've paid", an admin approving/reactivating a payment —
// must atomically re-check that the event still has room, exactly like the original
// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending'
// (e.g. reopening a failed payment) claims no seat and skips the check.
//
// Callers choose which ticket statuses are eligible to be flipped via
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are
// left untouched. Tickets that already hold a seat cost no new capacity.
// `options.skipCapacityCheck` lets an admin knowingly approve over capacity —
// the UI warns first (routes/payments.ts /approve with allowOverCapacity).
import { eq, and, inArray } from 'drizzle-orm';
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.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'>;
/** Admin override: reserve even when it puts the event over capacity. */
skipCapacityCheck?: boolean;
}
/**
* 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 (unless the target
* state holds no seat, or skipCapacityCheck is set).
*/
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'];
// Bare 'pending' payments hold no seat, so moving a booking back to 'pending'
// consumes no capacity and needs no check.
const targetHoldsSeat = targetPaymentStatus !== 'pending' || targetTicketStatus === 'confirmed';
const checkCapacity = targetHoldsSeat && !options.skipCapacityCheck;
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;
}
// Keep the ticket-level payment flag in sync when the payment settles
const ticketUpdate: Record<string, any> = { status: targetTicketStatus };
if (targetPaymentStatus === 'paid') {
ticketUpdate.paymentStatus = 'paid';
}
// `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 state 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) => {
if (checkCapacity) {
const countRow = seatHolderCountQuery(tx, eventId).get();
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
}
tx.update(tickets)
.set(ticketUpdate)
.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) => {
if (checkCapacity) {
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
}
await tx.update(tickets)
.set(ticketUpdate)
.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));
});
}
}
+107
View File
@@ -0,0 +1,107 @@
// Auto-hold stale unsettled manual-payment bookings.
//
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
// the customer never clicked "I've paid" and no admin settled them. These are exempt
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
// dead checkouts out of the admin's pending queues.
//
// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a
// claimed payment keeps its seat until an admin approves or rejects it — the admin
// UI surfaces aging claims instead of silently releasing them.
//
// The user receives no notification — they can recover via "I've paid", and an
// admin can approve/reactivate directly; every recovery path re-checks capacity.
import { and, eq, lt, inArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments } from '../db/index.js';
import { getNow, toDbDate } from './utils.js';
import { getLock } from './stores/lock.js';
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.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 unsettled manual payments (and their tickets) to 'on_hold'.
* Covers only bare 'pending' payments on manual providers; 'pending_approval'
* is never swept. Returns the number of payments put on hold.
*/
export async function sweepStaleApprovals(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
(db as any)
.select({
ticketId: (payments as any).ticketId,
paymentId: (payments as any).id,
})
.from(payments)
.where(and(
eq((payments as any).status, 'pending'),
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 unsettled manual 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
*/
+37
View File
@@ -0,0 +1,37 @@
// Payment provider registry.
//
// Every provider is either:
// - 'automatic': the gateway itself confirms the payment (webhook/invoice
// settlement) and the booking is auto-approved on success. No admin involved.
// Currently Lightning; future online gateways (e.g. Stripe) go here.
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
// card handled offline, cash at the door). These are never auto-confirmed
// and never auto-failed; an admin settles them by hand. Bank transfer and
// TPago additionally expose an online "I've paid" step that moves the
// payment to 'pending_approval'.
//
// Capacity note (see lib/capacity.ts): only paid/checked-in tickets and
// 'pending_approval' payments hold a seat. A bare 'pending' payment — of either
// kind — holds no seat, so an abandoned checkout can never block sales.
export type PaymentProviderKind = 'automatic' | 'manual';
export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> = {
lightning: { kind: 'automatic' },
tpago: { kind: 'manual' },
bank_transfer: { kind: 'manual' },
card: { kind: 'manual' },
cash: { kind: 'manual' },
};
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
(p) => PAYMENT_PROVIDERS[p].kind === 'manual'
);
export function isManualProvider(provider: string): boolean {
return PAYMENT_PROVIDERS[provider]?.kind === 'manual';
}
export function isAutomaticProvider(provider: string): boolean {
return PAYMENT_PROVIDERS[provider]?.kind === 'automatic';
}
+80 -10
View File
@@ -4,7 +4,15 @@
// before with in-memory backends. When set, this module owns a single shared
// command connection plus a dedicated subscriber connection (a connection in
// subscribe mode cannot run normal commands), with auto-reconnect, capped
// backoff, and a health flag that callers and the health endpoint can read.
// backoff, an active PING probe, and a health flag that callers and the health
// endpoint can read.
//
// TLS: use a rediss:// URL — ioredis enables TLS from the scheme. A /N path
// selects a DB index (e.g. redis://host:6379/1) when sharing an instance.
// We deliberately do not use ioredis's keyPrefix option: all keys are already
// namespaced per subsystem (cache:, rl:, lock:, lockout:), and keyPrefix has a
// pub/sub asymmetry (SUBSCRIBE channels get prefixed, PUBLISH channels do not)
// that would silently break the payment SSE channel.
import Redis from 'ioredis';
@@ -12,6 +20,14 @@ let client: Redis | null = null;
let subscriber: Redis | null = null;
let healthy = false;
let initialized = false;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let lastPingOkAt: string | null = null;
let lastPingMs: number | null = null;
// Debounce repeated error logs during a sustained outage: transitions are
// always logged, repeated per-retry errors at most once per LOG_EVERY_MS.
const ERROR_LOG_EVERY_MS = 30_000;
let lastErrorLogAt = 0;
/** Whether Redis is configured via REDIS_URL. */
export function isRedisEnabled(): boolean {
@@ -23,14 +39,41 @@ export function isRedisHealthy(): boolean {
return isRedisEnabled() && healthy;
}
/** Detail for the health endpoint: when the last successful PING happened. */
export function getRedisHealthDetail(): { lastPingOkAt: string | null; lastPingMs: number | null } {
return { lastPingOkAt, lastPingMs };
}
function setHealthy(next: boolean, context: string): void {
if (next !== healthy) {
if (next) {
console.log(`[redis] healthy (${context})`);
} else {
console.warn(`[redis] unhealthy (${context})`);
}
}
healthy = next;
}
function logErrorDebounced(label: string, err: unknown): void {
const now = Date.now();
if (now - lastErrorLogAt >= ERROR_LOG_EVERY_MS) {
lastErrorLogAt = now;
console.error(`[redis] (${label}) error:`, (err as any)?.message || err);
}
}
function buildClient(label: string): Redis {
const url = process.env.REDIS_URL as string;
const instance = new Redis(url, {
// Keep the process responsive: fail fast on a per-command basis and let the
// callers degrade to their in-memory fallback rather than hanging.
// callers degrade to their in-memory fallback rather than hanging. Do not
// queue commands while disconnected — with fail-open callers everywhere a
// growing offline queue would only add latency and memory pressure.
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
lazyConnect: false,
connectTimeout: 5000,
retryStrategy(times) {
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
const delay = Math.min(times * 200, 5000);
@@ -42,30 +85,53 @@ function buildClient(label: string): Redis {
console.log(`[redis] (${label}) connecting`);
});
instance.on('ready', () => {
healthy = true;
console.log(`[redis] (${label}) ready`);
setHealthy(true, `${label} ready`);
});
instance.on('error', (err) => {
healthy = false;
console.error(`[redis] (${label}) error:`, err?.message || err);
setHealthy(false, `${label} error`);
logErrorDebounced(label, err);
});
instance.on('reconnecting', () => {
healthy = false;
console.warn(`[redis] (${label}) reconnecting`);
setHealthy(false, `${label} reconnecting`);
});
instance.on('end', () => {
healthy = false;
console.warn(`[redis] (${label}) connection closed`);
setHealthy(false, `${label} connection closed`);
});
return instance;
}
// Actively probe the command connection. The event-driven flag alone misses a
// silently hung connection; a periodic PING with a hard timeout catches it.
async function pingOnce(): Promise<void> {
if (!client) return;
const started = Date.now();
try {
await Promise.race([
client.ping(),
new Promise((_, reject) => {
const t = setTimeout(() => reject(new Error('ping timeout')), 2000);
(t as any).unref?.();
}),
]);
lastPingMs = Date.now() - started;
lastPingOkAt = new Date().toISOString();
setHealthy(true, 'ping ok');
} catch (err) {
setHealthy(false, 'ping failed');
logErrorDebounced('ping', err);
}
}
function ensureInit(): void {
if (initialized || !isRedisEnabled()) return;
initialized = true;
client = buildClient('commands');
subscriber = buildClient('subscriber');
pingTimer = setInterval(() => {
void pingOnce();
}, 10_000);
(pingTimer as any).unref?.();
}
/** Shared command connection, or null when Redis is not configured. */
@@ -82,6 +148,10 @@ export function getSubscriber(): Redis | null {
/** Close connections (used for graceful shutdown). */
export async function closeRedis(): Promise<void> {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
const tasks: Promise<unknown>[] = [];
if (client) tasks.push(client.quit().catch(() => undefined));
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';
const mocks = vi.hoisted(() => ({ redis: null as any }));
vi.mock('../redis.js', () => ({
isRedisEnabled: () => true,
getRedis: () => mocks.redis,
}));
import { MemoryLock, RedisLock, LockUnavailableError } from './lock.js';
describe('MemoryLock', () => {
it('grants the lock, blocks contenders, and frees on release', async () => {
const lock = new MemoryLock();
const token = await lock.acquire('a', 60_000);
expect(token).toBeTruthy();
expect(await lock.acquire('a', 60_000)).toBeNull();
await lock.release('a', token!);
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
it('ignores release with the wrong token', async () => {
const lock = new MemoryLock();
const token = await lock.acquire('a', 60_000);
await lock.release('a', 'not-the-token');
expect(await lock.acquire('a', 60_000)).toBeNull();
await lock.release('a', token!);
});
it('withLock runs fn while held and returns null under contention', async () => {
const lock = new MemoryLock();
const held = await lock.acquire('a', 60_000);
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBeNull();
await lock.release('a', held!);
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBe('ran');
// Released after withLock completes.
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
});
describe('RedisLock', () => {
beforeEach(async () => {
mocks.redis = new RedisMock();
// ioredis-mock shares data between instances by connection string.
await mocks.redis.flushall();
});
it('grants the lock and blocks contenders', async () => {
const lock = new RedisLock();
const token = await lock.acquire('a', 60_000);
expect(token).toBeTruthy();
expect(await lock.acquire('a', 60_000)).toBeNull();
});
it('release is compare-and-delete: wrong token does not free the lock', async () => {
const lock = new RedisLock();
const token = await lock.acquire('a', 60_000);
await lock.release('a', 'not-the-token');
expect(await lock.acquire('a', 60_000)).toBeNull();
await lock.release('a', token!);
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
it('throws LockUnavailableError when the client is not initialized', async () => {
mocks.redis = null;
const lock = new RedisLock();
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
});
it('throws LockUnavailableError when the backend errors', async () => {
mocks.redis = { set: () => Promise.reject(new Error('connection refused')) };
const lock = new RedisLock();
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
});
it('withLock skips (returns null, fn not called) when unavailable by default', async () => {
mocks.redis = null;
const lock = new RedisLock();
const fn = vi.fn(async () => 'ran');
expect(await lock.withLock('a', 60_000, fn)).toBeNull();
expect(fn).not.toHaveBeenCalled();
});
it('withLock runs fn without the lock when onUnavailable is "run"', async () => {
mocks.redis = null;
const lock = new RedisLock();
const fn = vi.fn(async () => 'ran');
expect(await lock.withLock('a', 60_000, fn, { onUnavailable: 'run' })).toBe('ran');
expect(fn).toHaveBeenCalledOnce();
});
it('withLock returns fn result and releases the lock afterwards', async () => {
const lock = new RedisLock();
expect(await lock.withLock('a', 60_000, async () => 42)).toBe(42);
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
});
+44 -10
View File
@@ -5,22 +5,45 @@
//
// Use acquire/release for long-lived ownership (e.g. a background poller) and
// withLock for a one-shot critical section. Selection is based on REDIS_URL.
//
// There is deliberately no auto-renewal/watchdog: every guarded section (sweep
// jobs, template seeding) is a handful of status-conditional bulk UPDATEs that
// finish far below the lock TTL, and a rare TTL overrun only risks one
// idempotent overlapping run. Keep TTLs generous instead of adding renewal.
import { randomUUID } from 'crypto';
import { getRedis, isRedisEnabled } from '../redis.js';
// Thrown when Redis is configured but the lock backend cannot be reached.
// This is distinct from contention (acquire resolves null): the caller must
// decide whether its critical section is safe to run without mutual exclusion.
export class LockUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'LockUnavailableError';
}
}
export interface WithLockOptions {
// What withLock should do when the lock backend is unavailable (not mere
// contention): 'skip' (default) returns null as if the lock were held
// elsewhere; 'run' executes fn without mutual exclusion.
onUnavailable?: 'skip' | 'run';
}
export interface Lock {
readonly backend: 'memory' | 'redis';
// Returns a token when the lock was acquired, or null when already held.
// Throws LockUnavailableError when the backend is configured but erroring.
acquire(key: string, ttlMs: number): Promise<string | null>;
release(key: string, token: string): Promise<void>;
// Runs fn while holding the lock; returns fn's result, or null if not acquired.
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null>;
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null>;
}
// ==================== Memory implementation ====================
class MemoryLock implements Lock {
export class MemoryLock implements Lock {
readonly backend = 'memory' as const;
private held = new Map<string, { token: string; expiresAt: number }>();
@@ -58,23 +81,23 @@ class MemoryLock implements Lock {
const RELEASE_SCRIPT =
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
class RedisLock implements Lock {
export class RedisLock implements Lock {
readonly backend = 'redis' as const;
async acquire(key: string, ttlMs: number): Promise<string | null> {
const redis = getRedis();
if (!redis) {
// Redis configured but unavailable: do not block critical sections.
return randomUUID();
throw new LockUnavailableError('redis client not initialized');
}
const token = randomUUID();
try {
const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX');
return result === 'OK' ? token : null;
} catch (err: any) {
console.error('[lock] redis acquire error, proceeding without lock:', err?.message || err);
// Fail open so a Redis outage does not deadlock startup or jobs.
return randomUUID();
// Do NOT fabricate a token here: during an outage every replica would
// "acquire" every lock and run the guarded sections concurrently. Let the
// caller choose between skipping the run and running unlocked.
throw new LockUnavailableError(err?.message || String(err));
}
}
@@ -88,8 +111,19 @@ class RedisLock implements Lock {
}
}
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
const token = await this.acquire(key, ttlMs);
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null> {
let token: string | null;
try {
token = await this.acquire(key, ttlMs);
} catch (err) {
if (!(err instanceof LockUnavailableError)) throw err;
if (opts?.onUnavailable === 'run') {
console.warn(`[lock] backend unavailable, running "${key}" WITHOUT mutual exclusion:`, err.message);
return fn();
}
console.warn(`[lock] backend unavailable, skipping "${key}" this run:`, err.message);
return null;
}
if (!token) return null;
try {
return await fn();
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';
const mocks = vi.hoisted(() => ({ redis: null as any }));
vi.mock('../redis.js', () => ({
isRedisEnabled: () => true,
getRedis: () => mocks.redis,
}));
import {
MemoryLoginLockout,
RedisLoginLockout,
MAX_LOGIN_ATTEMPTS,
} from './loginLockout.js';
describe('MemoryLoginLockout', () => {
it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
await lockout.recordFailure('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
}
await lockout.recordFailure('user@example.com');
const status = await lockout.isLocked('user@example.com');
expect(status.locked).toBe(true);
expect(status.retryAfter).toBeGreaterThan(0);
});
it('clear removes the lockout', async () => {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('user@example.com');
}
await lockout.clear('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
});
it('treats emails case-insensitively', async () => {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('User@Example.com');
}
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
});
it('expires after the lockout window', async () => {
vi.useFakeTimers();
try {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('user@example.com');
}
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
vi.advanceTimersByTime(16 * 60 * 1000);
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
describe('RedisLoginLockout', () => {
beforeEach(async () => {
mocks.redis = new RedisMock();
// ioredis-mock shares data between instances by connection string.
await mocks.redis.flushall();
});
it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => {
const lockout = new RedisLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
await lockout.recordFailure('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
}
await lockout.recordFailure('user@example.com');
const status = await lockout.isLocked('user@example.com');
expect(status.locked).toBe(true);
expect(status.retryAfter).toBeGreaterThan(0);
});
it('sets the lockout window TTL on the first failure', async () => {
const lockout = new RedisLoginLockout();
await lockout.recordFailure('user@example.com');
const ttl = await mocks.redis.pttl('lockout:user@example.com');
expect(ttl).toBeGreaterThan(0);
});
it('clear removes the lockout', async () => {
const lockout = new RedisLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('user@example.com');
}
await lockout.clear('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
});
it('treats emails case-insensitively', async () => {
const lockout = new RedisLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('User@Example.com');
}
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
});
it('fails open when the client is not initialized', async () => {
mocks.redis = null;
const lockout = new RedisLoginLockout();
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
});
it('fails open when the backend errors', async () => {
mocks.redis = {
get: () => Promise.reject(new Error('connection refused')),
pttl: () => Promise.reject(new Error('connection refused')),
del: () => Promise.reject(new Error('connection refused')),
lockoutRecordFailure: () => Promise.reject(new Error('connection refused')),
};
const lockout = new RedisLoginLockout();
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
await expect(lockout.clear('user@example.com')).resolves.toBeUndefined();
});
});
+164
View File
@@ -0,0 +1,164 @@
// Per-email login lockout abstraction with two implementations:
// - memory: per-process Map (the original routes/auth.ts behavior)
// - redis: shared counter across all instances so the lockout cannot be
// bypassed by round-robining replicas
//
// Semantics: recordFailure starts a window on the first failure; once the
// failure count reaches the max the email is locked until the window expires;
// clear removes the counter on successful login. Selection is based on
// REDIS_URL. The redis implementation fails open (never locked, failures not
// recorded) — the per-IP auth rate limit remains as a backstop during a
// Redis outage.
import type Redis from 'ioredis';
import { getRedis, isRedisEnabled } from '../redis.js';
export const MAX_LOGIN_ATTEMPTS = 5;
export const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
export interface LockoutStatus {
locked: boolean;
// Seconds until the lockout lifts; only set when locked.
retryAfter?: number;
}
export interface LoginLockout {
readonly backend: 'memory' | 'redis';
isLocked(email: string): Promise<LockoutStatus>;
recordFailure(email: string): Promise<void>;
clear(email: string): Promise<void>;
}
// Emails are compared case-insensitively so "User@x.com" and "user@x.com"
// share one counter.
function normalize(email: string): string {
return email.trim().toLowerCase();
}
// ==================== Memory implementation ====================
export class MemoryLoginLockout implements LoginLockout {
readonly backend = 'memory' as const;
private attempts = new Map<string, { count: number; resetAt: number }>();
constructor() {
// Periodically drop expired entries so the Map does not grow unbounded.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of this.attempts) {
if (now > entry.resetAt) this.attempts.delete(key);
}
}, 60_000);
(cleanup as any).unref?.();
}
async isLocked(email: string): Promise<LockoutStatus> {
const entry = this.attempts.get(normalize(email));
const now = Date.now();
if (!entry || now > entry.resetAt) return { locked: false };
if (entry.count >= MAX_LOGIN_ATTEMPTS) {
return { locked: true, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
}
return { locked: false };
}
async recordFailure(email: string): Promise<void> {
const key = normalize(email);
const now = Date.now();
const entry = this.attempts.get(key);
if (!entry || now > entry.resetAt) {
this.attempts.set(key, { count: 1, resetAt: now + LOCKOUT_DURATION_MS });
return;
}
entry.count++;
}
async clear(email: string): Promise<void> {
this.attempts.delete(normalize(email));
}
}
// ==================== Redis implementation ====================
// Same atomic INCR+PEXPIRE shape as the rate limiter's consume script: the
// window starts at the first failure and any TTL-less counter is repaired.
const RECORD_FAILURE_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
if redis.call('PTTL', KEYS[1]) < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return count
`;
type RedisWithLockout = Redis & {
lockoutRecordFailure(key: string, windowMs: number): Promise<number>;
};
function withLockoutCommand(redis: Redis): RedisWithLockout {
if (typeof (redis as any).lockoutRecordFailure !== 'function') {
redis.defineCommand('lockoutRecordFailure', { numberOfKeys: 1, lua: RECORD_FAILURE_SCRIPT });
}
return redis as RedisWithLockout;
}
export class RedisLoginLockout implements LoginLockout {
readonly backend = 'redis' as const;
private key(email: string): string {
return `lockout:${normalize(email)}`;
}
async isLocked(email: string): Promise<LockoutStatus> {
const redis = getRedis();
if (!redis) return { locked: false };
try {
const [count, ttl] = await Promise.all([
redis.get(this.key(email)),
redis.pttl(this.key(email)),
]);
if (count !== null && parseInt(count, 10) >= MAX_LOGIN_ATTEMPTS) {
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(LOCKOUT_DURATION_MS / 1000);
return { locked: true, retryAfter };
}
return { locked: false };
} catch (err: any) {
// Fail open: the per-IP auth rate limit still applies.
console.error('[loginLockout] redis error, treating as unlocked:', err?.message || err);
return { locked: false };
}
}
async recordFailure(email: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await withLockoutCommand(redis).lockoutRecordFailure(this.key(email), LOCKOUT_DURATION_MS);
} catch (err: any) {
console.error('[loginLockout] redis error recording failure:', err?.message || err);
}
}
async clear(email: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.del(this.key(email));
} catch (err: any) {
console.error('[loginLockout] redis error clearing failures:', err?.message || err);
}
}
}
// ==================== Selection ====================
let instance: LoginLockout | null = null;
export function getLoginLockout(): LoginLockout {
if (!instance) {
instance = isRedisEnabled() ? new RedisLoginLockout() : new MemoryLoginLockout();
}
return instance;
}
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';
const mocks = vi.hoisted(() => ({ redis: null as any }));
vi.mock('../redis.js', () => ({
isRedisEnabled: () => true,
getRedis: () => mocks.redis,
}));
import { MemoryRateLimiter, RedisRateLimiter } from './rateLimiter.js';
describe('MemoryRateLimiter', () => {
it('allows up to max within the window, then blocks with retryAfter', async () => {
const limiter = new MemoryRateLimiter();
for (let i = 0; i < 3; i++) {
expect((await limiter.consume('k', 3, 60_000)).allowed).toBe(true);
}
const blocked = await limiter.consume('k', 3, 60_000);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfter).toBeGreaterThan(0);
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
});
it('resets after the window elapses', async () => {
vi.useFakeTimers();
try {
const limiter = new MemoryRateLimiter();
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(false);
vi.advanceTimersByTime(1_500);
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
describe('RedisRateLimiter', () => {
beforeEach(async () => {
mocks.redis = new RedisMock();
// ioredis-mock shares data between instances by connection string.
await mocks.redis.flushall();
});
it('sets the window TTL atomically on the first hit', async () => {
const limiter = new RedisRateLimiter();
expect((await limiter.consume('k', 5, 60_000)).allowed).toBe(true);
const ttl = await mocks.redis.pttl('rl:k');
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(60_000);
});
it('blocks over the limit with a sane retryAfter', async () => {
const limiter = new RedisRateLimiter();
for (let i = 0; i < 2; i++) {
expect((await limiter.consume('k', 2, 60_000)).allowed).toBe(true);
}
const blocked = await limiter.consume('k', 2, 60_000);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfter).toBeGreaterThan(0);
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
});
it('self-heals a counter stranded without a TTL', async () => {
await mocks.redis.set('rl:k', '3');
expect(await mocks.redis.pttl('rl:k')).toBeLessThan(0);
const limiter = new RedisRateLimiter();
await limiter.consume('k', 10, 60_000);
expect(await mocks.redis.pttl('rl:k')).toBeGreaterThan(0);
});
it('fails open when the backend errors', async () => {
mocks.redis = { rlConsume: () => Promise.reject(new Error('connection refused')) };
const limiter = new RedisRateLimiter();
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
});
it('fails open when the client is not initialized', async () => {
mocks.redis = null;
const limiter = new RedisRateLimiter();
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
});
});
+35 -10
View File
@@ -1,10 +1,13 @@
// Rate limiter abstraction with two implementations:
// - memory: per-process fixed window (the original behavior)
// - redis: shared fixed window across all instances (INCR + PEXPIRE)
// - redis: shared fixed window across all instances, implemented as a single
// Lua script so INCR and PEXPIRE are atomic (a crash between separate calls
// would otherwise strand a counter with no expiry)
//
// Selection happens once based on REDIS_URL. On any Redis error the limiter
// fails open (allows the request) so a Redis blip never takes the API down.
import type Redis from 'ioredis';
import { getRedis, isRedisEnabled } from '../redis.js';
export interface RateLimitResult {
@@ -24,7 +27,7 @@ interface Bucket {
resetAt: number;
}
class MemoryRateLimiter implements RateLimiter {
export class MemoryRateLimiter implements RateLimiter {
readonly backend = 'memory' as const;
private buckets = new Map<string, Bucket>();
@@ -58,22 +61,44 @@ class MemoryRateLimiter implements RateLimiter {
// ==================== Redis implementation ====================
class RedisRateLimiter implements RateLimiter {
// Atomically increments the window counter, sets the expiry on the first hit,
// and repairs any counter left without a TTL (self-heals keys stranded by the
// pre-Lua implementation or a lost PEXPIRE). Returns {count, ttlMs}.
export const CONSUME_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('PTTL', KEYS[1])
if ttl < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
ttl = tonumber(ARGV[1])
end
return {count, ttl}
`;
type RedisWithConsume = Redis & {
rlConsume(key: string, windowMs: number): Promise<[number, number]>;
};
function withConsumeCommand(redis: Redis): RedisWithConsume {
if (typeof (redis as any).rlConsume !== 'function') {
// ioredis caches the script SHA and transparently handles NOSCRIPT.
redis.defineCommand('rlConsume', { numberOfKeys: 1, lua: CONSUME_SCRIPT });
}
return redis as RedisWithConsume;
}
export class RedisRateLimiter implements RateLimiter {
readonly backend = 'redis' as const;
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
const redis = getRedis();
if (!redis) return { allowed: true };
const redisKey = `rl:${key}`;
try {
const count = await redis.incr(redisKey);
if (count === 1) {
// First hit in this window: set the expiry that defines the window.
await redis.pexpire(redisKey, windowMs);
}
const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs);
if (count > max) {
const ttl = await redis.pttl(redisKey);
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
return { allowed: false, retryAfter };
}
+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();
}
/**
+47 -5
View File
@@ -3,6 +3,7 @@ import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubs
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
const adminRouter = new Hono();
@@ -20,8 +21,9 @@ const csvEscape = (value: string) => {
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
const now = getNow();
// Get upcoming events
const upcomingEvents = await dbAll(
// Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts)
// so the dashboard's capacity alerts reflect real availability.
const upcomingEventsRaw = await dbAll<any>(
(db as any)
.select()
.from(events)
@@ -34,6 +36,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.orderBy((events as any).startDatetime)
.limit(5)
);
const seatRows = await dbAll<any>(eventSeatBreakdownQuery(db));
const seatsByEvent = new Map<string, { paid: number; claimed: number }>();
for (const row of seatRows) {
seatsByEvent.set(row.eventId, {
paid: Number(row.paidCount) || 0,
claimed: Number(row.claimedCount) || 0,
});
}
const upcomingEvents = upcomingEventsRaw.map((event: any) => {
const counts = seatsByEvent.get(event.id) || { paid: 0, claimed: 0 };
return {
...event,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: Math.max(0, (event.capacity || 0) - counts.paid - counts.claimed),
};
});
// Get recent tickets
const recentTickets = await dbAll(
@@ -70,13 +90,29 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.where(eq((tickets as any).status, 'confirmed'))
);
// 'pending' = checkout opened, nothing paid or claimed (informational);
// 'pending_approval' = customer says they paid, needs admin verification (actionable).
const pendingPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'pending'))
);
const awaitingApprovalPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'pending_approval'))
);
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 +144,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
totalTickets: totalTickets?.count || 0,
confirmedTickets: confirmedTickets?.count || 0,
pendingPayments: pendingPayments?.count || 0,
awaitingApprovalPayments: awaitingApprovalPayments?.count || 0,
onHoldPayments: onHoldPayments?.count || 0,
totalRevenue,
newContacts: newContacts?.count || 0,
totalSubscribers: totalSubscribers?.count || 0,
@@ -213,6 +251,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 +330,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 +342,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 +420,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 +499,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,
+9 -44
View File
@@ -21,6 +21,7 @@ import {
import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import { getLoginLockout } from '../lib/stores/loginLockout.js';
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
// email-dispatching flows) to curb credential stuffing and email flooding.
@@ -36,42 +37,6 @@ type AuthUser = User & {
const auth = new Hono();
// Rate limiting store (in production, use Redis)
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const attempts = loginAttempts.get(email);
if (!attempts) {
return { allowed: true };
}
if (now > attempts.resetAt) {
loginAttempts.delete(email);
return { allowed: true };
}
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) };
}
return { allowed: true };
}
function recordFailedAttempt(email: string): void {
const now = Date.now();
const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION };
attempts.count++;
loginAttempts.set(email, attempts);
}
function clearFailedAttempts(email: string): void {
loginAttempts.delete(email);
}
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(10, 'Password must be at least 10 characters'),
@@ -188,12 +153,12 @@ auth.post('/register', authRateLimit, zValidator('json', registerSchema), async
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
const data = c.req.valid('json');
// Check rate limit
const rateLimit = checkRateLimit(data.email);
if (!rateLimit.allowed) {
return c.json({
// Per-email lockout (shared across instances when Redis is configured).
const lockout = await getLoginLockout().isLocked(data.email);
if (lockout.locked) {
return c.json({
error: 'Too many login attempts. Please try again later.',
retryAfter: rateLimit.retryAfter
retryAfter: lockout.retryAfter
}, 429);
}
@@ -201,7 +166,7 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) =>
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (!user) {
recordFailedAttempt(data.email);
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
@@ -223,12 +188,12 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) =>
const validPassword = await verifyPassword(data.password, user.password);
if (!validPassword) {
recordFailedAttempt(data.email);
await getLoginLockout().recordFailure(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Clear failed attempts on successful login
clearFailedAttempts(data.email);
await getLoginLockout().clear(data.email);
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have the
// plaintext and have verified it. Best-effort: a failure here must not block
+36 -51
View File
@@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
import { slugify, uniqueSlug } from '../lib/slugify.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
interface UserContext {
id: string;
@@ -201,27 +202,28 @@ eventsRouter.get('/', async (c) => {
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
// Single grouped query for booked counts across all events (avoids N+1: previously
// this ran one COUNT query per event).
const countRows = await dbAll<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
.from(tickets)
.where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`)
.groupBy((tickets as any).eventId)
);
const countByEvent = new Map<string, number>();
// Single grouped query for seat counts across all events (avoids N+1: previously
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
// so availableSeats subtracts them together — the same formula the booking-creation
// capacity check enforces (lib/capacity.ts).
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
const countByEvent = new Map<string, { paid: number; claimed: number }>();
for (const row of countRows) {
countByEvent.set(row.eventId, Number(row.count) || 0);
countByEvent.set(row.eventId, {
paid: Number(row.paidCount) || 0,
claimed: Number(row.claimedCount) || 0,
});
}
const eventsWithCounts = result.map((event: any) => {
const normalized = normalizeEvent(event);
const bookedCount = countByEvent.get(event.id) || 0;
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
return {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
};
});
@@ -246,27 +248,14 @@ eventsRouter.get('/:id', async (c) => {
}
}
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
// This ensures check-in doesn't affect capacity/spots_left
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
const normalized = normalizeEvent(event);
const bookedCount = ticketCount?.count || 0;
const counts = await getEventSeatCounts(event.id);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
},
});
});
@@ -278,20 +267,14 @@ async function getSiteTimezone(): Promise<string> {
return settings?.timezone || 'America/Asuncion';
}
// Helper function to get ticket count for an event
async function getEventTicketCount(eventId: string): Promise<number> {
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
return ticketCount?.count || 0;
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat
// counts for one event — see lib/capacity.ts for the seat-holding rule.
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> {
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId));
return {
paid: Number(row?.paidCount) || 0,
claimed: Number(row?.claimedCount) || 0,
};
}
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
@@ -315,12 +298,13 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
return null;
}
const bookedCount = await getEventTicketCount(event.id);
const counts = await getEventSeatCounts(event.id);
const normalized = normalizeEvent(event);
return {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
};
}
@@ -383,13 +367,14 @@ eventsRouter.get('/next/upcoming', async (c) => {
// If we have a valid featured event, return it
if (featuredEvent) {
const bookedCount = await getEventTicketCount(featuredEvent.id);
const counts = await getEventSeatCounts(featuredEvent.id);
const normalized = normalizeEvent(featuredEvent);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
isFeatured: true,
},
});
+207 -22
View File
@@ -1,12 +1,18 @@
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';
import { getLock, LockUnavailableError } from '../lib/stores/lock.js';
const lnbitsRouter = new Hono();
@@ -100,12 +106,26 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
const expiryMs = expirySeconds * 1000;
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
if (!lockToken) {
let lockToken: string | null = null;
let lockUnavailable = false;
try {
lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
} catch (err) {
if (!(err instanceof LockUnavailableError)) throw err;
// Fail open: in webhook-less deployments this poller is the only way a
// Lightning payment gets confirmed, so a Redis outage must not stop it.
// Duplicate pollers across replicas are harmless because
// handlePaymentComplete only acts on rows it actually transitions.
console.warn(`[lnbits] lock backend unavailable, polling ticket ${ticketId} without lock:`, err.message);
lockUnavailable = true;
}
if (!lockToken && !lockUnavailable) {
// Another instance is already polling this ticket.
return;
}
checkerLockTokens.set(ticketId, lockToken);
if (lockToken) {
checkerLockTokens.set(ticketId, lockToken);
}
const startTime = Date.now();
let checkCount = 0;
@@ -264,25 +284,35 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
}
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
let transitioned = 0;
for (const ticket of ticketsToConfirm) {
await (db as any)
const result: any = await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.set({ status: 'confirmed', paymentStatus: 'paid' })
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
transitioned += result?.changes ?? result?.rowCount ?? 0;
await (db as any)
.update(payments)
.set({
.set({
status: 'paid',
reference: paymentHash,
paidAt: now,
updatedAt: now,
})
.where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending')));
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
}
// Only the caller that actually flipped rows sends the emails. The webhook
// and the background poller (and pollers on multiple replicas during a Redis
// outage) can all land here; without this guard they would each email.
if (transitioned === 0) {
console.log(`Ticket ${ticketId} was already confirmed by a concurrent caller, skipping emails`);
return;
}
// Get primary payment for sending receipt
const payment = await dbGet<any>(
(db as any)
@@ -290,7 +320,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
);
// Send confirmation emails asynchronously
// For multi-ticket bookings, send email with all ticket info
Promise.all([
@@ -366,8 +396,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
}
}, 15000);
// Clean up on disconnect
stream.onAbort(() => {
const cleanup = () => {
clearInterval(heartbeat);
const connections = activeConnections.get(ticketId);
if (connections) {
@@ -382,15 +411,171 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
}
}
}
});
// Keep the stream open
while (true) {
await stream.sleep(30000);
};
// Clean up on disconnect
stream.onAbort(cleanup);
// Keep the stream open, and every 15s fall back to reading the ticket
// status from the DB. Pub/sub is best-effort: a message published while the
// subscriber connection was reconnecting is lost, and without this check
// the client would wait forever on a payment that already confirmed.
try {
while (true) {
await stream.sleep(15000);
const current = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
);
if (current?.status === 'confirmed') {
await sendEvent({ type: 'paid', ticketId });
return;
}
}
} finally {
cleanup();
}
});
});
/**
* 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)
*/
+221 -39
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(),
});
@@ -18,6 +19,9 @@ const updatePaymentSchema = z.object({
const approvePaymentSchema = z.object({
adminNote: z.string().optional(),
sendEmail: z.boolean().optional().default(true),
// Admin override: confirm the booking even when it puts the event over
// capacity. The UI asks for explicit confirmation before sending this.
allowOverCapacity: z.boolean().optional().default(false),
});
const rejectPaymentSchema = z.object({
@@ -25,6 +29,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 +93,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 +104,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
};
})
);
// Filter by event(s)
if (eventId) {
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
@@ -164,12 +173,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 +190,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 +239,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;
@@ -271,7 +288,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.set({ status: 'confirmed', paymentStatus: 'paid' })
.where(eq((tickets as any).id, (t as any).id));
}
@@ -303,27 +320,28 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
// Approve payment (admin) - specifically for pending_approval payments
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote, sendEmail } = c.req.valid('json');
const { adminNote, sendEmail, allowOverCapacity } = c.req.valid('json');
const user = (c as any).get('user');
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);
}
// 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.
// Bare 'pending' covers customers who paid but never clicked "I've paid".
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 +349,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 +363,57 @@ 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);
}
}
// Confirm the booking through the shared capacity-checked reservation.
// Tickets that already hold a seat ('pending_approval' claims) cost no new
// capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do.
// When the event is full, the admin gets a structured over-capacity error and
// may retry with allowOverCapacity to knowingly overbook.
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', 'pending'],
skipCapacityCheck: allowOverCapacity,
...(adminNote ? { extraPaymentFields: { adminNote } } : {}),
}
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'Approving this payment puts the event over capacity.',
code: 'EVENT_OVER_CAPACITY',
availableSeats: err.available,
requestedSeats: ticketsToConfirm.length,
}, 409);
}
throw err;
}
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
if (sendEmail !== false) {
Promise.all([
@@ -405,7 +453,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 +512,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');
+222 -241
View File
@@ -4,11 +4,13 @@ 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';
import { seatHolderCountQuery } from '../lib/capacity.js';
const ticketsRouter = new Hono();
@@ -30,11 +32,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 +63,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 +85,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
@@ -118,21 +129,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
}
// Check capacity - count pending, confirmed AND checked_in tickets.
// Pending reservations must hold seats to prevent overselling via unpaid bookings
// (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats).
// Check capacity against held seats (paid/checked-in tickets plus claimed
// manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat.
const existingTicketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, data.eventId),
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
)
)
seatHolderCountQuery(db, data.eventId)
);
const confirmedCount = existingTicketCount?.count || 0;
const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount);
@@ -167,12 +169,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;
@@ -211,16 +222,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
try {
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, data.eventId),
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
)
)
.get();
const countRow = seatHolderCountQuery(tx, data.eventId).get();
const reserved = Number(countRow?.count || 0);
if (isEventSoldOut(event.capacity, reserved)) {
throw new BookingCapacityError('SOLD_OUT');
@@ -243,7 +245,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,
@@ -271,17 +273,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
});
} 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, data.eventId),
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
)
)
);
const countRow = await dbGet<any>(seatHolderCountQuery(tx, data.eventId));
const reserved = Number(countRow?.count || 0);
if (isEventSoldOut(event.capacity, reserved)) {
throw new BookingCapacityError('SOLD_OUT');
@@ -304,7 +296,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,
@@ -369,7 +361,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
for (const t of createdTickets) {
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.set({ status: 'confirmed', paymentStatus: 'paid' })
.where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending')));
await (db as any)
.update(payments)
@@ -408,7 +400,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 +412,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);
@@ -1001,6 +1002,9 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone,
status: ticket.status,
paymentStatus: ticket.paymentStatus,
// Balance to collect at the door for unpaid tickets
amountDue: ticket.paymentStatus === 'unpaid' && event ? event.price : 0,
checkinAt: ticket.checkinAt,
checkedInBy,
},
@@ -1081,10 +1085,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
return c.json({ error: 'Ticket not found' }, 404);
}
if (ticket.status === 'confirmed') {
// Confirmed/checked-in tickets can still be marked paid when they carry an
// unpaid balance (admin-added unpaid tickets collected at the door)
if (['confirmed', 'checked_in'].includes(ticket.status) && ticket.paymentStatus !== 'unpaid') {
return c.json({ error: 'Ticket already confirmed' }, 400);
}
if (ticket.status === 'cancelled') {
return c.json({ error: 'Cannot confirm cancelled ticket' }, 400);
}
@@ -1104,26 +1110,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 (checked-in tickets keep their status)
for (const t of ticketsToConfirm) {
// Update ticket status
await (db as any)
.update(tickets)
.set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' })
.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 +1221,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
@@ -1412,6 +1476,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: ticketStatus,
paymentStatus: 'paid',
qrCode,
checkinAt: data.autoCheckin ? now : null,
adminNote: data.adminNote || null,
@@ -1455,148 +1520,26 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
}, 201);
});
// Admin create manual ticket (sends confirmation email + ticket to attendee)
ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
eventId: z.string(),
firstName: z.string().min(2),
lastName: z.string().optional().or(z.literal('')),
email: z.string().email('Valid email is required for manual tickets'),
phone: z.string().optional().or(z.literal('')),
preferredLanguage: z.enum(['en', 'es']).optional(),
adminNote: z.string().max(1000).optional(),
})), async (c) => {
const data = c.req.valid('json');
// Get event
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
);
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets)
const now = getNow();
const attendeeEmail = data.email.trim();
// Find or create user
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
);
const fullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
if (!user) {
const userId = generateId();
user = {
id: userId,
email: attendeeEmail,
password: '',
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(user);
}
// Check for existing active ticket for this user and event
const existingTicket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(
and(
eq((tickets as any).userId, user.id),
eq((tickets as any).eventId, data.eventId)
)
)
);
if (existingTicket && existingTicket.status !== 'cancelled') {
return c.json({ error: 'This person already has a ticket for this event' }, 400);
}
// Create ticket as confirmed
const ticketId = generateId();
const qrCode = generateTicketCode();
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: attendeeEmail,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: 'confirmed',
qrCode,
checkinAt: null,
adminNote: data.adminNote || null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Create payment record (marked as paid - manual entry)
const paymentId = generateId();
const adminUser = (c as any).get('user');
const newPayment = {
id: paymentId,
ticketId,
provider: 'cash',
amount: event.price,
currency: event.currency,
status: 'paid',
reference: 'Manual ticket',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
// Send booking confirmation email + ticket (asynchronously)
emailService.sendBookingConfirmation(ticketId).then(result => {
if (result.success) {
console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`);
} else {
console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending booking confirmation for manual ticket:', err);
});
return c.json({
ticket: {
...newTicket,
event: {
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
},
},
payment: newPayment,
message: 'Manual ticket created and confirmation email sent',
}, 201);
});
// Admin invite guest ticket (free, confirmed, not counted in revenue)
ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
// type drives payment handling:
// paid — email required; paid cash payment; confirmation email + QR sent
// unpaid — QR issued with balance due (collect at door); pending tpago payment;
// pay-link (Bancard/TPago) email sent when an email is provided
// guest — free comp ticket, not counted in revenue; confirmation email only
// when an email is provided
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
eventId: z.string(),
type: z.enum(['paid', 'unpaid', 'guest']),
firstName: z.string().min(1),
lastName: z.string().optional().or(z.literal('')),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().optional().or(z.literal('')),
preferredLanguage: z.enum(['en', 'es']).optional(),
checkinNow: z.boolean().optional().default(false),
adminNote: z.string().max(1000).optional(),
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
message: 'Email is required for paid tickets',
path: ['email'],
})), async (c) => {
const data = c.req.valid('json');
@@ -1607,18 +1550,20 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
return c.json({ error: 'Event not found' }, 404);
}
// Admin-added tickets bypass the capacity check (intentional over-capacity)
const now = getNow();
const adminUser = (c as any).get('user');
// Find or create user (use placeholder email if none provided)
const attendeeEmail = data.email && data.email.trim()
? data.email.trim()
: `guest-${generateId()}@guestinvite.local`;
const hasEmail = !!(data.email && data.email.trim());
const attendeeEmail = hasEmail
? data.email!.trim()
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
const fullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
// Find or create user
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
);
@@ -1639,8 +1584,8 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
await (db as any).insert(users).values(user);
}
// Check for existing active ticket (only for real emails, not placeholder)
if (data.email && data.email.trim()) {
// Check for existing active ticket (only when a real email was provided)
if (hasEmail) {
const existingTicket = await dbGet<any>(
(db as any)
.select()
@@ -1659,6 +1604,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
const ticketId = generateId();
const qrCode = generateTicketCode();
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid';
const newTicket = {
id: ticketId,
@@ -1666,50 +1612,85 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
eventId: data.eventId,
attendeeFirstName: data.firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
attendeeEmail: hasEmail ? data.email!.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: 'confirmed',
isGuest: 1,
status: data.checkinNow ? 'checked_in' : 'confirmed',
isGuest: data.type === 'guest' ? 1 : 0,
paymentStatus,
qrCode,
checkinAt: null,
checkinAt: data.checkinNow ? now : null,
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
adminNote: data.adminNote || null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Create a $0 payment record to track the invite
// Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid
const paymentId = generateId();
const newPayment = {
id: paymentId,
ticketId,
provider: 'cash',
amount: 0,
currency: event.currency,
status: 'paid',
reference: 'Guest invite',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
const newPayment = data.type === 'unpaid'
? {
id: paymentId,
ticketId,
provider: 'tpago',
amount: event.price,
currency: event.currency,
status: 'pending',
reference: 'Unpaid ticket — collect at door',
paidAt: null,
paidByAdminId: null,
createdAt: now,
updatedAt: now,
}
: {
id: paymentId,
ticketId,
provider: 'cash',
amount: data.type === 'guest' ? 0 : event.price,
currency: event.currency,
status: 'paid',
reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
// Send booking confirmation email if a real email was provided
if (data.email && data.email.trim()) {
// Emails (asynchronous): paid always confirms; guest confirms when an email
// exists; unpaid sends the TPago (Bancard) pay-link instructions instead
if (data.type === 'unpaid') {
if (hasEmail) {
emailService.sendPaymentInstructions(ticketId).then(result => {
if (!result.success) {
console.error(`[Email] Failed to send pay link for unpaid ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending pay link for unpaid ticket:', err);
});
}
} else if (data.type === 'paid' || hasEmail) {
emailService.sendBookingConfirmation(ticketId).then(result => {
if (result.success) {
console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
} else {
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
if (!result.success) {
console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending booking confirmation for guest ticket:', err);
console.error(`[Email] Exception sending booking confirmation for ${data.type} ticket:`, err);
});
}
const messages: Record<string, string> = {
paid: 'Ticket created — confirmation email sent',
unpaid: hasEmail
? 'Unpaid ticket created — payment link sent'
: 'Unpaid ticket created — collect payment at the door',
guest: hasEmail
? 'Guest invited — confirmation email sent'
: 'Guest invited',
};
return c.json({
ticket: {
...newTicket,
@@ -1720,7 +1701,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
},
},
payment: newPayment,
message: 'Guest ticket created successfully',
message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type],
}, 201);
});
+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
+1 -1
View File
@@ -12,5 +12,5 @@
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
+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;
+15 -2
View File
@@ -28,7 +28,20 @@ services:
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
# noeviction is deliberate: rate-limit, lock, and lockout keys must never
# be evicted for correctness. The cache workload is a single short-TTL key,
# so memory pressure is negligible; if caching ever grows, revisit this or
# move the cache to its own DB index.
command:
[
"redis-server",
"--appendonly", "yes",
"--requirepass", "${REDIS_PASSWORD:-change-me-redis-password}",
"--maxmemory", "256mb",
"--maxmemory-policy", "noeviction",
]
environment:
REDISCLI_AUTH: "${REDIS_PASSWORD:-change-me-redis-password}"
volumes:
- redisdata:/data
healthcheck:
@@ -46,7 +59,7 @@ services:
DB_TYPE: postgres
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
DB_POOL_MAX: "15"
REDIS_URL: redis://redis:6379
REDIS_URL: "redis://:${REDIS_PASSWORD:-change-me-redis-password}@redis:6379"
JWT_SECRET: change-me-to-a-strong-secret
FRONTEND_URL: http://localhost:8080
# Optional S3-compatible storage so uploads are shared across replicas.
+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, eventSpotsLeft, isEventSoldOut } 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') });
}
};
@@ -111,16 +108,15 @@ export default function BookingPage() {
return;
}
const bookedCount = eventRes.event.bookedCount ?? 0;
const capacity = eventRes.event.capacity ?? 0;
const soldOut = bookedCount >= capacity;
if (soldOut) {
// Server-authoritative availability — same formula the booking API
// enforces, so a sold-out event is caught here, not at submit time.
if (isEventSoldOut(eventRes.event)) {
toast.error(t('events.details.soldOut'));
router.push(`/events/${eventRes.event.slug}`);
return;
}
const spotsLeft = Math.max(0, capacity - bookedCount);
const spotsLeft = eventSpotsLeft(eventRes.event);
setEvent(eventRes.event);
// Cap quantity by available spots (never allow requesting more than spotsLeft)
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
@@ -131,18 +127,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 +149,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 +193,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 +235,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 +294,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 +326,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 +357,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');
@@ -370,6 +365,23 @@ export default function BookingPage() {
}
} catch (error: any) {
toast.error(error.message || t('booking.form.errors.bookingFailed'));
// Capacity race on the last seats: refresh availability so the page
// reflects reality (sold-out block / lower quantity cap) instead of the
// stale counts loaded when the form was opened.
const message = String(error?.message || '');
if (/sold out|seats available/i.test(message)) {
try {
const { event: freshEvent } = await eventsApi.getById(params.eventId as string);
setEvent(freshEvent);
const freshSpots = eventSpotsLeft(freshEvent);
if (freshSpots > 0) {
setTicketQuantity((q) => Math.min(q, freshSpots));
setAttendees((prev) => prev.slice(0, Math.max(0, freshSpots - 1)));
}
} catch {
// Keep the stale event state if the refresh fails
}
}
} finally {
setSubmitting(false);
}
@@ -392,8 +404,8 @@ export default function BookingPage() {
return null;
}
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
const spotsLeft = eventSpotsLeft(event);
const isSoldOut = isEventSoldOut(event);
// Paying step - waiting for Lightning payment (compact design)
if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) {
@@ -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.',
@@ -0,0 +1,533 @@
'use client';
import { useEffect, useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
dashboardApi,
authApi,
UserProfile,
UserSession,
} from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast';
interface AccountTabProps {
onUpdate?: () => void;
}
/**
* Merged Profile + Security tab: account info, edit-profile form, authentication
* methods, change/set password, active sessions and account deletion.
*/
export default function AccountTab({ onUpdate }: AccountTabProps) {
const { locale } = useLanguage();
const { user, updateUser, logout } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [settingPassword, setSettingPassword] = useState(false);
const [formData, setFormData] = useState({
name: '',
phone: '',
languagePreference: '',
rucNumber: '',
});
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [newPasswordForm, setNewPasswordForm] = useState({
password: '',
confirmPassword: '',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [profileRes, sessionsRes] = await Promise.all([
dashboardApi.getProfile(),
dashboardApi.getSessions(),
]);
setProfile(profileRes.profile);
setSessions(sessionsRes.sessions);
setFormData({
name: profileRes.profile.name || '',
phone: profileRes.profile.phone || '',
languagePreference: profileRes.profile.languagePreference || '',
rucNumber: profileRes.profile.rucNumber || '',
});
} catch (error) {
toast.error(locale === 'es' ? 'Error al cargar datos' : 'Failed to load data');
} finally {
setLoading(false);
}
};
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await dashboardApi.updateProfile(formData);
toast.success(locale === 'es' ? 'Perfil actualizado' : 'Profile updated');
if (user) {
updateUser({
...user,
name: formData.name,
phone: formData.phone,
languagePreference: formData.languagePreference,
rucNumber: formData.rucNumber,
});
}
onUpdate?.();
} catch (error: any) {
toast.error(error.message || (locale === 'es' ? 'Error al actualizar' : 'Update failed'));
} finally {
setSaving(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error(locale === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (passwordForm.newPassword.length < 10) {
toast.error(
locale === 'es'
? 'La contraseña debe tener al menos 10 caracteres'
: 'Password must be at least 10 characters'
);
return;
}
setChangingPassword(true);
try {
await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword);
toast.success(locale === 'es' ? 'Contraseña actualizada' : 'Password updated');
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error: any) {
toast.error(
error.message || (locale === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password')
);
} finally {
setChangingPassword(false);
}
};
const handleSetPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPasswordForm.password !== newPasswordForm.confirmPassword) {
toast.error(locale === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (newPasswordForm.password.length < 10) {
toast.error(
locale === 'es'
? 'La contraseña debe tener al menos 10 caracteres'
: 'Password must be at least 10 characters'
);
return;
}
setSettingPassword(true);
try {
await dashboardApi.setPassword(newPasswordForm.password);
toast.success(locale === 'es' ? 'Contraseña establecida' : 'Password set');
setNewPasswordForm({ password: '', confirmPassword: '' });
loadData();
} catch (error: any) {
toast.error(
error.message || (locale === 'es' ? 'Error al establecer contraseña' : 'Failed to set password')
);
} finally {
setSettingPassword(false);
}
};
const handleUnlinkGoogle = async () => {
if (
!confirm(
locale === 'es'
? '¿Estás seguro de que quieres desvincular tu cuenta de Google?'
: 'Are you sure you want to unlink your Google account?'
)
)
return;
try {
await dashboardApi.unlinkGoogle();
toast.success(locale === 'es' ? 'Google desvinculado' : 'Google unlinked');
loadData();
} catch (error: any) {
toast.error(error.message || (locale === 'es' ? 'Error' : 'Failed'));
}
};
const handleRevokeSession = async (sessionId: string) => {
try {
await dashboardApi.revokeSession(sessionId);
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
toast.success(locale === 'es' ? 'Sesión cerrada' : 'Session revoked');
} catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed');
}
};
const handleRevokeAllSessions = async () => {
if (
!confirm(
locale === 'es'
? '¿Cerrar todas las sesiones? Serás desconectado.'
: 'Log out of all sessions? You will be logged out.'
)
)
return;
try {
await dashboardApi.revokeAllSessions();
toast.success(locale === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
logout();
} catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed');
}
};
const formatDateTime = (dateStr: string) =>
parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-yellow" />
</div>
);
}
const isActive = profile?.accountStatus === 'active';
return (
<div className="max-w-2xl space-y-6">
{/* Account info */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'Información de la cuenta' : 'Account information'}
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between border-b py-2">
<span className="text-gray-600">{locale === 'es' ? 'Correo' : 'Email'}</span>
<span className="font-medium">{profile?.email}</span>
</div>
<div className="flex items-center justify-between border-b py-2">
<span className="text-gray-600">{locale === 'es' ? 'Estado' : 'Status'}</span>
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${
isActive ? 'bg-green-100 text-green-800' : 'bg-amber-100 text-amber-800'
}`}
>
{isActive
? locale === 'es'
? 'Activa'
: 'Active'
: profile?.accountStatus}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">{locale === 'es' ? 'Miembro desde' : 'Member since'}</span>
<span className="font-medium">
{profile?.memberSince
? parseDate(profile.memberSince).toLocaleDateString(
locale === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
)
: '-'}
</span>
</div>
</div>
</Card>
{/* Edit profile */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'Editar perfil' : 'Edit profile'}
</h3>
<form onSubmit={handleSaveProfile} className="space-y-4">
<Input
id="name"
label={locale === 'es' ? 'Nombre' : 'Name'}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<Input
id="phone"
type="tel"
label={locale === 'es' ? 'Teléfono' : 'Phone'}
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<div>
<label className="mb-1.5 block text-sm font-medium text-primary-dark">
{locale === 'es' ? 'Idioma preferido' : 'Preferred language'}
</label>
<select
value={formData.languagePreference}
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
className="w-full rounded-btn border border-secondary-light-gray px-4 py-3 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="">{locale === 'es' ? 'Seleccionar' : 'Select'}</option>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<div>
<Input
id="rucNumber"
label={locale === 'es' ? 'Número de RUC (para facturas)' : 'RUC number (for invoices)'}
value={formData.rucNumber}
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
placeholder={locale === 'es' ? 'Opcional' : 'Optional'}
/>
<p className="mt-1.5 text-xs text-gray-500">
{locale === 'es'
? 'Tu número de RUC paraguayo para facturas fiscales'
: 'Your Paraguayan RUC number for fiscal invoices'}
</p>
</div>
<div className="pt-2">
<Button type="submit" isLoading={saving}>
{locale === 'es' ? 'Guardar cambios' : 'Save changes'}
</Button>
</div>
</form>
<p className="mt-4 border-t pt-4 text-xs text-gray-500">
{locale === 'es'
? 'Para cambiar tu correo, contacta al soporte.'
: 'To change your email, please contact support.'}
</p>
</Card>
{/* Authentication methods */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'Métodos de autenticación' : 'Authentication methods'}
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-card bg-secondary-gray p-3">
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full ${
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
}`}
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className="font-medium">{locale === 'es' ? 'Contraseña' : 'Password'}</p>
<p className="text-sm text-gray-500">
{profile?.hasPassword
? locale === 'es' ? 'Configurada' : 'Set'
: locale === 'es' ? 'No configurada' : 'Not set'}
</p>
</div>
</div>
</div>
<div className="flex items-center justify-between rounded-card bg-secondary-gray p-3">
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full ${
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
}`}
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
</div>
<div>
<p className="font-medium">Google</p>
<p className="text-sm text-gray-500">
{profile?.hasGoogleLinked
? locale === 'es' ? 'Vinculado' : 'Linked'
: locale === 'es' ? 'No vinculado' : 'Not linked'}
</p>
</div>
</div>
{profile?.hasGoogleLinked && profile?.hasPassword && (
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
{locale === 'es' ? 'Desvincular' : 'Unlink'}
</Button>
)}
</div>
</div>
</Card>
{/* Password management */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{profile?.hasPassword
? locale === 'es' ? 'Cambiar contraseña' : 'Change password'
: locale === 'es' ? 'Establecer contraseña' : 'Set password'}
</h3>
{profile?.hasPassword ? (
<form onSubmit={handleChangePassword} className="space-y-4">
<Input
id="currentPassword"
type="password"
label={locale === 'es' ? 'Contraseña actual' : 'Current password'}
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
required
/>
<div>
<Input
id="newPassword"
type="password"
label={locale === 'es' ? 'Nueva contraseña' : 'New password'}
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
required
/>
<p className="mt-1.5 text-xs text-gray-500">
{locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
</div>
<Input
id="confirmPassword"
type="password"
label={locale === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={changingPassword}>
{locale === 'es' ? 'Cambiar contraseña' : 'Change password'}
</Button>
</form>
) : (
<form onSubmit={handleSetPassword} className="space-y-4">
<p className="mb-2 text-sm text-gray-600">
{locale === 'es'
? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.'
: 'You currently sign in with Google. Set a password for more access options.'}
</p>
<div>
<Input
id="password"
type="password"
label={locale === 'es' ? 'Nueva contraseña' : 'New password'}
value={newPasswordForm.password}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
required
/>
<p className="mt-1.5 text-xs text-gray-500">
{locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
</div>
<Input
id="confirmNewPassword"
type="password"
label={locale === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
value={newPasswordForm.confirmPassword}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={settingPassword}>
{locale === 'es' ? 'Establecer contraseña' : 'Set password'}
</Button>
</form>
)}
</Card>
{/* Active sessions */}
<Card className="p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">
{locale === 'es' ? 'Sesiones activas' : 'Active sessions'}
</h3>
{sessions.length > 1 && (
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
{locale === 'es' ? 'Cerrar todas' : 'Log out all'}
</Button>
)}
</div>
{sessions.length === 0 ? (
<p className="text-sm text-gray-600">
{locale === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
</p>
) : (
<div className="space-y-3">
{sessions.map((session, index) => (
<div
key={session.id}
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{session.userAgent
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '…' : '')
: locale === 'es' ? 'Dispositivo desconocido' : 'Unknown device'}
</p>
<p className="text-xs text-gray-500">
{locale === 'es' ? 'Última actividad:' : 'Last active:'}{' '}
{formatDateTime(session.lastActiveAt)}
{session.ipAddress && `${session.ipAddress}`}
</p>
</div>
{index === 0 ? (
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
{locale === 'es' ? 'Esta sesión' : 'This session'}
</span>
) : (
<Button variant="outline" size="sm" onClick={() => handleRevokeSession(session.id)}>
{locale === 'es' ? 'Cerrar' : 'Revoke'}
</Button>
)}
</div>
))}
</div>
)}
</Card>
{/* Danger zone */}
<Card className="border border-red-200 bg-red-50 p-6">
<h3 className="mb-2 text-lg font-semibold text-red-800">
{locale === 'es' ? 'Zona de peligro' : 'Danger zone'}
</h3>
<p className="mb-4 text-sm text-red-700">
{locale === 'es'
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
: 'If you want to delete your account, please contact support.'}
</p>
<Button
variant="outline"
size="sm"
className="border-red-300 text-red-700 hover:bg-red-100"
disabled
>
{locale === 'es' ? 'Eliminar cuenta' : 'Delete account'}
</Button>
</Card>
</div>
);
}
@@ -0,0 +1,423 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import {
CalendarIcon,
ClockIcon,
MapPinIcon,
ArrowTopRightOnSquareIcon,
TicketIcon,
UserPlusIcon,
QrCodeIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import type { UserTicket, NextEventInfo } from '@/lib/api';
import { useLanguage } from '@/context/LanguageContext';
import { formatDateLong, formatTime, parseDate } from '@/lib/utils';
import { StatusPill, deriveTicketStatus } from './_shared/status';
import {
groupByBooking,
isUnpaid,
isAwaitingApproval,
isOnHold,
isActionableAttention,
ticketAmount,
shareTicket,
isToday,
type BookingGroup,
} from './_shared/helpers';
import PayActions from './_shared/PayActions';
import AttentionBanner from './_shared/AttentionBanner';
import QrTicketModal from './_shared/QrTicketModal';
interface OverviewTabProps {
nextEvent: NextEventInfo | null;
tickets: UserTicket[];
locale: string;
userName: string;
onChange: () => void;
}
export default function OverviewTab({
nextEvent,
tickets,
locale,
userName,
onChange,
}: OverviewTabProps) {
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(null);
const activeTickets = useMemo(
() => tickets.filter((t) => t.status !== 'cancelled'),
[tickets]
);
// Banner: the single booking that most needs attention (unpaid first, then
// awaiting approval), ordered by soonest event.
const attentionTicket = useMemo(() => {
const candidates = activeTickets.filter(
(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 = priority(a);
const bUnpaid = priority(b);
if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid;
const aStart = a.event?.startDatetime
? parseDate(a.event.startDatetime).getTime()
: Infinity;
const bStart = b.event?.startDatetime
? parseDate(b.event.startDatetime).getTime()
: Infinity;
return aStart - bStart;
});
return candidates[0] || null;
}, [activeTickets]);
// Hero: full tickets for the next event's booking.
const heroGroup = useMemo<BookingGroup | null>(() => {
if (!nextEvent) return null;
const primary =
activeTickets.find((t) => t.id === nextEvent.ticket.id) || null;
if (!primary) return null;
const groupTickets = primary.bookingId
? activeTickets.filter((t) => t.bookingId === primary.bookingId)
: [primary];
return { bookingId: primary.bookingId || primary.id, tickets: groupTickets };
}, [nextEvent, activeTickets]);
// "Also coming up": upcoming booking groups other than the hero.
const upcomingGroups = useMemo<BookingGroup[]>(() => {
const now = Date.now();
const groups = groupByBooking(
activeTickets.filter(
(t) => t.event && parseDate(t.event.startDatetime).getTime() > now
)
);
return groups.filter((g) => g.bookingId !== heroGroup?.bookingId);
}, [activeTickets, heroGroup]);
const handleShare = async (ticket: UserTicket) => {
const title =
(locale === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: ticket.event?.title) || 'Event';
const result = await shareTicket(ticket.id, title, locale);
if (result === 'copied')
toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied');
else if (result === 'failed')
toast.error(locale === 'es' ? 'No se pudo compartir' : 'Could not share');
};
// Empty state.
if (activeTickets.length === 0) {
return (
<Card className="p-8 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-yellow/15">
<TicketIcon className="h-7 w-7 text-primary-yellow" />
</div>
<h2 className="mb-1 text-xl font-semibold">
{locale === 'es' ? `¡Hola, ${userName}!` : `Welcome, ${userName}!`}
</h2>
<p className="mx-auto mb-6 max-w-sm text-gray-600">
{locale === 'es'
? 'Todavía no tienes reservas. Encuentra tu primer evento de intercambio de idiomas.'
: "You have no bookings yet. Find your first language exchange event."}
</p>
<Link href="/events">
<Button size="lg">
{locale === 'es' ? 'Explorar eventos' : 'Browse events'}
</Button>
</Link>
</Card>
);
}
return (
<div className="space-y-6">
{/* 1 — Attention banner (only when money is owed / awaiting). */}
{attentionTicket && (
<AttentionBanner
ticket={attentionTicket}
locale={locale}
onChange={onChange}
/>
)}
{/* 2 — Next event hero. */}
{heroGroup && heroGroup.tickets[0].event && (
<HeroCard
group={heroGroup}
locale={locale}
onChange={onChange}
onShowQr={() => setQrTickets(heroGroup.tickets)}
onShare={handleShare}
/>
)}
{/* 3 — Also coming up. */}
{upcomingGroups.length > 0 && (
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'También se viene' : 'Also coming up'}
</h3>
<div className="space-y-3">
{upcomingGroups.map((group) => {
const t = group.tickets[0];
const status = deriveTicketStatus(t.status, t.payment?.status);
const title =
(locale === 'es' && t.event?.titleEs
? t.event.titleEs
: t.event?.title) || 'Event';
const { amount, currency } = ticketAmount(t);
return (
<div
key={group.bookingId}
className="flex flex-col gap-3 rounded-card bg-secondary-gray p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate font-medium">{title}</p>
{group.tickets.length > 1 && (
<span className="text-xs text-gray-500">
×{group.tickets.length}
</span>
)}
</div>
<p className="mt-0.5 text-sm text-gray-600">
{t.event && formatDateLong(t.event.startDatetime, locale as 'en' | 'es')}
</p>
</div>
<div className="flex items-center gap-3">
<StatusPill status={status} locale={locale} />
{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}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
/>
) : (
(status === 'confirmed' || status === 'attended') && (
<Button
variant="outline"
size="sm"
onClick={() => setQrTickets(group.tickets)}
>
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
</Button>
)
)}
</div>
</div>
);
})}
</div>
</Card>
)}
{/* 6 — Browse events. */}
<div className="flex justify-center pt-2">
<Link href="/events">
<Button variant="outline" size="lg">
{locale === 'es' ? 'Explorar eventos' : 'Browse events'}
</Button>
</Link>
</div>
{qrTickets && (
<QrTicketModal
tickets={qrTickets}
locale={locale}
onClose={() => setQrTickets(null)}
/>
)}
</div>
);
}
function HeroCard({
group,
locale,
onChange,
onShowQr,
onShare,
}: {
group: BookingGroup;
locale: string;
onChange: () => void;
onShowQr: () => void;
onShare: (ticket: UserTicket) => void;
}) {
const { t } = useLanguage();
const ticket = group.tickets[0];
const event = ticket.event!;
const title =
(locale === 'es' && event.titleEs ? event.titleEs : event.title) || 'Event';
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
const { amount, currency } = ticketAmount(ticket);
const multi = group.tickets.length > 1;
const today = isToday(event.startDatetime);
const showQrReady = status === 'confirmed' || status === 'attended';
return (
<Card className="overflow-hidden">
{/* Image with status pill + today badge overlay. */}
<div className="relative h-44 w-full bg-secondary-gray sm:h-56">
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<TicketIcon className="h-12 w-12 text-gray-300" />
</div>
)}
<div className="absolute right-3 top-3 flex items-center gap-2">
{today && (
<span className="rounded-full bg-primary-yellow px-3 py-1 text-xs font-semibold text-primary-dark shadow-sm">
{locale === 'es' ? 'Hoy' : 'Today'}
</span>
)}
<StatusPill status={status} locale={locale} className="shadow-sm" />
</div>
</div>
<div className="p-6">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-primary-yellow">
{locale === 'es' ? 'Tu próximo evento' : 'Your next event'}
</p>
<div className="mb-4 flex items-start justify-between gap-3">
<h3 className="text-2xl font-bold leading-tight">{title}</h3>
{multi && (
<span className="whitespace-nowrap rounded-full bg-secondary-gray px-3 py-1 text-sm font-medium text-gray-700">
{locale === 'es'
? `${group.tickets.length} entradas`
: `${group.tickets.length} tickets`}
</span>
)}
</div>
<div className="mb-5 space-y-2 text-gray-600">
<p className="flex items-center gap-3">
<CalendarIcon className="h-5 w-5 text-primary-yellow" />
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
</p>
<p className="flex items-center gap-3">
<ClockIcon className="h-5 w-5 text-primary-yellow" />
{formatTime(event.startDatetime, locale as 'en' | 'es')}
</p>
<p className="flex items-center gap-3">
<MapPinIcon className="h-5 w-5 text-primary-yellow" />
<span className="flex-1">{event.location}</span>
{event.locationUrl && (
<a
href={event.locationUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 whitespace-nowrap text-sm font-medium text-secondary-blue hover:underline"
>
{locale === 'es' ? 'Cómo llegar' : 'Get directions'}
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
</a>
)}
</p>
</div>
{/* Today + ready → show the QR prominently for check-in. */}
{today && showQrReady && (
<div className="mb-4 rounded-card bg-primary-yellow/10 p-4 text-center">
<p className="mb-3 text-sm font-medium text-primary-dark">
{locale === 'es'
? 'Muestra este código en la entrada'
: 'Show this code at the door'}
</p>
<Button onClick={onShowQr} size="lg" className="w-full sm:w-auto">
<QrCodeIcon className="mr-2 h-5 w-5" />
{locale === 'es' ? 'Mostrar código QR' : 'Show QR code'}
</Button>
</div>
)}
{/* Smart primary action. */}
{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}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="md"
/>
) : isAwaitingApproval(ticket) ? (
<p className="rounded-card bg-amber-50 px-4 py-3 text-sm text-amber-800">
{t('dashboard.payment.receivedConfirmShortly')}
</p>
) : (
!today && (
<Button onClick={onShowQr} size="md">
<TicketIcon className="mr-2 h-5 w-5" />
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
</Button>
)
)}
{/* Multi-ticket guest reminder. */}
{multi && showQrReady && (
<div className="mt-4 flex flex-col gap-2 rounded-card border border-secondary-light-gray p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-600">
{locale === 'es'
? 'Tienes una entrada de más para un invitado.'
: 'You have a spare ticket for a guest.'}
</p>
<Button
variant="outline"
size="sm"
onClick={() => onShare(group.tickets[1])}
>
<UserPlusIcon className="mr-2 h-4 w-4" />
{locale === 'es' ? 'Compartir con un invitado' : 'Share with a guest'}
</Button>
</div>
)}
</div>
</Card>
);
}
@@ -1,94 +1,35 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { UserPayment } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import { formatDateLong } from '@/lib/utils';
import { StatusPill, derivePaymentStatus } from './_shared/status';
import { pyg, isManualProvider } from './_shared/helpers';
import PayActions from './_shared/PayActions';
interface PaymentsTabProps {
payments: UserPayment[];
language: string;
onChange: () => void;
}
export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
const [filter, setFilter] = useState<'all' | 'paid' | 'pending'>('all');
type Filter = 'all' | 'paid' | 'pending';
const filteredPayments = payments.filter((payment) => {
if (filter === 'all') return true;
if (filter === 'paid') return payment.status === 'paid';
if (filter === 'pending') return payment.status !== 'paid' && payment.status !== 'refunded';
return true;
});
export default function PaymentsTab({ payments, language: locale, onChange }: PaymentsTabProps) {
const [filter, setFilter] = useState<Filter>('all');
const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'America/Asuncion',
});
};
const filtered = useMemo(
() =>
payments.filter((p) => {
if (filter === 'all') return true;
if (filter === 'paid') return p.status === 'paid';
return p.status !== 'paid' && p.status !== 'refunded';
}),
[payments, filter]
);
const formatCurrency = (amount: number, currency: string = 'PYG') => {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
paid: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
pending_approval: 'bg-orange-100 text-orange-800',
refunded: 'bg-purple-100 text-purple-800',
failed: 'bg-red-100 text-red-800',
};
const labels: Record<string, Record<string, string>> = {
en: {
paid: 'Paid',
pending: 'Pending',
pending_approval: 'Awaiting Approval',
refunded: 'Refunded',
failed: 'Failed',
},
es: {
paid: 'Pagado',
pending: 'Pendiente',
pending_approval: 'Esperando Aprobación',
refunded: 'Reembolsado',
failed: 'Fallido',
},
};
return (
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
{labels[language]?.[status] || status}
</span>
);
};
const getProviderLabel = (provider: string) => {
const labels: Record<string, Record<string, string>> = {
en: {
lightning: 'Lightning (Bitcoin)',
cash: 'Cash',
bank_transfer: 'Bank Transfer',
tpago: 'TPago',
bancard: 'Card',
},
es: {
lightning: 'Lightning (Bitcoin)',
cash: 'Efectivo',
bank_transfer: 'Transferencia Bancaria',
tpago: 'TPago',
bancard: 'Tarjeta',
},
};
return labels[language]?.[provider] || provider;
};
// Summary calculations
const totalPaid = payments
.filter((p) => p.status === 'paid')
.reduce((sum, p) => sum + Number(p.amount), 0);
@@ -96,112 +37,143 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
.filter((p) => p.status !== 'paid' && p.status !== 'refunded')
.reduce((sum, p) => sum + Number(p.amount), 0);
const providerLabel = (provider: string) => {
const labels: Record<string, { en: string; es: string }> = {
tpago: { en: 'TPago', es: 'TPago' },
bank_transfer: { en: 'Bank transfer', es: 'Transferencia bancaria' },
lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' },
cash: { en: 'Cash', es: 'Efectivo' },
bancard: { en: 'Card', es: 'Tarjeta' },
};
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
};
const chips: { id: Filter; label: { en: string; es: string } }[] = [
{ id: 'all', label: { en: 'All', es: 'Todos' } },
{ id: 'paid', label: { en: 'Paid', es: 'Pagados' } },
{ id: 'pending', label: { en: 'Pending', es: 'Pendientes' } },
];
return (
<div className="space-y-6">
{/* Summary Cards */}
{/* Totals */}
<div className="grid grid-cols-2 gap-4">
<Card className="p-4">
<p className="text-sm text-gray-600 mb-1">
{language === 'es' ? 'Total Pagado' : 'Total Paid'}
</p>
<p className="text-2xl font-bold text-green-600">
{formatCurrency(totalPaid)}
<Card className="p-5">
<p className="mb-1 text-sm text-gray-600">
{locale === 'es' ? 'Total pagado' : 'Total paid'}
</p>
<p className="text-2xl font-bold text-green-700">{pyg(totalPaid)}</p>
</Card>
<Card className="p-4">
<p className="text-sm text-gray-600 mb-1">
{language === 'es' ? 'Pendiente' : 'Pending'}
</p>
<p className="text-2xl font-bold text-yellow-600">
{formatCurrency(totalPending)}
<Card className="p-5">
<p className="mb-1 text-sm text-gray-600">
{locale === 'es' ? 'Pendiente' : 'Pending'}
</p>
<p className="text-2xl font-bold text-red-700">{pyg(totalPending)}</p>
</Card>
</div>
{/* Filter Buttons */}
<div className="flex gap-2">
{(['all', 'paid', 'pending'] as const).map((f) => (
{/* Filter chips */}
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-secondary-blue text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
key={chip.id}
onClick={() => setFilter(chip.id)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
filter === chip.id
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
}`}
>
{f === 'all' && (language === 'es' ? 'Todos' : 'All')}
{f === 'paid' && (language === 'es' ? 'Pagados' : 'Paid')}
{f === 'pending' && (language === 'es' ? 'Pendientes' : 'Pending')}
{locale === 'es' ? chip.label.es : chip.label.en}
</button>
))}
</div>
{/* Payments List */}
{filteredPayments.length === 0 ? (
{/* Records */}
{filtered.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{language === 'es' ? 'No hay pagos que mostrar' : 'No payments to display'}
{locale === 'es' ? 'No hay pagos que mostrar' : 'No payments to show'}
</p>
</Card>
) : (
<div className="space-y-3">
{filteredPayments.map((payment) => (
<Card key={payment.id} className="p-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold">
{formatCurrency(Number(payment.amount), payment.currency)}
</span>
{getStatusBadge(payment.status)}
{filtered.map((payment) => {
const status = derivePaymentStatus(payment.status);
const eventTitle =
(locale === 'es' && payment.event?.titleEs
? payment.event.titleEs
: 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">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="font-semibold">
{pyg(Number(payment.amount), payment.currency)}
</span>
<StatusPill status={status} locale={locale} />
</div>
<div className="space-y-0.5 text-sm text-gray-600">
<p className="font-medium text-gray-800">{eventTitle}</p>
<p>
{providerLabel(payment.provider)} ·{' '}
{formatDateLong(payment.createdAt, locale as 'en' | 'es')}
</p>
{payment.reference && (
<p className="text-xs text-gray-500">
{locale === 'es' ? 'Ref' : 'Ref'}: {payment.reference}
</p>
)}
</div>
</div>
<div className="text-sm text-gray-600 space-y-1">
{payment.event && (
<p>
{language === 'es' && payment.event.titleEs
? payment.event.titleEs
: payment.event.title}
</p>
<div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end">
{canRebook ? (
<PayActions
ticketId={payment.ticketId}
amount={Number(payment.amount)}
currency={payment.currency}
destination={eventTitle}
locale={locale}
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"
/>
)
)}
<p>
<span className="font-medium">
{language === 'es' ? 'Método:' : 'Method:'}
</span>{' '}
{getProviderLabel(payment.provider)}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Fecha:' : 'Date:'}
</span>{' '}
{formatDate(payment.createdAt)}
</p>
{payment.reference && (
<p>
<span className="font-medium">
{language === 'es' ? 'Referencia:' : 'Reference:'}
</span>{' '}
{payment.reference}
</p>
{payment.invoice && (
<a
href={payment.invoice.pdfUrl || '#'}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
{locale === 'es' ? 'Factura' : 'Invoice'}
</Button>
</a>
)}
</div>
</div>
{payment.invoice && (
<a
href={payment.invoice.pdfUrl || '#'}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
</Button>
</a>
)}
</div>
</Card>
))}
</Card>
);
})}
</div>
)}
</div>
@@ -1,211 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { dashboardApi, UserProfile } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast';
interface ProfileTabProps {
onUpdate?: () => void;
}
export default function ProfileTab({ onUpdate }: ProfileTabProps) {
const { locale: language } = useLanguage();
const { updateUser, user } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
phone: '',
languagePreference: '',
rucNumber: '',
});
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
try {
const res = await dashboardApi.getProfile();
setProfile(res.profile);
setFormData({
name: res.profile.name || '',
phone: res.profile.phone || '',
languagePreference: res.profile.languagePreference || '',
rucNumber: res.profile.rucNumber || '',
});
} catch (error) {
toast.error(language === 'es' ? 'Error al cargar perfil' : 'Failed to load profile');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const res = await dashboardApi.updateProfile(formData);
toast.success(language === 'es' ? 'Perfil actualizado' : 'Profile updated');
// Update auth context
if (user) {
updateUser({
...user,
name: formData.name,
phone: formData.phone,
languagePreference: formData.languagePreference,
rucNumber: formData.rucNumber,
});
}
if (onUpdate) onUpdate();
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error al actualizar' : 'Update failed'));
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
</div>
);
}
return (
<div className="max-w-2xl space-y-6">
{/* Account Info Card */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Información de la Cuenta' : 'Account Information'}
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between py-2 border-b">
<span className="text-gray-600">Email</span>
<span className="font-medium">{profile?.email}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-gray-600">
{language === 'es' ? 'Estado de Cuenta' : 'Account Status'}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${
profile?.accountStatus === 'active'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{profile?.accountStatus === 'active'
? (language === 'es' ? 'Activo' : 'Active')
: profile?.accountStatus}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-gray-600">
{language === 'es' ? 'Miembro Desde' : 'Member Since'}
</span>
<span className="font-medium">
{profile?.memberSince
? parseDate(profile.memberSince).toLocaleDateString(
language === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
)
: '-'}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">
{language === 'es' ? 'Días de Membresía' : 'Membership Days'}
</span>
<span className="font-medium">{profile?.membershipDays || 0}</span>
</div>
</div>
</Card>
{/* Edit Profile Form */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Editar Perfil' : 'Edit Profile'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
id="name"
label={language === 'es' ? 'Nombre' : 'Name'}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<Input
id="phone"
label={language === 'es' ? 'Teléfono' : 'Phone'}
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{language === 'es' ? 'Idioma Preferido' : 'Preferred Language'}
</label>
<select
value={formData.languagePreference}
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary-blue"
>
<option value="">{language === 'es' ? 'Seleccionar' : 'Select'}</option>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<Input
id="rucNumber"
label={language === 'es' ? 'Número de RUC (para facturas)' : 'RUC Number (for invoices)'}
value={formData.rucNumber}
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
placeholder={language === 'es' ? 'Opcional' : 'Optional'}
/>
<p className="text-xs text-gray-500 -mt-2">
{language === 'es'
? 'Tu número de RUC paraguayo para facturas fiscales'
: 'Your Paraguayan RUC number for fiscal invoices'}
</p>
<div className="pt-4">
<Button type="submit" isLoading={saving}>
{language === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
</div>
</form>
</Card>
{/* Email Change Notice */}
<Card className="p-6 bg-gray-50">
<h3 className="text-lg font-semibold mb-2">
{language === 'es' ? 'Cambiar Email' : 'Change Email'}
</h3>
<p className="text-sm text-gray-600 mb-4">
{language === 'es'
? 'Para cambiar tu dirección de email, por favor contacta al soporte.'
: 'To change your email address, please contact support.'}
</p>
<Button variant="outline" size="sm" disabled>
{language === 'es' ? 'Contactar Soporte' : 'Contact Support'}
</Button>
</Card>
</div>
);
}
@@ -1,382 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast';
export default function SecurityTab() {
const { locale: language } = useLanguage();
const { logout } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
const [changingPassword, setChangingPassword] = useState(false);
const [settingPassword, setSettingPassword] = useState(false);
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [newPasswordForm, setNewPasswordForm] = useState({
password: '',
confirmPassword: '',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [profileRes, sessionsRes] = await Promise.all([
dashboardApi.getProfile(),
dashboardApi.getSessions(),
]);
setProfile(profileRes.profile);
setSessions(sessionsRes.sessions);
} catch (error) {
toast.error(language === 'es' ? 'Error al cargar datos' : 'Failed to load data');
} finally {
setLoading(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (passwordForm.newPassword.length < 10) {
toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters');
return;
}
setChangingPassword(true);
try {
await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword);
toast.success(language === 'es' ? 'Contraseña actualizada' : 'Password updated');
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password'));
} finally {
setChangingPassword(false);
}
};
const handleSetPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPasswordForm.password !== newPasswordForm.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (newPasswordForm.password.length < 10) {
toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters');
return;
}
setSettingPassword(true);
try {
await dashboardApi.setPassword(newPasswordForm.password);
toast.success(language === 'es' ? 'Contraseña establecida' : 'Password set');
setNewPasswordForm({ password: '', confirmPassword: '' });
loadData(); // Reload to update profile
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error al establecer contraseña' : 'Failed to set password'));
} finally {
setSettingPassword(false);
}
};
const handleUnlinkGoogle = async () => {
if (!confirm(language === 'es'
? '¿Estás seguro de que quieres desvincular tu cuenta de Google?'
: 'Are you sure you want to unlink your Google account?'
)) {
return;
}
try {
await dashboardApi.unlinkGoogle();
toast.success(language === 'es' ? 'Google desvinculado' : 'Google unlinked');
loadData();
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
}
};
const handleRevokeSession = async (sessionId: string) => {
try {
await dashboardApi.revokeSession(sessionId);
setSessions(sessions.filter((s) => s.id !== sessionId));
toast.success(language === 'es' ? 'Sesión cerrada' : 'Session revoked');
} catch (error) {
toast.error(language === 'es' ? 'Error' : 'Failed');
}
};
const handleRevokeAllSessions = async () => {
if (!confirm(language === 'es'
? '¿Cerrar todas las sesiones? Serás desconectado.'
: 'Log out of all sessions? You will be logged out.'
)) {
return;
}
try {
await dashboardApi.revokeAllSessions();
toast.success(language === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
logout();
} catch (error) {
toast.error(language === 'es' ? 'Error' : 'Failed');
}
};
const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
};
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
</div>
);
}
return (
<div className="max-w-2xl space-y-6">
{/* Authentication Methods */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Métodos de Autenticación' : 'Authentication Methods'}
</h3>
<div className="space-y-4">
{/* Password Status */}
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
}`}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className="font-medium">
{language === 'es' ? 'Contraseña' : 'Password'}
</p>
<p className="text-sm text-gray-500">
{profile?.hasPassword
? (language === 'es' ? 'Configurada' : 'Set')
: (language === 'es' ? 'No configurada' : 'Not set')}
</p>
</div>
</div>
{profile?.hasPassword && (
<span className="text-green-600 text-sm font-medium">
{language === 'es' ? 'Activo' : 'Active'}
</span>
)}
</div>
{/* Google Status */}
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
}`}>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
</div>
<div>
<p className="font-medium">Google</p>
<p className="text-sm text-gray-500">
{profile?.hasGoogleLinked
? (language === 'es' ? 'Vinculado' : 'Linked')
: (language === 'es' ? 'No vinculado' : 'Not linked')}
</p>
</div>
</div>
{profile?.hasGoogleLinked && profile?.hasPassword && (
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
{language === 'es' ? 'Desvincular' : 'Unlink'}
</Button>
)}
</div>
</div>
</Card>
{/* Password Management */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{profile?.hasPassword
? (language === 'es' ? 'Cambiar Contraseña' : 'Change Password')
: (language === 'es' ? 'Establecer Contraseña' : 'Set Password')}
</h3>
{profile?.hasPassword ? (
<form onSubmit={handleChangePassword} className="space-y-4">
<Input
id="currentPassword"
type="password"
label={language === 'es' ? 'Contraseña Actual' : 'Current Password'}
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
required
/>
<Input
id="newPassword"
type="password"
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
required
/>
<p className="text-xs text-gray-500 -mt-2">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmPassword"
type="password"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={changingPassword}>
{language === 'es' ? 'Cambiar Contraseña' : 'Change Password'}
</Button>
</form>
) : (
<form onSubmit={handleSetPassword} className="space-y-4">
<p className="text-sm text-gray-600 mb-4">
{language === 'es'
? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.'
: 'You currently sign in with Google. Set a password for more access options.'}
</p>
<Input
id="password"
type="password"
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
value={newPasswordForm.password}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
required
/>
<p className="text-xs text-gray-500 -mt-2">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmNewPassword"
type="password"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
value={newPasswordForm.confirmPassword}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={settingPassword}>
{language === 'es' ? 'Establecer Contraseña' : 'Set Password'}
</Button>
</form>
)}
</Card>
{/* Active Sessions */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">
{language === 'es' ? 'Sesiones Activas' : 'Active Sessions'}
</h3>
{sessions.length > 1 && (
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
{language === 'es' ? 'Cerrar Todas' : 'Logout All'}
</Button>
)}
</div>
{sessions.length === 0 ? (
<p className="text-sm text-gray-600">
{language === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
</p>
) : (
<div className="space-y-3">
{sessions.map((session, index) => (
<div
key={session.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium text-sm">
{session.userAgent
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '...' : '')
: (language === 'es' ? 'Dispositivo desconocido' : 'Unknown device')}
</p>
<p className="text-xs text-gray-500">
{language === 'es' ? 'Última actividad:' : 'Last active:'} {formatDate(session.lastActiveAt)}
{session.ipAddress && `${session.ipAddress}`}
</p>
</div>
{index !== 0 && (
<Button
variant="outline"
size="sm"
onClick={() => handleRevokeSession(session.id)}
>
{language === 'es' ? 'Cerrar' : 'Revoke'}
</Button>
)}
{index === 0 && (
<span className="text-xs text-green-600 font-medium">
{language === 'es' ? 'Esta sesión' : 'This session'}
</span>
)}
</div>
))}
</div>
)}
</Card>
{/* Danger Zone */}
<Card className="p-6 border-red-200 bg-red-50">
<h3 className="text-lg font-semibold text-red-800 mb-4">
{language === 'es' ? 'Zona de Peligro' : 'Danger Zone'}
</h3>
<p className="text-sm text-red-700 mb-4">
{language === 'es'
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
: 'If you want to delete your account, please contact support.'}
</p>
<Button variant="outline" size="sm" className="border-red-300 text-red-700 hover:bg-red-100" disabled>
{language === 'es' ? 'Eliminar Cuenta' : 'Delete Account'}
</Button>
</Card>
</div>
);
}
@@ -1,209 +1,249 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import {
CalendarIcon,
MapPinIcon,
ArrowDownTrayIcon,
TicketIcon,
} from '@heroicons/react/24/outline';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { UserTicket } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import { formatDateLong, parseDate } from '@/lib/utils';
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';
import QrTicketModal from './_shared/QrTicketModal';
interface TicketsTabProps {
tickets: UserTicket[];
language: string;
onChange: () => void;
}
export default function TicketsTab({ tickets, language }: TicketsTabProps) {
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('all');
type Filter = 'all' | 'upcoming' | 'past';
const now = new Date();
const filteredTickets = tickets.filter((ticket) => {
if (filter === 'all') return true;
const eventDate = ticket.event?.startDatetime
? new Date(ticket.event.startDatetime)
: null;
if (filter === 'upcoming') return eventDate && eventDate > now;
if (filter === 'past') return eventDate && eventDate <= now;
return true;
});
export default function TicketsTab({ tickets, language: locale, onChange }: TicketsTabProps) {
const [filter, setFilter] = useState<Filter>('all');
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(null);
const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'America/Asuncion',
// Group multi-ticket bookings so both QRs sit together, then filter by the
// group's event date.
const groups = useMemo<BookingGroup[]>(() => {
const now = Date.now();
return groupByBooking(tickets).filter((group) => {
if (filter === 'all') return true;
const start = group.tickets[0].event?.startDatetime;
if (!start) return false; // undated tickets only show under "All"
const isUpcoming = parseDate(start).getTime() > now;
return filter === 'upcoming' ? isUpcoming : !isUpcoming;
});
};
}, [tickets, filter]);
const formatCurrency = (amount: number, currency: string = 'PYG') => {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
confirmed: 'bg-green-100 text-green-800',
checked_in: 'bg-blue-100 text-blue-800',
pending: 'bg-yellow-100 text-yellow-800',
cancelled: 'bg-red-100 text-red-800',
};
const labels: Record<string, Record<string, string>> = {
en: {
confirmed: 'Confirmed',
checked_in: 'Checked In',
pending: 'Pending',
cancelled: 'Cancelled',
},
es: {
confirmed: 'Confirmado',
checked_in: 'Registrado',
pending: 'Pendiente',
cancelled: 'Cancelado',
},
};
return (
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
{labels[language]?.[status] || status}
</span>
);
};
const chips: { id: Filter; label: { en: string; es: string } }[] = [
{ id: 'all', label: { en: 'All', es: 'Todas' } },
{ id: 'upcoming', label: { en: 'Upcoming', es: 'Próximas' } },
{ id: 'past', label: { en: 'Past', es: 'Pasadas' } },
];
return (
<div className="space-y-6">
{/* Filter Buttons */}
<div className="flex gap-2">
{(['all', 'upcoming', 'past'] as const).map((f) => (
{/* Filter chips */}
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-secondary-blue text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
key={chip.id}
onClick={() => setFilter(chip.id)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
filter === chip.id
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
}`}
>
{f === 'all' && (language === 'es' ? 'Todas' : 'All')}
{f === 'upcoming' && (language === 'es' ? 'Próximas' : 'Upcoming')}
{f === 'past' && (language === 'es' ? 'Pasadas' : 'Past')}
{locale === 'es' ? chip.label.es : chip.label.en}
</button>
))}
</div>
{/* Tickets List */}
{filteredTickets.length === 0 ? (
{groups.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600 mb-4">
{language === 'es' ? 'No tienes entradas' : 'You have no tickets'}
<p className="mb-4 text-gray-600">
{locale === 'es' ? 'No tienes entradas' : 'You have no tickets'}
</p>
<Link href="/events">
<Button>
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
</Button>
<Button>{locale === 'es' ? 'Explorar eventos' : 'Browse events'}</Button>
</Link>
</Card>
) : (
<div className="space-y-4">
{filteredTickets.map((ticket) => (
<Card key={ticket.id} className="p-4">
<div className="flex flex-col md:flex-row md:items-center gap-4">
{/* Event Image */}
{ticket.event?.bannerUrl && (
<div className="w-full md:w-32 h-24 rounded-lg overflow-hidden flex-shrink-0">
<img
src={ticket.event.bannerUrl}
alt={ticket.event.title}
className="w-full h-full object-cover"
/>
</div>
)}
{/* Ticket Info */}
<div className="flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold">
{language === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: ticket.event?.title || 'Event'}
</h3>
{getStatusBadge(ticket.status)}
</div>
<div className="mt-2 space-y-1 text-sm text-gray-600">
{ticket.event?.startDatetime && (
<p>
<span className="font-medium">
{language === 'es' ? 'Fecha:' : 'Date:'}
</span>{' '}
{formatDate(ticket.event.startDatetime)}
</p>
)}
{ticket.event?.location && (
<p>
<span className="font-medium">
{language === 'es' ? 'Lugar:' : 'Location:'}
</span>{' '}
{ticket.event.location}
</p>
)}
{ticket.payment && (
<p>
<span className="font-medium">
{language === 'es' ? 'Pago:' : 'Payment:'}
</span>{' '}
{formatCurrency(ticket.payment.amount, ticket.payment.currency)} -
<span className={`ml-1 ${
ticket.payment.status === 'paid' ? 'text-green-600' : 'text-yellow-600'
}`}>
{ticket.payment.status === 'paid'
? (language === 'es' ? 'Pagado' : 'Paid')
: (language === 'es' ? 'Pendiente' : 'Pending')}
</span>
</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<Link href={`/booking/success/${ticket.id}`}>
<Button size="sm" className="w-full">
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
</Button>
</Link>
{(ticket.status === 'confirmed' || ticket.status === 'checked_in') && (
<a
href={ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${ticket.id}/pdf`
}
download
className="text-center"
>
<Button variant="outline" size="sm" className="w-full">
{language === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
</Button>
</a>
)}
{ticket.invoice && (
<a
href={ticket.invoice.pdfUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="text-center"
>
<Button variant="outline" size="sm" className="w-full">
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
</Button>
</a>
)}
</div>
</div>
</Card>
{groups.map((group) => (
<BookingCard
key={group.bookingId}
group={group}
locale={locale}
onChange={onChange}
onShowQr={() => setQrTickets(group.tickets)}
/>
))}
</div>
)}
{qrTickets && (
<QrTicketModal
tickets={qrTickets}
locale={locale}
onClose={() => setQrTickets(null)}
/>
)}
</div>
);
}
function BookingCard({
group,
locale,
onChange,
onShowQr,
}: {
group: BookingGroup;
locale: string;
onChange: () => void;
onShowQr: () => void;
}) {
const ticket = group.tickets[0];
const event = ticket.event;
const title =
(locale === 'es' && event?.titleEs ? event.titleEs : event?.title) || 'Event';
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
const { amount, currency } = ticketAmount(ticket);
const multi = group.tickets.length > 1;
const ready = status === 'confirmed' || status === 'attended';
return (
<Card className="p-4">
<div className="flex flex-col gap-4 sm:flex-row">
{/* Image */}
<div className="h-32 w-full flex-shrink-0 overflow-hidden rounded-card bg-secondary-gray sm:h-24 sm:w-32">
{event?.bannerUrl ? (
<img src={event.bannerUrl} alt={title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center">
<TicketIcon className="h-8 w-8 text-gray-300" />
</div>
)}
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="flex items-center gap-2 font-semibold">
<span className="truncate">{title}</span>
{multi && (
<span className="whitespace-nowrap rounded-full bg-secondary-gray px-2 py-0.5 text-xs font-medium text-gray-700">
{locale === 'es'
? `${group.tickets.length} entradas`
: `${group.tickets.length} tickets`}
</span>
)}
</h3>
</div>
<StatusPill status={status} locale={locale} />
</div>
<div className="mt-2 space-y-1 text-sm text-gray-600">
{event?.startDatetime && (
<p className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-primary-yellow" />
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
</p>
)}
{event?.location && (
<p className="flex items-center gap-2">
<MapPinIcon className="h-4 w-4 text-primary-yellow" />
<span className="truncate">{event.location}</span>
</p>
)}
<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">
{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}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="stack"
size="sm"
/>
) : (
<>
{ready && (
<Button size="sm" className="w-full" onClick={onShowQr}>
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
</Button>
)}
{ready && (
<a href={ticketPdfUrl(ticket)} download className="w-full">
<Button variant="outline" size="sm" className="w-full">
<ArrowDownTrayIcon className="mr-1.5 h-4 w-4" />
{locale === 'es' ? 'Descargar' : 'Download'}
</Button>
</a>
)}
{ticket.invoice && (
<a
href={ticket.invoice.pdfUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="w-full"
>
<Button variant="ghost" size="sm" className="w-full">
{locale === 'es' ? 'Factura' : 'Invoice'}
</Button>
</a>
)}
</>
)}
</div>
</div>
</Card>
);
}
@@ -0,0 +1,130 @@
'use client';
import {
ExclamationTriangleIcon,
CheckCircleIcon,
} from '@heroicons/react/24/outline';
import type { UserTicket } from '@/lib/api';
import { useLanguage } from '@/context/LanguageContext';
import PayActions from './PayActions';
import { HoldCountdown } from './Countdown';
import {
holdExpiry,
ticketAmount,
pyg,
isAwaitingApproval,
isOnHold,
HOLD_THRESHOLD_HOURS,
} from './helpers';
/**
* The very-top Overview banner. Shown only when a booking needs attention:
* unpaid -> red banner naming event + amount, a hold-expiry countdown,
* and the "Pay now" / "I've paid" actions.
* awaiting -> calm amber "payment received, we will confirm it
* shortly" state with no further action.
*/
export default function AttentionBanner({
ticket,
locale,
onChange,
}: {
ticket: UserTicket;
locale: string;
onChange: () => void;
}) {
const { t } = useLanguage();
const eventTitle =
(locale === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: 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">
<CheckCircleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-amber-600" />
<div>
<p className="font-semibold text-amber-900">
{t('dashboard.payment.received')}
</p>
<p className="text-sm text-amber-800">
{t('dashboard.payment.confirmForEvent', {
amount: pyg(amount, currency),
event: eventTitle,
})}
</p>
</div>
</div>
);
}
const expiry = holdExpiry(ticket);
return (
<div className="rounded-card border border-red-200 bg-red-50 p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-red-900">
{locale === 'es'
? `Debes ${pyg(amount, currency)} para ${eventTitle}`
: `You owe ${pyg(amount, currency)} for ${eventTitle}`}
</p>
{expiry && (
<p className="mt-0.5 text-sm text-red-700">
<HoldCountdown target={expiry} locale={locale} />
</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"
/>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Live, human countdown to a target time. Renders a short message such as
* "Pay within 23h or your spot is released". Once the target passes it shows a
* calm "expired" message instead of a negative timer.
*/
export function HoldCountdown({
target,
locale,
}: {
target: Date;
locale: string;
}) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60 * 1000);
return () => clearInterval(id);
}, []);
const msLeft = target.getTime() - now;
if (msLeft <= 0) {
return (
<span>
{locale === 'es'
? 'Paga pronto para mantener tu lugar'
: 'Pay soon to keep your spot'}
</span>
);
}
const totalMinutes = Math.floor(msLeft / (60 * 1000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
let remaining: string;
if (hours >= 1) {
remaining = `${hours}h${minutes > 0 ? ` ${minutes}m` : ''}`;
} else {
remaining = `${minutes}m`;
}
return (
<span>
{locale === 'es'
? `Paga en ${remaining} o tu lugar se libera`
: `Pay within ${remaining} or your spot is released`}
</span>
);
}
@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import toast from 'react-hot-toast';
import { CheckCircleIcon } from '@heroicons/react/24/outline';
import Button from '@/components/ui/Button';
import { ticketsApi } from '@/lib/api';
import { useLanguage } from '@/context/LanguageContext';
import { pyg } from './helpers';
interface PayActionsProps {
ticketId: string;
/** Amount owed, used in the confirm copy. */
amount: number;
currency?: string;
/** Where the money is going — event name or account label. */
destination: string;
locale: string;
/** Called after the payment is successfully marked as sent. */
onPaid?: () => void;
size?: 'sm' | 'md' | 'lg';
/** 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;
}
/**
* The two unpaid-ticket actions used across Overview, Tickets and Payments:
* "Pay now" -> opens the existing manual-payment page (TPago / bank details)
* "I've paid" -> short confirm step, then marks the payment as sent
* (moves the ticket to "Awaiting approval").
*/
export default function PayActions({
ticketId,
amount,
currency = 'PYG',
destination,
locale,
onPaid,
size = 'sm',
layout = 'stack',
className,
onHold = false,
}: PayActionsProps) {
const { t } = useLanguage();
const [confirming, setConfirming] = useState(false);
const [marking, setMarking] = useState(false);
const handleConfirm = async () => {
setMarking(true);
try {
await ticketsApi.markPaymentSent(ticketId);
toast.success(t('dashboard.payment.receivedConfirmSoon'));
setConfirming(false);
onPaid?.();
} catch (error: any) {
// Idempotent backend: if already marked, treat as success.
if (error?.message?.includes('already')) {
setConfirming(false);
onPaid?.();
return;
}
toast.error(
error?.message ||
(locale === 'es' ? 'No se pudo marcar el pago' : 'Could not mark payment')
);
} finally {
setMarking(false);
}
};
const containerClass =
layout === 'stack' ? 'flex flex-col gap-2' : 'flex flex-wrap gap-2';
const btnWidth = layout === 'stack' ? 'w-full' : '';
return (
<>
<div className={`${containerClass} ${className || ''}`}>
{!onHold && (
<Link href={`/booking/${ticketId}`} className={btnWidth}>
<Button size={size} className={btnWidth}>
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
</Button>
</Link>
)}
<Button
variant={onHold ? 'primary' : 'outline'}
size={size}
className={btnWidth}
onClick={() => setConfirming(true)}
>
{onHold
? (locale === 'es' ? 'Reservar de nuevo' : 'Rebook')
: (locale === 'es' ? 'Ya pagué' : "I've paid")}
</Button>
</div>
{confirming && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
onClick={() => !marking && setConfirming(false)}
>
<div
className="w-full max-w-sm rounded-card bg-white p-6 shadow-card-hover"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
<CheckCircleIcon className="h-7 w-7 text-amber-600" />
</div>
</div>
<h3 className="mb-2 text-center text-lg font-semibold text-primary-dark">
{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">
{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}>
{onHold
? (locale === 'es' ? 'Sí, reservar de nuevo' : 'Yes, rebook')
: (locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid")}
</Button>
<Button
variant="ghost"
className="w-full"
disabled={marking}
onClick={() => setConfirming(false)}
>
{locale === 'es' ? 'Todavía no' : 'Not yet'}
</Button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,187 @@
'use client';
import { useEffect } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import toast from 'react-hot-toast';
import {
XMarkIcon,
ArrowDownTrayIcon,
CalendarDaysIcon,
CalendarIcon,
MapPinIcon,
ClockIcon,
UserPlusIcon,
} from '@heroicons/react/24/outline';
import Button from '@/components/ui/Button';
import type { UserTicket } from '@/lib/api';
import { formatDateLong, formatTime } from '@/lib/utils';
import {
ticketScanUrl,
ticketPdfUrl,
googleCalendarUrl,
shareTicket,
} from './helpers';
/**
* Full-screen, scannable QR view for the door. Handles single tickets and
* multi-ticket bookings (one QR per ticket, with "Share with a guest" on the
* spares so guests can be let in with their own code).
*/
export default function QrTicketModal({
tickets,
locale,
onClose,
}: {
tickets: UserTicket[];
locale: string;
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [onClose]);
if (tickets.length === 0) return null;
const first = tickets[0];
const event = first.event;
const eventTitle =
(locale === 'es' && event?.titleEs ? event.titleEs : event?.title) || 'Event';
const isMulti = tickets.length > 1;
const handleShare = async (ticketId: string) => {
const result = await shareTicket(ticketId, eventTitle, locale);
if (result === 'copied') {
toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied');
} else if (result === 'failed') {
toast.error(locale === 'es' ? 'No se pudo compartir' : 'Could not share');
}
};
return (
<div className="fixed inset-0 z-[70] overflow-y-auto bg-white">
{/* Top bar */}
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-secondary-light-gray bg-white px-4 py-3">
<span className="text-sm font-semibold text-primary-dark">
{isMulti
? locale === 'es'
? `${tickets.length} entradas`
: `${tickets.length} tickets`
: locale === 'es'
? 'Tu entrada'
: 'Your ticket'}
</span>
<button
onClick={onClose}
aria-label={locale === 'es' ? 'Cerrar' : 'Close'}
className="rounded-full p-2 text-gray-500 hover:bg-gray-100"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="mx-auto max-w-md px-4 pb-12 pt-6">
{/* Event details */}
<div className="mb-6 text-center">
<h2 className="text-xl font-bold text-primary-dark">{eventTitle}</h2>
{event && (
<div className="mt-3 space-y-1.5 text-sm text-gray-600">
<p className="flex items-center justify-center gap-2">
<CalendarIcon className="h-4 w-4 text-primary-yellow" />
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
</p>
<p className="flex items-center justify-center gap-2">
<ClockIcon className="h-4 w-4 text-primary-yellow" />
{formatTime(event.startDatetime, locale as 'en' | 'es')}
</p>
<p className="flex items-center justify-center gap-2">
<MapPinIcon className="h-4 w-4 text-primary-yellow" />
{event.location}
</p>
</div>
)}
</div>
{/* One QR per ticket */}
<div className="space-y-5">
{tickets.map((ticket, idx) => {
const attendee = [ticket.attendeeFirstName, ticket.attendeeLastName]
.filter(Boolean)
.join(' ');
const isSpare = isMulti && idx > 0;
return (
<div
key={ticket.id}
className="rounded-card border border-secondary-light-gray p-5 text-center"
>
{isMulti && (
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">
{locale === 'es' ? 'Entrada' : 'Ticket'} {idx + 1}
{attendee ? `${attendee}` : ''}
{isSpare
? `${locale === 'es' ? 'Invitado' : 'Guest'}`
: ''}
</p>
)}
<div className="inline-block rounded-lg bg-white p-4 shadow-inner">
<QRCodeSVG
value={ticketScanUrl(ticket.id)}
size={220}
level="M"
includeMargin={false}
/>
</div>
<p className="mt-3 font-mono text-sm font-bold tracking-wide text-primary-dark">
{ticket.qrCode}
</p>
{isSpare && (
<button
onClick={() => handleShare(ticket.id)}
className="mx-auto mt-3 flex items-center gap-2 rounded-btn border border-secondary-light-gray px-4 py-2 text-sm font-medium text-primary-dark hover:bg-gray-50"
>
<UserPlusIcon className="h-4 w-4" />
{locale === 'es' ? 'Compartir con un invitado' : 'Share with a guest'}
</button>
)}
</div>
);
})}
</div>
{/* Download + calendar */}
<div className="mt-6 flex flex-col gap-2">
<a href={ticketPdfUrl(first)} download>
<Button className="w-full">
<ArrowDownTrayIcon className="mr-2 h-5 w-5" />
{isMulti
? locale === 'es'
? 'Descargar entradas'
: 'Download tickets'
: locale === 'es'
? 'Descargar'
: 'Download'}
</Button>
</a>
{event && (
<a
href={googleCalendarUrl(event, locale)}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" className="w-full">
<CalendarDaysIcon className="mr-2 h-5 w-5" />
{locale === 'es' ? 'Agregar al calendario' : 'Add to calendar'}
</Button>
</a>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,174 @@
import type { UserTicket, Event } from '@/lib/api';
import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
// Hours a booking holds a seat before the spot is released. There is no
// server-side enforcement field, so the countdown is derived from when the
// 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');
}
export function isManualProvider(provider?: string): boolean {
return provider === 'bank_transfer' || provider === 'tpago';
}
/** A ticket is unpaid when its payment is still pending (not approved/paid). */
export function isUnpaid(ticket: Pick<UserTicket, 'status'> & { payment?: { status?: string } | null }): boolean {
const ps = ticket.payment?.status;
return ticket.status !== 'cancelled' && (ps === 'pending' || ps === 'failed');
}
export function isAwaitingApproval(ticket: { payment?: { status?: string } | null }): boolean {
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.
*/
export function holdExpiry(ticket: UserTicket): Date | null {
if (!isUnpaid(ticket)) return null;
const createdStr = ticket.payment?.createdAt || ticket.createdAt;
if (!createdStr) return null;
const created = parseDate(createdStr);
let expiry = new Date(created.getTime() + PAYMENT_HOLD_HOURS * 60 * 60 * 1000);
// Never hold past the event itself.
const eventStart = ticket.event?.startDatetime ? parseDate(ticket.event.startDatetime) : null;
if (eventStart && eventStart < expiry) expiry = eventStart;
return expiry;
}
/** Amount owed for a ticket (payment amount preferred, falls back to event price). */
export function ticketAmount(ticket: UserTicket): { amount: number; currency: string } {
const amount = ticket.payment?.amount ?? ticket.event?.price ?? 0;
const currency = ticket.payment?.currency ?? ticket.event?.currency ?? 'PYG';
return { amount: Number(amount) || 0, currency };
}
export interface BookingGroup {
bookingId: string;
tickets: UserTicket[];
}
/**
* Group tickets that belong to the same booking (multi-ticket / guest tickets).
* Tickets without a bookingId become single-ticket groups keyed by their id.
* Order of first appearance is preserved.
*/
export function groupByBooking(tickets: UserTicket[]): BookingGroup[] {
const groups = new Map<string, UserTicket[]>();
const order: string[] = [];
for (const ticket of tickets) {
const key = ticket.bookingId || ticket.id;
if (!groups.has(key)) {
groups.set(key, []);
order.push(key);
}
groups.get(key)!.push(ticket);
}
return order.map((bookingId) => ({ bookingId, tickets: groups.get(bookingId)! }));
}
/** Public URL embedded in the scannable QR (matches the PDF + scanner contract). */
export function ticketScanUrl(ticketId: string): string {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `${origin}/ticket/${ticketId}`;
}
/** Download URL for a ticket / whole booking PDF. */
export function ticketPdfUrl(ticket: UserTicket): string {
return ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${ticket.id}/pdf`;
}
/** Build a Google Calendar "add event" URL — no backend needed. */
export function googleCalendarUrl(event: Event, locale: string): string {
const start = parseDate(event.startDatetime);
const end = event.endDatetime
? parseDate(event.endDatetime)
: new Date(start.getTime() + 2 * 60 * 60 * 1000);
const fmt = (d: Date) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
const title = (locale === 'es' && event.titleEs ? event.titleEs : event.title) || 'Spanglish';
const params = new URLSearchParams({
action: 'TEMPLATE',
text: title,
dates: `${fmt(start)}/${fmt(end)}`,
location: event.location || '',
ctz: EVENT_TIMEZONE,
});
return `https://www.google.com/calendar/render?${params.toString()}`;
}
/**
* Share a single (spare) ticket's QR with a guest. Uses the Web Share API when
* available, otherwise copies the ticket link to the clipboard.
*/
export async function shareTicket(
ticketId: string,
eventTitle: string,
locale: string
): Promise<'shared' | 'copied' | 'failed'> {
const url = ticketScanUrl(ticketId);
const text =
locale === 'es'
? `Tu entrada para ${eventTitle}`
: `Your ticket for ${eventTitle}`;
try {
if (typeof navigator !== 'undefined' && (navigator as any).share) {
await (navigator as any).share({ title: eventTitle, text, url });
return 'shared';
}
if (typeof navigator !== 'undefined' && navigator.clipboard) {
await navigator.clipboard.writeText(url);
return 'copied';
}
return 'failed';
} catch (err: any) {
// User cancelled the native share sheet.
if (err?.name === 'AbortError') return 'shared';
return 'failed';
}
}
/** True when the event's calendar date is today (Asunción time). */
export function isToday(dateStr?: string): boolean {
if (!dateStr) return false;
const opts: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: EVENT_TIMEZONE,
};
const fmt = (d: Date) => d.toLocaleDateString('en-CA', opts);
return fmt(parseDate(dateStr)) === fmt(new Date());
}
@@ -0,0 +1,101 @@
'use client';
import clsx from 'clsx';
import type { UserTicket, Payment } from '@/lib/api';
// One logical status per meaning, mapped to a single pale colour everywhere.
// confirmed -> pale green
// awaiting -> pale amber (user said they paid, admin checking)
// 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'
| 'onHold';
/**
* Collapse a ticket status + payment status into a single user-facing status.
* Never surface raw values like "checked_in" or "pending_approval".
*/
export function deriveTicketStatus(
ticketStatus?: string,
paymentStatus?: string
): 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';
}
/** Status for a standalone payment record (Payments tab). */
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';
}
export function statusLabel(status: DashStatus, locale: string): string {
const labels: Record<DashStatus, { en: string; es: string }> = {
confirmed: { en: 'Confirmed', es: 'Confirmado' },
awaiting: { en: 'Awaiting approval', es: 'Esperando aprobación' },
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;
}
const PILL_STYLES: Record<DashStatus, string> = {
confirmed: 'bg-green-100 text-green-800',
awaiting: 'bg-amber-100 text-amber-800',
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({
status,
locale,
className,
}: {
status: DashStatus;
locale: string;
className?: string;
}) {
return (
<span
className={clsx(
'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap',
PILL_STYLES[status],
className
)}
>
{statusLabel(status, locale)}
</span>
);
}
/** Convenience: derive + render a pill straight from a ticket. */
export function TicketStatusPill({
ticket,
locale,
className,
}: {
ticket: Pick<UserTicket, 'status'> & { payment?: Pick<Payment, 'status'> | null };
locale: string;
className?: string;
}) {
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
return <StatusPill status={status} locale={locale} className={className} />;
}
+42 -320
View File
@@ -4,34 +4,28 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
import { formatDateLong, formatTime } from '@/lib/utils';
import {
dashboardApi,
NextEventInfo,
UserTicket,
UserPayment,
} from '@/lib/api';
import toast from 'react-hot-toast';
import Link from 'next/link';
import {
socialConfig,
getWhatsAppUrl,
getInstagramUrl,
getTelegramUrl
} from '@/lib/socialLinks';
import { CardListSkeleton } from '@/components/ui/Skeleton';
// Tab components
import OverviewTab from './components/OverviewTab';
import TicketsTab from './components/TicketsTab';
import PaymentsTab from './components/PaymentsTab';
import ProfileTab from './components/ProfileTab';
import SecurityTab from './components/SecurityTab';
import AccountTab from './components/AccountTab';
type Tab = 'overview' | 'tickets' | 'payments' | 'profile' | 'security';
type Tab = 'overview' | 'tickets' | 'payments' | 'account';
export default function DashboardPage() {
const router = useRouter();
const { t, locale: language } = useLanguage();
const { locale } = useLanguage();
const { user, isLoading: authLoading, token } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
const [tickets, setTickets] = useState<UserTicket[]>([]);
const [payments, setPayments] = useState<UserPayment[]>([]);
@@ -42,369 +36,97 @@ export default function DashboardPage() {
router.push('/login');
return;
}
if (user && token) {
loadDashboardData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authLoading, token]);
const loadDashboardData = async () => {
setLoading(true);
try {
const [summaryRes, nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
dashboardApi.getSummary(),
const [nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
dashboardApi.getNextEvent(),
dashboardApi.getTickets(),
dashboardApi.getPayments(),
]);
setSummary(summaryRes.summary);
setNextEvent(nextEventRes.nextEvent);
setTickets(ticketsRes.tickets);
setPayments(paymentsRes.payments);
} catch (error: any) {
} catch (error) {
console.error('Failed to load dashboard:', error);
toast.error('Failed to load dashboard data');
toast.error(locale === 'es' ? 'Error al cargar el panel' : 'Failed to load dashboard data');
} finally {
setLoading(false);
}
};
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: language === 'es' ? 'Resumen' : 'Overview' },
{ id: 'tickets', label: language === 'es' ? 'Mis Entradas' : 'My Tickets' },
{ id: 'payments', label: language === 'es' ? 'Pagos y Facturas' : 'Payments & Invoices' },
{ id: 'profile', label: language === 'es' ? 'Perfil' : 'Profile' },
{ id: 'security', label: language === 'es' ? 'Seguridad' : 'Security' },
const tabs: { id: Tab; label: { en: string; es: string } }[] = [
{ id: 'overview', label: { en: 'Overview', es: 'Resumen' } },
{ id: 'tickets', label: { en: 'Tickets', es: 'Entradas' } },
{ id: 'payments', label: { en: 'Payments', es: 'Pagos' } },
{ id: 'account', label: { en: 'Account', es: 'Cuenta' } },
];
if (authLoading || !user) {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
<div className="section-padding flex min-h-[70vh] items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-primary-yellow" />
</div>
);
}
const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es');
return (
<div className="section-padding min-h-[70vh]">
<div className="container-page">
{/* Welcome Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">
{language === 'es' ? `Hola, ${user.name}!` : `Welcome, ${user.name}!`}
</h1>
{summary && (
<p className="text-gray-600">
{language === 'es'
? `Miembro desde hace ${summary.user.membershipDays} días`
: `Member for ${summary.user.membershipDays} days`
}
</p>
)}
</div>
{/* Welcome header */}
<h1 className="mb-6 text-3xl font-bold">
{locale === 'es' ? `¡Hola, ${user.name}!` : `Welcome, ${user.name}!`}
</h1>
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex gap-4 -mb-px overflow-x-auto">
{/* Tab navigation */}
<div className="mb-6 border-b border-secondary-light-gray">
<nav className="-mb-px flex gap-6 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm transition-colors ${
className={`whitespace-nowrap border-b-2 px-1 pb-3 text-sm font-semibold transition-colors ${
activeTab === tab.id
? 'border-secondary-blue text-secondary-blue'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
{locale === 'es' ? tab.label.es : tab.label.en}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{/* Tab content */}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
</div>
<CardListSkeleton count={3} />
) : (
<>
{activeTab === 'overview' && (
<OverviewTab
summary={summary}
nextEvent={nextEvent}
tickets={tickets}
language={language}
formatDate={formatDate}
formatTime={formatTime}
locale={locale}
userName={user.name}
onChange={loadDashboardData}
/>
)}
{activeTab === 'tickets' && (
<TicketsTab tickets={tickets} language={language} />
<TicketsTab tickets={tickets} language={locale} onChange={loadDashboardData} />
)}
{activeTab === 'payments' && (
<PaymentsTab payments={payments} language={language} />
)}
{activeTab === 'profile' && (
<ProfileTab onUpdate={loadDashboardData} />
)}
{activeTab === 'security' && (
<SecurityTab />
<PaymentsTab payments={payments} language={locale} onChange={loadDashboardData} />
)}
{activeTab === 'account' && <AccountTab onUpdate={loadDashboardData} />}
</>
)}
</div>
</div>
);
}
// Overview Tab Component
function OverviewTab({
summary,
nextEvent,
tickets,
language,
formatDate,
formatTime,
}: {
summary: DashboardSummary | null;
nextEvent: NextEventInfo | null;
tickets: UserTicket[];
language: string;
formatDate: (date: string) => string;
formatTime: (date: string) => string;
}) {
return (
<div className="space-y-6">
{/* Stats Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-secondary-blue">{summary.stats.totalTickets}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Total Entradas' : 'Total Tickets'}
</div>
</Card>
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-green-600">{summary.stats.confirmedTickets}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Confirmadas' : 'Confirmed'}
</div>
</Card>
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-purple-600">{summary.stats.upcomingEvents}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Próximos' : 'Upcoming'}
</div>
</Card>
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-orange-500">{summary.stats.pendingPayments}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Pagos Pendientes' : 'Pending Payments'}
</div>
</Card>
</div>
)}
{/* Next Event Card */}
{nextEvent ? (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Tu Próximo Evento' : 'Your Next Event'}
</h3>
<div className="flex flex-col md:flex-row gap-6">
{nextEvent.event.bannerUrl && (
<div className="w-full md:w-48 h-32 rounded-lg overflow-hidden">
<img
src={nextEvent.event.bannerUrl}
alt={nextEvent.event.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex-1">
<h4 className="text-xl font-bold mb-2">
{language === 'es' && nextEvent.event.titleEs
? nextEvent.event.titleEs
: nextEvent.event.title}
</h4>
<div className="space-y-2 text-gray-600">
<p>
<span className="font-medium">
{language === 'es' ? 'Fecha:' : 'Date:'}
</span>{' '}
{formatDate(nextEvent.event.startDatetime)}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Hora:' : 'Time:'}
</span>{' '}
{formatTime(nextEvent.event.startDatetime)}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Lugar:' : 'Location:'}
</span>{' '}
{nextEvent.event.location}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Estado:' : 'Status:'}
</span>{' '}
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
nextEvent.payment?.status === 'paid'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{nextEvent.payment?.status === 'paid'
? (language === 'es' ? 'Pagado' : 'Paid')
: (language === 'es' ? 'Pendiente' : 'Pending')}
</span>
</p>
</div>
<div className="mt-4 flex gap-2">
<Link href={`/booking/success/${nextEvent.ticket.id}`}>
<Button size="sm">
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
</Button>
</Link>
{nextEvent.event.locationUrl && (
<a
href={nextEvent.event.locationUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
{language === 'es' ? 'Ver Mapa' : 'View Map'}
</Button>
</a>
)}
</div>
</div>
</div>
</Card>
) : (
<Card className="p-6 text-center">
<p className="text-gray-600 mb-4">
{language === 'es'
? 'No tienes eventos próximos'
: 'You have no upcoming events'}
</p>
<Link href="/events">
<Button>
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
</Button>
</Link>
</Card>
)}
{/* Recent Tickets */}
{tickets.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Entradas Recientes' : 'Recent Tickets'}
</h3>
<div className="space-y-3">
{tickets.slice(0, 3).map((ticket) => (
<div
key={ticket.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium">
{language === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: ticket.event?.title || 'Event'}
</p>
<p className="text-sm text-gray-600">
{ticket.event?.startDatetime
? formatDate(ticket.event.startDatetime)
: ''}
</p>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
ticket.status === 'confirmed' || ticket.status === 'checked_in'
? 'bg-green-100 text-green-800'
: ticket.status === 'cancelled'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{ticket.status}
</span>
</div>
))}
</div>
{tickets.length > 3 && (
<div className="mt-4 text-center">
<Button variant="outline" size="sm" onClick={() => {}}>
{language === 'es' ? 'Ver Todas' : 'View All'}
</Button>
</div>
)}
</Card>
)}
{/* Community Links */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Comunidad' : 'Community'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{getWhatsAppUrl(socialConfig.whatsapp) && (
<a
href={getWhatsAppUrl(socialConfig.whatsapp)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-green-50 rounded-lg hover:bg-green-100 transition-colors"
>
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
</div>
<span className="font-medium">WhatsApp</span>
</a>
)}
{getInstagramUrl(socialConfig.instagram) && (
<a
href={getInstagramUrl(socialConfig.instagram)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-pink-50 rounded-lg hover:bg-pink-100 transition-colors"
>
<div className="w-10 h-10 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
</div>
<span className="font-medium">Instagram</span>
</a>
)}
{getTelegramUrl(socialConfig.telegram) && (
<a
href={getTelegramUrl(socialConfig.telegram)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors"
>
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
</div>
<span className="font-medium">Telegram</span>
</a>
)}
</div>
</Card>
</div>
);
}
@@ -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>
);
}
@@ -5,7 +5,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons';
@@ -43,9 +43,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
.catch(console.error);
}, [eventId]);
// Spots left: never negative; sold out when confirmed >= capacity
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
// Server-authoritative availability (paid + claimed seats count; abandoned
// pending bookings don't) — matches the booking API's sold-out check exactly.
const spotsLeft = eventSpotsLeft(event);
const isSoldOut = isEventSoldOut(event);
const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
useEffect(() => {
@@ -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>
);
}
@@ -117,13 +117,18 @@ function generateEventJsonLd(event: Event) {
'@type': 'Offer',
price: event.price,
priceCurrency: event.currency,
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
availability:
(typeof event.availableSeats === 'number'
? event.availableSeats
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0
? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut',
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(
@@ -17,3 +18,23 @@ export function StatusBadge({ status, compact = false }: { status: string; compa
</span>
);
}
// Ticket payment status: Paid (revenue), Unpaid (balance due at door), Comp (free guest).
// Tickets created before the payment_status column default to unpaid via backfill.
export function PaymentBadge({ paymentStatus, compact = false }: { paymentStatus?: string; compact?: boolean }) {
if (!paymentStatus) return null;
const styles: Record<string, string> = {
paid: 'bg-emerald-100 text-emerald-700',
unpaid: 'bg-orange-100 text-orange-700',
comp: 'bg-amber-100 text-amber-700',
};
return (
<span className={clsx(
'inline-flex items-center rounded-full font-medium',
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
styles[paymentStatus] || 'bg-gray-100 text-gray-800'
)}>
{paymentStatus === 'comp' ? 'Comp' : paymentStatus === 'unpaid' ? 'Unpaid' : 'Paid'}
</span>
);
}
@@ -0,0 +1,209 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import clsx from 'clsx';
import {
BanknotesIcon,
CheckCircleIcon,
EnvelopeIcon,
LinkIcon,
StarIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { AddTicketType, AddTicketFormState } from '../_types';
interface AddTicketModalProps {
open: boolean;
onClose: () => void;
form: AddTicketFormState;
setForm: Dispatch<SetStateAction<AddTicketFormState>>;
onSubmit: (e: FormEvent) => void;
submitting: boolean;
eventPriceLabel: string;
}
const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [
{ value: 'paid', label: 'Paid' },
{ value: 'unpaid', label: 'Unpaid' },
{ value: 'guest', label: 'Guest' },
];
const SUBMIT_LABELS: Record<AddTicketType, string> = {
paid: 'Create & send ticket',
unpaid: 'Create & send pay link',
guest: 'Invite guest',
};
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: EnvelopeIcon,
unpaid: LinkIcon,
guest: StarIcon,
};
// Live "what happens" preview lines for the selected type / email / check-in combo
function previewLines(form: AddTicketFormState, eventPriceLabel: string): string[] {
const hasEmail = !!form.email.trim();
const lines: string[] = [];
if (form.type === 'paid') {
lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`);
lines.push('Confirmation email with QR ticket sent');
} else if (form.type === 'unpaid') {
lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`);
lines.push('QR code issued, flagged "unpaid" for door staff');
lines.push(hasEmail
? 'Bancard (TPago) payment link emailed to the attendee'
: 'No email — no pay link sent, payment collected at the door');
} else {
lines.push('Free guest ticket (comp) — not counted in revenue');
lines.push('Auto-confirmed with QR code');
lines.push(hasEmail ? 'Confirmation email sent' : 'No email — nothing is sent');
}
if (form.checkinNow) {
lines.push('Checked in immediately');
}
return lines;
}
const PREVIEW_STYLES: Record<AddTicketType, { box: string; icon: string; text: string }> = {
paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' },
unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' },
guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' },
};
const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
paid: CheckCircleIcon,
unpaid: BanknotesIcon,
guest: StarIcon,
};
export function AddTicketModal({
open,
onClose,
form,
setForm,
onSubmit,
submitting,
eventPriceLabel,
}: AddTicketModalProps) {
if (!open) return null;
const emailRequired = form.type === 'paid';
const style = PREVIEW_STYLES[form.type];
const PreviewIcon = PREVIEW_ICONS[form.type];
const SubmitIcon = SUBMIT_ICONS[form.type];
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={onClose}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-bold">Add Ticket</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={onSubmit} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
{/* Segmented ticket-type control */}
<div className="flex rounded-btn bg-gray-100 p-1">
{TYPE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
className={clsx(
'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors',
form.type === option.value
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
)}
>
{option.label}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={form.lastName}
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email {emailRequired && '*'}</label>
<input type="email" required={emailRequired} value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
<p className="text-[10px] text-gray-500 mt-1">
{form.type === 'paid' && 'Ticket will be sent to this email'}
{form.type === 'unpaid' && 'If provided, the payment link is sent here'}
{form.type === 'guest' && 'If provided, a confirmation email will be sent'}
</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={form.adminNote}
onChange={(e) => setForm((f) => ({ ...f, adminNote: e.target.value }))}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} placeholder="Internal note..." />
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="checkinNow" checked={form.checkinNow}
onChange={(e) => setForm((f) => ({ ...f, checkinNow: e.target.checked }))}
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
<label htmlFor="checkinNow" className="text-sm font-medium">Check in now</label>
</div>
{/* Live preview of what this submission does */}
<div className={clsx('border rounded-lg p-3', style.box)}>
<div className="flex items-start gap-2">
<PreviewIcon className={clsx('w-4 h-4 mt-0.5 flex-shrink-0', style.icon)} />
<div className={clsx('text-xs', style.text)}>
<p className="font-medium">What happens:</p>
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
{previewLines(form, eventPriceLabel).map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<SubmitIcon className="w-4 h-4 mr-1.5" />
{SUBMIT_LABELS[form.type]}
</Button>
</div>
</form>
</Card>
</div>
);
}
@@ -1,17 +1,16 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react';
import { Ticket } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { BottomSheet } from '@/components/admin/MobileComponents';
import clsx from 'clsx';
import {
BanknotesIcon,
CheckCircleIcon,
EnvelopeIcon,
PlusIcon,
StarIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
import type { AttendeeStatusFilter, AddTicketType } from '../_types';
interface EventModalsProps {
// counts + filter
@@ -20,6 +19,7 @@ interface EventModalsProps {
confirmedCount: number;
checkedInCount: number;
cancelledCount: number;
onHoldCount: number;
statusFilter: AttendeeStatusFilter;
setStatusFilter: (value: AttendeeStatusFilter) => void;
// mobile filter sheet
@@ -28,6 +28,7 @@ interface EventModalsProps {
// add ticket sheet
showAddTicketSheet: boolean;
setShowAddTicketSheet: (value: boolean) => void;
openAddTicket: (type: AddTicketType) => void;
// export sheets
showExportSheet: boolean;
setShowExportSheet: (value: boolean) => void;
@@ -35,24 +36,6 @@ interface EventModalsProps {
showTicketExportSheet: boolean;
setShowTicketExportSheet: (value: boolean) => void;
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
// add at door
showAddAtDoorModal: boolean;
setShowAddAtDoorModal: (value: boolean) => void;
addAtDoorForm: AddAtDoorFormState;
setAddAtDoorForm: Dispatch<SetStateAction<AddAtDoorFormState>>;
handleAddAtDoor: (e: FormEvent) => void;
// manual ticket
showManualTicketModal: boolean;
setShowManualTicketModal: (value: boolean) => void;
manualTicketForm: AttendeeFormState;
setManualTicketForm: Dispatch<SetStateAction<AttendeeFormState>>;
handleManualTicket: (e: FormEvent) => void;
// invite guest
showInviteGuestModal: boolean;
setShowInviteGuestModal: (value: boolean) => void;
inviteGuestForm: AttendeeFormState;
setInviteGuestForm: Dispatch<SetStateAction<AttendeeFormState>>;
handleInviteGuest: (e: FormEvent) => void;
// shared submit flag
submitting: boolean;
// note modal
@@ -75,33 +58,20 @@ export function EventModals(props: EventModalsProps) {
confirmedCount,
checkedInCount,
cancelledCount,
onHoldCount,
statusFilter,
setStatusFilter,
mobileFilterOpen,
setMobileFilterOpen,
showAddTicketSheet,
setShowAddTicketSheet,
openAddTicket,
showExportSheet,
setShowExportSheet,
handleExportAttendees,
showTicketExportSheet,
setShowTicketExportSheet,
handleExportTickets,
showAddAtDoorModal,
setShowAddAtDoorModal,
addAtDoorForm,
setAddAtDoorForm,
handleAddAtDoor,
showManualTicketModal,
setShowManualTicketModal,
manualTicketForm,
setManualTicketForm,
handleManualTicket,
showInviteGuestModal,
setShowInviteGuestModal,
inviteGuestForm,
setInviteGuestForm,
handleInviteGuest,
submitting,
showNoteModal,
setShowNoteModal,
@@ -129,6 +99,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}
@@ -153,27 +124,27 @@ export function EventModals(props: EventModalsProps) {
>
<div className="space-y-1">
<button
onClick={() => { setShowManualTicketModal(true); setShowAddTicketSheet(false); }}
onClick={() => { openAddTicket('paid'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<EnvelopeIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Manual Ticket</p>
<p className="text-xs text-gray-500">Send confirmation email with ticket</p>
<p className="font-medium">Paid Ticket</p>
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
</div>
</button>
<button
onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketSheet(false); }}
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<PlusIcon className="w-5 h-5 text-gray-500" />
<BanknotesIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Add at Door</p>
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
<p className="font-medium">Unpaid Ticket</p>
<p className="text-xs text-gray-500">Pay link now or collect at the door</p>
</div>
</button>
<button
onClick={() => { setShowInviteGuestModal(true); setShowAddTicketSheet(false); }}
onClick={() => { openAddTicket('guest'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<StarIcon className="w-5 h-5 text-gray-500" />
@@ -234,243 +205,6 @@ export function EventModals(props: EventModalsProps) {
</div>
</BottomSheet>
{/* Add at Door Modal */}
{showAddAtDoorModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowAddAtDoorModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-bold">Add Attendee at Door</h2>
<button
onClick={() => setShowAddAtDoorModal(false)}
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={handleAddAtDoor} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={addAtDoorForm.firstName}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, firstName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={addAtDoorForm.lastName}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, lastName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={addAtDoorForm.email}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, email: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="email@example.com" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={addAtDoorForm.phone}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, phone: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={addAtDoorForm.adminNote}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, adminNote: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} placeholder="Internal note..." />
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="autoCheckin" checked={addAtDoorForm.autoCheckin}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, autoCheckin: e.target.checked })}
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
<label htmlFor="autoCheckin" className="text-sm font-medium">Auto check-in immediately</label>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowAddAtDoorModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">Add Attendee</Button>
</div>
</form>
</Card>
</div>
)}
{/* Manual Ticket Modal */}
{showManualTicketModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowManualTicketModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<div>
<h2 className="text-base font-bold">Create Manual Ticket</h2>
<p className="text-xs text-gray-500">Confirmation email will be sent</p>
</div>
<button onClick={() => setShowManualTicketModal(false)}
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={handleManualTicket} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={manualTicketForm.firstName}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, firstName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={manualTicketForm.lastName}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email *</label>
<input type="email" required value={manualTicketForm.email}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="email@example.com" />
<p className="text-[10px] text-gray-500 mt-1">Ticket will be sent to this email</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={manualTicketForm.phone}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={manualTicketForm.adminNote}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} placeholder="Internal note..." />
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<EnvelopeIcon className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="text-xs text-blue-800">
<p className="font-medium">This will send:</p>
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
<li>Booking confirmation email</li>
<li>Ticket with QR code</li>
</ul>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowManualTicketModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<EnvelopeIcon className="w-4 h-4 mr-1.5" />
Create & Send
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Invite Guest Modal */}
{showInviteGuestModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowInviteGuestModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<div>
<h2 className="text-base font-bold">Invite Guest</h2>
<p className="text-xs text-gray-500">Free ticket not counted in revenue</p>
</div>
<button onClick={() => setShowInviteGuestModal(false)}
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={handleInviteGuest} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={inviteGuestForm.firstName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, firstName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={inviteGuestForm.lastName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, lastName: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Last name" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={inviteGuestForm.email}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, email: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="email@example.com (optional)" />
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={inviteGuestForm.phone}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, phone: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="+595 981 123456" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={inviteGuestForm.adminNote}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, adminNote: e.target.value })}
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} placeholder="Internal note..." />
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-amber-800">
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
</p>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<StarIcon className="w-4 h-4 mr-1.5" />
Invite Guest
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Note Modal */}
{showNoteModal && selectedTicket && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
@@ -13,9 +13,12 @@ import {
StarIcon,
FunnelIcon,
ChatBubbleLeftIcon,
ArrowPathIcon,
BanknotesIcon,
CheckCircleIcon,
} from '@heroicons/react/24/outline';
import { StatusBadge } from '../_components/StatusBadge';
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
import { StatusBadge, PaymentBadge } from '../_components/StatusBadge';
import type { AttendeeStatusFilter, AddTicketType, PrimaryAction } from '../_types';
interface AttendeesTabProps {
locale: string;
@@ -29,20 +32,22 @@ interface AttendeesTabProps {
confirmedCount: number;
checkedInCount: number;
cancelledCount: number;
onHoldCount: number;
exporting: boolean;
showExportDropdown: boolean;
setShowExportDropdown: (value: boolean) => void;
showAddTicketDropdown: boolean;
setShowAddTicketDropdown: (value: boolean) => void;
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
setShowManualTicketModal: (value: boolean) => void;
setShowAddAtDoorModal: (value: boolean) => void;
setShowInviteGuestModal: (value: boolean) => void;
openAddTicket: (type: AddTicketType) => void;
setMobileFilterOpen: (value: boolean) => void;
setShowExportSheet: (value: boolean) => void;
setShowAddTicketSheet: (value: boolean) => void;
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
handleOpenNoteModal: (ticket: Ticket) => void;
handleReactivate: (ticket: Ticket) => void;
handleMarkPaid: (ticketId: string) => void;
handleCheckin: (ticketId: string) => void;
}
export function AttendeesTab({
@@ -57,20 +62,22 @@ export function AttendeesTab({
confirmedCount,
checkedInCount,
cancelledCount,
onHoldCount,
exporting,
showExportDropdown,
setShowExportDropdown,
showAddTicketDropdown,
setShowAddTicketDropdown,
handleExportAttendees,
setShowManualTicketModal,
setShowAddAtDoorModal,
setShowInviteGuestModal,
openAddTicket,
setMobileFilterOpen,
setShowExportSheet,
setShowAddTicketSheet,
getPrimaryAction,
handleOpenNoteModal,
handleReactivate,
handleMarkPaid,
handleCheckin,
}: AttendeesTabProps) {
return (
<div className="space-y-3">
@@ -98,6 +105,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" />
@@ -137,13 +145,13 @@ export function AttendeesTab({
</Button>
}
>
<DropdownItem onClick={() => { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Manual Ticket
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
</DropdownItem>
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
</DropdownItem>
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
<DropdownItem onClick={() => { openAddTicket('guest'); setShowAddTicketDropdown(false); }}>
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
</DropdownItem>
</Dropdown>
@@ -241,13 +249,12 @@ 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">
<StatusBadge status={ticket.status} compact />
{!!ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
</div>
{ticket.checkinAt && (
<p className="text-[10px] text-gray-400 mt-0.5">
@@ -267,6 +274,21 @@ export function AttendeesTab({
</Button>
)}
<MoreMenu>
{ticket.status === 'on_hold' && (
<DropdownItem onClick={() => handleReactivate(ticket)}>
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'checked_in' && (
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Mark as Paid
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'confirmed' && (
<DropdownItem onClick={() => handleCheckin(ticket.id)}>
<CheckCircleIcon className="w-4 h-4 mr-2" /> Check In (unpaid)
</DropdownItem>
)}
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
@@ -307,12 +329,11 @@ 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 />
{!!ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
@@ -328,6 +349,21 @@ export function AttendeesTab({
</Button>
)}
<MoreMenu>
{ticket.status === 'on_hold' && (
<DropdownItem onClick={() => handleReactivate(ticket)}>
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'checked_in' && (
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Mark as Paid
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'confirmed' && (
<DropdownItem onClick={() => handleCheckin(ticket.id)}>
<CheckCircleIcon className="w-4 h-4 mr-2" /> Check In (unpaid)
</DropdownItem>
)}
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
@@ -1,5 +1,6 @@
import { Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import SensitiveValue from '@/components/admin/SensitiveValue';
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
interface OverviewTabProps {
@@ -48,8 +49,8 @@ export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confir
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Capacity</p>
<p className="text-sm text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
<p className="text-xs text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
<p className="text-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
</div>
</div>
</div>
+10 -6
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';
@@ -13,14 +13,18 @@ export interface PrimaryAction {
icon?: ComponentType<{ className?: string }>;
}
export interface AttendeeFormState {
// Ticket type in the unified Add Ticket modal:
// paid = confirmation + QR emailed, counts toward revenue
// unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible
// guest = free comp ticket, auto-confirmed, no revenue
export type AddTicketType = 'paid' | 'unpaid' | 'guest';
export interface AddTicketFormState {
type: AddTicketType;
firstName: string;
lastName: string;
email: string;
phone: string;
adminNote: string;
}
export interface AddAtDoorFormState extends AttendeeFormState {
autoCheckin: boolean;
checkinNow: boolean;
}
+111 -131
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,
@@ -20,7 +21,6 @@ import {
EnvelopeIcon,
PencilIcon,
EyeIcon,
EyeSlashIcon,
UserGroupIcon,
CreditCardIcon,
ChevronDownIcon,
@@ -29,14 +29,14 @@ import {
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { useStatsPrivacy } from '@/hooks/useStatsPrivacy';
import { usePrivacy } from '@/context/PrivacyContext';
import type {
TabType,
AttendeeStatusFilter,
TicketStatusFilter,
RecipientFilter,
AttendeeFormState,
AddAtDoorFormState,
AddTicketType,
AddTicketFormState,
PrimaryAction,
} from './_types';
import { formatCurrency, downloadBlob } from './_utils/format';
@@ -48,10 +48,21 @@ import { TicketsTab } from './_tabs/TicketsTab';
import { EmailTab } from './_tabs/EmailTab';
import { PaymentsTab } from './_tabs/PaymentsTab';
import { EventModals } from './_modals/EventModals';
import { AddTicketModal } from './_modals/AddTicketModal';
import EventFormModal from '../_components/EventFormModal';
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
type: 'paid',
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
checkinNow: false,
};
export default function AdminEventDetailPage() {
const params = useParams();
const router = useRouter();
const eventId = params.id as string;
const { locale } = useLanguage();
@@ -68,37 +79,21 @@ export default function AdminEventDetailPage() {
// Attendees tab state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
const [showStats, , toggleStats] = useStatsPrivacy();
const { privacyMode } = usePrivacy();
const showStats = !privacyMode;
const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState('');
const [addAtDoorForm, setAddAtDoorForm] = useState<AddAtDoorFormState>({
firstName: '',
lastName: '',
email: '',
phone: '',
autoCheckin: true,
adminNote: '',
});
const [manualTicketForm, setManualTicketForm] = useState<AttendeeFormState>({
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
});
const [showInviteGuestModal, setShowInviteGuestModal] = useState(false);
const [inviteGuestForm, setInviteGuestForm] = useState<AttendeeFormState>({
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
});
// Unified Add Ticket modal (paid / unpaid / guest via segmented control)
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
const [submitting, setSubmitting] = useState(false);
const openAddTicket = (type: AddTicketType) => {
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
setShowAddTicketModal(true);
};
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
@@ -116,6 +111,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 +169,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 {
@@ -197,74 +214,27 @@ export default function AdminEventDetailPage() {
}
};
const handleAddAtDoor = async (e: React.FormEvent) => {
const handleAddTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.adminCreate({
const res = await ticketsApi.adminAdd({
eventId: event.id,
firstName: addAtDoorForm.firstName,
lastName: addAtDoorForm.lastName || undefined,
email: addAtDoorForm.email,
phone: addAtDoorForm.phone,
autoCheckin: addAtDoorForm.autoCheckin,
adminNote: addAtDoorForm.adminNote || undefined,
type: addTicketForm.type,
firstName: addTicketForm.firstName,
lastName: addTicketForm.lastName || undefined,
email: addTicketForm.email || undefined,
phone: addTicketForm.phone || undefined,
checkinNow: addTicketForm.checkinNow,
adminNote: addTicketForm.adminNote || undefined,
});
toast.success(addAtDoorForm.autoCheckin ? 'Attendee added and checked in' : 'Attendee added');
setShowAddAtDoorModal(false);
setAddAtDoorForm({ firstName: '', lastName: '', email: '', phone: '', autoCheckin: true, adminNote: '' });
toast.success(res.message || 'Ticket created');
setShowAddTicketModal(false);
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to add attendee');
} finally {
setSubmitting(false);
}
};
const handleManualTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.manualCreate({
eventId: event.id,
firstName: manualTicketForm.firstName,
lastName: manualTicketForm.lastName || undefined,
email: manualTicketForm.email,
phone: manualTicketForm.phone || undefined,
adminNote: manualTicketForm.adminNote || undefined,
});
toast.success('Manual ticket created — confirmation email sent');
setShowManualTicketModal(false);
setManualTicketForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to create manual ticket');
} finally {
setSubmitting(false);
}
};
const handleInviteGuest = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.guestCreate({
eventId: event.id,
firstName: inviteGuestForm.firstName,
lastName: inviteGuestForm.lastName || undefined,
email: inviteGuestForm.email || undefined,
phone: inviteGuestForm.phone || undefined,
adminNote: inviteGuestForm.adminNote || undefined,
});
toast.success('Guest invited successfully');
setShowInviteGuestModal(false);
setInviteGuestForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to invite guest');
toast.error(error.message || 'Failed to add ticket');
} finally {
setSubmitting(false);
}
@@ -398,8 +368,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,8 +395,12 @@ export default function AdminEventDetailPage() {
const pendingCount = getTicketsByStatus('pending').length;
const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length;
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
const onHoldCount = getTicketsByStatus('on_hold').length;
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
// tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
@@ -435,10 +413,14 @@ 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') {
// Unpaid tickets resolve their balance first; check-in stays available via scanner
if (ticket.paymentStatus === 'unpaid') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
}
if (ticket.status === 'checked_in') {
@@ -462,22 +444,16 @@ export default function AdminEventDetailPage() {
</div>
{/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
<Button variant="outline" size="sm" onClick={toggleStats} title={showStats ? 'Hide stats' : 'Show stats'}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</Button>
<Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" />
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,13 +469,9 @@ 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); }}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</DropdownItem>
</Dropdown>
</div>
</div>
@@ -671,20 +643,22 @@ export default function AdminEventDetailPage() {
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
exporting={exporting}
showExportDropdown={showExportDropdown}
setShowExportDropdown={setShowExportDropdown}
showAddTicketDropdown={showAddTicketDropdown}
setShowAddTicketDropdown={setShowAddTicketDropdown}
handleExportAttendees={handleExportAttendees}
setShowManualTicketModal={setShowManualTicketModal}
setShowAddAtDoorModal={setShowAddAtDoorModal}
setShowInviteGuestModal={setShowInviteGuestModal}
openAddTicket={openAddTicket}
setMobileFilterOpen={setMobileFilterOpen}
setShowExportSheet={setShowExportSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
getPrimaryAction={getPrimaryAction}
handleOpenNoteModal={handleOpenNoteModal}
handleReactivate={handleReactivate}
handleMarkPaid={handleMarkPaid}
handleCheckin={handleCheckin}
/>
)}
@@ -742,33 +716,20 @@ export default function AdminEventDetailPage() {
confirmedCount={confirmedCount}
checkedInCount={checkedInCount}
cancelledCount={cancelledCount}
onHoldCount={onHoldCount}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
mobileFilterOpen={mobileFilterOpen}
setMobileFilterOpen={setMobileFilterOpen}
showAddTicketSheet={showAddTicketSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
openAddTicket={openAddTicket}
showExportSheet={showExportSheet}
setShowExportSheet={setShowExportSheet}
handleExportAttendees={handleExportAttendees}
showTicketExportSheet={showTicketExportSheet}
setShowTicketExportSheet={setShowTicketExportSheet}
handleExportTickets={handleExportTickets}
showAddAtDoorModal={showAddAtDoorModal}
setShowAddAtDoorModal={setShowAddAtDoorModal}
addAtDoorForm={addAtDoorForm}
setAddAtDoorForm={setAddAtDoorForm}
handleAddAtDoor={handleAddAtDoor}
showManualTicketModal={showManualTicketModal}
setShowManualTicketModal={setShowManualTicketModal}
manualTicketForm={manualTicketForm}
setManualTicketForm={setManualTicketForm}
handleManualTicket={handleManualTicket}
showInviteGuestModal={showInviteGuestModal}
setShowInviteGuestModal={setShowInviteGuestModal}
inviteGuestForm={inviteGuestForm}
setInviteGuestForm={setInviteGuestForm}
handleInviteGuest={handleInviteGuest}
submitting={submitting}
showNoteModal={showNoteModal}
setShowNoteModal={setShowNoteModal}
@@ -781,6 +742,25 @@ export default function AdminEventDetailPage() {
setPreviewHtml={setPreviewHtml}
/>
<AddTicketModal
open={showAddTicketModal}
onClose={() => setShowAddTicketModal(false)}
form={addTicketForm}
setForm={setAddTicketForm}
onSubmit={handleAddTicket}
submitting={submitting}
eventPriceLabel={event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
/>
<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>
);
}
+39 -360
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">
@@ -555,7 +222,14 @@ export default function AdminEventsPage() {
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
<td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td>
<td className="px-4 py-3 text-sm">
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity}
{(event.claimedCount || 0) > 0 && (
<span className="block text-[11px] text-yellow-600">
{event.claimedCount} pending approval
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
{getStatusBadge(event.status)}
@@ -665,7 +339,12 @@ export default function AdminEventsPage() {
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
<p className="text-xs text-gray-500">
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} spots
{(event.claimedCount || 0) > 0 && (
<span className="text-yellow-600"> · {event.claimedCount} pending</span>
)}
</p>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Link href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
@@ -724,7 +403,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>
);
}
+44 -4
View File
@@ -6,6 +6,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { PrivacyProvider, usePrivacy } from '@/context/PrivacyContext';
import LanguageToggle from '@/components/LanguageToggle';
import Button from '@/components/ui/Button';
import {
@@ -17,6 +18,7 @@ import {
EnvelopeIcon,
InboxIcon,
PhotoIcon,
CameraIcon,
Cog6ToothIcon,
ArrowLeftOnRectangleIcon,
Bars3Icon,
@@ -25,6 +27,8 @@ import {
QrCodeIcon,
DocumentTextIcon,
QuestionMarkCircleIcon,
EyeIcon,
EyeSlashIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useState } from 'react';
@@ -33,11 +37,24 @@ export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PrivacyProvider>
<AdminLayoutInner>{children}</AdminLayoutInner>
</PrivacyProvider>
);
}
function AdminLayoutInner({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const { t, locale } = useLanguage();
const { user, hasAdminAccess, isLoading, logout } = useAuth();
const { privacyMode, togglePrivacyMode } = usePrivacy();
const [sidebarOpen, setSidebarOpen] = useState(false);
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
@@ -54,6 +71,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 +80,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 +101,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]);
@@ -211,6 +236,21 @@ export default function AdminLayout({
</button>
<div className="flex items-center gap-4 ml-auto">
<Button
variant="outline"
size="sm"
onClick={togglePrivacyMode}
title={privacyMode ? t('admin.privacy.show') : t('admin.privacy.hide')}
>
{privacyMode ? (
<EyeIcon className="w-4 h-4 sm:mr-1.5" />
) : (
<EyeSlashIcon className="w-4 h-4 sm:mr-1.5" />
)}
<span className="hidden sm:inline">
{privacyMode ? t('admin.privacy.show') : t('admin.privacy.hide')}
</span>
</Button>
<LanguageToggle />
<Link href="/">
<Button variant="outline" size="sm">
+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>
);
}
+48 -20
View File
@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { adminApi, DashboardData } from '@/lib/api';
import Card from '@/components/ui/Card';
import SensitiveValue from '@/components/admin/SensitiveValue';
import {
UsersIcon,
CalendarIcon,
@@ -15,11 +17,12 @@ import {
UserGroupIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
import { parseDate } from '@/lib/utils';
import { parseDate, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
export default function AdminDashboardPage() {
const { t, locale } = useLanguage();
const { user } = useAuth();
const { privacyMode } = usePrivacy();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
@@ -89,6 +92,7 @@ export default function AdminDashboardPage() {
</div>
{/* Stats Grid */}
{!privacyMode && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat) => (
<Link key={stat.label} href={stat.href}>
@@ -106,22 +110,22 @@ export default function AdminDashboardPage() {
</Link>
))}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Alerts */}
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
<div className="space-y-3">
{/* Low capacity warnings */}
{/* Low capacity warnings (availableSeats accounts for paid + claimed seats) */}
{data?.upcomingEvents
.filter(event => {
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
return percentFull >= 80 && spotsLeft > 0;
const spotsLeft = eventSpotsLeft(event);
return event.capacity > 0 && spotsLeft > 0 && spotsLeft / event.capacity <= 0.2;
})
.map(event => {
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
const spotsLeft = eventSpotsLeft(event);
const percentFull = Math.round(((event.capacity - spotsLeft) / event.capacity) * 100);
return (
<Link
key={event.id}
@@ -132,7 +136,7 @@ export default function AdminDashboardPage() {
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
<div>
<span className="text-sm font-medium">{event.title}</span>
<p className="text-xs text-gray-500">Only {spotsLeft} spots left ({percentFull}% full)</p>
<p className="text-xs text-gray-500"><SensitiveValue>Only {spotsLeft} spots left ({percentFull}% full)</SensitiveValue></p>
</div>
</div>
<span className="badge badge-warning">Low capacity</span>
@@ -142,7 +146,7 @@ export default function AdminDashboardPage() {
{/* Sold out events */}
{data?.upcomingEvents
.filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
.filter(event => isEventSoldOut(event))
.map(event => (
<Link
key={event.id}
@@ -160,16 +164,30 @@ export default function AdminDashboardPage() {
</Link>
))}
{data && data.stats.pendingPayments > 0 && (
<Link
{/* Actionable: customer says they paid, needs verification */}
{data && (data.stats.awaitingApprovalPayments ?? 0) > 0 && (
<Link
href="/admin/payments"
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
>
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
<span className="text-sm">Pending payments</span>
<span className="text-sm">Payments awaiting verification</span>
</div>
<span className="badge badge-warning">{data.stats.pendingPayments}</span>
<span className="badge badge-warning"><SensitiveValue>{data.stats.awaitingApprovalPayments}</SensitiveValue></span>
</Link>
)}
{/* Informational: opened checkouts that never paid — hold no seats */}
{data && data.stats.pendingPayments > 0 && (
<Link
href="/admin/payments"
className="flex items-center justify-between p-3 bg-gray-50 rounded-btn hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-gray-400" />
<span className="text-sm text-gray-500">Unpaid started bookings</span>
</div>
<span className="badge badge-info"><SensitiveValue>{data.stats.pendingPayments}</SensitiveValue></span>
</Link>
)}
{data && data.stats.newContacts > 0 && (
@@ -186,10 +204,11 @@ export default function AdminDashboardPage() {
)}
{/* No alerts */}
{data &&
data.stats.pendingPayments === 0 &&
data.stats.newContacts === 0 &&
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
{data &&
data.stats.pendingPayments === 0 &&
(data.stats.awaitingApprovalPayments ?? 0) === 0 &&
data.stats.newContacts === 0 &&
!data.upcomingEvents.some(e => e.capacity > 0 && eventSpotsLeft(e) / e.capacity <= 0.2) && (
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
)}
</div>
@@ -217,9 +236,16 @@ export default function AdminDashboardPage() {
<p className="font-medium text-sm">{event.title}</p>
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
</div>
<span className="text-sm text-gray-600">
{event.bookedCount || 0}/{event.capacity}
</span>
{!privacyMode && (
<span className="text-sm text-gray-600">
{(event.bookedCount || 0) + (event.claimedCount || 0)}/{event.capacity}
{(event.claimedCount || 0) > 0 && (
<span className="text-xs text-yellow-600 block text-right">
{event.claimedCount} pending approval
</span>
)}
</span>
)}
</Link>
))}
</div>
@@ -227,6 +253,7 @@ export default function AdminDashboardPage() {
</Card>
{/* Quick Stats */}
{!privacyMode && (
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
<div className="grid grid-cols-2 gap-4">
@@ -242,6 +269,7 @@ export default function AdminDashboardPage() {
</div>
</div>
</Card>
)}
</div>
</div>
);
+277 -58
View File
@@ -3,9 +3,11 @@
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 { isManualProvider } from '@/lib/api/payments';
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 {
@@ -35,6 +37,9 @@ export default function AdminPaymentsPage() {
const { t, locale } = useLanguage();
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
// Manual-gateway payments still in bare 'pending': the customer may have paid
// without clicking "I've paid" — approvable directly from the approval tab.
const [unclaimedManualPayments, setUnclaimedManualPayments] = useState<PaymentWithDetails[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
@@ -68,17 +73,19 @@ export default function AdminPaymentsPage() {
const loadData = async () => {
try {
setLoading(true);
const [pendingRes, allRes, eventsRes] = await Promise.all([
const [pendingRes, allRes, unclaimedRes, eventsRes] = await Promise.all([
paymentsApi.getPendingApproval(),
paymentsApi.getAll({
status: statusFilter || undefined,
paymentsApi.getAll({
status: statusFilter || undefined,
provider: providerFilter || undefined,
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
}),
paymentsApi.getAll({ status: 'pending' }),
eventsApi.getAll(),
]);
setPendingApprovalPayments(pendingRes.payments);
setPayments(allRes.payments);
setUnclaimedManualPayments(unclaimedRes.payments.filter(p => isManualProvider(p.provider)));
setEvents(eventsRes.events);
} catch (error) {
toast.error('Failed to load payments');
@@ -87,15 +94,35 @@ export default function AdminPaymentsPage() {
}
};
// Approve with over-capacity confirmation: the backend rejects an approval
// that would overbook the event unless the admin explicitly allows it.
const approveWithCapacityConfirm = async (id: string, note?: string, email?: boolean) => {
try {
await paymentsApi.approve(id, note, email);
} catch (error: any) {
if (error?.code !== 'EVENT_OVER_CAPACITY') throw error;
const seatsLeft = error?.data?.availableSeats ?? 0;
const requested = error?.data?.requestedSeats ?? 1;
const message = locale === 'es'
? `El evento está lleno (quedan ${seatsLeft} lugares, esta reserva necesita ${requested}). ¿Aprobar de todas formas y sobrevender?`
: `This event is full (${seatsLeft} seat(s) left, this booking needs ${requested}). Approve anyway and overbook?`;
if (!confirm(message)) return false;
await paymentsApi.approve(id, note, email, true);
}
return true;
};
const handleApprove = async (payment: PaymentWithDetails) => {
setProcessing(true);
try {
await paymentsApi.approve(payment.id, noteText, sendEmail);
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
setSelectedPayment(null);
setNoteText('');
setSendEmail(true);
loadData();
const approved = await approveWithCapacityConfirm(payment.id, noteText, sendEmail);
if (approved) {
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
setSelectedPayment(null);
setNoteText('');
setSendEmail(true);
loadData();
}
} catch (error: any) {
toast.error(error.message || 'Failed to approve payment');
} finally {
@@ -139,11 +166,43 @@ export default function AdminPaymentsPage() {
const handleConfirmPayment = async (id: string) => {
try {
await paymentsApi.approve(id);
toast.success('Payment confirmed');
const approved = await approveWithCapacityConfirm(id);
if (approved) {
toast.success('Payment confirmed');
loadData();
}
} catch (error: any) {
toast.error(error.message || 'Failed to confirm payment');
}
};
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) {
toast.error('Failed to confirm payment');
} 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);
}
};
@@ -178,7 +237,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 +249,7 @@ export default function AdminPaymentsPage() {
p.createdAt,
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
p.attendeeEmail || '',
formatRucDisplay(p.attendeeRuc) || '',
p.eventTitle,
p.eventDate,
]);
@@ -225,6 +285,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 +294,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'}`}>
@@ -264,6 +326,33 @@ export default function AdminPaymentsPage() {
return labels[provider] || provider;
};
// Manual gateways need admin verification; automatic ones confirm themselves.
const getProviderKindBadge = (provider: string) => (
isManualProvider(provider) ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-orange-50 text-orange-600">
{locale === 'es' ? 'Manual' : 'Manual'}
</span>
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-blue-50 text-blue-600">
{locale === 'es' ? 'Automático' : 'Auto'}
</span>
)
);
// Age of a claim/booking, e.g. "3h" / "2d"; used to surface rotting approvals.
const getAgeInfo = (dateStr?: string | null) => {
if (!dateStr) return null;
const ms = Date.now() - parseDate(dateStr).getTime();
if (ms < 0) return null;
const hours = Math.floor(ms / (60 * 60 * 1000));
const label = hours < 1
? (locale === 'es' ? 'hace <1 h' : '<1h ago')
: hours < 48
? (locale === 'es' ? `hace ${hours} h` : `${hours}h ago`)
: (locale === 'es' ? `hace ${Math.floor(hours / 24)} días` : `${Math.floor(hours / 24)}d ago`);
return { hours, label, stale: hours >= 48 };
};
// Helper to get booking info for a payment (ticket count and total)
const getBookingInfo = (payment: PaymentWithDetails) => {
if (!payment.ticket?.bookingId) {
@@ -297,6 +386,22 @@ export default function AdminPaymentsPage() {
});
})();
// Manual payments never claimed by the customer — they may have paid and
// forgotten to press "I've paid", so they stay directly approvable here.
// Hidden once the event has ended (same rule as pending approvals above).
const visibleUnclaimedManualPayments = (() => {
const now = new Date();
return unclaimedManualPayments.filter((payment) => {
const eventId = payment.event?.id;
const fullEvent = eventId ? events.find((e) => e.id === eventId) : undefined;
const endIso = fullEvent?.endDatetime
|| fullEvent?.startDatetime
|| payment.event?.startDatetime;
if (!endIso) return true;
return parseDate(endIso).getTime() >= now.getTime();
});
})();
// Get booking info for pending approval payments
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
if (!payment.ticket?.bookingId) {
@@ -314,9 +419,15 @@ export default function AdminPaymentsPage() {
};
};
// Calculate totals (sum all individual payment amounts)
const totalPending = payments
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
// Calculate totals (sum all individual payment amounts).
// Claimed ('pending_approval') money is probably already in the account and
// just needs verification; bare 'pending' money may never arrive — keep the
// two apart so the totals don't overstate what's owed.
const totalAwaitingVerification = payments
.filter(p => p.status === 'pending_approval')
.reduce((sum, p) => sum + Number(p.amount), 0);
const totalUnclaimed = payments
.filter(p => p.status === 'pending')
.reduce((sum, p) => sum + Number(p.amount), 0);
const totalPaid = payments
.filter(p => p.status === 'paid')
@@ -336,20 +447,15 @@ export default function AdminPaymentsPage() {
return count;
};
const pendingBookingsCount = getUniqueBookingsCount(
payments.filter(p => p.status === 'pending' || p.status === 'pending_approval')
);
const paidBookingsCount = getUniqueBookingsCount(
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 +520,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 +584,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 +760,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,15 +775,32 @@ 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">
<ClockIcon className="w-5 h-5 text-gray-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Por Verificar' : 'Awaiting Verification'}</p>
<p className="text-xl font-bold text-yellow-600">{formatCurrency(totalAwaitingVerification, 'PYG')}</p>
<p className="text-xs text-gray-400">
{locale === 'es' ? 'Sin pagar (sin reclamar): ' : 'Unpaid (unclaimed): '}
{formatCurrency(totalUnclaimed, 'PYG')}
</p>
</div>
</div>
</Card>
@@ -744,13 +894,18 @@ export default function AdminPaymentsPage() {
<span className="flex items-center gap-1">
{getProviderIcon(payment.provider)}
{getProviderLabel(payment.provider)}
{getProviderKindBadge(payment.provider)}
</span>
{payment.userMarkedPaidAt && (
<span className="flex items-center gap-1">
<ClockIcon className="w-3 h-3" />
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
</span>
)}
{payment.userMarkedPaidAt && (() => {
const age = getAgeInfo(payment.userMarkedPaidAt);
return (
<span className={clsx('flex items-center gap-1', age?.stale && 'text-amber-600 font-medium')}>
<ClockIcon className="w-3 h-3" />
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
{age && <span>({age.label})</span>}
</span>
);
})()}
</div>
{payment.payerName && (
<p className="text-xs text-amber-600 mt-1 font-medium">
@@ -768,6 +923,55 @@ export default function AdminPaymentsPage() {
})}
</div>
)}
{/* Manual payments the customer never confirmed they may have paid
(bank transfer / TPago received) without pressing "I've paid".
Approving one claims a seat, so it goes through the same
over-capacity confirmation as any approval. */}
{visibleUnclaimedManualPayments.length > 0 && (
<details className="mt-8">
<summary className="cursor-pointer text-sm font-medium text-gray-600 select-none">
{locale === 'es'
? `Pagos manuales sin confirmar por el cliente (${visibleUnclaimedManualPayments.length})`
: `Manual payments not yet confirmed by customer (${visibleUnclaimedManualPayments.length})`}
<span className="block text-xs font-normal text-gray-400 mt-0.5">
{locale === 'es'
? 'Puede que hayan pagado sin presionar "Ya pagué". No reservan lugar hasta ser aprobados.'
: 'They may have paid without pressing "I\'ve paid". These hold no seat until approved.'}
</span>
</summary>
<div className="space-y-3 mt-4">
{visibleUnclaimedManualPayments.map((payment) => {
const age = getAgeInfo(payment.createdAt);
return (
<Card key={payment.id} className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
{getProviderIcon(payment.provider)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{payment.ticket?.attendeeFirstName} {payment.ticket?.attendeeLastName}
<span className="text-gray-400 font-normal"> · {formatCurrency(payment.amount, payment.currency)}</span>
</p>
<p className="text-xs text-gray-400 truncate">
{payment.event?.title}
{' · '}{getProviderLabel(payment.provider)}
{age && <span> · {age.label}</span>}
</p>
</div>
</div>
<Button onClick={() => setSelectedPayment(payment)} size="sm" variant="outline" className="flex-shrink-0 min-h-[40px]">
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
</div>
</Card>
);
})}
</div>
</details>
)}
</>
)}
@@ -816,6 +1020,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 +1102,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 +1112,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 +1126,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>
@@ -928,16 +1135,22 @@ export default function AdminPaymentsPage() {
<td className="px-4 py-3">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
{getProviderKindBadge(payment.provider)}
</div>
</td>
<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')}
@@ -982,7 +1195,7 @@ export default function AdminPaymentsPage() {
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
<span className="text-gray-300">|</span>
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}</span>
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} {getProviderKindBadge(payment.provider)}</span>
{bookingInfo.ticketCount > 1 && (
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
)}
@@ -990,11 +1203,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 +1258,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>
);
}

Some files were not shown because too many files have changed in this diff Show More