Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ec8b8400f | ||
|
|
9b2668f498 | ||
|
|
e38d14970d | ||
|
|
71c277045b | ||
|
|
c9a600b6d6 | ||
|
|
19461098a0 | ||
|
|
4772b85f3d | ||
|
|
74dcc8e5ac | ||
|
|
0d47156071 | ||
|
|
bd1043a3f4 | ||
|
|
75e317d73e | ||
|
|
107cd9753e | ||
|
|
194604004e | ||
|
|
bb920ce6f0 | ||
|
|
c0315a705d | ||
|
|
fbc437a670 | ||
|
|
e0f0700398 | ||
|
|
defd9685e0 | ||
|
|
1ed62b0d3f | ||
|
|
91de6df04d | ||
|
|
a5d97d65e1 | ||
|
|
f0128f66b0 | ||
|
|
b33c68feb0 | ||
|
|
15655e3987 | ||
|
|
d8b3864411 | ||
|
|
194cbd6ca8 | ||
|
|
d5445c2282 | ||
|
|
dcfefc8371 | ||
|
|
b5f14335c4 | ||
|
|
d44ac949b5 | ||
|
|
a5e939221d | ||
|
|
833e3e5a9c | ||
|
|
ba1975dd6d | ||
|
|
3025ef3d21 | ||
|
|
8564f8af83 |
@@ -7,6 +7,7 @@ package-lock.json
|
|||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
|
.next-build/
|
||||||
out/
|
out/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@ yarn-error.log*
|
|||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Go photo-api service
|
||||||
|
photo-api/bin/
|
||||||
|
photo-api/data/
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -22,10 +22,18 @@ DATABASE_URL=./data/spanglish.db
|
|||||||
# Note: running more than one instance requires DB_TYPE=postgres. SQLite is a
|
# Note: running more than one instance requires DB_TYPE=postgres. SQLite is a
|
||||||
# single local file and cannot be shared safely across instances.
|
# single local file and cannot be shared safely across instances.
|
||||||
|
|
||||||
# Redis connection URL. When set, the cache, rate limiter, pub/sub (real-time
|
# Redis connection URL. When set, the cache, rate limiter, login lockout,
|
||||||
# payment events), distributed locks, and the email hourly cap are shared across
|
# pub/sub (real-time payment events), distributed locks, and the email hourly
|
||||||
# all instances. When unset, each instance uses in-memory equivalents.
|
# 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://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,
|
# 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
|
# AWS S3). When S3_ENDPOINT and S3_BUCKET are set, uploads go to the bucket and
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"test": "vitest run",
|
||||||
"start": "NODE_ENV=production node dist/index.js",
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx src/db/migrate.ts",
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
@@ -42,7 +43,9 @@
|
|||||||
"@types/pg": "^8.11.6",
|
"@types/pg": "^8.11.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"drizzle-kit": "^0.22.8",
|
"drizzle-kit": "^0.22.8",
|
||||||
|
"ioredis-mock": "^8.13.1",
|
||||||
"tsx": "^4.15.7",
|
"tsx": "^4.15.7",
|
||||||
"typescript": "^5.5.2"
|
"typescript": "^5.5.2",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,18 @@ async function migrate() {
|
|||||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} 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)
|
// 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
|
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||||
|
|
||||||
@@ -702,6 +714,18 @@ async function migrate() {
|
|||||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} 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`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS payments (
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ export const sqliteTickets = sqliteTable('tickets', {
|
|||||||
qrCode: text('qr_code'),
|
qrCode: text('qr_code'),
|
||||||
adminNote: text('admin_note'),
|
adminNote: text('admin_note'),
|
||||||
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
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(),
|
createdAt: text('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -468,6 +470,8 @@ export const pgTickets = pgTable('tickets', {
|
|||||||
qrCode: varchar('qr_code', { length: 255 }),
|
qrCode: varchar('qr_code', { length: 255 }),
|
||||||
adminNote: pgText('admin_note'),
|
adminNote: pgText('admin_note'),
|
||||||
isGuest: pgInteger('is_guest').notNull().default(0),
|
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(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+46
-6
@@ -24,10 +24,11 @@ import legalPagesRoutes from './routes/legal-pages.js';
|
|||||||
import legalSettingsRoutes from './routes/legal-settings.js';
|
import legalSettingsRoutes from './routes/legal-settings.js';
|
||||||
import faqRoutes from './routes/faq.js';
|
import faqRoutes from './routes/faq.js';
|
||||||
import emailService from './lib/email.js';
|
import emailService from './lib/email.js';
|
||||||
import { initEmailQueue } from './lib/emailQueue.js';
|
import { initEmailQueue, stopQueue } from './lib/emailQueue.js';
|
||||||
import { startBookingCleanup } from './lib/bookingCleanup.js';
|
import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js';
|
||||||
import { startHoldSweep } from './lib/holdSweep.js';
|
import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js';
|
||||||
import { startEventEndSweep } from './lib/eventEndSweep.js';
|
import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js';
|
||||||
|
import { closeRedis } from './lib/redis.js';
|
||||||
import { getLock } from './lib/stores/lock.js';
|
import { getLock } from './lib/stores/lock.js';
|
||||||
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
||||||
|
|
||||||
@@ -1943,8 +1944,11 @@ startEventEndSweep();
|
|||||||
// Initialize email templates on startup.
|
// Initialize email templates on startup.
|
||||||
// Guarded by a distributed lock so that, when running multiple replicas, only
|
// 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.
|
// 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()
|
getLock()
|
||||||
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates())
|
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates(), { onUnavailable: 'run' })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
console.log('[Email] Template seeding skipped (another instance holds the lock)');
|
console.log('[Email] Template seeding skipped (another instance holds the lock)');
|
||||||
@@ -1961,7 +1965,43 @@ console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
|
|||||||
// Log which backend (memory/redis, local/s3) each subsystem selected.
|
// Log which backend (memory/redis, local/s3) each subsystem selected.
|
||||||
logSelectedBackends();
|
logSelectedBackends();
|
||||||
|
|
||||||
serve({
|
const server = serve({
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
port,
|
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'));
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Reports which backend each scalable subsystem is using, for the health
|
// Reports which backend each scalable subsystem is using, for the health
|
||||||
// endpoint and startup logging.
|
// endpoint and startup logging.
|
||||||
|
|
||||||
import { isRedisEnabled, isRedisHealthy } from './redis.js';
|
import { isRedisEnabled, isRedisHealthy, getRedisHealthDetail } from './redis.js';
|
||||||
import { getRateLimiter } from './stores/rateLimiter.js';
|
import { getRateLimiter } from './stores/rateLimiter.js';
|
||||||
import { getPubSub } from './stores/pubsub.js';
|
import { getPubSub } from './stores/pubsub.js';
|
||||||
import { getCache } from './stores/cache.js';
|
import { getCache } from './stores/cache.js';
|
||||||
import { getLock } from './stores/lock.js';
|
import { getLock } from './stores/lock.js';
|
||||||
|
import { getLoginLockout } from './stores/loginLockout.js';
|
||||||
import { getStorage } from './storage.js';
|
import { getStorage } from './storage.js';
|
||||||
|
|
||||||
export function describeBackends() {
|
export function describeBackends() {
|
||||||
@@ -14,12 +15,13 @@ export function describeBackends() {
|
|||||||
rateLimiter: getRateLimiter().backend,
|
rateLimiter: getRateLimiter().backend,
|
||||||
pubsub: getPubSub().backend,
|
pubsub: getPubSub().backend,
|
||||||
lock: getLock().backend,
|
lock: getLock().backend,
|
||||||
|
loginLockout: getLoginLockout().backend,
|
||||||
storage: getStorage().backend,
|
storage: getStorage().backend,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeRedis() {
|
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. */
|
/** 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(` rate limiter: ${b.rateLimiter}`);
|
||||||
console.log(` pub/sub: ${b.pubsub}`);
|
console.log(` pub/sub: ${b.pubsub}`);
|
||||||
console.log(` lock: ${b.lock}`);
|
console.log(` lock: ${b.lock}`);
|
||||||
|
console.log(` login lockout:${b.loginLockout}`);
|
||||||
console.log(` storage: ${b.storage}`);
|
console.log(` storage: ${b.storage}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,20 @@
|
|||||||
// abandoned checkout would otherwise hold those seats forever. This job cancels
|
// abandoned checkout would otherwise hold those seats forever. This job cancels
|
||||||
// pending tickets whose payment is still 'pending' (i.e. never paid and not
|
// pending tickets whose payment is still 'pending' (i.e. never paid and not
|
||||||
// awaiting admin approval) after a configurable TTL, freeing the seats.
|
// 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 { db, dbAll, tickets, payments } from '../db/index.js';
|
||||||
import { getNow, toDbDate } from './utils.js';
|
import { getNow, toDbDate } from './utils.js';
|
||||||
import { getLock } from './stores/lock.js';
|
import { getLock } from './stores/lock.js';
|
||||||
|
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||||
|
|
||||||
function getTtlMs(): number {
|
function getTtlMs(): number {
|
||||||
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
|
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.
|
* Cancel stale pending bookings. Returns the number of tickets cancelled.
|
||||||
*
|
*
|
||||||
* A booking is considered stale when its payment is still 'pending' (not
|
* A booking is considered stale when its payment is still 'pending' (not
|
||||||
* 'pending_approval', which means an admin is reviewing a manual transfer) and
|
* 'pending_approval', which means an admin is reviewing a manual transfer),
|
||||||
* older than PENDING_BOOKING_TTL_MINUTES.
|
* uses a non-manual provider, and has not been touched (updatedAt) for
|
||||||
|
* PENDING_BOOKING_TTL_MINUTES.
|
||||||
*/
|
*/
|
||||||
export async function cleanupStalePendingBookings(): Promise<number> {
|
export async function cleanupStalePendingBookings(): Promise<number> {
|
||||||
const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
|
const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
|
||||||
@@ -35,7 +45,8 @@ export async function cleanupStalePendingBookings(): Promise<number> {
|
|||||||
.from(payments)
|
.from(payments)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq((payments as any).status, 'pending'),
|
eq((payments as any).status, 'pending'),
|
||||||
lt((payments as any).createdAt, cutoff)
|
notInArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
||||||
|
lt((payments as any).updatedAt, cutoff)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
// Shared capacity-checked recovery for on-hold bookings.
|
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
|
||||||
//
|
//
|
||||||
// When a booking is put on hold, its ticket(s) drop out of the capacity-counting
|
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
|
||||||
// statuses ('pending', 'confirmed', 'checked_in'), releasing the seat. Recovering
|
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
|
||||||
// an on-hold booking (user "I've paid" again, or an admin reactivating / marking it
|
// states — user clicking "I've paid", an admin approving/reactivating a payment —
|
||||||
// paid) must atomically re-check that the event still has room before re-reserving
|
// must atomically re-check that the event still has room, exactly like the original
|
||||||
// the seat, exactly like the original booking-creation flow in routes/tickets.ts.
|
// 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, sql } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
|
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
|
||||||
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
|
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
|
||||||
|
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js';
|
||||||
|
|
||||||
export class HoldCapacityError extends Error {
|
export class HoldCapacityError extends Error {
|
||||||
constructor(public available: number) {
|
constructor(public available: number) {
|
||||||
@@ -19,22 +27,36 @@ export class HoldCapacityError extends Error {
|
|||||||
interface ReserveOptions {
|
interface ReserveOptions {
|
||||||
paidByAdminId?: string;
|
paidByAdminId?: string;
|
||||||
extraPaymentFields?: Record<string, any>;
|
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 on-hold tickets (e.g. all tickets sharing a
|
* Re-reserve seats for a group of released tickets (e.g. all tickets sharing a
|
||||||
* bookingId), atomically re-checking capacity before flipping their status.
|
* bookingId), atomically re-checking capacity before flipping their status.
|
||||||
* Throws HoldCapacityError if the event no longer has room for ticketIds.length seats.
|
* 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(
|
export async function reserveOnHoldBooking(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
ticketIds: string[],
|
ticketIds: string[],
|
||||||
targetTicketStatus: 'pending' | 'confirmed',
|
targetTicketStatus: 'pending' | 'confirmed',
|
||||||
targetPaymentStatus: 'pending_approval' | 'paid',
|
targetPaymentStatus: 'pending_approval' | 'paid' | 'pending',
|
||||||
options: ReserveOptions = {}
|
options: ReserveOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (ticketIds.length === 0) return;
|
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>(
|
const event = await dbGet<any>(
|
||||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||||
);
|
);
|
||||||
@@ -53,33 +75,38 @@ export async function reserveOnHoldBooking(
|
|||||||
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
|
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assertCapacity = (reserved: number) => {
|
// 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)) {
|
if (isEventSoldOut(event.capacity, reserved)) {
|
||||||
throw new HoldCapacityError(0);
|
throw new HoldCapacityError(0);
|
||||||
}
|
}
|
||||||
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
||||||
if (ticketIds.length > seatsLeft) {
|
if (needed > seatsLeft) {
|
||||||
throw new HoldCapacityError(seatsLeft);
|
throw new HoldCapacityError(seatsLeft);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isSqlite()) {
|
if (isSqlite()) {
|
||||||
(db as any).transaction((tx: any) => {
|
(db as any).transaction((tx: any) => {
|
||||||
const countRow = tx
|
if (checkCapacity) {
|
||||||
.select({ count: sql<number>`count(*)` })
|
const countRow = seatHolderCountQuery(tx, eventId).get();
|
||||||
.from(tickets)
|
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
|
||||||
.where(and(
|
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||||
eq((tickets as any).eventId, eventId),
|
}
|
||||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
|
||||||
))
|
|
||||||
.get();
|
|
||||||
assertCapacity(Number(countRow?.count || 0));
|
|
||||||
|
|
||||||
tx.update(tickets)
|
tx.update(tickets)
|
||||||
.set({ status: targetTicketStatus })
|
.set(ticketUpdate)
|
||||||
.where(and(
|
.where(and(
|
||||||
inArray((tickets as any).id, ticketIds),
|
inArray((tickets as any).id, ticketIds),
|
||||||
eq((tickets as any).status, 'on_hold')
|
inArray((tickets as any).status, fromTicketStatuses)
|
||||||
))
|
))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
@@ -90,22 +117,17 @@ export async function reserveOnHoldBooking(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await (db as any).transaction(async (tx: any) => {
|
await (db as any).transaction(async (tx: any) => {
|
||||||
const countRow = await dbGet<any>(
|
if (checkCapacity) {
|
||||||
tx
|
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
|
||||||
.select({ count: sql<number>`count(*)` })
|
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
|
||||||
.from(tickets)
|
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||||
.where(and(
|
}
|
||||||
eq((tickets as any).eventId, eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
|
||||||
))
|
|
||||||
);
|
|
||||||
assertCapacity(Number(countRow?.count || 0));
|
|
||||||
|
|
||||||
await tx.update(tickets)
|
await tx.update(tickets)
|
||||||
.set({ status: targetTicketStatus })
|
.set(ticketUpdate)
|
||||||
.where(and(
|
.where(and(
|
||||||
inArray((tickets as any).id, ticketIds),
|
inArray((tickets as any).id, ticketIds),
|
||||||
eq((tickets as any).status, 'on_hold')
|
inArray((tickets as any).status, fromTicketStatuses)
|
||||||
));
|
));
|
||||||
|
|
||||||
await tx.update(payments)
|
await tx.update(payments)
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
// Auto-hold stale pending-approval bookings.
|
// Auto-hold stale unsettled manual-payment bookings.
|
||||||
//
|
//
|
||||||
// A payment enters 'pending_approval' when a user clicks "I've paid" on a manual
|
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
|
||||||
// payment method (bank transfer / TPago) and is waiting for an admin to review it.
|
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
|
||||||
// If no admin acts within HOLD_THRESHOLD_HOURS, this job silently moves the payment
|
// the customer never clicked "I've paid" and no admin settled them. These are exempt
|
||||||
// (and its ticket) to 'on_hold', which drops it out of the capacity-counting statuses
|
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
|
||||||
// ('pending', 'confirmed', 'checked_in') and so releases the seat back to the event.
|
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
|
||||||
// The user receives no notification — they can recover via "I've paid" again, and an
|
// dead checkouts out of the admin's pending queues.
|
||||||
// admin can reactivate or mark it paid directly, both re-checking capacity.
|
//
|
||||||
|
// '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 { and, eq, lt, inArray } from 'drizzle-orm';
|
||||||
import { db, dbAll, tickets, payments } from '../db/index.js';
|
import { db, dbAll, tickets, payments } from '../db/index.js';
|
||||||
import { getNow, toDbDate } from './utils.js';
|
import { getNow, toDbDate } from './utils.js';
|
||||||
import { getLock } from './stores/lock.js';
|
import { getLock } from './stores/lock.js';
|
||||||
|
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||||
|
|
||||||
function getThresholdMs(): number {
|
function getThresholdMs(): number {
|
||||||
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
|
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
|
||||||
@@ -19,8 +26,9 @@ function getThresholdMs(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move stale pending-approval payments (and their tickets) to 'on_hold'.
|
* Move stale unsettled manual payments (and their tickets) to 'on_hold'.
|
||||||
* Returns the number of payments put 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> {
|
export async function sweepStaleApprovals(): Promise<number> {
|
||||||
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
|
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
|
||||||
@@ -33,7 +41,8 @@ export async function sweepStaleApprovals(): Promise<number> {
|
|||||||
})
|
})
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq((payments as any).status, 'pending_approval'),
|
eq((payments as any).status, 'pending'),
|
||||||
|
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
||||||
lt((payments as any).createdAt, cutoff)
|
lt((payments as any).createdAt, cutoff)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
@@ -59,7 +68,7 @@ export async function sweepStaleApprovals(): Promise<number> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[HoldSweep] Put ${stale.length} stale pending-approval payment(s) on hold.`);
|
console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`);
|
||||||
return stale.length;
|
return stale.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -4,7 +4,15 @@
|
|||||||
// before with in-memory backends. When set, this module owns a single shared
|
// before with in-memory backends. When set, this module owns a single shared
|
||||||
// command connection plus a dedicated subscriber connection (a connection in
|
// command connection plus a dedicated subscriber connection (a connection in
|
||||||
// subscribe mode cannot run normal commands), with auto-reconnect, capped
|
// 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';
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
@@ -12,6 +20,14 @@ let client: Redis | null = null;
|
|||||||
let subscriber: Redis | null = null;
|
let subscriber: Redis | null = null;
|
||||||
let healthy = false;
|
let healthy = false;
|
||||||
let initialized = 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. */
|
/** Whether Redis is configured via REDIS_URL. */
|
||||||
export function isRedisEnabled(): boolean {
|
export function isRedisEnabled(): boolean {
|
||||||
@@ -23,14 +39,41 @@ export function isRedisHealthy(): boolean {
|
|||||||
return isRedisEnabled() && healthy;
|
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 {
|
function buildClient(label: string): Redis {
|
||||||
const url = process.env.REDIS_URL as string;
|
const url = process.env.REDIS_URL as string;
|
||||||
const instance = new Redis(url, {
|
const instance = new Redis(url, {
|
||||||
// Keep the process responsive: fail fast on a per-command basis and let the
|
// 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,
|
maxRetriesPerRequest: 1,
|
||||||
enableOfflineQueue: false,
|
enableOfflineQueue: false,
|
||||||
lazyConnect: false,
|
lazyConnect: false,
|
||||||
|
connectTimeout: 5000,
|
||||||
retryStrategy(times) {
|
retryStrategy(times) {
|
||||||
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
|
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
|
||||||
const delay = Math.min(times * 200, 5000);
|
const delay = Math.min(times * 200, 5000);
|
||||||
@@ -42,30 +85,53 @@ function buildClient(label: string): Redis {
|
|||||||
console.log(`[redis] (${label}) connecting`);
|
console.log(`[redis] (${label}) connecting`);
|
||||||
});
|
});
|
||||||
instance.on('ready', () => {
|
instance.on('ready', () => {
|
||||||
healthy = true;
|
setHealthy(true, `${label} ready`);
|
||||||
console.log(`[redis] (${label}) ready`);
|
|
||||||
});
|
});
|
||||||
instance.on('error', (err) => {
|
instance.on('error', (err) => {
|
||||||
healthy = false;
|
setHealthy(false, `${label} error`);
|
||||||
console.error(`[redis] (${label}) error:`, err?.message || err);
|
logErrorDebounced(label, err);
|
||||||
});
|
});
|
||||||
instance.on('reconnecting', () => {
|
instance.on('reconnecting', () => {
|
||||||
healthy = false;
|
setHealthy(false, `${label} reconnecting`);
|
||||||
console.warn(`[redis] (${label}) reconnecting`);
|
|
||||||
});
|
});
|
||||||
instance.on('end', () => {
|
instance.on('end', () => {
|
||||||
healthy = false;
|
setHealthy(false, `${label} connection closed`);
|
||||||
console.warn(`[redis] (${label}) connection closed`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return instance;
|
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 {
|
function ensureInit(): void {
|
||||||
if (initialized || !isRedisEnabled()) return;
|
if (initialized || !isRedisEnabled()) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
client = buildClient('commands');
|
client = buildClient('commands');
|
||||||
subscriber = buildClient('subscriber');
|
subscriber = buildClient('subscriber');
|
||||||
|
pingTimer = setInterval(() => {
|
||||||
|
void pingOnce();
|
||||||
|
}, 10_000);
|
||||||
|
(pingTimer as any).unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared command connection, or null when Redis is not configured. */
|
/** 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). */
|
/** Close connections (used for graceful shutdown). */
|
||||||
export async function closeRedis(): Promise<void> {
|
export async function closeRedis(): Promise<void> {
|
||||||
|
if (pingTimer) {
|
||||||
|
clearInterval(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
}
|
||||||
const tasks: Promise<unknown>[] = [];
|
const tasks: Promise<unknown>[] = [];
|
||||||
if (client) tasks.push(client.quit().catch(() => undefined));
|
if (client) tasks.push(client.quit().catch(() => undefined));
|
||||||
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
|
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,22 +5,45 @@
|
|||||||
//
|
//
|
||||||
// Use acquire/release for long-lived ownership (e.g. a background poller) and
|
// 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.
|
// 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 { randomUUID } from 'crypto';
|
||||||
import { getRedis, isRedisEnabled } from '../redis.js';
|
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 {
|
export interface Lock {
|
||||||
readonly backend: 'memory' | 'redis';
|
readonly backend: 'memory' | 'redis';
|
||||||
// Returns a token when the lock was acquired, or null when already held.
|
// 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>;
|
acquire(key: string, ttlMs: number): Promise<string | null>;
|
||||||
release(key: string, token: string): Promise<void>;
|
release(key: string, token: string): Promise<void>;
|
||||||
// Runs fn while holding the lock; returns fn's result, or null if not acquired.
|
// 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 ====================
|
// ==================== Memory implementation ====================
|
||||||
|
|
||||||
class MemoryLock implements Lock {
|
export class MemoryLock implements Lock {
|
||||||
readonly backend = 'memory' as const;
|
readonly backend = 'memory' as const;
|
||||||
private held = new Map<string, { token: string; expiresAt: number }>();
|
private held = new Map<string, { token: string; expiresAt: number }>();
|
||||||
|
|
||||||
@@ -58,23 +81,23 @@ class MemoryLock implements Lock {
|
|||||||
const RELEASE_SCRIPT =
|
const RELEASE_SCRIPT =
|
||||||
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
|
'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;
|
readonly backend = 'redis' as const;
|
||||||
|
|
||||||
async acquire(key: string, ttlMs: number): Promise<string | null> {
|
async acquire(key: string, ttlMs: number): Promise<string | null> {
|
||||||
const redis = getRedis();
|
const redis = getRedis();
|
||||||
if (!redis) {
|
if (!redis) {
|
||||||
// Redis configured but unavailable: do not block critical sections.
|
throw new LockUnavailableError('redis client not initialized');
|
||||||
return randomUUID();
|
|
||||||
}
|
}
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
try {
|
try {
|
||||||
const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX');
|
const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX');
|
||||||
return result === 'OK' ? token : null;
|
return result === 'OK' ? token : null;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[lock] redis acquire error, proceeding without lock:', err?.message || err);
|
// Do NOT fabricate a token here: during an outage every replica would
|
||||||
// Fail open so a Redis outage does not deadlock startup or jobs.
|
// "acquire" every lock and run the guarded sections concurrently. Let the
|
||||||
return randomUUID();
|
// 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> {
|
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null> {
|
||||||
const token = await this.acquire(key, ttlMs);
|
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;
|
if (!token) return null;
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
// Rate limiter abstraction with two implementations:
|
// Rate limiter abstraction with two implementations:
|
||||||
// - memory: per-process fixed window (the original behavior)
|
// - 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
|
// 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.
|
// 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';
|
import { getRedis, isRedisEnabled } from '../redis.js';
|
||||||
|
|
||||||
export interface RateLimitResult {
|
export interface RateLimitResult {
|
||||||
@@ -24,7 +27,7 @@ interface Bucket {
|
|||||||
resetAt: number;
|
resetAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemoryRateLimiter implements RateLimiter {
|
export class MemoryRateLimiter implements RateLimiter {
|
||||||
readonly backend = 'memory' as const;
|
readonly backend = 'memory' as const;
|
||||||
private buckets = new Map<string, Bucket>();
|
private buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
@@ -58,22 +61,44 @@ class MemoryRateLimiter implements RateLimiter {
|
|||||||
|
|
||||||
// ==================== Redis implementation ====================
|
// ==================== 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;
|
readonly backend = 'redis' as const;
|
||||||
|
|
||||||
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
||||||
const redis = getRedis();
|
const redis = getRedis();
|
||||||
if (!redis) return { allowed: true };
|
if (!redis) return { allowed: true };
|
||||||
|
|
||||||
const redisKey = `rl:${key}`;
|
|
||||||
try {
|
try {
|
||||||
const count = await redis.incr(redisKey);
|
const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs);
|
||||||
if (count === 1) {
|
|
||||||
// First hit in this window: set the expiry that defines the window.
|
|
||||||
await redis.pexpire(redisKey, windowMs);
|
|
||||||
}
|
|
||||||
if (count > max) {
|
if (count > max) {
|
||||||
const ttl = await redis.pttl(redisKey);
|
|
||||||
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
|
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
|
||||||
return { allowed: false, retryAfter };
|
return { allowed: false, retryAfter };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
|
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||||
|
|
||||||
const adminRouter = new Hono();
|
const adminRouter = new Hono();
|
||||||
|
|
||||||
@@ -20,8 +21,9 @@ const csvEscape = (value: string) => {
|
|||||||
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
// Get upcoming events
|
// Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts)
|
||||||
const upcomingEvents = await dbAll(
|
// so the dashboard's capacity alerts reflect real availability.
|
||||||
|
const upcomingEventsRaw = await dbAll<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
@@ -35,6 +37,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.limit(5)
|
.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
|
// Get recent tickets
|
||||||
const recentTickets = await dbAll(
|
const recentTickets = await dbAll(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -70,6 +90,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.where(eq((tickets as any).status, 'confirmed'))
|
.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>(
|
const pendingPayments = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -77,6 +99,13 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.where(eq((payments as any).status, 'pending'))
|
.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>(
|
const onHoldPayments = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -115,6 +144,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
totalTickets: totalTickets?.count || 0,
|
totalTickets: totalTickets?.count || 0,
|
||||||
confirmedTickets: confirmedTickets?.count || 0,
|
confirmedTickets: confirmedTickets?.count || 0,
|
||||||
pendingPayments: pendingPayments?.count || 0,
|
pendingPayments: pendingPayments?.count || 0,
|
||||||
|
awaitingApprovalPayments: awaitingApprovalPayments?.count || 0,
|
||||||
onHoldPayments: onHoldPayments?.count || 0,
|
onHoldPayments: onHoldPayments?.count || 0,
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
newContacts: newContacts?.count || 0,
|
newContacts: newContacts?.count || 0,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||||
import { sendEmail } from '../lib/email.js';
|
import { sendEmail } from '../lib/email.js';
|
||||||
import { rateLimitMiddleware } from '../lib/rateLimit.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
|
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
|
||||||
// email-dispatching flows) to curb credential stuffing and email flooding.
|
// email-dispatching flows) to curb credential stuffing and email flooding.
|
||||||
@@ -36,42 +37,6 @@ type AuthUser = User & {
|
|||||||
|
|
||||||
const auth = new Hono();
|
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({
|
const registerSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(10, 'Password must be at least 10 characters'),
|
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) => {
|
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
// Check rate limit
|
// Per-email lockout (shared across instances when Redis is configured).
|
||||||
const rateLimit = checkRateLimit(data.email);
|
const lockout = await getLoginLockout().isLocked(data.email);
|
||||||
if (!rateLimit.allowed) {
|
if (lockout.locked) {
|
||||||
return c.json({
|
return c.json({
|
||||||
error: 'Too many login attempts. Please try again later.',
|
error: 'Too many login attempts. Please try again later.',
|
||||||
retryAfter: rateLimit.retryAfter
|
retryAfter: lockout.retryAfter
|
||||||
}, 429);
|
}, 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))
|
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
||||||
);
|
);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
recordFailedAttempt(data.email);
|
await getLoginLockout().recordFailure(data.email);
|
||||||
return c.json({ error: 'Invalid credentials' }, 401);
|
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);
|
const validPassword = await verifyPassword(data.password, user.password);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
recordFailedAttempt(data.email);
|
await getLoginLockout().recordFailure(data.email);
|
||||||
return c.json({ error: 'Invalid credentials' }, 401);
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear failed attempts on successful login
|
// 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
|
// 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
|
// plaintext and have verified it. Best-effort: a failure here must not block
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
|
|||||||
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
|
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -201,27 +202,28 @@ eventsRouter.get('/', async (c) => {
|
|||||||
|
|
||||||
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
|
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
|
// Single grouped query for seat counts across all events (avoids N+1: previously
|
||||||
// this ran one COUNT query per event).
|
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
|
||||||
const countRows = await dbAll<any>(
|
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
|
||||||
(db as any)
|
// so availableSeats subtracts them together — the same formula the booking-creation
|
||||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
// capacity check enforces (lib/capacity.ts).
|
||||||
.from(tickets)
|
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
||||||
.where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`)
|
const countByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||||
.groupBy((tickets as any).eventId)
|
|
||||||
);
|
|
||||||
const countByEvent = new Map<string, number>();
|
|
||||||
for (const row of countRows) {
|
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 eventsWithCounts = result.map((event: any) => {
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
const bookedCount = countByEvent.get(event.id) || 0;
|
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
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 normalized = normalizeEvent(event);
|
||||||
const bookedCount = ticketCount?.count || 0;
|
const counts = await getEventSeatCounts(event.id);
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
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';
|
return settings?.timezone || 'America/Asuncion';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get ticket count for an event
|
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat
|
||||||
async function getEventTicketCount(eventId: string): Promise<number> {
|
// counts for one event — see lib/capacity.ts for the seat-holding rule.
|
||||||
const ticketCount = await dbGet<any>(
|
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> {
|
||||||
(db as any)
|
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId));
|
||||||
.select({ count: sql<number>`count(*)` })
|
return {
|
||||||
.from(tickets)
|
paid: Number(row?.paidCount) || 0,
|
||||||
.where(
|
claimed: Number(row?.claimedCount) || 0,
|
||||||
and(
|
};
|
||||||
eq((tickets as any).eventId, eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return ticketCount?.count || 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
|
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
|
||||||
@@ -315,12 +298,13 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookedCount = await getEventTicketCount(event.id);
|
const counts = await getEventSeatCounts(event.id);
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
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 we have a valid featured event, return it
|
||||||
if (featuredEvent) {
|
if (featuredEvent) {
|
||||||
const bookedCount = await getEventTicketCount(featuredEvent.id);
|
const counts = await getEventSeatCounts(featuredEvent.id);
|
||||||
const normalized = normalizeEvent(featuredEvent);
|
const normalized = normalizeEvent(featuredEvent);
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
claimedCount: counts.claimed,
|
||||||
|
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '../lib/lnbits.js';
|
} from '../lib/lnbits.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { getPubSub } from '../lib/stores/pubsub.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();
|
const lnbitsRouter = new Hono();
|
||||||
|
|
||||||
@@ -106,12 +106,26 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
|
|||||||
|
|
||||||
const expiryMs = expirySeconds * 1000;
|
const expiryMs = expirySeconds * 1000;
|
||||||
|
|
||||||
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
|
let lockToken: string | null = null;
|
||||||
if (!lockToken) {
|
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.
|
// Another instance is already polling this ticket.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
checkerLockTokens.set(ticketId, lockToken);
|
if (lockToken) {
|
||||||
|
checkerLockTokens.set(ticketId, lockToken);
|
||||||
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let checkCount = 0;
|
let checkCount = 0;
|
||||||
@@ -270,11 +284,13 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
|
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
|
||||||
|
let transitioned = 0;
|
||||||
for (const ticket of ticketsToConfirm) {
|
for (const ticket of ticketsToConfirm) {
|
||||||
await (db as any)
|
const result: any = await (db as any)
|
||||||
.update(tickets)
|
.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')));
|
.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)
|
await (db as any)
|
||||||
.update(payments)
|
.update(payments)
|
||||||
@@ -289,6 +305,14 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
|||||||
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
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
|
// Get primary payment for sending receipt
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -372,8 +396,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
|||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
// Clean up on disconnect
|
const cleanup = () => {
|
||||||
stream.onAbort(() => {
|
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
const connections = activeConnections.get(ticketId);
|
const connections = activeConnections.get(ticketId);
|
||||||
if (connections) {
|
if (connections) {
|
||||||
@@ -388,11 +411,28 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
// Keep the stream open
|
// Clean up on disconnect
|
||||||
while (true) {
|
stream.onAbort(cleanup);
|
||||||
await stream.sleep(30000);
|
|
||||||
|
// 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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+146
-48
@@ -19,6 +19,9 @@ const updatePaymentSchema = z.object({
|
|||||||
const approvePaymentSchema = z.object({
|
const approvePaymentSchema = z.object({
|
||||||
adminNote: z.string().optional(),
|
adminNote: z.string().optional(),
|
||||||
sendEmail: z.boolean().optional().default(true),
|
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({
|
const rejectPaymentSchema = z.object({
|
||||||
@@ -26,6 +29,10 @@ const rejectPaymentSchema = z.object({
|
|||||||
sendEmail: z.boolean().optional().default(true),
|
sendEmail: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reopenPaymentSchema = z.object({
|
||||||
|
adminNote: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// Get all payments (admin) - with ticket and event details
|
// Get all payments (admin) - with ticket and event details
|
||||||
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
@@ -232,6 +239,12 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
|||||||
return c.json({ error: 'Payment not found' }, 404);
|
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 now = getNow();
|
||||||
|
|
||||||
const updateData: any = { ...data, updatedAt: now };
|
const updateData: any = { ...data, updatedAt: now };
|
||||||
@@ -275,7 +288,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
|||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(tickets)
|
.update(tickets)
|
||||||
.set({ status: 'confirmed' })
|
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
||||||
.where(eq((tickets as any).id, (t as any).id));
|
.where(eq((tickets as any).id, (t as any).id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +320,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
|||||||
// Approve payment (admin) - specifically for pending_approval payments
|
// Approve payment (admin) - specifically for pending_approval payments
|
||||||
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
||||||
const id = c.req.param('id');
|
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 user = (c as any).get('user');
|
||||||
|
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
@@ -321,13 +334,14 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
|||||||
return c.json({ error: 'Payment not found' }, 404);
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can approve pending, pending_approval, or on_hold payments
|
// Can approve pending, pending_approval, on_hold, or failed payments.
|
||||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
// '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);
|
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = getNow();
|
|
||||||
|
|
||||||
// Get the ticket associated with this payment
|
// Get the ticket associated with this payment
|
||||||
const ticket = await dbGet<any>(
|
const ticket = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -350,51 +364,54 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
|||||||
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payment.status === 'on_hold') {
|
// For a failed payment, only recover tickets whose own payment is also failed.
|
||||||
// The seat was released when this booking went on hold - re-check capacity
|
// This protects mixed bookings (e.g. a sibling ticket was refunded) from being
|
||||||
// before confirming it directly.
|
// resurrected or having its payment flipped to paid.
|
||||||
try {
|
if (payment.status === 'failed') {
|
||||||
await reserveOnHoldBooking(
|
const bookingPayments = await dbAll<any>(
|
||||||
ticket.eventId,
|
(db as any)
|
||||||
ticketsToConfirm.map((t: any) => t.id),
|
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
||||||
'confirmed',
|
.from(payments)
|
||||||
'paid',
|
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)))
|
||||||
{ paidByAdminId: user.id }
|
);
|
||||||
);
|
const failedTicketIds = new Set(
|
||||||
} catch (err) {
|
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
||||||
if (err instanceof HoldCapacityError) {
|
);
|
||||||
return c.json({
|
ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id));
|
||||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
if (ticketsToConfirm.length === 0) {
|
||||||
}, 400);
|
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
if (adminNote) {
|
}
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({ adminNote })
|
|
||||||
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Update all payments in the booking to paid
|
|
||||||
for (const t of ticketsToConfirm) {
|
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({
|
|
||||||
status: 'paid',
|
|
||||||
paidAt: now,
|
|
||||||
paidByAdminId: user.id,
|
|
||||||
adminNote: adminNote || payment.adminNote,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq((payments as any).ticketId, (t as any).id));
|
|
||||||
|
|
||||||
// Update ticket status to confirmed
|
// Confirm the booking through the shared capacity-checked reservation.
|
||||||
await (db as any)
|
// Tickets that already hold a seat ('pending_approval' claims) cost no new
|
||||||
.update(tickets)
|
// capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do.
|
||||||
.set({ status: 'confirmed' })
|
// When the event is full, the admin gets a structured over-capacity error and
|
||||||
.where(eq((tickets as any).id, (t as any).id));
|
// 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)
|
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||||
@@ -548,6 +565,87 @@ paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), asyn
|
|||||||
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
|
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
|
// Send payment reminder email
|
||||||
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
|
|||||||
+121
-217
@@ -10,6 +10,7 @@ import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
|||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||||
|
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||||
|
|
||||||
const ticketsRouter = new Hono();
|
const ticketsRouter = new Hono();
|
||||||
|
|
||||||
@@ -31,11 +32,19 @@ const createTicketSchema = z.object({
|
|||||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
// 'bancard' intentionally excluded: no checkout integration exists for it
|
// 'bancard' intentionally excluded: no checkout integration exists for it
|
||||||
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
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)
|
// 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(),
|
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
|
// Maps a payment provider to the merged payment-option flag that enables it
|
||||||
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
|
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
|
||||||
const truthy = (v: any) => v === true || v === 1;
|
const truthy = (v: any) => v === true || v === 1;
|
||||||
@@ -76,6 +85,7 @@ const adminCreateTicketSchema = z.object({
|
|||||||
// Book a ticket (public) - supports single or multi-ticket bookings
|
// Book a ticket (public) - supports single or multi-ticket bookings
|
||||||
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
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)
|
// Determine attendees list (use attendees array if provided, otherwise single attendee from main fields)
|
||||||
const attendeesList = data.attendees && data.attendees.length > 0
|
const attendeesList = data.attendees && data.attendees.length > 0
|
||||||
@@ -119,19 +129,10 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
|
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check capacity - count pending, confirmed AND checked_in tickets.
|
// Check capacity against held seats (paid/checked-in tickets plus claimed
|
||||||
// Pending reservations must hold seats to prevent overselling via unpaid bookings
|
// manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat.
|
||||||
// (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats).
|
|
||||||
const existingTicketCount = await dbGet<any>(
|
const existingTicketCount = await dbGet<any>(
|
||||||
(db as any)
|
seatHolderCountQuery(db, data.eventId)
|
||||||
.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 confirmedCount = existingTicketCount?.count || 0;
|
const confirmedCount = existingTicketCount?.count || 0;
|
||||||
@@ -168,19 +169,19 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
languagePreference: null,
|
languagePreference: null,
|
||||||
rucNumber: data.ruc || null,
|
rucNumber,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await (db as any).insert(users).values(user);
|
await (db as any).insert(users).values(user);
|
||||||
} else if (data.ruc) {
|
} else if (rucNumber) {
|
||||||
// Keep the user's saved RUC up to date for future bookings, but never blank
|
// 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.
|
// out an existing value if this booking didn't include one.
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ rucNumber: data.ruc, updatedAt: now })
|
.set({ rucNumber, updatedAt: now })
|
||||||
.where(eq((users as any).id, user.id));
|
.where(eq((users as any).id, user.id));
|
||||||
user.rucNumber = data.ruc;
|
user.rucNumber = rucNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||||
@@ -221,16 +222,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
try {
|
try {
|
||||||
if (isSqlite()) {
|
if (isSqlite()) {
|
||||||
(db as any).transaction((tx: any) => {
|
(db as any).transaction((tx: any) => {
|
||||||
const countRow = tx
|
const countRow = seatHolderCountQuery(tx, data.eventId).get();
|
||||||
.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 reserved = Number(countRow?.count || 0);
|
const reserved = Number(countRow?.count || 0);
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
if (isEventSoldOut(event.capacity, reserved)) {
|
||||||
throw new BookingCapacityError('SOLD_OUT');
|
throw new BookingCapacityError('SOLD_OUT');
|
||||||
@@ -253,7 +245,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||||
attendeeEmail: data.email,
|
attendeeEmail: data.email,
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
attendeeRuc: data.ruc || null,
|
attendeeRuc: rucNumber,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
qrCode,
|
qrCode,
|
||||||
@@ -281,17 +273,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await (db as any).transaction(async (tx: any) => {
|
await (db as any).transaction(async (tx: any) => {
|
||||||
const countRow = await dbGet<any>(
|
const countRow = await dbGet<any>(seatHolderCountQuery(tx, data.eventId));
|
||||||
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 reserved = Number(countRow?.count || 0);
|
const reserved = Number(countRow?.count || 0);
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
if (isEventSoldOut(event.capacity, reserved)) {
|
||||||
throw new BookingCapacityError('SOLD_OUT');
|
throw new BookingCapacityError('SOLD_OUT');
|
||||||
@@ -314,7 +296,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||||
attendeeEmail: data.email,
|
attendeeEmail: data.email,
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
attendeeRuc: data.ruc || null,
|
attendeeRuc: rucNumber,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
qrCode,
|
qrCode,
|
||||||
@@ -379,7 +361,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
for (const t of createdTickets) {
|
for (const t of createdTickets) {
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(tickets)
|
.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')));
|
.where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending')));
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(payments)
|
.update(payments)
|
||||||
@@ -1020,6 +1002,9 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
|
|||||||
attendeeEmail: ticket.attendeeEmail,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
attendeePhone: ticket.attendeePhone,
|
attendeePhone: ticket.attendeePhone,
|
||||||
status: ticket.status,
|
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,
|
checkinAt: ticket.checkinAt,
|
||||||
checkedInBy,
|
checkedInBy,
|
||||||
},
|
},
|
||||||
@@ -1100,7 +1085,9 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
|||||||
return c.json({ error: 'Ticket not found' }, 404);
|
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);
|
return c.json({ error: 'Ticket already confirmed' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1143,12 +1130,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Confirm all tickets in the booking
|
// Confirm all tickets in the booking (checked-in tickets keep their status)
|
||||||
for (const t of ticketsToConfirm) {
|
for (const t of ticketsToConfirm) {
|
||||||
// Update ticket status
|
// Update ticket status
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(tickets)
|
.update(tickets)
|
||||||
.set({ status: 'confirmed' })
|
.set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' })
|
||||||
.where(eq((tickets as any).id, t.id));
|
.where(eq((tickets as any).id, t.id));
|
||||||
|
|
||||||
// Update payment status
|
// Update payment status
|
||||||
@@ -1489,6 +1476,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: ticketStatus,
|
status: ticketStatus,
|
||||||
|
paymentStatus: 'paid',
|
||||||
qrCode,
|
qrCode,
|
||||||
checkinAt: data.autoCheckin ? now : null,
|
checkinAt: data.autoCheckin ? now : null,
|
||||||
adminNote: data.adminNote || null,
|
adminNote: data.adminNote || null,
|
||||||
@@ -1532,19 +1520,29 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin create manual ticket (sends confirmation email + ticket to attendee)
|
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
|
||||||
ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
// 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(),
|
eventId: z.string(),
|
||||||
firstName: z.string().min(2),
|
type: z.enum(['paid', 'unpaid', 'guest']),
|
||||||
|
firstName: z.string().min(1),
|
||||||
lastName: z.string().optional().or(z.literal('')),
|
lastName: z.string().optional().or(z.literal('')),
|
||||||
email: z.string().email('Valid email is required for manual tickets'),
|
email: z.string().email().optional().or(z.literal('')),
|
||||||
phone: z.string().optional().or(z.literal('')),
|
phone: z.string().optional().or(z.literal('')),
|
||||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
|
checkinNow: z.boolean().optional().default(false),
|
||||||
adminNote: z.string().max(1000).optional(),
|
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) => {
|
})), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
// Get event
|
|
||||||
const event = await dbGet<any>(
|
const event = await dbGet<any>(
|
||||||
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
||||||
);
|
);
|
||||||
@@ -1552,20 +1550,24 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets)
|
// Admin-added tickets bypass the capacity check (intentional over-capacity)
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
const attendeeEmail = data.email.trim();
|
const adminUser = (c as any).get('user');
|
||||||
|
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
|
// Find or create user
|
||||||
let user = await dbGet<any>(
|
let user = await dbGet<any>(
|
||||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
(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) {
|
if (!user) {
|
||||||
const userId = generateId();
|
const userId = generateId();
|
||||||
user = {
|
user = {
|
||||||
@@ -1582,142 +1584,8 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
await (db as any).insert(users).values(user);
|
await (db as any).insert(users).values(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing active ticket for this user and event
|
// Check for existing active ticket (only when a real email was provided)
|
||||||
const existingTicket = await dbGet<any>(
|
if (hasEmail) {
|
||||||
(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({
|
|
||||||
eventId: z.string(),
|
|
||||||
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(),
|
|
||||||
adminNote: z.string().max(1000).optional(),
|
|
||||||
})), async (c) => {
|
|
||||||
const data = c.req.valid('json');
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 fullName = data.lastName && data.lastName.trim()
|
|
||||||
? `${data.firstName} ${data.lastName}`.trim()
|
|
||||||
: data.firstName;
|
|
||||||
|
|
||||||
let user = await dbGet<any>(
|
|
||||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
|
||||||
);
|
|
||||||
|
|
||||||
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 (only for real emails, not placeholder)
|
|
||||||
if (data.email && data.email.trim()) {
|
|
||||||
const existingTicket = await dbGet<any>(
|
const existingTicket = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
@@ -1736,6 +1604,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
|||||||
|
|
||||||
const ticketId = generateId();
|
const ticketId = generateId();
|
||||||
const qrCode = generateTicketCode();
|
const qrCode = generateTicketCode();
|
||||||
|
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid';
|
||||||
|
|
||||||
const newTicket = {
|
const newTicket = {
|
||||||
id: ticketId,
|
id: ticketId,
|
||||||
@@ -1743,50 +1612,85 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
|||||||
eventId: data.eventId,
|
eventId: data.eventId,
|
||||||
attendeeFirstName: data.firstName,
|
attendeeFirstName: data.firstName,
|
||||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
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,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: 'confirmed',
|
status: data.checkinNow ? 'checked_in' : 'confirmed',
|
||||||
isGuest: 1,
|
isGuest: data.type === 'guest' ? 1 : 0,
|
||||||
|
paymentStatus,
|
||||||
qrCode,
|
qrCode,
|
||||||
checkinAt: null,
|
checkinAt: data.checkinNow ? now : null,
|
||||||
|
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
|
||||||
adminNote: data.adminNote || null,
|
adminNote: data.adminNote || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await (db as any).insert(tickets).values(newTicket);
|
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 paymentId = generateId();
|
||||||
const newPayment = {
|
const newPayment = data.type === 'unpaid'
|
||||||
id: paymentId,
|
? {
|
||||||
ticketId,
|
id: paymentId,
|
||||||
provider: 'cash',
|
ticketId,
|
||||||
amount: 0,
|
provider: 'tpago',
|
||||||
currency: event.currency,
|
amount: event.price,
|
||||||
status: 'paid',
|
currency: event.currency,
|
||||||
reference: 'Guest invite',
|
status: 'pending',
|
||||||
paidAt: now,
|
reference: 'Unpaid ticket — collect at door',
|
||||||
paidByAdminId: adminUser?.id || null,
|
paidAt: null,
|
||||||
createdAt: now,
|
paidByAdminId: null,
|
||||||
updatedAt: now,
|
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);
|
await (db as any).insert(payments).values(newPayment);
|
||||||
|
|
||||||
// Send booking confirmation email if a real email was provided
|
// Emails (asynchronous): paid always confirms; guest confirms when an email
|
||||||
if (data.email && data.email.trim()) {
|
// 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 => {
|
emailService.sendBookingConfirmation(ticketId).then(result => {
|
||||||
if (result.success) {
|
if (!result.success) {
|
||||||
console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
|
console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error);
|
||||||
} else {
|
|
||||||
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
|
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}).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({
|
return c.json({
|
||||||
ticket: {
|
ticket: {
|
||||||
...newTicket,
|
...newTicket,
|
||||||
@@ -1797,7 +1701,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
payment: newPayment,
|
payment: newPayment,
|
||||||
message: 'Guest ticket created successfully',
|
message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type],
|
||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -63,6 +63,29 @@ server {
|
|||||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
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 / {
|
location / {
|
||||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,20 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
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:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -46,7 +59,7 @@ services:
|
|||||||
DB_TYPE: postgres
|
DB_TYPE: postgres
|
||||||
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
|
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
|
||||||
DB_POOL_MAX: "15"
|
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
|
JWT_SECRET: change-me-to-a-strong-secret
|
||||||
FRONTEND_URL: http://localhost:8080
|
FRONTEND_URL: http://localhost:8080
|
||||||
# Optional S3-compatible storage so uploads are shared across replicas.
|
# Optional S3-compatible storage so uploads are shared across replicas.
|
||||||
|
|||||||
@@ -79,6 +79,24 @@ server {
|
|||||||
proxy_connect_timeout 300s;
|
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
|
# Proxy /api to backend
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://spanglish_backend;
|
proxy_pass http://spanglish_backend;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Spanglish Photo Gallery API
|
||||||
|
Documentation=https://git.azzamo.net/Michilis/Spanglish
|
||||||
|
After=network.target spanglish-backend.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=spanglish
|
||||||
|
Group=spanglish
|
||||||
|
WorkingDirectory=/home/spanglish/Spanglish/photo-api
|
||||||
|
EnvironmentFile=/home/spanglish/Spanglish/photo-api/.env
|
||||||
|
Environment=PORT=3020
|
||||||
|
# Apply pending photos_* migrations before serving (idempotent, advisory-locked)
|
||||||
|
ExecStartPre=/home/spanglish/Spanglish/photo-api/bin/photo-api migrate
|
||||||
|
ExecStart=/home/spanglish/Spanglish/photo-api/bin/photo-api
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=syslog
|
||||||
|
StandardError=syslog
|
||||||
|
SyslogIdentifier=spanglish-photos
|
||||||
|
|
||||||
|
# Security hardening (mirrors spanglish-backend.service)
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=/home/spanglish/Spanglish/photo-api/data
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -5,3 +5,7 @@ upstream spanglish_frontend {
|
|||||||
upstream spanglish_backend {
|
upstream spanglish_backend {
|
||||||
server 127.0.0.1:3018;
|
server 127.0.0.1:3018;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upstream spanglish_photos {
|
||||||
|
server 127.0.0.1:3020;
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com
|
|||||||
# API URL (leave empty for same-origin proxy)
|
# API URL (leave empty for same-origin proxy)
|
||||||
NEXT_PUBLIC_API_URL=
|
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)
|
# Google OAuth (optional - leave empty to hide Google Sign-In button)
|
||||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||||
# 1. Create a new OAuth 2.0 Client ID (Web application)
|
# 1. Create a new OAuth 2.0 Client ID (Web application)
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
// being hardcoded to localhost.
|
// being hardcoded to localhost.
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
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).
|
// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN).
|
||||||
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
|
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -20,6 +24,9 @@ const securityHeaders = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const nextConfig = {
|
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: {
|
images: {
|
||||||
// Restrict remote image sources to a known allowlist instead of allowing any
|
// 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).
|
// https host (which let the Next image optimizer be used as an open proxy).
|
||||||
@@ -39,6 +46,10 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/photos/:path*',
|
||||||
|
destination: `${PHOTO_API_URL}/api/photos/:path*`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
destination: `${BACKEND_URL}/api/:path*`,
|
destination: `${BACKEND_URL}/api/:path*`,
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { PaymentMethod, BookingResult } from '../_types';
|
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 {
|
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. */
|
/** Truncate a long invoice string for display. */
|
||||||
|
|||||||
@@ -226,25 +226,10 @@ export function BookingFormStep({
|
|||||||
onBlur={handleRucBlur}
|
onBlur={handleRucBlur}
|
||||||
placeholder={t('booking.form.rucPlaceholder')}
|
placeholder={t('booking.form.rucPlaceholder')}
|
||||||
error={errors.ruc}
|
error={errors.ruc}
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
aria-label={t('booking.form.ruc')}
|
aria-label={t('booking.form.ruc')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface BookingFormData {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
preferredLanguage: 'en' | 'es';
|
|
||||||
// Empty until the user explicitly picks a method (no default selection).
|
// Empty until the user explicitly picks a method (no default selection).
|
||||||
paymentMethod: PaymentMethod | '';
|
paymentMethod: PaymentMethod | '';
|
||||||
ruc: string;
|
ruc: string;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
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 { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type {
|
import type {
|
||||||
@@ -56,7 +56,6 @@ export default function BookingPage() {
|
|||||||
lastName: '',
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
preferredLanguage: locale as 'en' | 'es',
|
|
||||||
paymentMethod: '',
|
paymentMethod: '',
|
||||||
ruc: '',
|
ruc: '',
|
||||||
});
|
});
|
||||||
@@ -78,11 +77,10 @@ export default function BookingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate RUC on blur (optional field: 6–10 digits)
|
// Validate RUC on blur (optional field: base + "-" + check digit)
|
||||||
const handleRucBlur = () => {
|
const handleRucBlur = () => {
|
||||||
if (!formData.ruc) return;
|
if (!formData.ruc) return;
|
||||||
const digits = formData.ruc.replace(/\D/g, '');
|
if (!rucPattern.test(formData.ruc)) {
|
||||||
if (digits.length > 0 && !rucPattern.test(digits)) {
|
|
||||||
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
|
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -110,16 +108,15 @@ export default function BookingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookedCount = eventRes.event.bookedCount ?? 0;
|
// Server-authoritative availability — same formula the booking API
|
||||||
const capacity = eventRes.event.capacity ?? 0;
|
// enforces, so a sold-out event is caught here, not at submit time.
|
||||||
const soldOut = bookedCount >= capacity;
|
if (isEventSoldOut(eventRes.event)) {
|
||||||
if (soldOut) {
|
|
||||||
toast.error(t('events.details.soldOut'));
|
toast.error(t('events.details.soldOut'));
|
||||||
router.push(`/events/${eventRes.event.slug}`);
|
router.push(`/events/${eventRes.event.slug}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spotsLeft = Math.max(0, capacity - bookedCount);
|
const spotsLeft = eventSpotsLeft(eventRes.event);
|
||||||
setEvent(eventRes.event);
|
setEvent(eventRes.event);
|
||||||
// Cap quantity by available spots (never allow requesting more than spotsLeft)
|
// Cap quantity by available spots (never allow requesting more than spotsLeft)
|
||||||
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
|
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
|
||||||
@@ -152,8 +149,7 @@ export default function BookingPage() {
|
|||||||
lastName: prev.lastName || lastName,
|
lastName: prev.lastName || lastName,
|
||||||
email: prev.email || user.email || '',
|
email: prev.email || user.email || '',
|
||||||
phone: prev.phone || user.phone || '',
|
phone: prev.phone || user.phone || '',
|
||||||
preferredLanguage: (user.languagePreference as 'en' | 'es') || prev.preferredLanguage,
|
ruc: prev.ruc || formatRucDisplay(user.rucNumber),
|
||||||
ruc: prev.ruc || user.rucNumber || '',
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -197,12 +193,9 @@ export default function BookingPage() {
|
|||||||
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
||||||
}
|
}
|
||||||
|
|
||||||
// RUC validation (optional field - 6–10 digits if filled)
|
// RUC validation (optional field - base + "-" + check digit if filled)
|
||||||
if (formData.ruc.trim()) {
|
if (formData.ruc.trim() && !rucPattern.test(formData.ruc.trim())) {
|
||||||
const digits = formData.ruc.replace(/\D/g, '');
|
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
||||||
if (!/^\d{6,10}$/.test(digits)) {
|
|
||||||
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment method must be explicitly chosen and currently enabled
|
// Payment method must be explicitly chosen and currently enabled
|
||||||
@@ -301,9 +294,9 @@ export default function BookingPage() {
|
|||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
phone: formData.phone,
|
phone: formData.phone,
|
||||||
preferredLanguage: formData.preferredLanguage,
|
preferredLanguage: locale as 'en' | 'es',
|
||||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||||
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
|
...(formData.ruc.trim() && { ruc: formData.ruc.trim() }),
|
||||||
// Include attendees array for multi-ticket bookings
|
// Include attendees array for multi-ticket bookings
|
||||||
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
||||||
});
|
});
|
||||||
@@ -372,6 +365,23 @@ export default function BookingPage() {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || t('booking.form.errors.bookingFailed'));
|
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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -394,8 +404,8 @@ export default function BookingPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
const isSoldOut = isEventSoldOut(event);
|
||||||
|
|
||||||
// Paying step - waiting for Lightning payment (compact design)
|
// Paying step - waiting for Lightning payment (compact design)
|
||||||
if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) {
|
if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { faqApi, FaqItem } from '@/lib/api';
|
import { faqApi, FaqItem } from '@/lib/api';
|
||||||
|
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -23,7 +24,20 @@ export default function HomepageFaqSection() {
|
|||||||
return () => { cancelled = true; };
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
|
import { FeaturedEventSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
|
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
interface NextEventSectionProps {
|
interface NextEventSectionProps {
|
||||||
@@ -51,11 +52,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <FeaturedEventSkeleton />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nextEvent) {
|
if (!nextEvent) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UserPayment,
|
UserPayment,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { CardListSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
import OverviewTab from './components/OverviewTab';
|
import OverviewTab from './components/OverviewTab';
|
||||||
import TicketsTab from './components/TicketsTab';
|
import TicketsTab from './components/TicketsTab';
|
||||||
@@ -104,9 +105,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12">
|
<CardListSkeleton count={3} />
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-yellow" />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, Event } from '@/lib/api';
|
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 Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import ShareButtons from '@/components/ShareButtons';
|
import ShareButtons from '@/components/ShareButtons';
|
||||||
@@ -43,9 +43,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
// Spots left: never negative; sold out when confirmed >= capacity
|
// Server-authoritative availability (paid + claimed seats count; abandoned
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
// pending bookings don't) — matches the booking API's sold-out check exactly.
|
||||||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
|
const isSoldOut = isEventSoldOut(event);
|
||||||
const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
|
const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
|
||||||
|
|
||||||
useEffect(() => {
|
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,7 +117,10 @@ function generateEventJsonLd(event: Event) {
|
|||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
price: event.price,
|
price: event.price,
|
||||||
priceCurrency: event.currency,
|
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/InStock'
|
||||||
: 'https://schema.org/SoldOut',
|
: 'https://schema.org/SoldOut',
|
||||||
url: `${siteUrl}/events/${event.slug}`,
|
url: `${siteUrl}/events/${event.slug}`,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import type { PhotoGallery } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import { CameraIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function PhotosIndexClient({ galleries }: { galleries: PhotoGallery[] }) {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const es = locale === 'es';
|
||||||
|
|
||||||
|
const formatDate = (iso?: string) => {
|
||||||
|
if (!iso) return null;
|
||||||
|
return new Date(iso).toLocaleDateString(es ? 'es-ES' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page">
|
||||||
|
<h1 className="section-title text-center mb-2">
|
||||||
|
{es ? 'Galerías de Fotos' : 'Photo Galleries'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-gray-600 mb-10">
|
||||||
|
{es
|
||||||
|
? 'Recuerdos de nuestros intercambios de idiomas en Asunción'
|
||||||
|
: 'Memories from our language exchanges in Asunción'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{galleries.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{es ? 'Aún no hay galerías publicadas.' : 'No galleries published yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{galleries.map((g) => (
|
||||||
|
// Event-linked galleries live under the event's URL.
|
||||||
|
<Link key={g.id} href={g.event ? `/events/${g.event.slug}/gallery` : `/photos/${g.slug}`}>
|
||||||
|
<Card variant="elevated" className="overflow-hidden hover:shadow-card-hover transition-shadow h-full">
|
||||||
|
<div className="aspect-video bg-gray-100 flex items-center justify-center">
|
||||||
|
{g.coverUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<CameraIcon className="w-12 h-12 text-gray-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="font-heading font-semibold text-lg text-primary-dark">
|
||||||
|
{es && g.titleEs ? g.titleEs : g.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{g.photoCount} {es ? 'fotos' : 'photos'}
|
||||||
|
{g.event?.startDatetime && <> · {formatDate(g.event.startDatetime)}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
import Lightbox from '@/components/Lightbox';
|
||||||
|
import {
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
CameraIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
TicketIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface GalleryClientProps {
|
||||||
|
/** Fetch by gallery slug (standalone galleries at /photos/[slug]) … */
|
||||||
|
slug?: string;
|
||||||
|
/** … or by event slug (event galleries at /events/[slug]/gallery). */
|
||||||
|
eventSlug?: string;
|
||||||
|
initial: { gallery: PhotoGallery; photos: Photo[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeniedState = 'login' | 'ticket' | 'notfound' | null;
|
||||||
|
|
||||||
|
export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const es = locale === 'es';
|
||||||
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const shareToken = searchParams.get('token') || undefined;
|
||||||
|
|
||||||
|
const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null);
|
||||||
|
const [photos, setPhotos] = useState<Photo[]>(initial?.photos ?? []);
|
||||||
|
const [loading, setLoading] = useState(!initial);
|
||||||
|
const [denied, setDenied] = useState<DeniedState>(null);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Server-rendered public galleries need no client fetch. Everything else
|
||||||
|
// (link/ticket/private) is fetched here with the share token and/or the
|
||||||
|
// viewer's own auth token attached.
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) return;
|
||||||
|
if (authLoading) return; // wait so the Bearer token is available
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
const fetcher = eventSlug
|
||||||
|
? photosApi.getEventGallery(eventSlug, shareToken)
|
||||||
|
: photosApi.getPublicGallery(slug || '', shareToken);
|
||||||
|
fetcher
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setGallery(data.gallery);
|
||||||
|
setPhotos(data.photos);
|
||||||
|
setDenied(null);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const msg = err.message || '';
|
||||||
|
if (msg.includes('Authentication required')) setDenied('login');
|
||||||
|
else if (msg.includes('attendees')) setDenied('ticket');
|
||||||
|
else setDenied('notfound');
|
||||||
|
})
|
||||||
|
.finally(() => !cancelled && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
|
||||||
|
|
||||||
|
if (loading || (authLoading && !initial)) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page">
|
||||||
|
<ImageGridSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (denied === 'login') {
|
||||||
|
const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`);
|
||||||
|
return (
|
||||||
|
<GateMessage
|
||||||
|
icon={LockClosedIcon}
|
||||||
|
title={es ? 'Inicia sesión para ver esta galería' : 'Log in to view this gallery'}
|
||||||
|
body={
|
||||||
|
es
|
||||||
|
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
|
||||||
|
: 'This gallery is for event attendees. Log in with the account you used to book.'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button onClick={() => router.push(`/login?redirect=${redirect}`)}>
|
||||||
|
{es ? 'Iniciar sesión' : 'Log in'}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (denied === 'ticket') {
|
||||||
|
return (
|
||||||
|
<GateMessage
|
||||||
|
icon={TicketIcon}
|
||||||
|
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||||||
|
body={
|
||||||
|
es
|
||||||
|
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada.'
|
||||||
|
: 'This gallery is only available to people who attended the event with a confirmed ticket.'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
eventSlug || gallery?.event ? (
|
||||||
|
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
|
||||||
|
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
|
||||||
|
</Link>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (denied === 'notfound' || !gallery) {
|
||||||
|
return (
|
||||||
|
<GateMessage
|
||||||
|
icon={CameraIcon}
|
||||||
|
title={es ? 'Galería no encontrada' : 'Gallery not found'}
|
||||||
|
body={
|
||||||
|
es
|
||||||
|
? 'Este enlace no existe o ya no está disponible.'
|
||||||
|
: 'This link does not exist or is no longer available.'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Link href="/photos">
|
||||||
|
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
||||||
|
const lightboxItems = readyPhotos.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
previewUrl: p.urls.preview || p.urls.original,
|
||||||
|
downloadUrl: p.urls.original,
|
||||||
|
filename: p.originalFilename,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
|
||||||
|
const description = es && gallery.descriptionEs ? gallery.descriptionEs : gallery.description;
|
||||||
|
const eventTitle = gallery.event
|
||||||
|
? es && gallery.event.titleEs
|
||||||
|
? gallery.event.titleEs
|
||||||
|
: gallery.event.title
|
||||||
|
: null;
|
||||||
|
const eventDate = gallery.event?.startDatetime
|
||||||
|
? new Date(gallery.event.startDatetime).toLocaleDateString(es ? 'es-ES' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Hero backdrop: the cover photo's preview when available, else the
|
||||||
|
// first photo. Falls back to a navy gradient with no image.
|
||||||
|
const coverPhoto =
|
||||||
|
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
|
||||||
|
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="relative bg-brand-navy overflow-hidden">
|
||||||
|
{heroUrl && (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={heroUrl}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">
|
||||||
|
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
|
||||||
|
<CameraIcon className="w-4 h-4" />
|
||||||
|
{readyPhotos.length} {es ? 'fotos' : 'photos'}
|
||||||
|
</span>
|
||||||
|
{gallery.event && (
|
||||||
|
<Link
|
||||||
|
href={`/events/${gallery.event.slug}`}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
{eventTitle}
|
||||||
|
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Masonry grid */}
|
||||||
|
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
|
||||||
|
{readyPhotos.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{es ? 'Las fotos estarán disponibles pronto.' : 'Photos will be available soon.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
|
||||||
|
{readyPhotos.map((photo, i) => (
|
||||||
|
<div
|
||||||
|
key={photo.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setLightboxIndex(i)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setLightboxIndex(i);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
style={
|
||||||
|
photo.width && photo.height
|
||||||
|
? { aspectRatio: `${photo.width} / ${photo.height}` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={photo.urls.thumb}
|
||||||
|
alt=""
|
||||||
|
loading={i < 8 ? 'eager' : 'lazy'}
|
||||||
|
className="w-full h-auto group-hover:scale-[1.03] transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||||
|
<a
|
||||||
|
href={photo.urls.original}
|
||||||
|
download={photo.originalFilename || true}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
|
||||||
|
aria-label={es ? 'Descargar' : 'Download'}
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lightboxIndex !== null && (
|
||||||
|
<Lightbox
|
||||||
|
items={lightboxItems}
|
||||||
|
index={lightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
onNavigate={setLightboxIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GateMessage({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon: typeof CameraIcon;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page max-w-lg mx-auto text-center py-16">
|
||||||
|
<Icon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark mb-2">{title}</h1>
|
||||||
|
<p className="text-gray-600 mb-6">{body}</p>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import type { PhotoGallery, Photo } from '@/lib/api';
|
||||||
|
import GalleryClient from './GalleryClient';
|
||||||
|
|
||||||
|
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: { slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public galleries render server-side (SEO + fast first paint); link and
|
||||||
|
// ticket galleries return null here and are fetched client-side with the
|
||||||
|
// viewer's token / share token.
|
||||||
|
async function getPublicGallery(
|
||||||
|
slug: string
|
||||||
|
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${photoApiUrl}/api/photos/public/galleries/${encodeURIComponent(slug)}`,
|
||||||
|
{ next: { revalidate: 300 } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const data = await getPublicGallery(params.slug);
|
||||||
|
if (!data) {
|
||||||
|
return { title: 'Photo Gallery', robots: { index: false } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: `${data.gallery.title} – Photos`,
|
||||||
|
description:
|
||||||
|
data.gallery.description ||
|
||||||
|
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GalleryPage({ params }: PageProps) {
|
||||||
|
const initial = await getPublicGallery(params.slug);
|
||||||
|
return (
|
||||||
|
// Suspense boundary required by useSearchParams() in the client child.
|
||||||
|
<Suspense>
|
||||||
|
<GalleryClient slug={params.slug} initial={initial} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import type { PhotoGallery } from '@/lib/api';
|
||||||
|
import PhotosIndexClient from './PhotosIndexClient';
|
||||||
|
|
||||||
|
// Server-side calls go straight to the photo-api service; the browser uses
|
||||||
|
// the same-origin /api/photos path via the Next rewrite / nginx.
|
||||||
|
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Event Photo Galleries',
|
||||||
|
description: 'Photos from past Spanglish language exchange events in Asunción.',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getGalleries(): Promise<PhotoGallery[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, {
|
||||||
|
next: { revalidate: 300 },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.galleries || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PhotosPage() {
|
||||||
|
const galleries = await getGalleries();
|
||||||
|
return <PhotosIndexClient galleries={galleries} />;
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
TicketIcon,
|
TicketIcon,
|
||||||
@@ -246,11 +247,7 @@ export default function AdminBookingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={7} />;
|
||||||
<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 (
|
return (
|
||||||
@@ -420,7 +417,7 @@ export default function AdminBookingsPage() {
|
|||||||
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
|
<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>}
|
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{ticket.attendeeRuc || '-'}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{formatRucDisplay(ticket.attendeeRuc) || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm truncate max-w-[150px] block">
|
<span className="text-sm truncate max-w-[150px] block">
|
||||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { contactsApi, Contact } from '@/lib/api';
|
import { contactsApi, Contact } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton, SkeletonText, CardListSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
@@ -65,8 +66,21 @@ export default function AdminContactsPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div>
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
|||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
@@ -418,11 +419,7 @@ export default function AdminEmailsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={5} />;
|
||||||
<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 (
|
return (
|
||||||
|
|||||||
@@ -18,3 +18,23 @@ export function StatusBadge({ status, compact = false }: { status: string; compa
|
|||||||
</span>
|
</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 { Ticket } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { BottomSheet } from '@/components/admin/MobileComponents';
|
import { BottomSheet } from '@/components/admin/MobileComponents';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
|
BanknotesIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
PlusIcon,
|
|
||||||
StarIcon,
|
StarIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
|
import type { AttendeeStatusFilter, AddTicketType } from '../_types';
|
||||||
|
|
||||||
interface EventModalsProps {
|
interface EventModalsProps {
|
||||||
// counts + filter
|
// counts + filter
|
||||||
@@ -29,6 +28,7 @@ interface EventModalsProps {
|
|||||||
// add ticket sheet
|
// add ticket sheet
|
||||||
showAddTicketSheet: boolean;
|
showAddTicketSheet: boolean;
|
||||||
setShowAddTicketSheet: (value: boolean) => void;
|
setShowAddTicketSheet: (value: boolean) => void;
|
||||||
|
openAddTicket: (type: AddTicketType) => void;
|
||||||
// export sheets
|
// export sheets
|
||||||
showExportSheet: boolean;
|
showExportSheet: boolean;
|
||||||
setShowExportSheet: (value: boolean) => void;
|
setShowExportSheet: (value: boolean) => void;
|
||||||
@@ -36,24 +36,6 @@ interface EventModalsProps {
|
|||||||
showTicketExportSheet: boolean;
|
showTicketExportSheet: boolean;
|
||||||
setShowTicketExportSheet: (value: boolean) => void;
|
setShowTicketExportSheet: (value: boolean) => void;
|
||||||
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => 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
|
// shared submit flag
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
// note modal
|
// note modal
|
||||||
@@ -83,27 +65,13 @@ export function EventModals(props: EventModalsProps) {
|
|||||||
setMobileFilterOpen,
|
setMobileFilterOpen,
|
||||||
showAddTicketSheet,
|
showAddTicketSheet,
|
||||||
setShowAddTicketSheet,
|
setShowAddTicketSheet,
|
||||||
|
openAddTicket,
|
||||||
showExportSheet,
|
showExportSheet,
|
||||||
setShowExportSheet,
|
setShowExportSheet,
|
||||||
handleExportAttendees,
|
handleExportAttendees,
|
||||||
showTicketExportSheet,
|
showTicketExportSheet,
|
||||||
setShowTicketExportSheet,
|
setShowTicketExportSheet,
|
||||||
handleExportTickets,
|
handleExportTickets,
|
||||||
showAddAtDoorModal,
|
|
||||||
setShowAddAtDoorModal,
|
|
||||||
addAtDoorForm,
|
|
||||||
setAddAtDoorForm,
|
|
||||||
handleAddAtDoor,
|
|
||||||
showManualTicketModal,
|
|
||||||
setShowManualTicketModal,
|
|
||||||
manualTicketForm,
|
|
||||||
setManualTicketForm,
|
|
||||||
handleManualTicket,
|
|
||||||
showInviteGuestModal,
|
|
||||||
setShowInviteGuestModal,
|
|
||||||
inviteGuestForm,
|
|
||||||
setInviteGuestForm,
|
|
||||||
handleInviteGuest,
|
|
||||||
submitting,
|
submitting,
|
||||||
showNoteModal,
|
showNoteModal,
|
||||||
setShowNoteModal,
|
setShowNoteModal,
|
||||||
@@ -156,27 +124,27 @@ export function EventModals(props: EventModalsProps) {
|
|||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<button
|
<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"
|
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" />
|
<EnvelopeIcon className="w-5 h-5 text-gray-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Manual Ticket</p>
|
<p className="font-medium">Paid Ticket</p>
|
||||||
<p className="text-xs text-gray-500">Send confirmation email with ticket</p>
|
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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>
|
<div>
|
||||||
<p className="font-medium">Add at Door</p>
|
<p className="font-medium">Unpaid Ticket</p>
|
||||||
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
|
<p className="text-xs text-gray-500">Pay link now or collect at the door</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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" />
|
<StarIcon className="w-5 h-5 text-gray-500" />
|
||||||
@@ -237,243 +205,6 @@ export function EventModals(props: EventModalsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</BottomSheet>
|
</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 */}
|
{/* Note Modal */}
|
||||||
{showNoteModal && selectedTicket && (
|
{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">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
FunnelIcon,
|
FunnelIcon,
|
||||||
ChatBubbleLeftIcon,
|
ChatBubbleLeftIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
|
BanknotesIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { StatusBadge } from '../_components/StatusBadge';
|
import { StatusBadge, PaymentBadge } from '../_components/StatusBadge';
|
||||||
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
|
import type { AttendeeStatusFilter, AddTicketType, PrimaryAction } from '../_types';
|
||||||
|
|
||||||
interface AttendeesTabProps {
|
interface AttendeesTabProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -37,15 +39,15 @@ interface AttendeesTabProps {
|
|||||||
showAddTicketDropdown: boolean;
|
showAddTicketDropdown: boolean;
|
||||||
setShowAddTicketDropdown: (value: boolean) => void;
|
setShowAddTicketDropdown: (value: boolean) => void;
|
||||||
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
|
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
|
||||||
setShowManualTicketModal: (value: boolean) => void;
|
openAddTicket: (type: AddTicketType) => void;
|
||||||
setShowAddAtDoorModal: (value: boolean) => void;
|
|
||||||
setShowInviteGuestModal: (value: boolean) => void;
|
|
||||||
setMobileFilterOpen: (value: boolean) => void;
|
setMobileFilterOpen: (value: boolean) => void;
|
||||||
setShowExportSheet: (value: boolean) => void;
|
setShowExportSheet: (value: boolean) => void;
|
||||||
setShowAddTicketSheet: (value: boolean) => void;
|
setShowAddTicketSheet: (value: boolean) => void;
|
||||||
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
|
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
|
||||||
handleOpenNoteModal: (ticket: Ticket) => void;
|
handleOpenNoteModal: (ticket: Ticket) => void;
|
||||||
handleReactivate: (ticket: Ticket) => void;
|
handleReactivate: (ticket: Ticket) => void;
|
||||||
|
handleMarkPaid: (ticketId: string) => void;
|
||||||
|
handleCheckin: (ticketId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttendeesTab({
|
export function AttendeesTab({
|
||||||
@@ -67,15 +69,15 @@ export function AttendeesTab({
|
|||||||
showAddTicketDropdown,
|
showAddTicketDropdown,
|
||||||
setShowAddTicketDropdown,
|
setShowAddTicketDropdown,
|
||||||
handleExportAttendees,
|
handleExportAttendees,
|
||||||
setShowManualTicketModal,
|
openAddTicket,
|
||||||
setShowAddAtDoorModal,
|
|
||||||
setShowInviteGuestModal,
|
|
||||||
setMobileFilterOpen,
|
setMobileFilterOpen,
|
||||||
setShowExportSheet,
|
setShowExportSheet,
|
||||||
setShowAddTicketSheet,
|
setShowAddTicketSheet,
|
||||||
getPrimaryAction,
|
getPrimaryAction,
|
||||||
handleOpenNoteModal,
|
handleOpenNoteModal,
|
||||||
handleReactivate,
|
handleReactivate,
|
||||||
|
handleMarkPaid,
|
||||||
|
handleCheckin,
|
||||||
}: AttendeesTabProps) {
|
}: AttendeesTabProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -143,13 +145,13 @@ export function AttendeesTab({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DropdownItem onClick={() => { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}>
|
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
|
||||||
<EnvelopeIcon className="w-4 h-4 mr-2" /> Manual Ticket
|
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
|
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
|
||||||
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
|
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
|
<DropdownItem onClick={() => { openAddTicket('guest'); setShowAddTicketDropdown(false); }}>
|
||||||
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
|
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
@@ -252,9 +254,7 @@ export function AttendeesTab({
|
|||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
<StatusBadge status={ticket.status} compact />
|
<StatusBadge status={ticket.status} compact />
|
||||||
{!!ticket.isGuest && (
|
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{ticket.checkinAt && (
|
{ticket.checkinAt && (
|
||||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||||
@@ -279,6 +279,16 @@ export function AttendeesTab({
|
|||||||
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
||||||
</DropdownItem>
|
</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)}>
|
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
||||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
||||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
||||||
@@ -323,9 +333,7 @@ export function AttendeesTab({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
||||||
<StatusBadge status={ticket.status} compact />
|
<StatusBadge status={ticket.status} compact />
|
||||||
{!!ticket.isGuest && (
|
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
@@ -346,6 +354,16 @@ export function AttendeesTab({
|
|||||||
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
||||||
</DropdownItem>
|
</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)}>
|
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
||||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
||||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Event } from '@/lib/api';
|
import { Event } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
|
import SensitiveValue from '@/components/admin/SensitiveValue';
|
||||||
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
|
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
interface OverviewTabProps {
|
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" />
|
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Capacity</p>
|
<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-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
|
||||||
<p className="text-xs text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
|
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,14 +13,18 @@ export interface PrimaryAction {
|
|||||||
icon?: ComponentType<{ className?: string }>;
|
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;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
adminNote: string;
|
adminNote: string;
|
||||||
}
|
checkinNow: boolean;
|
||||||
|
|
||||||
export interface AddAtDoorFormState extends AttendeeFormState {
|
|
||||||
autoCheckin: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket }
|
|||||||
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton, TableSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
EyeSlashIcon,
|
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@@ -29,14 +29,14 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useStatsPrivacy } from '@/hooks/useStatsPrivacy';
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
import type {
|
import type {
|
||||||
TabType,
|
TabType,
|
||||||
AttendeeStatusFilter,
|
AttendeeStatusFilter,
|
||||||
TicketStatusFilter,
|
TicketStatusFilter,
|
||||||
RecipientFilter,
|
RecipientFilter,
|
||||||
AttendeeFormState,
|
AddTicketType,
|
||||||
AddAtDoorFormState,
|
AddTicketFormState,
|
||||||
PrimaryAction,
|
PrimaryAction,
|
||||||
} from './_types';
|
} from './_types';
|
||||||
import { formatCurrency, downloadBlob } from './_utils/format';
|
import { formatCurrency, downloadBlob } from './_utils/format';
|
||||||
@@ -48,8 +48,19 @@ import { TicketsTab } from './_tabs/TicketsTab';
|
|||||||
import { EmailTab } from './_tabs/EmailTab';
|
import { EmailTab } from './_tabs/EmailTab';
|
||||||
import { PaymentsTab } from './_tabs/PaymentsTab';
|
import { PaymentsTab } from './_tabs/PaymentsTab';
|
||||||
import { EventModals } from './_modals/EventModals';
|
import { EventModals } from './_modals/EventModals';
|
||||||
|
import { AddTicketModal } from './_modals/AddTicketModal';
|
||||||
import EventFormModal from '../_components/EventFormModal';
|
import EventFormModal from '../_components/EventFormModal';
|
||||||
|
|
||||||
|
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
|
||||||
|
type: 'paid',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
adminNote: '',
|
||||||
|
checkinNow: false,
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminEventDetailPage() {
|
export default function AdminEventDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const eventId = params.id as string;
|
const eventId = params.id as string;
|
||||||
@@ -68,37 +79,21 @@ export default function AdminEventDetailPage() {
|
|||||||
// Attendees tab state
|
// Attendees tab state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
|
||||||
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
const { privacyMode } = usePrivacy();
|
||||||
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
const showStats = !privacyMode;
|
||||||
const [showStats, , toggleStats] = useStatsPrivacy();
|
|
||||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||||
const [noteText, setNoteText] = useState('');
|
const [noteText, setNoteText] = useState('');
|
||||||
const [addAtDoorForm, setAddAtDoorForm] = useState<AddAtDoorFormState>({
|
// Unified Add Ticket modal (paid / unpaid / guest via segmented control)
|
||||||
firstName: '',
|
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
|
||||||
lastName: '',
|
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
|
||||||
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: '',
|
|
||||||
});
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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)
|
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
|
||||||
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
|
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
|
||||||
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
|
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
|
||||||
@@ -219,74 +214,27 @@ export default function AdminEventDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddAtDoor = async (e: React.FormEvent) => {
|
const handleAddTicket = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await ticketsApi.adminCreate({
|
const res = await ticketsApi.adminAdd({
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
firstName: addAtDoorForm.firstName,
|
type: addTicketForm.type,
|
||||||
lastName: addAtDoorForm.lastName || undefined,
|
firstName: addTicketForm.firstName,
|
||||||
email: addAtDoorForm.email,
|
lastName: addTicketForm.lastName || undefined,
|
||||||
phone: addAtDoorForm.phone,
|
email: addTicketForm.email || undefined,
|
||||||
autoCheckin: addAtDoorForm.autoCheckin,
|
phone: addTicketForm.phone || undefined,
|
||||||
adminNote: addAtDoorForm.adminNote || undefined,
|
checkinNow: addTicketForm.checkinNow,
|
||||||
|
adminNote: addTicketForm.adminNote || undefined,
|
||||||
});
|
});
|
||||||
toast.success(addAtDoorForm.autoCheckin ? 'Attendee added and checked in' : 'Attendee added');
|
toast.success(res.message || 'Ticket created');
|
||||||
setShowAddAtDoorModal(false);
|
setShowAddTicketModal(false);
|
||||||
setAddAtDoorForm({ firstName: '', lastName: '', email: '', phone: '', autoCheckin: true, adminNote: '' });
|
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
|
||||||
loadEventData();
|
loadEventData();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to add attendee');
|
toast.error(error.message || 'Failed to add ticket');
|
||||||
} 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');
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -420,8 +368,12 @@ export default function AdminEventDetailPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div>
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -444,8 +396,11 @@ export default function AdminEventDetailPage() {
|
|||||||
const checkedInCount = getTicketsByStatus('checked_in').length;
|
const checkedInCount = getTicketsByStatus('checked_in').length;
|
||||||
const cancelledCount = getTicketsByStatus('cancelled').length;
|
const cancelledCount = getTicketsByStatus('cancelled').length;
|
||||||
const onHoldCount = getTicketsByStatus('on_hold').length;
|
const onHoldCount = getTicketsByStatus('on_hold').length;
|
||||||
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
|
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
|
||||||
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
|
// 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 revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
|
||||||
|
|
||||||
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
|
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
|
||||||
@@ -462,6 +417,10 @@ export default function AdminEventDetailPage() {
|
|||||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||||||
}
|
}
|
||||||
if (ticket.status === 'confirmed') {
|
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' };
|
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
|
||||||
}
|
}
|
||||||
if (ticket.status === 'checked_in') {
|
if (ticket.status === 'checked_in') {
|
||||||
@@ -485,10 +444,6 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Desktop header actions */}
|
{/* Desktop header actions */}
|
||||||
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
<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">
|
<Link href={`/events/${event.slug}`} target="_blank">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||||||
@@ -517,10 +472,6 @@ export default function AdminEventDetailPage() {
|
|||||||
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
|
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
|
||||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
||||||
</DropdownItem>
|
</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>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -699,15 +650,15 @@ export default function AdminEventDetailPage() {
|
|||||||
showAddTicketDropdown={showAddTicketDropdown}
|
showAddTicketDropdown={showAddTicketDropdown}
|
||||||
setShowAddTicketDropdown={setShowAddTicketDropdown}
|
setShowAddTicketDropdown={setShowAddTicketDropdown}
|
||||||
handleExportAttendees={handleExportAttendees}
|
handleExportAttendees={handleExportAttendees}
|
||||||
setShowManualTicketModal={setShowManualTicketModal}
|
openAddTicket={openAddTicket}
|
||||||
setShowAddAtDoorModal={setShowAddAtDoorModal}
|
|
||||||
setShowInviteGuestModal={setShowInviteGuestModal}
|
|
||||||
setMobileFilterOpen={setMobileFilterOpen}
|
setMobileFilterOpen={setMobileFilterOpen}
|
||||||
setShowExportSheet={setShowExportSheet}
|
setShowExportSheet={setShowExportSheet}
|
||||||
setShowAddTicketSheet={setShowAddTicketSheet}
|
setShowAddTicketSheet={setShowAddTicketSheet}
|
||||||
getPrimaryAction={getPrimaryAction}
|
getPrimaryAction={getPrimaryAction}
|
||||||
handleOpenNoteModal={handleOpenNoteModal}
|
handleOpenNoteModal={handleOpenNoteModal}
|
||||||
handleReactivate={handleReactivate}
|
handleReactivate={handleReactivate}
|
||||||
|
handleMarkPaid={handleMarkPaid}
|
||||||
|
handleCheckin={handleCheckin}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -772,27 +723,13 @@ export default function AdminEventDetailPage() {
|
|||||||
setMobileFilterOpen={setMobileFilterOpen}
|
setMobileFilterOpen={setMobileFilterOpen}
|
||||||
showAddTicketSheet={showAddTicketSheet}
|
showAddTicketSheet={showAddTicketSheet}
|
||||||
setShowAddTicketSheet={setShowAddTicketSheet}
|
setShowAddTicketSheet={setShowAddTicketSheet}
|
||||||
|
openAddTicket={openAddTicket}
|
||||||
showExportSheet={showExportSheet}
|
showExportSheet={showExportSheet}
|
||||||
setShowExportSheet={setShowExportSheet}
|
setShowExportSheet={setShowExportSheet}
|
||||||
handleExportAttendees={handleExportAttendees}
|
handleExportAttendees={handleExportAttendees}
|
||||||
showTicketExportSheet={showTicketExportSheet}
|
showTicketExportSheet={showTicketExportSheet}
|
||||||
setShowTicketExportSheet={setShowTicketExportSheet}
|
setShowTicketExportSheet={setShowTicketExportSheet}
|
||||||
handleExportTickets={handleExportTickets}
|
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}
|
submitting={submitting}
|
||||||
showNoteModal={showNoteModal}
|
showNoteModal={showNoteModal}
|
||||||
setShowNoteModal={setShowNoteModal}
|
setShowNoteModal={setShowNoteModal}
|
||||||
@@ -805,6 +742,16 @@ export default function AdminEventDetailPage() {
|
|||||||
setPreviewHtml={setPreviewHtml}
|
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
|
<EventFormModal
|
||||||
open={showEditForm}
|
open={showEditForm}
|
||||||
event={event}
|
event={event}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, 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 { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||||
@@ -148,11 +149,7 @@ export default function AdminEventsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={5} />;
|
||||||
<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 (
|
return (
|
||||||
@@ -225,7 +222,14 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</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">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getStatusBadge(event.status)}
|
{getStatusBadge(event.status)}
|
||||||
@@ -335,7 +339,12 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
<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()}>
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
<Link href={`/admin/events/${event.id}`}
|
<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">
|
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { faqApi, FaqItemAdmin } from '@/lib/api';
|
import { faqApi, FaqItemAdmin } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
@@ -185,11 +186,7 @@ export default function AdminFaqPage() {
|
|||||||
const handleDragEnd = () => { setDraggedId(null); setDragOverId(null); };
|
const handleDragEnd = () => { setDraggedId(null); setDragOverId(null); };
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={5} />;
|
||||||
<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 (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { mediaApi, Media } from '@/lib/api';
|
|||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||||
import {
|
import {
|
||||||
PhotoIcon,
|
PhotoIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
@@ -126,8 +127,12 @@ export default function AdminGalleryPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div>
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { PrivacyProvider, usePrivacy } from '@/context/PrivacyContext';
|
||||||
import LanguageToggle from '@/components/LanguageToggle';
|
import LanguageToggle from '@/components/LanguageToggle';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
InboxIcon,
|
InboxIcon,
|
||||||
PhotoIcon,
|
PhotoIcon,
|
||||||
|
CameraIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
ArrowLeftOnRectangleIcon,
|
ArrowLeftOnRectangleIcon,
|
||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
@@ -25,6 +27,8 @@ import {
|
|||||||
QrCodeIcon,
|
QrCodeIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
QuestionMarkCircleIcon,
|
QuestionMarkCircleIcon,
|
||||||
|
EyeIcon,
|
||||||
|
EyeSlashIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -33,11 +37,24 @@ export default function AdminLayout({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PrivacyProvider>
|
||||||
|
<AdminLayoutInner>{children}</AdminLayoutInner>
|
||||||
|
</PrivacyProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminLayoutInner({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
||||||
|
const { privacyMode, togglePrivacyMode } = usePrivacy();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
|
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.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.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.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: 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: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
|
||||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
||||||
@@ -218,6 +236,21 @@ export default function AdminLayout({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 ml-auto">
|
<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 />
|
<LanguageToggle />
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { legalPagesApi, LegalPage } from '@/lib/api';
|
|||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -173,11 +174,7 @@ export default function AdminLegalPagesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={5} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editor view
|
// Editor view
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
import { adminApi, DashboardData } from '@/lib/api';
|
import { adminApi, DashboardData } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
|
import SensitiveValue from '@/components/admin/SensitiveValue';
|
||||||
import {
|
import {
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
@@ -15,11 +17,12 @@ import {
|
|||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
const [data, setData] = useState<DashboardData | null>(null);
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -89,6 +92,7 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
|
{!privacyMode && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
{statCards.map((stat) => (
|
{statCards.map((stat) => (
|
||||||
<Link key={stat.label} href={stat.href}>
|
<Link key={stat.label} href={stat.href}>
|
||||||
@@ -106,22 +110,22 @@ export default function AdminDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Alerts */}
|
{/* Alerts */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Low capacity warnings */}
|
{/* Low capacity warnings (availableSeats accounts for paid + claimed seats) */}
|
||||||
{data?.upcomingEvents
|
{data?.upcomingEvents
|
||||||
.filter(event => {
|
.filter(event => {
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
|
return event.capacity > 0 && spotsLeft > 0 && spotsLeft / event.capacity <= 0.2;
|
||||||
return percentFull >= 80 && spotsLeft > 0;
|
|
||||||
})
|
})
|
||||||
.map(event => {
|
.map(event => {
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
|
const percentFull = Math.round(((event.capacity - spotsLeft) / event.capacity) * 100);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={event.id}
|
key={event.id}
|
||||||
@@ -132,7 +136,7 @@ export default function AdminDashboardPage() {
|
|||||||
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium">{event.title}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge badge-warning">Low capacity</span>
|
<span className="badge badge-warning">Low capacity</span>
|
||||||
@@ -142,7 +146,7 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
{/* Sold out events */}
|
{/* Sold out events */}
|
||||||
{data?.upcomingEvents
|
{data?.upcomingEvents
|
||||||
.filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
|
.filter(event => isEventSoldOut(event))
|
||||||
.map(event => (
|
.map(event => (
|
||||||
<Link
|
<Link
|
||||||
key={event.id}
|
key={event.id}
|
||||||
@@ -160,16 +164,30 @@ export default function AdminDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{data && data.stats.pendingPayments > 0 && (
|
{/* Actionable: customer says they paid, needs verification */}
|
||||||
|
{data && (data.stats.awaitingApprovalPayments ?? 0) > 0 && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin/payments"
|
href="/admin/payments"
|
||||||
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
|
<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>
|
</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>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{data && data.stats.newContacts > 0 && (
|
{data && data.stats.newContacts > 0 && (
|
||||||
@@ -188,8 +206,9 @@ export default function AdminDashboardPage() {
|
|||||||
{/* No alerts */}
|
{/* No alerts */}
|
||||||
{data &&
|
{data &&
|
||||||
data.stats.pendingPayments === 0 &&
|
data.stats.pendingPayments === 0 &&
|
||||||
|
(data.stats.awaitingApprovalPayments ?? 0) === 0 &&
|
||||||
data.stats.newContacts === 0 &&
|
data.stats.newContacts === 0 &&
|
||||||
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
|
!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>
|
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -217,9 +236,16 @@ export default function AdminDashboardPage() {
|
|||||||
<p className="font-medium text-sm">{event.title}</p>
|
<p className="font-medium text-sm">{event.title}</p>
|
||||||
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-600">
|
{!privacyMode && (
|
||||||
{event.bookedCount || 0}/{event.capacity}
|
<span className="text-sm text-gray-600">
|
||||||
</span>
|
{(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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -227,6 +253,7 @@ export default function AdminDashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
|
{!privacyMode && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
|
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -242,6 +269,7 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
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 Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +37,9 @@ export default function AdminPaymentsPage() {
|
|||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
|
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
|
||||||
const [pendingApprovalPayments, setPendingApprovalPayments] = 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 [events, setEvents] = useState<Event[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
||||||
@@ -68,17 +73,19 @@ export default function AdminPaymentsPage() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [pendingRes, allRes, eventsRes] = await Promise.all([
|
const [pendingRes, allRes, unclaimedRes, eventsRes] = await Promise.all([
|
||||||
paymentsApi.getPendingApproval(),
|
paymentsApi.getPendingApproval(),
|
||||||
paymentsApi.getAll({
|
paymentsApi.getAll({
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
provider: providerFilter || undefined,
|
provider: providerFilter || undefined,
|
||||||
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
|
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
|
||||||
}),
|
}),
|
||||||
|
paymentsApi.getAll({ status: 'pending' }),
|
||||||
eventsApi.getAll(),
|
eventsApi.getAll(),
|
||||||
]);
|
]);
|
||||||
setPendingApprovalPayments(pendingRes.payments);
|
setPendingApprovalPayments(pendingRes.payments);
|
||||||
setPayments(allRes.payments);
|
setPayments(allRes.payments);
|
||||||
|
setUnclaimedManualPayments(unclaimedRes.payments.filter(p => isManualProvider(p.provider)));
|
||||||
setEvents(eventsRes.events);
|
setEvents(eventsRes.events);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load payments');
|
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) => {
|
const handleApprove = async (payment: PaymentWithDetails) => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
await paymentsApi.approve(payment.id, noteText, sendEmail);
|
const approved = await approveWithCapacityConfirm(payment.id, noteText, sendEmail);
|
||||||
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
if (approved) {
|
||||||
setSelectedPayment(null);
|
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
||||||
setNoteText('');
|
setSelectedPayment(null);
|
||||||
setSendEmail(true);
|
setNoteText('');
|
||||||
loadData();
|
setSendEmail(true);
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to approve payment');
|
toast.error(error.message || 'Failed to approve payment');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -139,11 +166,13 @@ export default function AdminPaymentsPage() {
|
|||||||
|
|
||||||
const handleConfirmPayment = async (id: string) => {
|
const handleConfirmPayment = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await paymentsApi.approve(id);
|
const approved = await approveWithCapacityConfirm(id);
|
||||||
toast.success('Payment confirmed');
|
if (approved) {
|
||||||
loadData();
|
toast.success('Payment confirmed');
|
||||||
} catch (error) {
|
loadData();
|
||||||
toast.error('Failed to confirm payment');
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to confirm payment');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,6 +190,22 @@ export default function AdminPaymentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReopen = async (payment: PaymentWithDetails) => {
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
await paymentsApi.reopen(payment.id, noteText);
|
||||||
|
toast.success(locale === 'es' ? 'Pago cambiado a pendiente' : 'Payment changed to pending');
|
||||||
|
setSelectedPayment(null);
|
||||||
|
setNoteText('');
|
||||||
|
setSendEmail(true);
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || (locale === 'es' ? 'No se pudo reabrir el pago' : 'Failed to reopen payment'));
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRefund = async (id: string) => {
|
const handleRefund = async (id: string) => {
|
||||||
if (!confirm('Are you sure you want to process this refund?')) return;
|
if (!confirm('Are you sure you want to process this refund?')) return;
|
||||||
|
|
||||||
@@ -204,7 +249,7 @@ export default function AdminPaymentsPage() {
|
|||||||
p.createdAt,
|
p.createdAt,
|
||||||
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
`${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(),
|
||||||
p.attendeeEmail || '',
|
p.attendeeEmail || '',
|
||||||
p.attendeeRuc || '',
|
formatRucDisplay(p.attendeeRuc) || '',
|
||||||
p.eventTitle,
|
p.eventTitle,
|
||||||
p.eventDate,
|
p.eventDate,
|
||||||
]);
|
]);
|
||||||
@@ -281,6 +326,33 @@ export default function AdminPaymentsPage() {
|
|||||||
return labels[provider] || provider;
|
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)
|
// Helper to get booking info for a payment (ticket count and total)
|
||||||
const getBookingInfo = (payment: PaymentWithDetails) => {
|
const getBookingInfo = (payment: PaymentWithDetails) => {
|
||||||
if (!payment.ticket?.bookingId) {
|
if (!payment.ticket?.bookingId) {
|
||||||
@@ -314,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
|
// Get booking info for pending approval payments
|
||||||
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
|
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
|
||||||
if (!payment.ticket?.bookingId) {
|
if (!payment.ticket?.bookingId) {
|
||||||
@@ -331,9 +419,15 @@ export default function AdminPaymentsPage() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate totals (sum all individual payment amounts)
|
// Calculate totals (sum all individual payment amounts).
|
||||||
const totalPending = payments
|
// Claimed ('pending_approval') money is probably already in the account and
|
||||||
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
|
// 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);
|
.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||||
const totalPaid = payments
|
const totalPaid = payments
|
||||||
.filter(p => p.status === 'paid')
|
.filter(p => p.status === 'paid')
|
||||||
@@ -353,9 +447,6 @@ export default function AdminPaymentsPage() {
|
|||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingBookingsCount = getUniqueBookingsCount(
|
|
||||||
payments.filter(p => p.status === 'pending' || p.status === 'pending_approval')
|
|
||||||
);
|
|
||||||
const paidBookingsCount = getUniqueBookingsCount(
|
const paidBookingsCount = getUniqueBookingsCount(
|
||||||
payments.filter(p => p.status === 'paid')
|
payments.filter(p => p.status === 'paid')
|
||||||
);
|
);
|
||||||
@@ -364,11 +455,7 @@ export default function AdminPaymentsPage() {
|
|||||||
const onHoldBookingsCount = getUniqueBookingsCount(onHoldPayments);
|
const onHoldBookingsCount = getUniqueBookingsCount(onHoldPayments);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={7} />;
|
||||||
<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 (
|
return (
|
||||||
@@ -434,7 +521,7 @@ export default function AdminPaymentsPage() {
|
|||||||
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
|
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
|
||||||
)}
|
)}
|
||||||
{selectedPayment.ticket.attendeeRuc && (
|
{selectedPayment.ticket.attendeeRuc && (
|
||||||
<p className="text-sm text-gray-600">RUC: {selectedPayment.ticket.attendeeRuc}</p>
|
<p className="text-sm text-gray-600">RUC: {formatRucDisplay(selectedPayment.ticket.attendeeRuc)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -506,19 +593,32 @@ export default function AdminPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3">
|
{selectedPayment.status === 'failed' ? (
|
||||||
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
|
<div className="flex gap-3">
|
||||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
|
||||||
{locale === 'es' ? 'Aprobar' : 'Approve'}
|
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||||
</Button>
|
{locale === 'es' ? 'Confirmar pago' : 'Confirm payment'}
|
||||||
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
|
</Button>
|
||||||
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
|
<Button variant="outline" onClick={() => handleReopen(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
|
||||||
<XCircleIcon className="w-5 h-5 mr-2" />
|
<ArrowPathIcon className="w-5 h-5 mr-2" />
|
||||||
{locale === 'es' ? 'Rechazar' : 'Reject'}
|
{locale === 'es' ? 'Cambiar a pendiente' : 'Change to pending'}
|
||||||
</Button>
|
</Button>
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedPayment.status !== 'on_hold' && (
|
{!['on_hold', 'failed'].includes(selectedPayment.status) && (
|
||||||
<div className="pt-2 border-t">
|
<div className="pt-2 border-t">
|
||||||
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
|
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
|
||||||
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
||||||
@@ -695,9 +795,12 @@ export default function AdminPaymentsPage() {
|
|||||||
<ClockIcon className="w-5 h-5 text-gray-600" />
|
<ClockIcon className="w-5 h-5 text-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
|
<p className="text-sm text-gray-500">{locale === 'es' ? 'Por Verificar' : 'Awaiting Verification'}</p>
|
||||||
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
|
<p className="text-xl font-bold text-yellow-600">{formatCurrency(totalAwaitingVerification, 'PYG')}</p>
|
||||||
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
|
<p className="text-xs text-gray-400">
|
||||||
|
{locale === 'es' ? 'Sin pagar (sin reclamar): ' : 'Unpaid (unclaimed): '}
|
||||||
|
{formatCurrency(totalUnclaimed, 'PYG')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -791,13 +894,18 @@ export default function AdminPaymentsPage() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
{getProviderIcon(payment.provider)}
|
{getProviderIcon(payment.provider)}
|
||||||
{getProviderLabel(payment.provider)}
|
{getProviderLabel(payment.provider)}
|
||||||
|
{getProviderKindBadge(payment.provider)}
|
||||||
</span>
|
</span>
|
||||||
{payment.userMarkedPaidAt && (
|
{payment.userMarkedPaidAt && (() => {
|
||||||
<span className="flex items-center gap-1">
|
const age = getAgeInfo(payment.userMarkedPaidAt);
|
||||||
<ClockIcon className="w-3 h-3" />
|
return (
|
||||||
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
<span className={clsx('flex items-center gap-1', age?.stale && 'text-amber-600 font-medium')}>
|
||||||
</span>
|
<ClockIcon className="w-3 h-3" />
|
||||||
)}
|
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
||||||
|
{age && <span>({age.label})</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{payment.payerName && (
|
{payment.payerName && (
|
||||||
<p className="text-xs text-amber-600 mt-1 font-medium">
|
<p className="text-xs text-amber-600 mt-1 font-medium">
|
||||||
@@ -815,6 +923,55 @@ export default function AdminPaymentsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -978,12 +1135,13 @@ export default function AdminPaymentsPage() {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||||
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
|
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
|
||||||
|
{getProviderKindBadge(payment.provider)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
|
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && (
|
{(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">
|
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1">
|
||||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1037,7 +1195,7 @@ export default function AdminPaymentsPage() {
|
|||||||
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
<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="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
|
||||||
<span className="text-gray-300">|</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 && (
|
{bookingInfo.ticketCount > 1 && (
|
||||||
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
|
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
|
||||||
)}
|
)}
|
||||||
@@ -1045,7 +1203,7 @@ export default function AdminPaymentsPage() {
|
|||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
<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>
|
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{(payment.status === 'pending' || payment.status === 'pending_approval' || payment.status === 'on_hold') && (
|
{(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]">
|
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import type { GalleryVisibility } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
GlobeAltIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
LinkIcon,
|
||||||
|
TicketIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
const visibilityMeta: Record<
|
||||||
|
GalleryVisibility,
|
||||||
|
{ icon: typeof GlobeAltIcon; className: string; en: string; es: string }
|
||||||
|
> = {
|
||||||
|
public: { icon: GlobeAltIcon, className: 'bg-green-100 text-green-700', en: 'Public', es: 'Pública' },
|
||||||
|
private: { icon: LockClosedIcon, className: 'bg-gray-200 text-gray-700', en: 'Private', es: 'Privada' },
|
||||||
|
link: { icon: LinkIcon, className: 'bg-blue-100 text-blue-700', en: 'Link only', es: 'Solo enlace' },
|
||||||
|
ticket: { icon: TicketIcon, className: 'bg-yellow-100 text-yellow-800', en: 'Ticket holders', es: 'Con entrada' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VisibilityBadge({ visibility }: { visibility: GalleryVisibility }) {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const meta = visibilityMeta[visibility] || visibilityMeta.private;
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded ${meta.className}`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{locale === 'es' ? meta.es : meta.en}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { photosApi, eventsApi, PhotoGallery, Photo, Event, GalleryVisibility } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
import Lightbox from '@/components/Lightbox';
|
||||||
|
import VisibilityBadge from '../VisibilityBadge';
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
ArrowUpTrayIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
ExclamationCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
LinkIcon,
|
||||||
|
PhotoIcon,
|
||||||
|
StarIcon,
|
||||||
|
TrashIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface UploadItem {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
progress: number; // 0..1 while uploading
|
||||||
|
status: 'queued' | 'uploading' | 'processing' | 'error';
|
||||||
|
error?: string;
|
||||||
|
photoId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number) {
|
||||||
|
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
||||||
|
if (n >= 1 << 10) return `${Math.round(n / (1 << 10))} KB`;
|
||||||
|
return `${n} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminGalleryDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const es = locale === 'es';
|
||||||
|
|
||||||
|
const [gallery, setGallery] = useState<PhotoGallery | null>(null);
|
||||||
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [dragId, setDragId] = useState<string | null>(null);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Uploader panel state (Google Drive style: one row per file).
|
||||||
|
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||||
|
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||||
|
const uploadQueue = useRef<{ key: string; file: File }[]>([]);
|
||||||
|
const pumpRunning = useRef(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [detail, ev] = await Promise.all([photosApi.getGallery(id), eventsApi.getAll()]);
|
||||||
|
setGallery(detail.gallery);
|
||||||
|
setPhotos(detail.photos);
|
||||||
|
setEvents(ev.events);
|
||||||
|
} catch {
|
||||||
|
toast.error(es ? 'No se pudo cargar la galería' : 'Failed to load gallery');
|
||||||
|
router.push('/admin/photos');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// Poll while any photo is still processing so thumbnails (and the
|
||||||
|
// uploader panel rows) update as the worker finishes them.
|
||||||
|
const processingCount = photos.filter((p) => p.status === 'queued' || p.status === 'processing').length;
|
||||||
|
useEffect(() => {
|
||||||
|
if (processingCount === 0) return;
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const detail = await photosApi.getGallery(id);
|
||||||
|
setGallery(detail.gallery);
|
||||||
|
setPhotos(detail.photos);
|
||||||
|
} catch {
|
||||||
|
/* transient; next tick retries */
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [processingCount, id]);
|
||||||
|
|
||||||
|
const patchUpload = (key: string, patch: Partial<UploadItem>) => {
|
||||||
|
setUploads((prev) => prev.map((u) => (u.key === key ? { ...u, ...patch } : u)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sequential upload pump: one file at a time, byte progress per file.
|
||||||
|
const pump = useCallback(async () => {
|
||||||
|
if (pumpRunning.current) return;
|
||||||
|
pumpRunning.current = true;
|
||||||
|
while (uploadQueue.current.length > 0) {
|
||||||
|
const { key, file } = uploadQueue.current.shift()!;
|
||||||
|
patchUpload(key, { status: 'uploading', progress: 0 });
|
||||||
|
try {
|
||||||
|
const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) =>
|
||||||
|
patchUpload(key, { progress: fraction })
|
||||||
|
);
|
||||||
|
setPhotos((prev) => [...prev, ...added]);
|
||||||
|
patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id });
|
||||||
|
} catch (err) {
|
||||||
|
patchUpload(key, {
|
||||||
|
status: 'error',
|
||||||
|
error: err instanceof Error ? err.message : 'Upload failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pumpRunning.current = false;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleUpload = (files: FileList | File[]) => {
|
||||||
|
const list = Array.from(files);
|
||||||
|
if (list.length === 0) return;
|
||||||
|
const items: UploadItem[] = list.map((file) => ({
|
||||||
|
key: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
progress: 0,
|
||||||
|
status: 'queued',
|
||||||
|
}));
|
||||||
|
uploadQueue.current.push(...items.map((item, i) => ({ key: item.key, file: list[i] })));
|
||||||
|
setUploads((prev) => [...prev, ...items]);
|
||||||
|
setPanelCollapsed(false);
|
||||||
|
pump();
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// A row is "done" once its photo finished processing; the panel derives
|
||||||
|
// this from the photos list instead of tracking it separately.
|
||||||
|
const displayStatus = (u: UploadItem): { state: string; error?: string } => {
|
||||||
|
if (u.status === 'processing' && u.photoId) {
|
||||||
|
const photo = photos.find((p) => p.id === u.photoId);
|
||||||
|
if (photo?.status === 'ready') return { state: 'done' };
|
||||||
|
if (photo?.status === 'failed') return { state: 'error', error: photo.lastError || 'Processing failed' };
|
||||||
|
}
|
||||||
|
return { state: u.status, error: u.error };
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeUploads = uploads.filter((u) => {
|
||||||
|
const s = displayStatus(u).state;
|
||||||
|
return s === 'queued' || s === 'uploading' || s === 'processing';
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const saveField = async (patch: Parameters<typeof photosApi.updateGallery>[1], okMsg?: string) => {
|
||||||
|
try {
|
||||||
|
const { gallery: updated } = await photosApi.updateGallery(id, patch);
|
||||||
|
setGallery(updated);
|
||||||
|
if (okMsg) toast.success(okMsg);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Update failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShareLink = async () => {
|
||||||
|
if (!gallery?.shareUrl) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(gallery.shareUrl);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success(es ? 'Enlace copiado' : 'Share link copied');
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast.error(es ? 'No se pudo copiar' : 'Failed to copy');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateToken = async () => {
|
||||||
|
if (!confirm(es ? '¿Invalidar el enlace actual y generar uno nuevo?' : 'Invalidate the current link and generate a new one?'))
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const { gallery: updated } = await photosApi.rotateShareToken(id);
|
||||||
|
setGallery(updated);
|
||||||
|
toast.success(es ? 'Nuevo enlace generado' : 'New share link generated');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePhoto = async (photoId: string): Promise<boolean> => {
|
||||||
|
if (!confirm(es ? '¿Eliminar esta foto?' : 'Delete this photo?')) return false;
|
||||||
|
try {
|
||||||
|
await photosApi.deletePhoto(photoId);
|
||||||
|
setPhotos((prev) => prev.filter((p) => p.id !== photoId));
|
||||||
|
toast.success(es ? 'Foto eliminada' : 'Photo deleted');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to delete');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryPhoto = async (photoId: string) => {
|
||||||
|
try {
|
||||||
|
const { photo } = await photosApi.retryPhoto(photoId);
|
||||||
|
setPhotos((prev) => prev.map((p) => (p.id === photoId ? photo : p)));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to retry');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteGallery = async () => {
|
||||||
|
if (!confirm(es ? '¿Eliminar toda la galería y sus fotos?' : 'Delete the whole gallery and its photos?')) return;
|
||||||
|
try {
|
||||||
|
await photosApi.deleteGallery(id);
|
||||||
|
toast.success(es ? 'Galería eliminada' : 'Gallery deleted');
|
||||||
|
router.push('/admin/photos');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to delete');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// HTML5 drag-and-drop reorder; persisted on drop.
|
||||||
|
const onDropReorder = async (targetId: string) => {
|
||||||
|
if (!dragId || dragId === targetId) return;
|
||||||
|
const ids = photos.map((p) => p.id);
|
||||||
|
const from = ids.indexOf(dragId);
|
||||||
|
const to = ids.indexOf(targetId);
|
||||||
|
if (from < 0 || to < 0) return;
|
||||||
|
ids.splice(to, 0, ids.splice(from, 1)[0]);
|
||||||
|
const reordered = ids
|
||||||
|
.map((pid) => photos.find((p) => p.id === pid))
|
||||||
|
.filter((p): p is Photo => !!p);
|
||||||
|
setPhotos(reordered);
|
||||||
|
setDragId(null);
|
||||||
|
try {
|
||||||
|
await photosApi.reorderPhotos(id, ids);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to save order');
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !gallery) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-8 w-64 mb-6" />
|
||||||
|
<ImageGridSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
||||||
|
const lightboxItems = readyPhotos.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
previewUrl: p.urls.preview || p.urls.original,
|
||||||
|
downloadUrl: p.urls.original,
|
||||||
|
filename: p.originalFilename,
|
||||||
|
}));
|
||||||
|
const openLightboxFor = (photoId: string) => {
|
||||||
|
const idx = readyPhotos.findIndex((p) => p.id === photoId);
|
||||||
|
if (idx >= 0) setLightboxIndex(idx);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (e.dataTransfer.types.includes('Files')) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUpload(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Link href="/admin/photos" className="text-gray-400 hover:text-gray-600">
|
||||||
|
<ArrowLeftIcon className="w-6 h-6" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark truncate">
|
||||||
|
{es && gallery.titleEs ? gallery.titleEs : gallery.title}
|
||||||
|
</h1>
|
||||||
|
<VisibilityBadge visibility={gallery.visibility} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
|
||||||
|
{es ? 'Subir fotos' : 'Upload photos'}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp,image/heic,.heic"
|
||||||
|
multiple
|
||||||
|
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{es ? 'Visibilidad' : 'Visibility'}</label>
|
||||||
|
<select
|
||||||
|
value={gallery.visibility}
|
||||||
|
onChange={(e) =>
|
||||||
|
saveField(
|
||||||
|
{ visibility: e.target.value as GalleryVisibility },
|
||||||
|
es ? 'Visibilidad actualizada' : 'Visibility updated'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="private">{es ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
|
||||||
|
<option value="public">{es ? 'Pública (listada)' : 'Public (listed)'}</option>
|
||||||
|
<option value="link">{es ? 'Solo con enlace' : 'Link only'}</option>
|
||||||
|
<option value="ticket">{es ? 'Con entrada del evento' : 'Ticket holders of the event'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{es ? 'Evento vinculado' : 'Linked event'}</label>
|
||||||
|
<select
|
||||||
|
value={gallery.eventId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
saveField({ eventId: e.target.value }, es ? 'Evento actualizado' : 'Event updated')
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">{es ? 'Ninguno' : 'None'}</option>
|
||||||
|
{events.map((ev) => (
|
||||||
|
<option key={ev.id} value={ev.id}>
|
||||||
|
{ev.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{gallery.visibility === 'ticket' && !gallery.eventId && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
{es
|
||||||
|
? 'El modo "con entrada" necesita un evento vinculado'
|
||||||
|
: 'Ticket mode needs a linked event'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{es ? 'Enlace para compartir' : 'Share link'}</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={gallery.shareUrl || ''}
|
||||||
|
className="flex-1 min-w-0 px-3 py-2 text-sm border rounded-btn bg-gray-50"
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={copyShareLink} title={es ? 'Copiar' : 'Copy'}>
|
||||||
|
{copied ? <CheckIcon className="w-4 h-4" /> : <LinkIcon className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={rotateToken} title={es ? 'Regenerar' : 'Regenerate'}>
|
||||||
|
<ArrowPathIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{gallery.event && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{es
|
||||||
|
? 'Publicada en la página del evento: '
|
||||||
|
: 'Published on the event page: '}
|
||||||
|
<span className="font-mono">/events/{gallery.event.slug}/gallery</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{processingCount > 0 && (
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
{es
|
||||||
|
? `Procesando ${processingCount} foto(s)…`
|
||||||
|
: `Processing ${processingCount} photo(s)…`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo grid */}
|
||||||
|
{photos.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<ArrowUpTrayIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{es
|
||||||
|
? 'Arrastra fotos aquí o usa el botón de subir'
|
||||||
|
: 'Drag photos here or use the upload button'}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{photos.map((photo) => (
|
||||||
|
<Card
|
||||||
|
key={photo.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragId(photo.id)}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (dragId) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (dragId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDropReorder(photo.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`group relative overflow-hidden aspect-square cursor-grab ${
|
||||||
|
dragId === photo.id ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{photo.status === 'ready' && photo.urls.thumb ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={photo.urls.thumb}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover cursor-pointer"
|
||||||
|
draggable={false}
|
||||||
|
onClick={() => openLightboxFor(photo.id)}
|
||||||
|
/>
|
||||||
|
) : photo.status === 'failed' ? (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 text-red-600 p-2 text-center">
|
||||||
|
<ExclamationTriangleIcon className="w-8 h-8 mb-1" />
|
||||||
|
<span className="text-xs line-clamp-3">{photo.lastError || 'Failed'}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gallery.coverPhotoId === photo.id && (
|
||||||
|
<StarIconSolid className="absolute top-2 left-2 w-5 h-5 text-primary-yellow drop-shadow" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 py-1.5">
|
||||||
|
{photo.status === 'ready' && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
saveField({ coverPhotoId: photo.id }, es ? 'Portada actualizada' : 'Cover updated')
|
||||||
|
}
|
||||||
|
className="p-1.5 text-white hover:text-primary-yellow"
|
||||||
|
title={es ? 'Usar como portada' : 'Set as cover'}
|
||||||
|
>
|
||||||
|
<StarIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{photo.status === 'failed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => retryPhoto(photo.id)}
|
||||||
|
className="p-1.5 text-white hover:text-primary-yellow"
|
||||||
|
title={es ? 'Reintentar' : 'Retry'}
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => deletePhoto(photo.id)}
|
||||||
|
className="p-1.5 text-white hover:text-red-400"
|
||||||
|
title={es ? 'Eliminar' : 'Delete'}
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<div className="mt-10 pt-6 border-t border-secondary-light-gray flex justify-end">
|
||||||
|
<Button variant="danger" onClick={deleteGallery}>
|
||||||
|
<TrashIcon className="w-5 h-5 mr-2" />
|
||||||
|
{es ? 'Eliminar galería' : 'Delete gallery'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lightbox with admin actions */}
|
||||||
|
{lightboxIndex !== null && lightboxItems.length > 0 && (
|
||||||
|
<Lightbox
|
||||||
|
items={lightboxItems}
|
||||||
|
index={Math.min(lightboxIndex, lightboxItems.length - 1)}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
onNavigate={setLightboxIndex}
|
||||||
|
renderActions={(item) => (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
saveField({ coverPhotoId: item.id }, es ? 'Portada actualizada' : 'Cover updated')
|
||||||
|
}
|
||||||
|
className="text-white hover:text-primary-yellow"
|
||||||
|
title={es ? 'Usar como portada' : 'Set as cover'}
|
||||||
|
>
|
||||||
|
{gallery.coverPhotoId === item.id ? (
|
||||||
|
<StarIconSolid className="w-7 h-7 text-primary-yellow" />
|
||||||
|
) : (
|
||||||
|
<StarIcon className="w-7 h-7" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const before = readyPhotos.length;
|
||||||
|
const deleted = await deletePhoto(item.id);
|
||||||
|
if (!deleted) return;
|
||||||
|
if (before <= 1) setLightboxIndex(null);
|
||||||
|
else setLightboxIndex((i) => (i === null ? null : Math.max(0, Math.min(i, before - 2))));
|
||||||
|
}}
|
||||||
|
className="text-white hover:text-red-400"
|
||||||
|
title={es ? 'Eliminar' : 'Delete'}
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-7 h-7" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploader panel (Google Drive style) */}
|
||||||
|
{uploads.length > 0 && (
|
||||||
|
<div className="fixed bottom-4 right-4 z-40 w-96 max-w-[calc(100vw-2rem)]">
|
||||||
|
<div className="bg-white rounded-card shadow-card-hover border border-secondary-light-gray overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-primary-dark text-white">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{activeUploads > 0
|
||||||
|
? es
|
||||||
|
? `Subiendo ${uploads.length - activeUploads}/${uploads.length}…`
|
||||||
|
: `Uploading ${uploads.length - activeUploads}/${uploads.length}…`
|
||||||
|
: es
|
||||||
|
? `${uploads.length} subida(s) completada(s)`
|
||||||
|
: `${uploads.length} upload(s) complete`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelCollapsed((c) => !c)}
|
||||||
|
className="p-1 hover:text-primary-yellow"
|
||||||
|
aria-label={panelCollapsed ? 'Expand' : 'Collapse'}
|
||||||
|
>
|
||||||
|
{panelCollapsed ? <ChevronUpIcon className="w-5 h-5" /> : <ChevronDownIcon className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
{activeUploads === 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setUploads([])}
|
||||||
|
className="p-1 hover:text-primary-yellow"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!panelCollapsed && (
|
||||||
|
<ul className="max-h-72 overflow-y-auto divide-y divide-secondary-light-gray">
|
||||||
|
{uploads.map((u) => {
|
||||||
|
const ds = displayStatus(u);
|
||||||
|
return (
|
||||||
|
<li key={u.key} className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<PhotoIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-primary-dark truncate" title={u.name}>
|
||||||
|
{u.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{ds.state === 'uploading' && `${Math.round(u.progress * 100)}% · ${formatBytes(u.sizeBytes)}`}
|
||||||
|
{ds.state === 'queued' && (es ? 'En cola' : 'Queued')}
|
||||||
|
{ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')}
|
||||||
|
{ds.state === 'done' && formatBytes(u.sizeBytes)}
|
||||||
|
{ds.state === 'error' && (
|
||||||
|
<span className="text-red-600" title={ds.error}>
|
||||||
|
{ds.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />}
|
||||||
|
{ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />}
|
||||||
|
{ds.state === 'processing' && (
|
||||||
|
<div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
)}
|
||||||
|
{ds.state === 'uploading' && (
|
||||||
|
<span className="text-xs text-gray-600 tabular-nums">{Math.round(u.progress * 100)}%</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ds.state === 'uploading' && (
|
||||||
|
<div className="mt-1.5 h-1 rounded-full bg-secondary-gray overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary-yellow transition-[width] duration-200"
|
||||||
|
style={{ width: `${Math.round(u.progress * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { photosApi, eventsApi, PhotoGallery, Event, GalleryVisibility } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
import VisibilityBadge from './VisibilityBadge';
|
||||||
|
import {
|
||||||
|
CameraIcon,
|
||||||
|
PlusIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AdminPhotosPage() {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const [galleries, setGalleries] = useState<PhotoGallery[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: '',
|
||||||
|
titleEs: '',
|
||||||
|
eventId: '',
|
||||||
|
visibility: 'private' as GalleryVisibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([photosApi.getGalleries(), eventsApi.getAll()])
|
||||||
|
.then(([g, e]) => {
|
||||||
|
setGalleries(g.galleries);
|
||||||
|
setEvents(e.events);
|
||||||
|
})
|
||||||
|
.catch(() => toast.error(locale === 'es' ? 'Error al cargar galerías' : 'Failed to load galleries'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.title.trim()) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const { gallery } = await photosApi.createGallery({
|
||||||
|
title: form.title.trim(),
|
||||||
|
titleEs: form.titleEs.trim() || undefined,
|
||||||
|
eventId: form.eventId || undefined,
|
||||||
|
visibility: form.visibility,
|
||||||
|
});
|
||||||
|
setGalleries((prev) => [gallery, ...prev]);
|
||||||
|
setShowCreate(false);
|
||||||
|
setForm({ title: '', titleEs: '', eventId: '', visibility: 'private' });
|
||||||
|
toast.success(locale === 'es' ? 'Galería creada' : 'Gallery created');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to create gallery');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||||||
|
</div>
|
||||||
|
<ImageGridSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">
|
||||||
|
{locale === 'es' ? 'Fotos de Eventos' : 'Event Photos'}
|
||||||
|
</h1>
|
||||||
|
<Button onClick={() => setShowCreate(true)}>
|
||||||
|
<PlusIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{galleries.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-600 mb-2">
|
||||||
|
{locale === 'es' ? 'Sin galerías todavía' : 'No galleries yet'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Crea una galería para compartir las fotos de un evento'
|
||||||
|
: 'Create a gallery to share photos from a past event'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShowCreate(true)}>
|
||||||
|
<PlusIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Crear la primera' : 'Create the first one'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{galleries.map((g) => (
|
||||||
|
<Link key={g.id} href={`/admin/photos/${g.id}`}>
|
||||||
|
<Card className="overflow-hidden hover:shadow-card-hover transition-shadow cursor-pointer h-full">
|
||||||
|
<div className="aspect-video bg-gray-100 flex items-center justify-center">
|
||||||
|
{g.coverUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<CameraIcon className="w-12 h-12 text-gray-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-semibold text-primary-dark truncate">
|
||||||
|
{locale === 'es' && g.titleEs ? g.titleEs : g.title}
|
||||||
|
</h3>
|
||||||
|
<VisibilityBadge visibility={g.visibility} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{g.photoCount} {locale === 'es' ? 'fotos' : 'photos'}
|
||||||
|
{g.event && <> · {locale === 'es' && g.event.titleEs ? g.event.titleEs : g.event.title}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-primary-dark">
|
||||||
|
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => setShowCreate(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<XMarkIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
{locale === 'es' ? 'Título (inglés)' : 'Title (English)'} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
placeholder="June Language Exchange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
{locale === 'es' ? 'Título (español)' : 'Title (Spanish)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.titleEs}
|
||||||
|
onChange={(e) => setForm({ ...form, titleEs: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
placeholder="Intercambio de Junio"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
{locale === 'es' ? 'Evento vinculado' : 'Linked event'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.eventId}
|
||||||
|
onChange={(e) => setForm({ ...form, eventId: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">{locale === 'es' ? 'Ninguno' : 'None'}</option>
|
||||||
|
{events.map((ev) => (
|
||||||
|
<option key={ev.id} value={ev.id}>
|
||||||
|
{ev.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
{locale === 'es' ? 'Visibilidad' : 'Visibility'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.visibility}
|
||||||
|
onChange={(e) => setForm({ ...form, visibility: e.target.value as GalleryVisibility })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="private">{locale === 'es' ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
|
||||||
|
<option value="public">{locale === 'es' ? 'Pública (listada)' : 'Public (listed)'}</option>
|
||||||
|
<option value="link">{locale === 'es' ? 'Solo con enlace' : 'Link only'}</option>
|
||||||
|
<option value="ticket">
|
||||||
|
{locale === 'es' ? 'Con entrada del evento' : 'Ticket holders of the event'}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>
|
||||||
|
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={creating}>
|
||||||
|
{locale === 'es' ? 'Crear' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
import { parseDate, formatCurrency, EVENT_TIMEZONE } from '@/lib/utils';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────
|
||||||
@@ -273,6 +273,17 @@ function ValidTicketScreen({
|
|||||||
{validation.ticket?.attendeeEmail && (
|
{validation.ticket?.attendeeEmail && (
|
||||||
<p className="text-emerald-100 text-lg mb-4">{validation.ticket.attendeeEmail}</p>
|
<p className="text-emerald-100 text-lg mb-4">{validation.ticket.attendeeEmail}</p>
|
||||||
)}
|
)}
|
||||||
|
{/* Unpaid tickets are valid for entry but the balance is collected at the door */}
|
||||||
|
{validation.ticket?.paymentStatus === 'unpaid' && (
|
||||||
|
<div className="bg-orange-500 rounded-2xl px-6 py-3 w-full max-w-sm text-center mb-3">
|
||||||
|
<p className="font-bold text-lg">UNPAID — collect payment</p>
|
||||||
|
{!!validation.ticket.amountDue && (
|
||||||
|
<p className="text-orange-100 text-sm">
|
||||||
|
Balance due: {formatCurrency(validation.ticket.amountDue)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="bg-white/15 rounded-2xl px-6 py-4 w-full max-w-sm space-y-2 text-center">
|
<div className="bg-white/15 rounded-2xl px-6 py-4 w-full max-w-sm space-y-2 text-center">
|
||||||
{validation.event && (
|
{validation.event && (
|
||||||
<p className="font-semibold text-lg">{validation.event.title}</p>
|
<p className="font-semibold text-lg">{validation.event.title}</p>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
|||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import { CheckCircleIcon, XCircleIcon, PlusIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { CheckCircleIcon, XCircleIcon, PlusIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
@@ -135,11 +136,7 @@ export default function AdminTicketsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={5} />;
|
||||||
<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 (
|
return (
|
||||||
|
|||||||
@@ -1,19 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { usersApi, eventsApi, User, Event } from '@/lib/api';
|
import { usersApi, eventsApi, User, Event } from '@/lib/api';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
type RegisteredRange = '' | '7d' | '30d' | '90d';
|
type RegisteredRange = '' | '7d' | '30d' | '90d';
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
function getPageNumbers(current: number, totalPages: number): (number | '...')[] {
|
||||||
|
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
|
const pages: (number | '...')[] = [1];
|
||||||
|
const start = Math.max(2, current - 1);
|
||||||
|
const end = Math.min(totalPages - 1, current + 1);
|
||||||
|
if (start > 2) pages.push('...');
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
if (end < totalPages - 1) pages.push('...');
|
||||||
|
pages.push(totalPages);
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
function registeredAfterFromRange(range: RegisteredRange): string | undefined {
|
function registeredAfterFromRange(range: RegisteredRange): string | undefined {
|
||||||
if (!range) return undefined;
|
if (!range) return undefined;
|
||||||
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
|
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
|
||||||
@@ -34,6 +49,8 @@ export default function AdminUsersPage() {
|
|||||||
const [eventFilter, setEventFilter] = useState<string>('');
|
const [eventFilter, setEventFilter] = useState<string>('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -56,9 +73,20 @@ export default function AdminUsersPage() {
|
|||||||
eventsApi.getAll().then((res) => setEvents(res.events)).catch(() => {});
|
eventsApi.getAll().then((res) => setEvents(res.events)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// When a filter changes, jump back to page 1 before fetching (skipping the
|
||||||
|
// fetch for the stale page); otherwise fetch for the current page/pageSize.
|
||||||
|
const filterKey = JSON.stringify([roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
|
||||||
|
const prevFilterKey = useRef(filterKey);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (prevFilterKey.current !== filterKey) {
|
||||||
|
prevFilterKey.current = filterKey;
|
||||||
|
if (page !== 1) {
|
||||||
|
setPage(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
loadUsers();
|
loadUsers();
|
||||||
}, [roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
|
}, [filterKey, page, pageSize]);
|
||||||
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
roleFilter || statusFilter || hasBookingsFilter || registeredRange || eventFilter || searchQuery;
|
roleFilter || statusFilter || hasBookingsFilter || registeredRange || eventFilter || searchQuery;
|
||||||
@@ -81,10 +109,16 @@ export default function AdminUsersPage() {
|
|||||||
registeredAfter: registeredAfterFromRange(registeredRange),
|
registeredAfter: registeredAfterFromRange(registeredRange),
|
||||||
eventId: eventFilter || undefined,
|
eventId: eventFilter || undefined,
|
||||||
search: debouncedSearch.trim() || undefined,
|
search: debouncedSearch.trim() || undefined,
|
||||||
pageSize: 200,
|
page,
|
||||||
|
pageSize,
|
||||||
});
|
});
|
||||||
setUsers(users);
|
setUsers(users);
|
||||||
setTotal(total);
|
setTotal(total);
|
||||||
|
// If the current page emptied out (e.g. after deleting the last user on
|
||||||
|
// it), fall back to the new last page.
|
||||||
|
if (users.length === 0 && total > 0 && page > 1) {
|
||||||
|
setPage(Math.max(1, Math.ceil(total / pageSize)));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load users');
|
toast.error('Failed to load users');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -166,11 +200,7 @@ export default function AdminUsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <AdminPageSkeleton cols={6} />;
|
||||||
<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 (
|
return (
|
||||||
@@ -317,7 +347,7 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{user.rucNumber || '-'}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{formatRucDisplay(user.rucNumber) || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||||
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
|
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
|
||||||
@@ -366,7 +396,7 @@ export default function AdminUsersPage() {
|
|||||||
<p className="font-medium text-sm truncate">{user.name}</p>
|
<p className="font-medium text-sm truncate">{user.name}</p>
|
||||||
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||||
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
|
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
|
||||||
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {user.rucNumber}</p>}
|
{user.rucNumber && <p className="text-[10px] text-gray-400">RUC: {formatRucDisplay(user.rucNumber)}</p>}
|
||||||
</div>
|
</div>
|
||||||
{getRoleBadge(user.role)}
|
{getRoleBadge(user.role)}
|
||||||
</div>
|
</div>
|
||||||
@@ -388,6 +418,65 @@ export default function AdminUsersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<label htmlFor="users-page-size" className="whitespace-nowrap">Per page</label>
|
||||||
|
<select
|
||||||
|
id="users-page-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||||
|
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) =>
|
||||||
|
p === '...' ? (
|
||||||
|
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">…</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className={clsx(
|
||||||
|
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
|
||||||
|
p === page
|
||||||
|
? 'bg-primary-yellow text-primary-dark font-semibold'
|
||||||
|
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
aria-current={p === page ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= Math.ceil(total / pageSize)}
|
||||||
|
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile Filter BottomSheet */}
|
{/* Mobile Filter BottomSheet */}
|
||||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -73,8 +73,22 @@ export default function LinktreePage() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 text-center">
|
<div
|
||||||
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 animate-pulse"
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Loading…</span>
|
||||||
|
<div className="h-6 w-2/3 rounded-lg bg-white/15" />
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="h-4 w-3/4 rounded-lg bg-white/15" />
|
||||||
|
<div className="h-4 w-1/2 rounded-lg bg-white/15" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="h-5 w-20 rounded-lg bg-white/15" />
|
||||||
|
<div className="h-4 w-24 rounded-lg bg-white/15" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 h-12 rounded-xl bg-white/15" />
|
||||||
</div>
|
</div>
|
||||||
) : nextEvent ? (
|
) : nextEvent ? (
|
||||||
<Link href={`/events/${nextEvent.slug}`} className="block group">
|
<Link href={`/events/${nextEvent.slug}`} className="block group">
|
||||||
|
|||||||
@@ -2,6 +2,26 @@ import { MetadataRoute } from 'next';
|
|||||||
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||||
|
|
||||||
|
interface SitemapPhotoGallery {
|
||||||
|
slug: string;
|
||||||
|
updatedAt: string;
|
||||||
|
event?: { slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public photo galleries from the photo-api service (public visibility only). */
|
||||||
|
async function getPublicPhotoGalleries(): Promise<SitemapPhotoGallery[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, {
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return ((await res.json()).galleries as SitemapPhotoGallery[]) || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface SitemapEvent {
|
interface SitemapEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,7 +61,10 @@ async function getIndexableEvents(): Promise<SitemapEvent[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const events = await getIndexableEvents();
|
const [events, photoGalleries] = await Promise.all([
|
||||||
|
getIndexableEvents(),
|
||||||
|
getPublicPhotoGalleries(),
|
||||||
|
]);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Static pages
|
// Static pages
|
||||||
@@ -88,6 +111,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
changeFrequency: 'monthly',
|
changeFrequency: 'monthly',
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/photos`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'weekly',
|
||||||
|
priority: 0.6,
|
||||||
|
},
|
||||||
// Legal pages
|
// Legal pages
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/legal/terms-policy`,
|
url: `${siteUrl}/legal/terms-policy`,
|
||||||
@@ -120,5 +149,13 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...staticPages, ...eventPages];
|
const photoPages: MetadataRoute.Sitemap = photoGalleries.map((g) => ({
|
||||||
|
// Event-linked galleries live under the event's URL.
|
||||||
|
url: g.event ? `${siteUrl}/events/${g.event.slug}/gallery` : `${siteUrl}/photos/${g.slug}`,
|
||||||
|
lastModified: g.updatedAt ? new Date(g.updatedAt) : now,
|
||||||
|
changeFrequency: 'monthly' as const,
|
||||||
|
priority: 0.5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...staticPages, ...eventPages, ...photoPages];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
XMarkIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export interface LightboxItem {
|
||||||
|
id: string;
|
||||||
|
previewUrl: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LightboxProps {
|
||||||
|
items: LightboxItem[];
|
||||||
|
index: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onNavigate: (index: number) => void;
|
||||||
|
/** Extra per-item action buttons rendered in the top bar (admin use). */
|
||||||
|
renderActions?: (item: LightboxItem, index: number) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen photo lightbox with keyboard and swipe navigation, in the
|
||||||
|
* style of the admin gallery preview modal (fixed inset-0 bg-black/90).
|
||||||
|
*/
|
||||||
|
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) {
|
||||||
|
const [touchStartX, setTouchStartX] = useState<number | null>(null);
|
||||||
|
const item = items[index];
|
||||||
|
|
||||||
|
const prev = useCallback(() => {
|
||||||
|
onNavigate(index > 0 ? index - 1 : items.length - 1);
|
||||||
|
}, [index, items.length, onNavigate]);
|
||||||
|
const next = useCallback(() => {
|
||||||
|
onNavigate(index < items.length - 1 ? index + 1 : 0);
|
||||||
|
}, [index, items.length, onNavigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
if (e.key === 'ArrowLeft') prev();
|
||||||
|
if (e.key === 'ArrowRight') next();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [onClose, prev, next]);
|
||||||
|
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
onTouchStart={(e) => setTouchStartX(e.touches[0].clientX)}
|
||||||
|
onTouchEnd={(e) => {
|
||||||
|
if (touchStartX === null) return;
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStartX;
|
||||||
|
if (dx > 60) prev();
|
||||||
|
if (dx < -60) next();
|
||||||
|
setTouchStartX(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="absolute top-4 right-4 text-white hover:text-gray-300 z-10 p-2 -m-2"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-8 h-8" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute top-4 left-4 z-10 flex items-center gap-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={item.downloadUrl}
|
||||||
|
download={item.filename || true}
|
||||||
|
className="text-white hover:text-gray-300"
|
||||||
|
aria-label="Download"
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon className="w-7 h-7" />
|
||||||
|
</a>
|
||||||
|
{renderActions?.(item, index)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
prev();
|
||||||
|
}}
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="w-9 h-9" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
next();
|
||||||
|
}}
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="w-9 h-9" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={item.previewUrl}
|
||||||
|
alt=""
|
||||||
|
className="max-w-[95vw] max-h-[90vh] object-contain select-none"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm">
|
||||||
|
{index + 1} / {items.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preload neighbours so prev/next feels instant. */}
|
||||||
|
<div className="hidden" aria-hidden>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={items[(index + 1) % items.length].previewUrl} alt="" />
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={items[(index - 1 + items.length) % items.length].previewUrl} alt="" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
|
|
||||||
|
export const PRIVACY_MASK = '••••';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders its children normally, but shows a mask while privacy mode is on.
|
||||||
|
* Use for inline sensitive numbers that can't be hidden without breaking
|
||||||
|
* the surrounding row/badge.
|
||||||
|
*/
|
||||||
|
export default function SensitiveValue({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
return <span className={className}>{privacyMode ? PRIVACY_MASK : children}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
// Skeleton loading previews. The base block uses Tailwind's animate-pulse and
|
||||||
|
// the light-gray surface tokens so placeholders match real card/table styling.
|
||||||
|
// Composites below mirror the layouts they stand in for (event cards, admin
|
||||||
|
// tables, FAQ accordions, article bodies) to avoid layout shift when data lands.
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ className }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx('animate-pulse rounded-lg bg-secondary-light-gray/70', className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen-reader announcement wrapper for a group of skeleton blocks.
|
||||||
|
export function SkeletonGroup({ className, children }: SkeletonProps & { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div role="status" aria-busy="true" className={className}>
|
||||||
|
<span className="sr-only">Loading…</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph-style stack of text lines with a shorter last line.
|
||||||
|
export function SkeletonText({ lines = 3, className }: SkeletonProps & { lines?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('space-y-2.5', className)} aria-hidden="true">
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className={clsx('h-4', i === lines - 1 ? 'w-2/3' : 'w-full')} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the event card in EventsClient: banner, title, meta rows, price + button.
|
||||||
|
export function EventCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-card overflow-hidden shadow-card h-full" aria-hidden="true">
|
||||||
|
<Skeleton className="h-40 w-full rounded-none" />
|
||||||
|
<div className="p-6">
|
||||||
|
<Skeleton className="h-6 w-3/4" />
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-2/5" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<Skeleton className="h-7 w-24" />
|
||||||
|
<Skeleton className="h-9 w-24 rounded-btn" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventGridSkeleton({ count = 6 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<EventCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the featured "next event" hero card on the homepage: side banner + info column.
|
||||||
|
export function FeaturedEventSkeleton() {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup>
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg">
|
||||||
|
<div className="flex flex-col md:flex-row">
|
||||||
|
<Skeleton className="w-full md:w-2/5 flex-shrink-0 h-48 md:h-auto md:min-h-[280px] rounded-none" />
|
||||||
|
<div className="flex-1 p-5 md:p-8">
|
||||||
|
<Skeleton className="h-7 w-2/3" />
|
||||||
|
<Skeleton className="mt-3 h-4 w-full" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-5/6" />
|
||||||
|
<div className="mt-4 md:mt-5 space-y-2.5">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-4 w-56" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 md:mt-6 flex items-center justify-between gap-4">
|
||||||
|
<Skeleton className="h-9 w-28" />
|
||||||
|
<Skeleton className="h-10 w-28 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accordion-style rows for FAQ lists. `compact` matches the homepage variant
|
||||||
|
// (rounded-btn, px-5); the default matches the /faq page cards (px-6, more spacing).
|
||||||
|
export function FaqListSkeleton({ count = 5, compact = false }: { count?: number; compact?: boolean }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup className={compact ? 'space-y-3' : 'space-y-4'}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={clsx(
|
||||||
|
'bg-white border border-gray-200 flex items-center justify-between',
|
||||||
|
compact ? 'rounded-btn px-5 py-4' : 'rounded-card px-6 py-4 shadow-card border-0'
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Skeleton className={clsx('h-5', i % 2 === 0 ? 'w-3/5' : 'w-2/5')} />
|
||||||
|
<Skeleton className="h-5 w-5 rounded-full flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long-form content: heading, meta line, then paragraph blocks.
|
||||||
|
export function ArticleSkeleton({ withBanner = false }: { withBanner?: boolean }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup>
|
||||||
|
{withBanner && <Skeleton className="h-56 md:h-80 w-full rounded-card mb-8" />}
|
||||||
|
<Skeleton className="h-9 w-3/4 md:w-1/2" />
|
||||||
|
<Skeleton className="mt-3 h-4 w-40" />
|
||||||
|
<div className="mt-8 space-y-6">
|
||||||
|
<SkeletonText lines={4} />
|
||||||
|
<SkeletonText lines={3} />
|
||||||
|
<Skeleton className="h-6 w-1/3" />
|
||||||
|
<SkeletonText lines={4} />
|
||||||
|
</div>
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stacked list of card rows (dashboard tickets/payments, contact lists).
|
||||||
|
export function CardListSkeleton({ count = 3 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup className="space-y-4">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-card shadow-card p-5" aria-hidden="true">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-5 w-1/3" />
|
||||||
|
<Skeleton className="mt-2.5 h-4 w-1/2" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-2/5" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin data tables: desktop table inside a Card, mobile stacked cards —
|
||||||
|
// mirrors the two-breakpoint layout every admin list page renders.
|
||||||
|
export function TableSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup>
|
||||||
|
{/* Desktop: table */}
|
||||||
|
<div className="bg-white rounded-card overflow-hidden shadow-card hidden md:block" aria-hidden="true">
|
||||||
|
<div className="bg-secondary-gray px-4 py-3 flex gap-4">
|
||||||
|
{Array.from({ length: cols }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-3.5 flex-1 bg-secondary-light-gray" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-secondary-light-gray">
|
||||||
|
{Array.from({ length: rows }).map((_, r) => (
|
||||||
|
<div key={r} className="px-4 py-3.5 flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<Skeleton className="w-10 h-10 rounded-lg flex-shrink-0" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: cols - 1 }).map((_, c) => (
|
||||||
|
<Skeleton key={c} className={clsx('h-4 flex-1', c === cols - 2 && 'max-w-[80px]')} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mobile: card list */}
|
||||||
|
<div className="md:hidden space-y-3" aria-hidden="true">
|
||||||
|
{Array.from({ length: Math.min(rows, 4) }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-card shadow-card p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="w-12 h-12 rounded-lg flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="mt-2 h-3.5 w-1/2" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full admin page placeholder: title bar + table. Used by the `if (loading)`
|
||||||
|
// gates that replace the entire page, so it includes the header row.
|
||||||
|
export function AdminPageSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||||||
|
</div>
|
||||||
|
<TableSkeleton rows={rows} cols={cols} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail grid (admin gallery/media pages).
|
||||||
|
export function ImageGridSkeleton({ count = 12 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="aspect-square w-full rounded-card" />
|
||||||
|
))}
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface PrivacyContextType {
|
||||||
|
/** true = stats and sensitive data are hidden */
|
||||||
|
privacyMode: boolean;
|
||||||
|
setPrivacyMode: (value: boolean) => void;
|
||||||
|
togglePrivacyMode: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrivacyContext = createContext<PrivacyContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Same key the old per-page useStatsPrivacy hook used ('true' = hidden), so
|
||||||
|
// existing operators keep their saved preference.
|
||||||
|
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
|
||||||
|
|
||||||
|
export function PrivacyProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [privacyMode, setPrivacyModeState] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored !== null) {
|
||||||
|
setPrivacyModeState(stored === 'true');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (private mode etc.) - keep default
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPrivacyMode = useCallback((value: boolean) => {
|
||||||
|
setPrivacyModeState(value);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePrivacyMode = useCallback(() => {
|
||||||
|
setPrivacyModeState((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(next));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrivacyContext.Provider value={{ privacyMode, setPrivacyMode, togglePrivacyMode }}>
|
||||||
|
{children}
|
||||||
|
</PrivacyContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrivacy() {
|
||||||
|
const context = useContext(PrivacyContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('usePrivacy must be used within a PrivacyProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
|
|
||||||
|
|
||||||
export function useStatsPrivacy() {
|
|
||||||
const [showStats, setShowStatsState] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (stored !== null) {
|
|
||||||
setShowStatsState(stored !== 'true');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setShowStats = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
|
|
||||||
setShowStatsState((prev) => {
|
|
||||||
const next = typeof value === 'function' ? value(prev) : value;
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem(STORAGE_KEY, String(!next));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleStats = useCallback(() => {
|
|
||||||
setShowStats((prev) => !prev);
|
|
||||||
}, [setShowStats]);
|
|
||||||
|
|
||||||
return [showStats, setShowStats, toggleStats] as const;
|
|
||||||
}
|
|
||||||
@@ -255,6 +255,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
"privacy": {
|
||||||
|
"hide": "Hide Stats",
|
||||||
|
"show": "Show Stats"
|
||||||
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"welcome": "Welcome back",
|
"welcome": "Welcome back",
|
||||||
@@ -275,6 +279,7 @@
|
|||||||
"contacts": "Messages",
|
"contacts": "Messages",
|
||||||
"emails": "Emails",
|
"emails": "Emails",
|
||||||
"gallery": "Gallery",
|
"gallery": "Gallery",
|
||||||
|
"photos": "Photos",
|
||||||
"settings": "Settings"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
|
|||||||
@@ -255,6 +255,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
"privacy": {
|
||||||
|
"hide": "Ocultar Datos",
|
||||||
|
"show": "Mostrar Datos"
|
||||||
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Panel de Control",
|
"title": "Panel de Control",
|
||||||
"welcome": "Bienvenido de nuevo",
|
"welcome": "Bienvenido de nuevo",
|
||||||
@@ -275,6 +279,7 @@
|
|||||||
"contacts": "Mensajes",
|
"contacts": "Mensajes",
|
||||||
"emails": "Emails",
|
"emails": "Emails",
|
||||||
"gallery": "Galería",
|
"gallery": "Galería",
|
||||||
|
"photos": "Fotos",
|
||||||
"settings": "Configuración"
|
"settings": "Configuración"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ export async function fetchApi<T>(
|
|||||||
const errorMessage = typeof errorData.error === 'string'
|
const errorMessage = typeof errorData.error === 'string'
|
||||||
? errorData.error
|
? errorData.error
|
||||||
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
|
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
|
||||||
throw new Error(errorMessage);
|
const error = new Error(errorMessage);
|
||||||
|
// Preserve structured error info (e.g. code: 'EVENT_OVER_CAPACITY') so
|
||||||
|
// callers can react beyond the message text.
|
||||||
|
(error as any).code = errorData.code;
|
||||||
|
(error as any).data = errorData;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -17,3 +17,11 @@ export { siteSettingsApi } from './siteSettings';
|
|||||||
export { legalSettingsApi } from './legalSettings';
|
export { legalSettingsApi } from './legalSettings';
|
||||||
export { legalPagesApi } from './legalPages';
|
export { legalPagesApi } from './legalPages';
|
||||||
export { faqApi } from './faq';
|
export { faqApi } from './faq';
|
||||||
|
export { photosApi } from './photos';
|
||||||
|
export type {
|
||||||
|
Photo,
|
||||||
|
PhotoGallery,
|
||||||
|
PhotoGalleryEvent,
|
||||||
|
GalleryVisibility,
|
||||||
|
CreateGalleryInput,
|
||||||
|
} from './photos';
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { fetchApi } from './client';
|
import { fetchApi } from './client';
|
||||||
import type { Payment, PaymentWithDetails } from './types';
|
import type { Payment, PaymentWithDetails } from './types';
|
||||||
|
|
||||||
|
// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to
|
||||||
|
// verify the money arrived; automatic ones (lightning) confirm themselves.
|
||||||
|
export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash'];
|
||||||
|
|
||||||
|
export function isManualProvider(provider: string): boolean {
|
||||||
|
return MANUAL_PAYMENT_PROVIDERS.includes(provider);
|
||||||
|
}
|
||||||
|
|
||||||
export const paymentsApi = {
|
export const paymentsApi = {
|
||||||
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
|
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
@@ -21,10 +29,10 @@ export const paymentsApi = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
approve: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
approve: (id: string, adminNote?: string, sendEmail: boolean = true, allowOverCapacity: boolean = false) =>
|
||||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
|
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ adminNote, sendEmail }),
|
body: JSON.stringify({ adminNote, sendEmail, allowOverCapacity }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
reject: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
reject: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
||||||
@@ -51,4 +59,10 @@ export const paymentsApi = {
|
|||||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, {
|
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reactivate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
reopen: (id: string, adminNote?: string) =>
|
||||||
|
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reopen`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ adminNote }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { fetchApi, API_BASE, getToken } from './client';
|
||||||
|
|
||||||
|
// Client for the standalone photo-api Go service (photo-api/), reachable
|
||||||
|
// under /api/photos via the Next rewrite (dev) or nginx (prod).
|
||||||
|
|
||||||
|
export type GalleryVisibility = 'public' | 'private' | 'link' | 'ticket';
|
||||||
|
|
||||||
|
export interface PhotoGalleryEvent {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
titleEs?: string;
|
||||||
|
startDatetime: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhotoGallery {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
titleEs?: string;
|
||||||
|
description?: string;
|
||||||
|
descriptionEs?: string;
|
||||||
|
eventId?: string;
|
||||||
|
visibility: GalleryVisibility;
|
||||||
|
coverPhotoId?: string;
|
||||||
|
photoCount: number;
|
||||||
|
coverUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
event?: PhotoGalleryEvent;
|
||||||
|
// Present on admin responses only:
|
||||||
|
shareToken?: string;
|
||||||
|
shareUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Photo {
|
||||||
|
id: string;
|
||||||
|
galleryId: string;
|
||||||
|
position: number;
|
||||||
|
originalFilename?: string;
|
||||||
|
contentType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
takenAt?: string;
|
||||||
|
status: 'queued' | 'processing' | 'ready' | 'failed';
|
||||||
|
lastError?: string;
|
||||||
|
createdAt: string;
|
||||||
|
urls: {
|
||||||
|
thumb?: string;
|
||||||
|
preview?: string;
|
||||||
|
original: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGalleryInput {
|
||||||
|
title: string;
|
||||||
|
titleEs?: string;
|
||||||
|
description?: string;
|
||||||
|
descriptionEs?: string;
|
||||||
|
eventId?: string;
|
||||||
|
visibility?: GalleryVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const photosApi = {
|
||||||
|
// Admin (requires admin/organizer token)
|
||||||
|
createGallery: (data: CreateGalleryInput) =>
|
||||||
|
fetchApi<{ gallery: PhotoGallery }>('/api/photos/galleries', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getGalleries: (eventId?: string) => {
|
||||||
|
const query = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
|
||||||
|
return fetchApi<{ galleries: PhotoGallery[] }>(`/api/photos/galleries${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getGallery: (id: string) =>
|
||||||
|
fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(`/api/photos/galleries/${id}`),
|
||||||
|
|
||||||
|
updateGallery: (id: string, data: Partial<CreateGalleryInput> & { coverPhotoId?: string }) =>
|
||||||
|
fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteGallery: (id: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/photos/galleries/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
rotateShareToken: (id: string) =>
|
||||||
|
fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}/share-token`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
|
||||||
|
uploadPhoto: async (galleryId: string, file: File) => {
|
||||||
|
const token = getToken();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('files', file);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({ error: 'Upload failed' }));
|
||||||
|
throw new Error(errorData.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ photos: Photo[] }>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload with byte-level progress (0..1). fetch() cannot report upload
|
||||||
|
* progress, so this uses XMLHttpRequest; used by the admin uploader panel.
|
||||||
|
*/
|
||||||
|
uploadPhotoWithProgress: (
|
||||||
|
galleryId: string,
|
||||||
|
file: File,
|
||||||
|
onProgress: (fraction: number) => void
|
||||||
|
) =>
|
||||||
|
new Promise<{ photos: Photo[] }>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`);
|
||||||
|
const token = getToken();
|
||||||
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(xhr.responseText || '{}');
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
|
||||||
|
else reject(new Error(body.error || `Upload failed (${xhr.status})`));
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`Upload failed (${xhr.status})`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error('Network error during upload'));
|
||||||
|
xhr.onabort = () => reject(new Error('Upload cancelled'));
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('files', file);
|
||||||
|
xhr.send(formData);
|
||||||
|
}),
|
||||||
|
|
||||||
|
reorderPhotos: (galleryId: string, photoIds: string[]) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/photos/galleries/${galleryId}/order`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ photoIds }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deletePhoto: (photoId: string) =>
|
||||||
|
fetchApi<{ message: string }>(`/api/photos/photos/${photoId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
retryPhoto: (photoId: string) =>
|
||||||
|
fetchApi<{ photo: Photo }>(`/api/photos/photos/${photoId}/retry`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// Viewer (anonymous or member token; share token via query param)
|
||||||
|
getPublicGalleries: () =>
|
||||||
|
fetchApi<{ galleries: PhotoGallery[] }>('/api/photos/public/galleries'),
|
||||||
|
|
||||||
|
getPublicGallery: (slug: string, token?: string) => {
|
||||||
|
const query = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||||
|
return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(
|
||||||
|
`/api/photos/public/galleries/${encodeURIComponent(slug)}${query}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Newest gallery linked to an event; backs /events/[slug]/gallery. */
|
||||||
|
getEventGallery: (eventSlug: string, token?: string) => {
|
||||||
|
const query = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||||
|
return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(
|
||||||
|
`/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery${query}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -105,30 +105,20 @@ export const ticketsApi = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
manualCreate: (data: {
|
// Unified add-attendee endpoint behind the single Add Ticket modal
|
||||||
eventId: string;
|
// (paid = confirmation + QR, unpaid = pay link + door collection, guest = free comp)
|
||||||
firstName: string;
|
adminAdd: (data: {
|
||||||
lastName?: string;
|
|
||||||
email: string;
|
|
||||||
phone?: string;
|
|
||||||
preferredLanguage?: 'en' | 'es';
|
|
||||||
adminNote?: string;
|
|
||||||
}) =>
|
|
||||||
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/manual', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
guestCreate: (data: {
|
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
type: 'paid' | 'unpaid' | 'guest';
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
preferredLanguage?: 'en' | 'es';
|
preferredLanguage?: 'en' | 'es';
|
||||||
|
checkinNow?: boolean;
|
||||||
adminNote?: string;
|
adminNote?: string;
|
||||||
}) =>
|
}) =>
|
||||||
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/guest', {
|
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export interface Event {
|
|||||||
bannerUrl?: string;
|
bannerUrl?: string;
|
||||||
externalBookingEnabled?: boolean;
|
externalBookingEnabled?: boolean;
|
||||||
externalBookingUrl?: string;
|
externalBookingUrl?: string;
|
||||||
bookedCount?: number;
|
bookedCount?: number; // paid seats (confirmed + checked_in)
|
||||||
availableSeats?: number;
|
claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats)
|
||||||
|
availableSeats?: number; // capacity - booked - claimed; the server-authoritative number
|
||||||
isFeatured?: boolean;
|
isFeatured?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -52,6 +53,7 @@ export interface Ticket {
|
|||||||
qrCode: string;
|
qrCode: string;
|
||||||
adminNote?: string;
|
adminNote?: string;
|
||||||
isGuest?: boolean;
|
isGuest?: boolean;
|
||||||
|
paymentStatus?: 'paid' | 'unpaid' | 'comp';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
event?: Event;
|
event?: Event;
|
||||||
payment?: Payment;
|
payment?: Payment;
|
||||||
@@ -69,6 +71,8 @@ export interface TicketValidationResult {
|
|||||||
attendeeEmail?: string;
|
attendeeEmail?: string;
|
||||||
attendeePhone?: string;
|
attendeePhone?: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
paymentStatus?: 'paid' | 'unpaid' | 'comp';
|
||||||
|
amountDue?: number;
|
||||||
checkinAt?: string;
|
checkinAt?: string;
|
||||||
checkedInBy?: string;
|
checkedInBy?: string;
|
||||||
};
|
};
|
||||||
@@ -221,7 +225,10 @@ export interface DashboardData {
|
|||||||
totalEvents: number;
|
totalEvents: number;
|
||||||
totalTickets: number;
|
totalTickets: number;
|
||||||
confirmedTickets: number;
|
confirmedTickets: number;
|
||||||
|
/** Checkouts opened but never paid nor claimed — informational, holds no seat */
|
||||||
pendingPayments: number;
|
pendingPayments: number;
|
||||||
|
/** Customer says they paid; needs admin verification — actionable */
|
||||||
|
awaitingApprovalPayments: number;
|
||||||
totalRevenue: number;
|
totalRevenue: number;
|
||||||
newContacts: number;
|
newContacts: number;
|
||||||
totalSubscribers: number;
|
totalSubscribers: number;
|
||||||
|
|||||||
@@ -166,6 +166,18 @@ export function formatCurrency(amount: number, currency: string = 'PYG'): string
|
|||||||
return formatPrice(amount, currency);
|
return formatPrice(amount, currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Paraguayan RUC for display as "base-checkdigit" (e.g. 1234567-9).
|
||||||
|
* Legacy records stored digits only; insert the dash before the check digit.
|
||||||
|
*/
|
||||||
|
export function formatRucDisplay(ruc: string | null | undefined): string {
|
||||||
|
if (!ruc) return '';
|
||||||
|
if (ruc.includes('-')) return ruc;
|
||||||
|
const digits = ruc.replace(/\D/g, '');
|
||||||
|
if (digits.length < 2) return ruc;
|
||||||
|
return `${digits.slice(0, -1)}-${digits.slice(-1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Payment helpers
|
// Payment helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -193,3 +205,33 @@ export function getTpagoLink(
|
|||||||
const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig;
|
const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig;
|
||||||
return config[key] || config.tpagoLink || null;
|
return config[key] || config.tpagoLink || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spots left for an event, trusting the server's `availableSeats` (which uses
|
||||||
|
* the same seat-holding formula the booking API enforces: paid + claimed
|
||||||
|
* seats count, abandoned pending bookings don't). Falls back to deriving it
|
||||||
|
* from the counts for older API responses.
|
||||||
|
*/
|
||||||
|
export function eventSpotsLeft(event: {
|
||||||
|
capacity: number;
|
||||||
|
bookedCount?: number;
|
||||||
|
claimedCount?: number;
|
||||||
|
availableSeats?: number;
|
||||||
|
}): number {
|
||||||
|
if (typeof event.availableSeats === 'number') {
|
||||||
|
return Math.max(0, event.availableSeats);
|
||||||
|
}
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
(event.capacity ?? 0) - (event.bookedCount ?? 0) - (event.claimedCount ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEventSoldOut(event: {
|
||||||
|
capacity: number;
|
||||||
|
bookedCount?: number;
|
||||||
|
claimedCount?: number;
|
||||||
|
availableSeats?: number;
|
||||||
|
}): boolean {
|
||||||
|
return eventSpotsLeft(event) <= 0;
|
||||||
|
}
|
||||||
|
|||||||
+18
-4
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -18,9 +22,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next-build/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -4,12 +4,16 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"description": "Spanglish - Language Exchange Event Platform",
|
"description": "Spanglish - Language Exchange Event Platform",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\" \"npm run dev:photos\"",
|
||||||
"dev:backend": "npm run dev --workspace=backend",
|
"dev:backend": "npm run dev --workspace=backend",
|
||||||
"dev:frontend": "npm run dev --workspace=frontend",
|
"dev:frontend": "npm run dev --workspace=frontend",
|
||||||
|
"dev:photos": "cd photo-api && go run ./cmd/photo-api",
|
||||||
"build": "npm run build --workspaces",
|
"build": "npm run build --workspaces",
|
||||||
"build:backend": "npm run build --workspace=backend",
|
"build:backend": "npm run build --workspace=backend",
|
||||||
"build:frontend": "npm run build --workspace=frontend",
|
"build:frontend": "npm run build --workspace=frontend",
|
||||||
|
"build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api",
|
||||||
|
"test:photos": "cd photo-api && go test ./...",
|
||||||
|
"migrate:photos": "cd photo-api && go run ./cmd/photo-api migrate",
|
||||||
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
|
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
|
||||||
"start:backend": "npm run start --workspace=backend",
|
"start:backend": "npm run start --workspace=backend",
|
||||||
"start:frontend": "npm run start --workspace=frontend",
|
"start:frontend": "npm run start --workspace=frontend",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Spanglish photo-api configuration
|
||||||
|
# Copy to .env — the service loads it on start (systemd also passes it via
|
||||||
|
# EnvironmentFile). This service has its own .env; nothing is shared with
|
||||||
|
# backend/.env except the VALUES of JWT_SECRET and DATABASE_URL.
|
||||||
|
|
||||||
|
# Port (dev default 3003; the systemd unit overrides to 3020 in production)
|
||||||
|
PORT=3003
|
||||||
|
|
||||||
|
# Database — the SAME database the backend uses. photo-api owns only the
|
||||||
|
# photos_* tables (created by `photo-api migrate`) and reads users/events/
|
||||||
|
# tickets/payments for auth and access checks.
|
||||||
|
# DB_TYPE must match the backend's engine: postgres | sqlite
|
||||||
|
DB_TYPE=postgres
|
||||||
|
DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db
|
||||||
|
# For sqlite instead:
|
||||||
|
# DB_TYPE=sqlite
|
||||||
|
# DATABASE_URL=../backend/data/spanglish.db
|
||||||
|
|
||||||
|
# MUST be identical to JWT_SECRET in backend/.env — photo-api validates the
|
||||||
|
# same HS256 tokens the backend issues.
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Public site origin, used to build share links and allow dev CORS
|
||||||
|
FRONTEND_URL=https://spanglishcommunity.com
|
||||||
|
|
||||||
|
# Photo storage. Local disk by default; setting BOTH S3_ENDPOINT and
|
||||||
|
# S3_BUCKET switches to S3 (same convention as the backend's media storage).
|
||||||
|
# Use a photos-specific bucket — do not reuse the backend's media bucket.
|
||||||
|
STORAGE_PATH=./data/photos
|
||||||
|
#S3_ENDPOINT=
|
||||||
|
#S3_REGION=auto
|
||||||
|
#S3_BUCKET=spanglish-photos
|
||||||
|
#S3_ACCESS_KEY_ID=
|
||||||
|
#S3_SECRET_ACCESS_KEY=
|
||||||
|
#S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# Upload and processing tuning
|
||||||
|
MAX_UPLOAD_MB=50
|
||||||
|
WORKER_CONCURRENCY=2
|
||||||
|
|
||||||
|
# HEIC conversion shells out to a host-installed CLI (autodetects `vips`
|
||||||
|
# then `heif-convert`; Debian: apt install libvips-tools). Set to override.
|
||||||
|
#HEIC_CONVERTER=
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Secondary deployment path (the primary is bare-metal systemd, see
|
||||||
|
# deploy/spanglish-photos.service). Built for the docker-compose "scale"
|
||||||
|
# setup or any container host.
|
||||||
|
#
|
||||||
|
# docker build -t spanglish-photo-api photo-api/
|
||||||
|
# docker run --env-file photo-api/.env -p 3020:3020 spanglish-photo-api
|
||||||
|
|
||||||
|
FROM golang:1.26 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /photo-api ./cmd/photo-api
|
||||||
|
|
||||||
|
# debian-slim rather than distroless: HEIC conversion needs the libvips CLI.
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libvips-tools ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /photo-api /usr/local/bin/photo-api
|
||||||
|
RUN useradd -r -u 10001 photoapi && mkdir -p /data/photos && chown photoapi /data/photos
|
||||||
|
USER photoapi
|
||||||
|
ENV STORAGE_PATH=/data/photos
|
||||||
|
EXPOSE 3020
|
||||||
|
ENTRYPOINT ["/usr/local/bin/photo-api"]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.PHONY: start build test migrate clean help
|
||||||
|
|
||||||
|
BINARY := bin/photo-api
|
||||||
|
CMD := ./cmd/photo-api
|
||||||
|
|
||||||
|
help: ## Show available targets
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \
|
||||||
|
awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
start: ## Run the photo-api server (go run)
|
||||||
|
go run $(CMD)
|
||||||
|
|
||||||
|
build: ## Build binary to bin/photo-api
|
||||||
|
go build -o $(BINARY) $(CMD)
|
||||||
|
|
||||||
|
test: ## Run all Go tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
migrate: ## Apply pending photos_* migrations
|
||||||
|
go run $(CMD) migrate
|
||||||
|
|
||||||
|
clean: ## Remove built binary
|
||||||
|
rm -rf bin
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
# Photo API — Implementation Plan
|
||||||
|
|
||||||
|
Event photo galleries for the Spanglish platform, built as a standalone Go service in `photo-api/`.
|
||||||
|
Written 2026-07-24 after a read-only investigation of the repo.
|
||||||
|
|
||||||
|
> **Revised 2026-07-24 after review.** Decisions from the review that amend the sections below:
|
||||||
|
> 1. **Both Postgres and SQLite are supported from day 1**, mirroring the backend's `DB_TYPE`/`DATABASE_URL` convention. Consequence: the dedicated `photos` Postgres schema is dropped (SQLite has no schemas); instead every table carries a **`photos_` name prefix** in both engines, which still cannot collide with any Drizzle-owned table name. Migrations are dual-dialect (`*.pg.sql` / `*.sqlite.sql`), SQLite driver is `modernc.org/sqlite` (pure Go, keeps the cgo-free static binary), and queue claiming uses `FOR UPDATE SKIP LOCKED` on Postgres and a plain claim-UPDATE on SQLite.
|
||||||
|
> 2. Deployment stays **systemd-first with the Dockerfile as the secondary path** (confirmed).
|
||||||
|
> 3. **Free events**: any `confirmed`/`checked_in` ticket grants access when the event's `price = 0`; paid events still require a `payments.status='paid'` row (§7 SQL updated).
|
||||||
|
> 4. Admin nav keeps the name **"Photos"** alongside the existing "Gallery" item (confirmed).
|
||||||
|
> 5. **HEIC is supported**, not rejected: uploads sniffed as HEIC are converted to JPEG for the variant pipeline by shelling out to a host-installed converter (`vips` from libvips-tools, falling back to `heif-convert`); the Go binary stays cgo-free and the original `.heic` is stored byte-identical for download. If no converter is installed, HEIC uploads fail with a clear 415 and a startup warning is logged.
|
||||||
|
> 6. Photo admin surface is **`admin`/`organizer` only** (confirmed).
|
||||||
|
> 7. **Local disk and S3 both supported from day 1** (was already planned).
|
||||||
|
> 8. The service has **its own `photo-api/.env`** (was already planned).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Investigation summary (what the codebase actually is)
|
||||||
|
|
||||||
|
**Database.** Drizzle schema lives in one file, `backend/src/db/schema.ts`, with *dual* SQLite/Postgres table definitions selected at runtime by `DB_TYPE` (bottom-of-file conditional exports, lines ~716–738). `drizzle.config.ts` + `npm run db:generate` exist, but the migration that actually runs is the hand-written idempotent script `backend/src/db/migrate.ts` (`npm run db:migrate`), which is **not** run automatically at server start. The local `backend/.env` sets `DB_TYPE=postgres` with `DATABASE_URL=postgresql://spanglish:...@localhost:5432/spanglish_db` (driver: `pg` via `drizzle-orm/node-postgres`, everything in the `public` schema). Relevant tables:
|
||||||
|
|
||||||
|
- `events`: `id` (uuid in PG), `slug`, bilingual title/description fields, `start_datetime`, `status` (`draft|published|unlisted|cancelled|completed|archived`), `banner_url`, …
|
||||||
|
- `tickets`: `id`, `booking_id`, `user_id`, `event_id`, attendee fields, `status` (`pending|confirmed|cancelled|checked_in|on_hold`), `is_guest`, …
|
||||||
|
- `payments`: one row per ticket (`ticket_id` FK), `status` (`pending|pending_approval|paid|refunded|failed|cancelled|on_hold`), `provider` (`bancard|lightning|cash|bank_transfer|tpago`), `paid_at`.
|
||||||
|
|
||||||
|
**A ticket is "paid" when its `payments` row has `status='paid'`** (set in `backend/src/routes/payments.ts` on admin approval/PATCH, and in `backend/src/routes/lnbits.ts` for Lightning), at which point the ticket itself is set to `confirmed`. "Paid" lives on the payment, not the ticket.
|
||||||
|
|
||||||
|
**Auth.** Stateless JWT, library `jose`, **HS256** with the shared symmetric secret `JWT_SECRET`, issuer `spanglish`, audience `spanglish-app`, 1-day expiry (`backend/src/lib/auth.ts`, `createToken` lines 236–246). Payload: `{ sub: userId, email, role, tokenVersion, iat, exp }`. Transport is `Authorization: Bearer <token>`; the frontend stores it in `localStorage` under `spanglish-token` (`frontend/src/lib/api/client.ts`). Roles live in a single `users.role` column: `admin|organizer|staff|marketing|user`; enforcement is `requireAuth(roles?)` (`auth.ts:332`), and media/admin routes use `['admin','organizer']`. Revocation is DB-backed: `users.token_version` must match the token's `tokenVersion` claim, and `account_status` must be `active`. **A Go service can validate tokens independently** with `JWT_SECRET` + HS256 + iss/aud checks; honoring revocation additionally needs one indexed read of `users`. (There is a `user_sessions` table but it is a session *list*, not the auth mechanism.)
|
||||||
|
|
||||||
|
**Backend conventions.** Hono on Node (`@hono/node-server`), entry `backend/src/index.ts`. One Hono sub-router per domain in `src/routes/*`, mounted under `/api/<domain>`; shared helpers in `src/lib/*`; no controller/service layering. Validation: zod via `@hono/zod-validator`. Config: `dotenv/config` + ad-hoc `process.env.*` with inline defaults, no env schema. Responses: success is the resource keyed by name (`{ media, url }`, `{ contacts: [...] }`) or `{ message }`; errors are `{ error: string }` with the status as the second arg; central `app.onError` → `{ error: 'Internal Server Error' }` and `app.notFound` → `{ error: 'Not Found' }`.
|
||||||
|
|
||||||
|
**Existing media code (do not touch).** `backend/src/routes/media.ts` (mounted at `/api/media`): `POST /api/media/upload` (auth `['admin','organizer']`, Hono `parseBody`, magic-byte sniffing, UUID filename), plus GET/DELETE/list. Storage abstraction `backend/src/lib/storage.ts`: local `./uploads` flat dir (served at `/uploads/*` from `index.ts:1877`) or S3 when `S3_ENDPOINT` + `S3_BUCKET` are set (`S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_PUBLIC_URL`, `S3_FORCE_PATH_STYLE`). One generic `media` table (`id, file_url, type, related_id, related_type, created_at`). **No image resizing anywhere** (no sharp). Note: despite the feature name, there is no gallery *table* or backend gallery feature — `frontend/src/app/admin/gallery/page.tsx` is an admin page that uploads via `/api/media` with `relatedType: 'gallery'`. Event flyers/banners are just `events.banner_url` strings pointing at `/uploads/...`.
|
||||||
|
|
||||||
|
**Frontend.** Next.js **14.2** App Router (`frontend/src/app`), React 18, TS 5.5. Admin shell + nav: `frontend/src/app/admin/layout.tsx` with a `navigationWithRoles` array (Heroicons); protection = `middleware.ts` checking a non-authoritative `spanglish-auth=1` cookie + client-side `useAuth()` guard; the API is the real gate. API calls: `fetchApi` wrapper in `src/lib/api/client.ts` (`API_BASE = NEXT_PUBLIC_API_URL || ''`, Bearer from localStorage); per-domain modules in `src/lib/api/*`. Same-origin `/api/*` and `/uploads/*` are rewritten to `BACKEND_URL` in `next.config.js`. Data fetching: server components with `revalidate` for public SEO pages handing off to `*Client.tsx`; plain `useEffect` in admin (no react-query). Tailwind 3.4 with custom theme (`primary.yellow #FBB82B`, `brand.navy`, Poppins, `rounded-card` 16px, `shadow-card`); custom UI primitives in `src/components/ui/` (`Button`, `Card`, `Input`, `Skeleton` incl. `ImageGridSkeleton`); no shadcn. Custom i18n: `src/i18n/` (en/es JSON) + `LanguageContext` with `t()`. No public gallery page exists today; there is a homepage carousel fed from `public/images/carrousel/`.
|
||||||
|
|
||||||
|
**Infra.** Real deployment is **bare-metal systemd + host nginx**, not Docker: `deploy/spanglish-backend.service` (node `dist/index.js`, port 3018, `EnvironmentFile=backend/.env`, hardened with `ProtectSystem=strict` + `ReadWritePaths` for `uploads`/`data`) and `deploy/spanglish-frontend.service` (`next start -p 3019`). Host nginx (`deploy/front-end_nginx.conf`, `back-end_nginx.conf`, `spanglish_upstreams.conf`) terminates TLS (Let's Encrypt) and routes `/api` and `/uploads` → backend :3018, everything else → frontend :3019; `client_max_body_size 20m`. `deploy/docker-compose.scale.yml` is a documented-as-non-turnkey alternative that references a `backend/Dockerfile` **which does not exist**. **There is no CI at all** (no `.github/`). Secrets come from gitignored per-service `.env` files referenced by `EnvironmentFile=`.
|
||||||
|
|
||||||
|
### Where your description and the repo disagree (flagged, not worked around)
|
||||||
|
|
||||||
|
1. **"Shared Postgres"** — the backend is dual-engine and *defaults* to SQLite. The local `backend/.env` says `DB_TYPE=postgres`, so this plan assumes production is Postgres, but I cannot verify the production host's env from the repo. **If production actually runs SQLite, the DB and ticket-check parts of this plan change materially** (see Risks).
|
||||||
|
2. **"Its own container"** — nothing in this repo is deployed in a container; there are zero Dockerfiles and the compose file is explicitly a non-production starting point. The plan makes the systemd path primary (a static Go binary fits it perfectly) and includes a Dockerfile so the service *can* run as a container in the compose-scale setup.
|
||||||
|
3. **"backend/ already has gallery … logic"** — there is no gallery model in the backend; only the generic `media` table and an admin frontend page. The collision surface is smaller than described: it's the `/api/media` routes, the `/uploads` path/dir, and the `S3_*`/`MEDIA_MAX_UPLOAD_MB` env names. This plan avoids all of them.
|
||||||
|
4. **Ticket "paid" status** lives on the `payments` table, not the ticket — the check must join both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture
|
||||||
|
|
||||||
|
A single Go 1.26 binary (`photo-api`) exposing HTTP under the path prefix **`/api/photos`**, sitting behind the same host nginx, sharing the existing Postgres **database** but owning its own **schema** (`photos`), and validating the existing HS256 JWTs itself.
|
||||||
|
|
||||||
|
Why this shape:
|
||||||
|
|
||||||
|
- **Same-origin path prefix instead of a new subdomain.** The frontend already reaches the backend via same-origin `/api/*` (nginx in prod, `next.config.js` rewrites in dev). Adding one more-specific nginx `location /api/photos/` and one more-specific Next rewrite keeps `fetchApi` working unchanged — no CORS, no new cert, no new env in the browser. Backend mounts (`index.ts:1901-1916`) include no `/api/photos` route, so there is no collision.
|
||||||
|
- **Shared DB, own schema.** The ticket-visibility feature *requires* reading `tickets`/`payments`/`users`; that data lives in `spanglish_db`. Putting photo tables in a `photos` schema in the same database gives one-query access checks, one backup story, and zero drizzle-kit interaction (drizzle introspects/manages only the tables it defines, all in `public`; it never touches other schemas).
|
||||||
|
- **Systemd-first deployment**, mirroring `deploy/spanglish-backend.service`: a new `spanglish-photos.service` running a static binary on port **3020** (prod; 3002 in dev, following the 3018/3019 prod vs 3001/3000 dev split).
|
||||||
|
- **Stdlib `net/http`** with Go 1.22+ method/pattern routing (`mux.HandleFunc("GET /api/photos/...")`). The backend's Hono style — one router per domain, per-route middleware, `{ error }` JSON — maps cleanly onto this without importing a framework. Boring, zero-dep, matches "small service".
|
||||||
|
- **Same response conventions as Hono backend**: resource-keyed success bodies (`{ gallery }`, `{ galleries }`, `{ photos }`), `{ error: "..." }` failures, `Authorization: Bearer` auth, roles `['admin','organizer']` for admin surface — so the frontend treats it exactly like another backend domain module.
|
||||||
|
|
||||||
|
Request path: browser → nginx (`location ^~ /api/photos/`) → photo-api :3020 → Postgres (`photos.*` for its data, read-only use of `public.users/tickets/payments/events` for auth + access checks) → local disk or S3 for bytes.
|
||||||
|
|
||||||
|
## 2. Database: schema and migrations
|
||||||
|
|
||||||
|
**Owned by the Go service. drizzle-kit never sees it.** All photo tables carry a `photos_` name prefix (`photos_galleries`, `photos_photos`, `photos_schema_migrations`) in the same database the backend uses — Postgres or SQLite per `DB_TYPE` — created and migrated by embedded, versioned, dual-dialect SQL files (`migrations/NNNN_*.pg.sql` + `NNNN_*.sqlite.sql`) applied by a ~60-line runner inside the binary (`photo-api migrate` subcommand). This matches the repo's existing convention — the backend also ignores drizzle-kit for application and runs a hand-written migration script manually at deploy time. The runner:
|
||||||
|
|
||||||
|
- on Postgres takes `pg_advisory_lock` on a fixed key (safe if two instances start); SQLite's file lock covers the single-host case,
|
||||||
|
- creates `photos_schema_migrations (version int primary key, applied_at)`,
|
||||||
|
- applies each migration in a transaction, recording versions.
|
||||||
|
|
||||||
|
It is invoked by systemd as `ExecStartPre=/…/photo-api migrate` so deploys stay one-step, while Drizzle's tables are never touched. On SQLite the service opens the same database file as the backend (`WAL` is already enabled by the backend; photo-api sets `busy_timeout` and keeps write transactions short).
|
||||||
|
|
||||||
|
`migrations/0001_init.pg.sql` (the `.sqlite.sql` twin mirrors the backend's SQLite conventions: `text` ids generated in Go, `text` RFC3339 timestamps, `integer` booleans):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE photos_galleries (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug varchar(160) NOT NULL UNIQUE,
|
||||||
|
title varchar(200) NOT NULL,
|
||||||
|
title_es varchar(200),
|
||||||
|
description text,
|
||||||
|
description_es text,
|
||||||
|
event_id uuid, -- references public.events(id); soft reference, no FK (see note)
|
||||||
|
visibility varchar(10) NOT NULL DEFAULT 'private'
|
||||||
|
CHECK (visibility IN ('public','private','link','ticket')),
|
||||||
|
share_token varchar(64) NOT NULL UNIQUE, -- 32 random bytes, base64url; used only for 'link' mode
|
||||||
|
cover_photo_id uuid, -- set after photos exist; soft reference to photos_photos
|
||||||
|
created_by uuid, -- public.users(id); soft reference
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX photos_galleries_event_idx ON photos_galleries (event_id);
|
||||||
|
CREATE INDEX photos_galleries_visibility_idx ON photos_galleries (visibility);
|
||||||
|
|
||||||
|
CREATE TABLE photos_photos (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
gallery_id uuid NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE,
|
||||||
|
position integer NOT NULL DEFAULT 0,
|
||||||
|
original_key text NOT NULL, -- storage key of the untouched original
|
||||||
|
original_filename text,
|
||||||
|
content_type varchar(50) NOT NULL,
|
||||||
|
size_bytes bigint NOT NULL,
|
||||||
|
width integer,
|
||||||
|
height integer,
|
||||||
|
thumb_key text, -- set when variant is ready
|
||||||
|
preview_key text,
|
||||||
|
taken_at timestamptz, -- from EXIF when present
|
||||||
|
status varchar(12) NOT NULL DEFAULT 'queued'
|
||||||
|
CHECK (status IN ('queued','processing','ready','failed')),
|
||||||
|
attempts integer NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_error text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position);
|
||||||
|
CREATE INDEX photos_photos_queue_idx ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed');
|
||||||
|
```
|
||||||
|
|
||||||
|
**No foreign keys into Drizzle-owned tables** (`events`/`users`), deliberately: an FK from `photos_*` into a Drizzle-owned table would make future Drizzle migrations (drop/recreate, type changes) fail in confusing ways — exactly the "two tools fighting" scenario. Referential integrity for `event_id` is enforced in the handler (verify the event exists on create/update); a dangling `event_id` degrades gracefully (ticket-gated gallery just denies access; event card omitted).
|
||||||
|
|
||||||
|
The photo rows themselves are the processing queue (`status/attempts/next_attempt_at`) — no separate jobs table, no Redis. Queue claiming: `FOR UPDATE SKIP LOCKED` on Postgres, plain claim-UPDATE on SQLite (single host there anyway). Query text uses `?` placeholders rebound to `$n` for Postgres by a small helper; ids are generated in Go (`uuid`), timestamps bound as Go `time.Time` (timestamptz on PG, RFC3339 text on SQLite, matching the backend's dual-schema style).
|
||||||
|
|
||||||
|
## 3. API surface
|
||||||
|
|
||||||
|
All routes under `/api/photos`. Auth = `Authorization: Bearer <existing JWT>`. "Admin" = role `admin` or `organizer` (mirrors `backend/src/routes/media.ts`). Errors are `{ error: string }` with 400/401/403/404/413/500.
|
||||||
|
|
||||||
|
### Admin (JWT, role admin|organizer)
|
||||||
|
|
||||||
|
| Method & path | Purpose | Response |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /api/photos/galleries` | Create gallery. Body: `{ title, titleEs?, description?, descriptionEs?, eventId?, visibility }`. Slug generated from title (suffix on collision), `share_token` generated always. | `201 { gallery }` |
|
||||||
|
| `GET /api/photos/galleries` | List all galleries (any visibility) with photo counts, cover thumb URL. Query: `eventId?`, `limit/offset`. | `{ galleries: [...] }` |
|
||||||
|
| `GET /api/photos/galleries/:id` | Full gallery detail incl. photos in position order with per-variant URLs and processing status. | `{ gallery, photos: [...] }` |
|
||||||
|
| `PATCH /api/photos/galleries/:id` | Update title/description/eventId/visibility/coverPhotoId. | `{ gallery }` |
|
||||||
|
| `DELETE /api/photos/galleries/:id` | Delete gallery + photos + stored objects. | `{ message }` |
|
||||||
|
| `POST /api/photos/galleries/:id/photos` | Multipart upload, field `files` (repeatable). Sniffs magic bytes (JPEG/PNG/WebP/GIF/AVIF; explicit `415 { error: "HEIC is not supported, please upload JPEG" }` for HEIC). Stores original, inserts row `status='queued'`, appends at end position. | `201 { photos: [...] }` |
|
||||||
|
| `PATCH /api/photos/galleries/:id/order` | Body `{ photoIds: [uuid,...] }` — full ordering; positions rewritten in one transaction. | `{ message }` |
|
||||||
|
| `DELETE /api/photos/photos/:photoId` | Delete one photo + its objects; compacts positions. | `{ message }` |
|
||||||
|
| `POST /api/photos/galleries/:id/share-token` | Rotate share token (invalidate old links). | `{ gallery }` |
|
||||||
|
| `POST /api/photos/photos/:photoId/retry` | Re-queue a `failed` photo. | `{ photo }` |
|
||||||
|
|
||||||
|
The share link the admin copies is a frontend URL: `https://spanglishcommunity.com/photos/<slug>?token=<share_token>`.
|
||||||
|
|
||||||
|
### Viewer (public / optional JWT)
|
||||||
|
|
||||||
|
| Method & path | Purpose | Response |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/photos/public/galleries` | Public index: `visibility='public'` only, with cover thumb + count + linked-event summary. | `{ galleries: [...] }` |
|
||||||
|
| `GET /api/photos/public/galleries/:slug?token=` | Single gallery incl. `ready` photos in order. Access check per matrix (§7); optional Bearer honored. | `{ gallery, photos: [...] }` or 401/403/404 |
|
||||||
|
| `GET /api/photos/files/:photoId/:variant?token=` | The bytes. `variant ∈ thumb|preview|original`. Same access check as its gallery. Local: streamed with long-lived `Cache-Control` (keyed URLs are stable); S3: 302 to a presigned GET (15 min). `original` adds `Content-Disposition: attachment; filename="<original_filename>"`. | image bytes / 302 |
|
||||||
|
| `GET /api/photos/health` | Liveness (also mapped at `/health` on the port for systemd checks). | `{ status: "ok" }` |
|
||||||
|
|
||||||
|
Photo objects in responses carry ready-to-use URLs (`/api/photos/files/<id>/thumb` etc., with `?token=` appended by the client when in link mode) so the frontend never constructs storage paths.
|
||||||
|
|
||||||
|
## 4. `photo-api/` layout and module setup
|
||||||
|
|
||||||
|
```
|
||||||
|
photo-api/
|
||||||
|
go.mod # module github.com/Michilis/spanglish/photo-api
|
||||||
|
.env.example
|
||||||
|
Dockerfile # secondary path; multi-stage, static binary, distroless
|
||||||
|
cmd/photo-api/main.go # flag/subcommand: serve (default) | migrate
|
||||||
|
migrations/ # embedded SQL (embed.FS)
|
||||||
|
0001_init.sql
|
||||||
|
internal/config/config.go # env parsing with defaults, mirrors backend's ad-hoc style but centralized
|
||||||
|
internal/auth/auth.go # JWT verify (HS256, iss/aud), tokenVersion+account_status check, role helpers
|
||||||
|
internal/store/ # pgx queries: galleries.go, photos.go, access.go (ticket check), migrate.go
|
||||||
|
internal/storage/ # storage.go (interface), local.go, s3.go
|
||||||
|
internal/imaging/ # decode, EXIF orient, resize, encode JPEG
|
||||||
|
internal/worker/worker.go # variant-processing loop
|
||||||
|
internal/httpapi/ # router.go, middleware.go, galleries.go, photos_upload.go, files.go, public.go, respond.go
|
||||||
|
```
|
||||||
|
|
||||||
|
**Plain `go.mod`, no `go.work`.** There is exactly one Go module and no cross-module Go imports, so a workspace file adds nothing. Dependencies (each justified, all boring):
|
||||||
|
|
||||||
|
- `github.com/jackc/pgx/v5` (via its `stdlib` adapter) — canonical Postgres driver; advisory locks.
|
||||||
|
- `modernc.org/sqlite` — pure-Go SQLite driver (no cgo), for `DB_TYPE=sqlite` parity with the backend.
|
||||||
|
- `github.com/golang-jwt/jwt/v5` — HS256 verification of the existing tokens.
|
||||||
|
- `github.com/aws/aws-sdk-go-v2` (s3 + presign) — same SDK family the backend uses for S3; needed for the S3 storage backend and presigned URLs.
|
||||||
|
- `golang.org/x/image` — high-quality scaling (`draw.CatmullRom`).
|
||||||
|
- `github.com/rwcarlsen/goexif` — EXIF orientation + `taken_at`. (Tiny, stable, read-only.)
|
||||||
|
|
||||||
|
**Monorepo integration.** npm workspaces stay `["backend","frontend"]` — a Go dir cannot be a workspace and nothing should pretend it is. Root `package.json` gets convenience scripts only:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"dev:photos": "cd photo-api && go run ./cmd/photo-api",
|
||||||
|
"build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api",
|
||||||
|
"test:photos": "cd photo-api && go test ./..."
|
||||||
|
```
|
||||||
|
|
||||||
|
and root `dev` adds the third `concurrently` entry. `.gitignore` additions: `photo-api/bin/`, `photo-api/data/`, (`.env` already covered). There is **no CI in this repo**, so nothing to wire; if CI is added later, path-filter Go steps on `photo-api/**`. Note: `pnpm-lock.yaml` sits untracked next to the committed `package-lock.json` — unrelated to this feature but worth resolving; scripts above are package-manager-agnostic.
|
||||||
|
|
||||||
|
## 5. Storage abstraction
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Storage interface {
|
||||||
|
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||||
|
Open(ctx context.Context, key string) (io.ReadCloser, int64, error) // local streaming
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
|
// PresignGet returns ("", ErrNoPresign) on local storage; handler then streams via Open.
|
||||||
|
PresignGet(ctx context.Context, key, downloadFilename string, expiry time.Duration) (string, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key layout (both backends): `galleries/<galleryID>/orig/<photoID>.<ext>`, `.../thumb/<photoID>.jpg`, `.../preview/<photoID>.jpg`.
|
||||||
|
|
||||||
|
- **`local.go`** — root dir from `STORAGE_PATH` (default `./data/photos`), `0600` files, atomic write via temp+rename. **Not** `backend/uploads` — completely disjoint tree, and nothing is nginx-static-served: all reads go through the access-checked `/api/photos/files/` handler.
|
||||||
|
- **`s3.go`** — same selection convention as `backend/src/lib/storage.ts`: S3 active when `S3_ENDPOINT` and `S3_BUCKET` are both set; honors `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE` (default true, Garage/MinIO-friendly). Serving uses presigned GETs so private galleries stay private even on S3 — the bucket is never public.
|
||||||
|
|
||||||
|
Env var names deliberately reuse the backend's vocabulary (`S3_*`, `DATABASE_URL`, `JWT_SECRET`, `PORT`) because each systemd service loads its **own** `EnvironmentFile` (`photo-api/.env`) — same conventions, zero collision. The one shared-by-value var is `JWT_SECRET` (must equal the backend's) and `DATABASE_URL` (same DB). Recommend a *different* `S3_BUCKET` (e.g. `spanglish-photos`) than any backend bucket. Full key list in §9.
|
||||||
|
|
||||||
|
## 6. Image processing
|
||||||
|
|
||||||
|
**Library: pure Go for the pipeline, external CLI only for HEIC decode** — stdlib `image/jpeg|png|gif`, `golang.org/x/image` (webp decode, CatmullRom scaler), `goexif`. Reasoning: the real deployment is a hardened systemd unit on a bare host; govips/bimg would add a libvips system dependency, cgo, and non-static binaries, complicating both the host install and the (secondary) container image for a service whose write volume is "admin uploads a batch after an event", not a hot path. Pure Go costs ~1–3 s/photo per variant on large JPEGs — fine for an async queue.
|
||||||
|
|
||||||
|
- **HEIC (required per review): decoded by shelling out**, since no viable pure-Go HEVC decoder exists. The worker converts HEIC→JPEG (quality 95, temp file in the service's data dir) via the first available of: `HEIC_CONVERTER` (explicit path/command), `vips copy` (Debian package `libvips-tools`), `heif-convert` (package `libheif-examples`), then feeds the JPEG into the normal pure-Go pipeline. The Go binary itself stays cgo-free/static; the converter is a runtime host dependency (added to the systemd host setup and the Dockerfile — which therefore uses `debian:bookworm-slim` rather than distroless). Missing converter ⇒ HEIC uploads get a clear 415 and startup logs a warning. Original `.heic` is stored byte-identical and served for download with its real content type. The sniffer recognizes `ftyp` brands `heic|heix|hevc|hevx|mif1|msf1`.
|
||||||
|
- AVIF: accepted at upload only if decodable; stdlib has no AVIF decoder, so in practice AVIF is rejected with an explicit message (can ride the same converter escape hatch later if needed).
|
||||||
|
|
||||||
|
**Variants** (originals stored byte-identical, never touched):
|
||||||
|
|
||||||
|
| Variant | Long edge | Format | Quality | Purpose |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `thumb` | 512 px | JPEG | 78 | grid |
|
||||||
|
| `preview` | 2048 px | JPEG | 85 | lightbox |
|
||||||
|
| `original` | — | as uploaded | — | download |
|
||||||
|
|
||||||
|
Re-encoding variants strips EXIF (including GPS — a privacy win for published pages); orientation is applied to pixels first. `width/height/taken_at` recorded on the row from the original.
|
||||||
|
|
||||||
|
**Queue/retry:** the `photos.photos` row is the job. Worker = single goroutine pool (size `WORKER_CONCURRENCY`, default 2) polling with `SELECT ... WHERE status IN ('queued','failed') AND attempts < 5 AND next_attempt_at <= now() ORDER BY created_at LIMIT n FOR UPDATE SKIP LOCKED`, plus an in-process channel nudge after each upload so the poll interval (10 s) is only a fallback. On error: `attempts+1`, exponential `next_attempt_at`, `last_error`; after 5 attempts stays `failed` and the admin UI shows a retry button (`/photos/:id/retry`). `FOR UPDATE SKIP LOCKED` makes multi-instance safe for free. Crash recovery: on startup, rows stuck in `processing` older than 10 min are re-queued.
|
||||||
|
|
||||||
|
## 7. Access control matrix
|
||||||
|
|
||||||
|
Caller types: **anon** (no/invalid JWT), **member** (valid JWT, any role), **ticket-holder** (member with paid ticket for the gallery's linked event), **link-holder** (anyone presenting the gallery's `share_token`), **admin** (JWT role `admin|organizer`).
|
||||||
|
|
||||||
|
| Visibility | anon | member | ticket-holder | link-holder | admin |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `public` | ✅ view/download | ✅ | ✅ | ✅ | ✅ + manage |
|
||||||
|
| `private` | ❌ 404 | ❌ 404 | ❌ 404 | ❌ 404 (token ignored) | ✅ + manage |
|
||||||
|
| `link` | ❌ 404 without token | ❌ 404 without token | ❌ 404 without token | ✅ with valid token | ✅ + manage |
|
||||||
|
| `ticket` | ❌ 401 | ❌ 403 | ✅ | valid token also grants ✅ (link is a superset escape hatch; token exists on every gallery but is only honored for `link` and `ticket` modes) | ✅ + manage |
|
||||||
|
|
||||||
|
- Listing: only `public` galleries ever appear in `/api/photos/public/galleries`. `link`/`ticket`/`private` are unlisted; non-public misses return **404, not 403**, so gallery existence isn't probeable (exception: `ticket` mode returns 401/403 so the frontend can prompt login/purchase — the gallery's existence is intentionally discoverable via its event page).
|
||||||
|
- Enforcement lives in **one function**, `store.Authorize(ctx, gallery, caller, token)`, called in exactly two places: the gallery-view handler and the **file handler** — every byte served re-checks; there are no capability URLs except the explicit share token, and S3 presigned URLs are short-lived (15 min) so a leaked presign expires.
|
||||||
|
- **Ticket check** (the `ticket` row + `payments` join, per how "paid" actually works here):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT 1
|
||||||
|
FROM tickets t
|
||||||
|
JOIN events e ON e.id = t.event_id
|
||||||
|
LEFT JOIN payments p ON p.ticket_id = t.id
|
||||||
|
WHERE t.user_id = ? AND t.event_id = ?
|
||||||
|
AND t.status IN ('confirmed','checked_in')
|
||||||
|
AND (p.status = 'paid' OR e.price = 0) -- review decision: free events need only a confirmed ticket
|
||||||
|
LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Chosen over the alternative (forwarding the user's JWT to the backend's existing `GET /api/dashboard/tickets`, `backend/src/routes/dashboard.ts:98`) because: we are already in the same database for our own tables, it is one indexed query instead of an HTTP hop + JSON parse, it has no runtime dependency on the backend being up, and it needs zero backend changes. The cost is read-coupling to four `public` columns (`tickets.user_id/event_id/status`, `payments.ticket_id/status`) — mitigated by confining every `public.*` query to `internal/store/access.go` and a startup sanity check that those columns exist (fail loud, not wrong).
|
||||||
|
- **JWT verification** in Go: HS256 + `iss=spanglish` + `aud=spanglish-app`, then one `SELECT role, token_version, account_status FROM public.users WHERE id=$1` to enforce the same revocation semantics as `getAuthUser` (`backend/src/lib/auth.ts:320-327`). Skipping the DB check would silently weaken logout-everywhere/suspension for photo routes; since we're in the DB anyway, we match the backend exactly.
|
||||||
|
|
||||||
|
## 8. Frontend work
|
||||||
|
|
||||||
|
New files (all following existing patterns — `fetchApi`, `useLanguage().t`, Tailwind theme, `ui/` primitives):
|
||||||
|
|
||||||
|
- `src/lib/api/photos.ts` — API module (types + calls for every endpoint in §3), re-exported from `src/lib/api/index.ts`.
|
||||||
|
- **Admin** — `src/app/admin/photos/page.tsx`: gallery list (cards: cover thumb, title, visibility badge, event, count; create-gallery modal). `src/app/admin/photos/[id]/page.tsx`: single gallery manager — multi-file upload (input + drag-drop, per-file progress, `status` polling for processing), thumbnail grid with drag-to-reorder (HTML5 DnD, no new dep; persists via `PATCH .../order`), visibility selector, event linker (reuses events list API), copy-share-link button (`navigator.clipboard`, `react-hot-toast`), set-cover, delete, retry-failed. Modeled on `admin/gallery/page.tsx` + `admin/events/[id]/` structurally.
|
||||||
|
- **Public** — `src/app/(public)/photos/page.tsx`: server component (fetch public index, `revalidate: 300`, metadata) → `PhotosIndexClient.tsx` grid. `src/app/(public)/photos/[slug]/page.tsx`: server component fetching the gallery *without* token (public galleries get SSR/SEO; link/ticket galleries fall through) → `GalleryClient.tsx` which re-fetches client-side with `?token=` from `useSearchParams()` and the Bearer token, and renders: masonry-ish thumb grid (`ImageGridSkeleton` while loading), lightbox (new shared `src/components/Lightbox.tsx` — full-screen `fixed inset-0 bg-black/90`, keyboard/swipe nav, preview variant, download button → original URL; pattern extracted from the inline modal in `admin/gallery/page.tsx`), download-original per photo, ticket-mode gates (401 → login redirect with `?redirect=`, 403 → "this gallery is for attendees" message linking the event).
|
||||||
|
- i18n: new `photos.*` and `admin.nav.photos` keys in `src/i18n/locales/{en,es}.json`.
|
||||||
|
|
||||||
|
Existing files changed (complete list):
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/app/admin/layout.tsx` | one entry in `navigationWithRoles`: `{ name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin','organizer'] }` (CameraIcon, since PhotoIcon is taken by the existing Gallery item) |
|
||||||
|
| `next.config.js` | prepend rewrite `{ source: '/api/photos/:path*', destination: PHOTO_API_URL + '/api/photos/:path*' }` **before** the generic `/api/:path*` rule (rewrites are order-sensitive); `PHOTO_API_URL` default `http://localhost:3003` |
|
||||||
|
| `src/lib/api/index.ts` | re-export `photos` module |
|
||||||
|
| `src/i18n/locales/en.json`, `es.json` | new keys |
|
||||||
|
| `src/app/sitemap.ts` | add `/photos` + public gallery slugs (nice-to-have, phase 7) |
|
||||||
|
|
||||||
|
Naming note: the admin nav will then contain both **"Gallery"** (existing homepage-media page at `/admin/gallery`) and **"Photos"** (new). Requirement says "Photos"; flagged in Questions since admins may find the pair confusing.
|
||||||
|
|
||||||
|
## 9. Deployment and config
|
||||||
|
|
||||||
|
New files in `deploy/` (mirroring existing ones; no existing file is *replaced* — the two nginx confs get additive location blocks):
|
||||||
|
|
||||||
|
- `deploy/spanglish-photos.service` — copy of the backend unit shape: user `spanglish`, `ExecStartPre=…/photo-api/bin/photo-api migrate`, `ExecStart=…/photo-api/bin/photo-api`, `EnvironmentFile=…/photo-api/.env`, `PORT=3020`, hardening incl. `ReadWritePaths=…/photo-api/data`.
|
||||||
|
- `deploy/spanglish_upstreams.conf` — add `upstream spanglish_photos { server 127.0.0.1:3020; }`.
|
||||||
|
- `deploy/front-end_nginx.conf` — add, above `location /api`: `location ^~ /api/photos/ { proxy_pass http://spanglish_photos; client_max_body_size 100m; proxy_request_buffering off; }` (uploads are big; the vhost default is 20m). Same block in `deploy/back-end_nginx.conf` if the api subdomain should serve it too.
|
||||||
|
- `photo-api/Dockerfile` — secondary/container path: `golang:1.26` build stage (`CGO_ENABLED=0`), `gcr.io/distroless/static` runtime; and a commented `photos` service example for `deploy/docker-compose.scale.yml` (compose file itself untouched until that path is actually used).
|
||||||
|
|
||||||
|
`photo-api/.env.example`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3003 # dev; systemd unit overrides to 3020
|
||||||
|
DB_TYPE=postgres # postgres | sqlite — must match the backend's engine
|
||||||
|
DATABASE_URL=postgresql://... # SAME database as backend (photos_* tables created by migrate);
|
||||||
|
# for sqlite: path to the backend's db file, e.g. ../backend/data/spanglish.db
|
||||||
|
JWT_SECRET= # MUST equal backend/.env JWT_SECRET
|
||||||
|
HEIC_CONVERTER= # optional; default autodetects `vips`, then `heif-convert`
|
||||||
|
STORAGE_PATH=./data/photos # local storage root (ignored when S3 is on)
|
||||||
|
S3_ENDPOINT= # + S3_BUCKET both set => S3 backend (same convention as backend)
|
||||||
|
S3_REGION=auto
|
||||||
|
S3_BUCKET= # use a photos-specific bucket
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
MAX_UPLOAD_MB=50 # per file
|
||||||
|
WORKER_CONCURRENCY=2
|
||||||
|
FRONTEND_URL=https://spanglishcommunity.com # for building share links
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend `.env` gains `PHOTO_API_URL=http://localhost:3003` (dev; prod nginx routes directly so the rewrite is unused there, matching how `/api` works today).
|
||||||
|
|
||||||
|
## 10. Build sequence
|
||||||
|
|
||||||
|
1. **Scaffold + DB.** `go.mod`, config, `cmd/photo-api` with `serve`/`migrate`, migration runner + `0001_init.sql`, `/health`, root `package.json` scripts. *Test: `photo-api migrate` creates `photos.*` against the dev DB; `psql \dn` shows the schema; re-run is a no-op; `drizzle-kit generate` still produces no diff.*
|
||||||
|
2. **Auth + gallery CRUD.** JWT middleware (incl. `token_version` check), all `/galleries` admin endpoints. *Test: curl with a real token from the running backend login; non-admin gets 403; CRUD round-trips.*
|
||||||
|
3. **Upload + local storage + worker.** Multipart handler, sniffing, `LocalStorage`, imaging pipeline, queue/retry, `files/:id/:variant` for admins. *Test: upload a batch of real photos incl. a portrait-orientation iPhone JPEG (EXIF rotation) and a deliberately corrupt file; variants appear; corrupt file lands in `failed` with `last_error`; retry works.*
|
||||||
|
4. **Access control + public endpoints.** `Authorize`, ticket-check query, `public/galleries`, `public/galleries/:slug`, token handling, presign-vs-stream in file handler. *Test: matrix in §7 as a Go table test against seeded DB rows (paid/pending/refunded payments), plus curl for each caller type.*
|
||||||
|
5. **S3 backend.** `S3Storage` + presigned GETs, verified against a local MinIO/Garage. *Test: same upload/download flows with `S3_*` set; private gallery file URL fails without presign.*
|
||||||
|
6. **Admin frontend.** `/admin/photos` pages, nav item, api module, i18n. *Test: create → upload → reorder → visibility → copy link, in-browser against dev services.*
|
||||||
|
7. **Public frontend.** `/photos`, `/photos/[slug]`, Lightbox, download, gated states, sitemap. *Test: all four visibility modes in-browser, incl. link URL in an incognito window and ticket mode with a paid vs pending test user.*
|
||||||
|
8. **Deploy.** systemd unit, nginx blocks, `.env.example`s, Dockerfile, README section in `photo-api/`. *Test: on the host — `systemctl start spanglish-photos`, nginx reload, end-to-end over HTTPS.*
|
||||||
|
|
||||||
|
## 11. Risks and open questions
|
||||||
|
|
||||||
|
**Risks / could-not-determine:**
|
||||||
|
|
||||||
|
- **Production DB engine is unverifiable from the repo** (local `backend/.env` says `DB_TYPE=postgres`; prod env is gitignored). Mitigated by the review decision to support both engines — whichever prod runs, photo-api points at the same database with matching `DB_TYPE`. On SQLite, two processes share one file: WAL is already on, photo-api adds `busy_timeout` and short write transactions, but write contention under heavy simultaneous booking + photo-processing load is a residual (small) risk.
|
||||||
|
- **Schema read-coupling.** The ticket/user queries read five `public` columns Drizzle owns. A future rename breaks photo access checks at runtime; mitigated by the startup column sanity-check (fail fast) and by the queries living in one file.
|
||||||
|
- **Free events.** If a PYG-0 event issues `confirmed` tickets whose payment row is not `'paid'` (e.g. no payment or `cash`/`pending`), the paid-ticket join denies attendees. I could not confirm from the code what a free booking writes into `payments`. May need `OR (e.price = 0 AND t.status IN ('confirmed','checked_in'))`.
|
||||||
|
- **Guest attendees** (`tickets.is_guest`, tickets bought by someone else): access is keyed on `tickets.user_id`, so a +1 without their own account cannot pass `ticket` gating. Workaround exists (admin shares the link token), but it's a product gap to acknowledge.
|
||||||
|
- **HEIC depends on a host-installed converter** (`vips` or `heif-convert`). If the prod host lacks the package, HEIC uploads 415 until it's installed; startup logs a warning so this is visible, and the deploy notes include the `apt install libvips-tools` step.
|
||||||
|
- **Nginx body size / timeouts** for 100 MB batch uploads over spotty Paraguayan mobile connections; `proxy_request_buffering off` planned, but real-world testing is in phase 8.
|
||||||
|
- **1-day JWT expiry with no working refresh flow** (refresh tokens are minted but no endpoint consumes them) means long admin upload sessions can hit 401 mid-batch; the admin UI must surface that as "session expired, log in again" rather than a generic failure.
|
||||||
|
|
||||||
|
**Questions — answered in review 2026-07-24** (see the revision note at the top): dual Postgres+SQLite support; systemd-first confirmed; free events count any confirmed ticket; nav name "Photos" kept; HEIC supported via converter shell-out; `admin`/`organizer` only; local disk and S3 both supported from day 1; the service keeps its own `.env`. The production engine check (and, if Postgres, connectivity for the `spanglish` role) is still on the user's side.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# photo-api
|
||||||
|
|
||||||
|
Standalone Go service for event photo galleries: admins upload photos from
|
||||||
|
past events, group them into galleries, and share them with the community.
|
||||||
|
Lives in this monorepo but is its own module, binary, and deployable unit.
|
||||||
|
Design/decisions: [PLAN.md](./PLAN.md).
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Galleries with four visibility modes: `public` (listed on /photos),
|
||||||
|
`private` (admins only), `link` (share-token URL), `ticket` (logged-in
|
||||||
|
users with a confirmed ticket for the linked event — any confirmed ticket
|
||||||
|
when the event is free).
|
||||||
|
- Originals are stored byte-identical for download; a worker generates JPEG
|
||||||
|
variants (thumb 512px q78, preview 2048px q85, EXIF stripped/orientation
|
||||||
|
applied) queued in the DB with retries.
|
||||||
|
- Storage: local disk (`STORAGE_PATH`) or S3/Garage/MinIO (set `S3_ENDPOINT`
|
||||||
|
+ `S3_BUCKET`), same selection convention as the backend. S3 downloads use
|
||||||
|
short-lived presigned URLs; nothing in the bucket is public.
|
||||||
|
- Auth: validates the backend's HS256 JWTs with the shared `JWT_SECRET`
|
||||||
|
(issuer `spanglish`, audience `spanglish-app`) including the DB-backed
|
||||||
|
tokenVersion/account-status revocation check. Admin surface is
|
||||||
|
`admin`/`organizer` only.
|
||||||
|
- Database: the same Postgres or SQLite database as the backend (`DB_TYPE`,
|
||||||
|
`DATABASE_URL`). This service owns only the `photos_*` tables via its own
|
||||||
|
embedded migrations (`photo-api migrate`); drizzle-kit never sees them.
|
||||||
|
Reads of backend tables are confined to `internal/store/access.go` and
|
||||||
|
sanity-checked at startup.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # set JWT_SECRET + DATABASE_URL to match backend/.env
|
||||||
|
go run ./cmd/photo-api migrate
|
||||||
|
go run ./cmd/photo-api # serves on :3003 (dev)
|
||||||
|
go test ./... # SQLite; add PHOTO_TEST_PG=<url> to also run on Postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
From the repo root: `npm run dev:photos`, `npm run build:photos`,
|
||||||
|
`npm run test:photos`. The Next dev server rewrites `/api/photos/*` to
|
||||||
|
`PHOTO_API_URL` (default `http://localhost:3003`), so the frontend needs no
|
||||||
|
extra config in dev.
|
||||||
|
|
||||||
|
HEIC uploads require a converter CLI on the host: `apt install libvips-tools`
|
||||||
|
(or `libheif-examples`). Without one, HEIC uploads are rejected with a clear
|
||||||
|
message and a startup warning is logged.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Everything under `/api/photos`. Errors are `{"error": string}`.
|
||||||
|
|
||||||
|
Admin (Bearer token, role admin/organizer):
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/photos/galleries` | create gallery |
|
||||||
|
| GET | `/api/photos/galleries` | list all (`?eventId=`) |
|
||||||
|
| GET | `/api/photos/galleries/:id` | gallery + photos (all statuses) |
|
||||||
|
| PATCH | `/api/photos/galleries/:id` | update title/visibility/event/cover |
|
||||||
|
| DELETE | `/api/photos/galleries/:id` | delete gallery + objects |
|
||||||
|
| POST | `/api/photos/galleries/:id/photos` | multipart upload (`files`) |
|
||||||
|
| PATCH | `/api/photos/galleries/:id/order` | reorder (`{photoIds}`) |
|
||||||
|
| POST | `/api/photos/galleries/:id/share-token` | rotate share token |
|
||||||
|
| DELETE | `/api/photos/photos/:photoId` | delete photo |
|
||||||
|
| POST | `/api/photos/photos/:photoId/retry` | requeue failed photo |
|
||||||
|
|
||||||
|
Viewer (anonymous or member token; `?token=` carries the share token):
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/photos/public/galleries` | public index |
|
||||||
|
| GET | `/api/photos/public/galleries/:slug` | gallery view (access-checked) |
|
||||||
|
| GET | `/api/photos/public/events/:eventSlug/gallery` | newest gallery of an event (access-checked) |
|
||||||
|
| GET | `/api/photos/files/:photoId/:variant` | bytes; `thumb\|preview\|original` |
|
||||||
|
| GET | `/api/photos/health` | liveness |
|
||||||
|
|
||||||
|
## Deploy (systemd, primary)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd photo-api && go build -o bin/photo-api ./cmd/photo-api
|
||||||
|
sudo apt install libvips-tools
|
||||||
|
sudo cp ../deploy/spanglish-photos.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl enable --now spanglish-photos
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx # after installing the updated confs
|
||||||
|
```
|
||||||
|
|
||||||
|
nginx routes `location ^~ /api/photos/` → `127.0.0.1:3020` (see
|
||||||
|
`deploy/front-end_nginx.conf`, `deploy/back-end_nginx.conf`,
|
||||||
|
`deploy/spanglish_upstreams.conf`). The frontend's production `.env` should
|
||||||
|
set `PHOTO_API_URL=http://127.0.0.1:3020` for server-side rendering of the
|
||||||
|
public gallery pages. A `Dockerfile` is included as a secondary path for the
|
||||||
|
compose-scale setup.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// photo-api serves event photo galleries for the Spanglish platform.
|
||||||
|
//
|
||||||
|
// photo-api start the HTTP server
|
||||||
|
// photo-api migrate apply pending photos_* migrations and exit
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
||||||
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
|
||||||
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/httpapi"
|
||||||
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging"
|
||||||
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
||||||
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||||
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||||
|
log.SetPrefix("photo-api: ")
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
db, err := store.Open(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "migrate" {
|
||||||
|
if err := db.Migrate(context.Background()); err != nil {
|
||||||
|
log.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("migrations up to date")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
log.Fatalf("unknown subcommand %q (expected: migrate)", os.Args[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
log.Fatalf("database ping: %v", err)
|
||||||
|
}
|
||||||
|
// Fail fast if the columns read from Drizzle-owned tables changed.
|
||||||
|
if err := db.CheckMainSchema(ctx); err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := storage.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("storage: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.S3Enabled() {
|
||||||
|
log.Printf("storage: S3 bucket %s at %s", cfg.S3Bucket, cfg.S3Endpoint)
|
||||||
|
} else {
|
||||||
|
log.Printf("storage: local disk at %s", cfg.StoragePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
heic := imaging.DetectHeicConverter(cfg.HeicConverter)
|
||||||
|
if heic == nil {
|
||||||
|
log.Println("WARNING: no HEIC converter found (install libvips-tools or libheif-examples); HEIC uploads will be rejected")
|
||||||
|
} else {
|
||||||
|
log.Printf("HEIC converter: %s", heic.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfg.StoragePath, 0o755); err != nil {
|
||||||
|
log.Fatalf("storage path: %v", err)
|
||||||
|
}
|
||||||
|
wrk := worker.New(db, st, heic, cfg.StoragePath, cfg.WorkerConcurrency)
|
||||||
|
wrk.Run(ctx)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||||
|
Handler: httpapi.New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk).Handler(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
log.Printf("listening on :%d", cfg.Port)
|
||||||
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("serve: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Println("shutting down")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Printf("shutdown: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
module git.azzamo.net/Michilis/Spanglish/photo-api
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.43.0
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.31
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.30
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||||
|
golang.org/x/image v0.44.0
|
||||||
|
modernc.org/sqlite v1.54.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect
|
||||||
|
github.com/aws/smithy-go v1.27.3 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.40.0 // indirect
|
||||||
|
modernc.org/libc v1.74.1 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user