38 Commits

Author SHA1 Message Date
c0315a705d Merge pull request 'Add human-readable event URL slugs with legacy redirect support.' (#21) from new-slugs into main
Reviewed-on: #21
2026-06-05 04:16:13 +00:00
Michilis
1b2463f4bc Add human-readable event URL slugs with legacy redirect support.
Store unique slugs on events, backfill existing records, redirect old UUID and alias URLs to canonical slug pages, and expose slug editing plus alias management in the admin event modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 04:09:05 +00:00
fbc437a670 Merge pull request 'Fix booking flow scroll position on mobile step changes.' (#20) from dev into main
Reviewed-on: #20
2026-06-05 04:06:39 +00:00
Michilis
d09c87a5a5 Fix booking flow scroll position on mobile step changes.
Scroll to the top whenever the booking step changes so users are not left at the bottom of the page after submitting the form.
2026-06-05 02:52:14 +00:00
e0f0700398 Merge pull request 'dev' (#19) from dev into main
Reviewed-on: #19
2026-06-04 23:37:15 +00:00
Michilis
69768077e5 Add search and event filters to admin email logs.
Let admins find logs by recipient or subject and narrow results by event on the Email Logs tab.
2026-06-04 23:35:53 +00:00
Michilis
ecd2a7d009 fix(legal): align bullet list markers with list text
Use list-outside and left padding so ReactMarkdown and TipTap list items render markers beside text instead of on separate lines.
2026-06-04 22:58:31 +00:00
Michilis
0f7573c934 Cap event page ticket quantity at 5 per booking.
Limit the quantity stepper to five tickets or remaining spots, whichever is lower.
2026-06-04 22:53:39 +00:00
Michilis
a8b72b47b1 fix(legal): show legal pages in site language with bidirectional toggle
Wire legal pages to LanguageContext, pass locale on footer/booking links,
translate layout chrome, and restore SSR content when switching back to English.
2026-06-04 22:51:32 +00:00
defd9685e0 Merge pull request 'dev' (#18) from dev into main
Reviewed-on: #18
2026-04-27 20:42:36 +00:00
Michilis
22e9254f42 fix(tickets): use integer 1 for isGuest in admin guest invite
Postgres is_guest column is integer; passing JS boolean true caused
"invalid input syntax for type integer" 500 on POST /api/tickets/admin/guest.

Made-with: Cursor
2026-04-27 20:41:58 +00:00
Michilis
2cabd8c92f fix(admin): avoid rendering stray 0 for non-guest tickets on isGuest
Made-with: Love
2026-04-27 18:21:10 +00:00
Michilis
622bb5171c fix(admin): hide pending-approval payments for past events
Made-with: Cursor
2026-04-27 18:14:15 +00:00
Michilis
55516ef1e7 chore: ignore .agents directory and skills-lock.json
Made-with: Cursor
2026-04-27 18:14:15 +00:00
Michilis
3dfb1689ad Booking flow: required terms and privacy checkbox with i18n
Also includes admin, dashboard, and API updates; PWA icon assets; and
assorted layout and utility changes on dev.
2026-04-27 03:21:15 +00:00
1ed62b0d3f Merge pull request 'Fix db:export ENOBUFS by streaming pg_dump output to file' (#17) from dev into main
Reviewed-on: #17
2026-03-12 19:18:57 +00:00
Michilis
f8ebc3760d Fix db:export ENOBUFS by streaming pg_dump output to file
Made-with: Cursor
2026-03-12 19:18:24 +00:00
91de6df04d Merge pull request 'feat(emails): add re-send for all emails, failed tab, and resend indicators' (#16) from dev into main
Reviewed-on: #16
2026-03-12 19:14:36 +00:00
Michilis
4da26e7ef1 feat(emails): add re-send for all emails, failed tab, and resend indicators
- Add resend_attempts and last_resent_at to email_logs schema and migrations
- Add POST /api/emails/logs/:id/resend and emailService.resendFromLog
- Add resendLog API and EmailLog.resendAttempts/lastResentAt
- Add All/Failed sub-tabs, resend button for all emails, re-sent indicator in logs and detail modal

Made-with: Cursor
2026-03-12 19:13:24 +00:00
a5d97d65e1 Merge pull request 'Admin: stats privacy toggle, clickable event rows, fix payment method display' (#15) from dev into main
Reviewed-on: #15
2026-03-10 01:14:36 +00:00
Michilis
e09ff4ed60 Admin: stats privacy toggle, clickable event rows, fix payment method display
- Add useStatsPrivacy hook with localStorage persistence for stats visibility
- Single event page: desktop privacy button, hide capacity chip when stats hidden
- Events list: row/card click navigates to event detail; stopPropagation on actions
- Backend GET /tickets: include payment for each ticket (removes N+1)
- Bookings page: use list payment data, show — when payment method unknown

Made-with: Cursor
2026-03-10 01:10:42 +00:00
f0128f66b0 Merge pull request 'Bug fixes and improvements' (#14) from dev into main
Reviewed-on: #14
2026-03-07 22:53:13 +00:00
Michilis
2f45966932 Add db:export and db:import for database backups
Made-with: Cursor
2026-03-07 19:44:27 +00:00
Michilis
7c1fdbf382 Add Spanglish icon image
Made-with: Cursor
2026-03-07 19:41:16 +00:00
Michilis
596ec71191 Fix stale social media preview: revalidate next-event fetch, reject past featured events
Made-with: Cursor
2026-03-07 19:36:12 +00:00
Michilis
25b7018743 Bookings/payments/linktree: fix payment method display, event filter, logo, search
- Bookings: align payment method labels with payments page (bank_transfer, tpago, etc), add sibling fallback
- Payments: add event filter (single/multi), add search by name/email/event
- Linktree: use Spanglish logo instead of icon
- API: payments getAll supports eventId/eventIds

Made-with: Cursor
2026-03-07 18:06:35 +00:00
b33c68feb0 Merge pull request 'dev' (#13) from dev into main
Reviewed-on: #13
2026-02-19 02:23:19 +00:00
Michilis
bbfaa1172a Add unlisted event status: hidden from listings but accessible by URL
- Backend: add 'unlisted' to schema enum and Zod validation; allow booking for unlisted events
- Frontend: Event type and guards updated; unlisted events bookable, excluded from public listing/sitemap
- Admin: badge, status dropdown, Make Unlisted / Make Public / Unpublish actions; scanner/emails/tickets include unlisted

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 02:21:41 +00:00
Michilis
958181e049 Mobile-friendly admin pages, redesigned homepage Next Event card
- Extract shared mobile components (BottomSheet, MoreMenu, Dropdown, etc.) into MobileComponents.tsx
- Make admin pages mobile-friendly: bookings, emails, events, faq, payments, tickets, users
- Redesign homepage Next Event card with banner image, responsive layout, and updated styling
- Fix past events showing on homepage/linktree: use proper Date comparison, auto-unfeature expired events
- Add "Over" tag to admin events list for past events
- Fix backend FRONTEND_URL for cache revalidation

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 03:27:49 +00:00
15655e3987 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-02-16 23:11:52 +00:00
Michilis
5263fa6834 Make llms.txt always fetch fresh data from the backend
- Switch from tag-based caching to cache: no-store for all backend fetches
- Add dynamic = force-dynamic to prevent Next.js static caching
- Ensures llms.txt always reflects the current featured event and FAQ data

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 23:10:33 +00:00
Michilis
923c86a3b3 Fix FRONTEND_URL pointing to wrong port, breaking cache revalidation
- Update FRONTEND_URL default from localhost:3002 to localhost:3019 (actual frontend port)
- Reorder systemd service so EnvironmentFile loads before Environment overrides

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 22:53:59 +00:00
d8b3864411 Merge pull request 'Fix stale featured event on homepage: revalidate cache when featured event changes' (#11) from dev into main
Reviewed-on: #11
2026-02-16 22:44:19 +00:00
Michilis
4aaffe99c7 Fix stale featured event on homepage: revalidate cache when featured event changes
- Extract revalidateFrontendCache() to backend/src/lib/revalidate.ts
- Call revalidation from site-settings when featuredEventId is set/cleared
- Ensures homepage shows updated featured event after admin changes

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 22:42:55 +00:00
194cbd6ca8 Merge pull request 'Scanner: close button on valid ticket, camera lifecycle fix' (#10) from dev into main
Reviewed-on: #10
2026-02-14 19:04:42 +00:00
Michilis
a11da5a977 Scanner: close button on valid ticket, camera lifecycle fix
- Add X close button on valid ticket screen to dismiss without check-in
- Rewrite QRScanner: full unmount when leaving Scan tab, stop MediaStream tracks
- Remount scanner via key when tab active; no hidden DOM
- Use 100dvh for mobile height; force layout reflow after camera start
- visibilitychange handler for tab suspend/resume

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 19:03:29 +00:00
d5445c2282 Merge pull request 'Admin event page: redesign UI, export endpoints, mobile fixes' (#9) from dev into main
Reviewed-on: #9
2026-02-14 18:38:57 +00:00
Michilis
6bc7e13e78 Admin event page: redesign UI, export endpoints, mobile fixes
- Backend: Add /events/:eventId/attendees/export and /events/:eventId/tickets/export with q/status; legacy redirect for old export path
- API: exportAttendees q param, new exportTicketsCSV for tickets CSV
- Admin event page: unified tabs+content container, portal dropdowns to fix clipping, separate mobile export/add-ticket sheets (fix double menu), responsive tab bar and card layout

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 18:27:27 +00:00
63 changed files with 5251 additions and 2909 deletions

2
.gitignore vendored
View File

@@ -37,6 +37,8 @@ backend/uploads/
# Tooling # Tooling
.turbo/ .turbo/
.cursor/ .cursor/
.agents/
skills-lock.json
.npm-cache/ .npm-cache/
# OS # OS

View File

@@ -64,6 +64,8 @@ npm run start
npm run db:generate npm run db:generate
npm run db:migrate npm run db:migrate
npm run db:studio npm run db:studio
npm run db:export # Backup database
npm run db:import # Restore from backup
``` ```
You can also run per workspace: You can also run per workspace:
@@ -117,6 +119,25 @@ Then run:
npm run db:migrate npm run db:migrate
``` ```
### Backups (export / import)
Create backups and restore if needed:
```bash
# Export (creates timestamped file in backend/data/backups/)
npm run db:export
# Export to custom path
npm run db:export -- -o ./my-backup.db # SQLite
npm run db:export -- -o ./my-backup.sql # PostgreSQL
# Import (stop the backend server first)
npm run db:import -- ./data/backups/spanglish-2025-03-07-143022.db
npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip confirmation
```
**Note:** Stop the backend before importing so the database file is not locked.
## Production deployment (nginx + systemd) ## Production deployment (nginx + systemd)
This repo includes example configs in `deploy/`: This repo includes example configs in `deploy/`:

View File

@@ -19,7 +19,7 @@ GOOGLE_CLIENT_ID=
# Server Configuration # Server Configuration
PORT=3001 PORT=3001
API_URL=http://localhost:3001 API_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3002 FRONTEND_URL=http://localhost:3019
# Revalidation secret (shared with frontend for on-demand cache revalidation) # Revalidation secret (shared with frontend for on-demand cache revalidation)
# Must match the REVALIDATE_SECRET in frontend/.env # Must match the REVALIDATE_SECRET in frontend/.env

View File

@@ -8,7 +8,9 @@
"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",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio",
"db:export": "tsx src/db/export.ts",
"db:import": "tsx src/db/import.ts"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.11.4", "@hono/node-server": "^1.11.4",

100
backend/src/db/export.ts Normal file
View File

@@ -0,0 +1,100 @@
import 'dotenv/config';
import { closeSync, existsSync, mkdirSync, openSync } from 'fs';
import { dirname, resolve } from 'path';
import { spawnSync } from 'child_process';
import Database from 'better-sqlite3';
const dbType = process.env.DB_TYPE || 'sqlite';
const dbPath = process.env.DATABASE_URL || './data/spanglish.db';
const BACKUP_DIR = resolve(process.cwd(), 'data', 'backups');
function parseArgs(): { output?: string } {
const args = process.argv.slice(2);
const result: { output?: string } = {};
for (let i = 0; i < args.length; i++) {
if (args[i] === '-o' || args[i] === '--output') {
result.output = args[i + 1];
i++;
}
}
return result;
}
function getTimestamp(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const h = String(now.getHours()).padStart(2, '0');
const min = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d}-${h}${min}${s}`;
}
function exportSqlite(outputPath: string): void {
const db = new Database(resolve(process.cwd(), dbPath), { readonly: true });
try {
db.backup(outputPath);
console.log(`Exported to ${outputPath}`);
} finally {
db.close();
}
}
function exportPostgres(outputPath: string): void {
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
const outFd = openSync(outputPath, 'w');
try {
const result = spawnSync(
'pg_dump',
['--clean', '--if-exists', connString],
{
stdio: ['ignore', outFd, 'pipe'],
encoding: 'utf-8',
}
);
if (result.error) {
console.error('pg_dump failed. Ensure pg_dump is installed and in PATH.');
console.error(result.error.message);
process.exit(1);
}
if (result.status !== 0) {
console.error('pg_dump failed:', result.stderr);
process.exit(1);
}
console.log(`Exported to ${outputPath}`);
} finally {
closeSync(outFd);
}
}
async function main() {
const { output } = parseArgs();
const ext = dbType === 'postgres' ? '.sql' : '.db';
const defaultName = `spanglish-${getTimestamp()}${ext}`;
const outputPath = output
? resolve(process.cwd(), output)
: resolve(BACKUP_DIR, defaultName);
const dir = dirname(outputPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
console.log(`Database type: ${dbType}`);
if (dbType === 'sqlite') {
exportSqlite(outputPath);
} else {
exportPostgres(outputPath);
}
process.exit(0);
}
main().catch((err) => {
console.error('Export failed:', err);
process.exit(1);
});

91
backend/src/db/import.ts Normal file
View File

@@ -0,0 +1,91 @@
import 'dotenv/config';
import { copyFileSync, existsSync, readFileSync } from 'fs';
import { resolve } from 'path';
import { spawnSync } from 'child_process';
const dbType = process.env.DB_TYPE || 'sqlite';
const dbPath = process.env.DATABASE_URL || './data/spanglish.db';
function parseArgs(): { file?: string; yes?: boolean } {
const args = process.argv.slice(2);
const result: { file?: string; yes?: boolean } = {};
for (let i = 0; i < args.length; i++) {
if (args[i] === '-y' || args[i] === '--yes') {
result.yes = true;
} else if (!args[i].startsWith('-')) {
result.file = args[i];
}
}
return result;
}
function importSqlite(backupPath: string): void {
const targetPath = resolve(process.cwd(), dbPath);
copyFileSync(backupPath, targetPath);
console.log(`Restored from ${backupPath} to ${targetPath}`);
}
function importPostgres(backupPath: string): void {
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
const sql = readFileSync(backupPath, 'utf-8');
const result = spawnSync(
'psql',
[connString],
{
stdio: ['pipe', 'inherit', 'inherit'],
input: sql,
}
);
if (result.error) {
console.error('psql failed. Ensure psql is installed and in PATH.');
console.error(result.error.message);
process.exit(1);
}
if (result.status !== 0) {
process.exit(1);
}
console.log(`Restored from ${backupPath}`);
}
async function main() {
const { file, yes } = parseArgs();
if (!file) {
console.error('Usage: npm run db:import -- <backup-file> [--yes]');
console.error('Example: npm run db:import -- ./data/backups/spanglish-2025-03-07.db');
process.exit(1);
}
const backupPath = resolve(process.cwd(), file);
if (!existsSync(backupPath)) {
console.error(`Backup file not found: ${backupPath}`);
process.exit(1);
}
if (!yes) {
console.log('WARNING: Import will overwrite the current database.');
console.log('Stop the backend server before importing.');
console.log('Press Ctrl+C to cancel, or run with --yes to skip this warning.');
await new Promise((r) => setTimeout(r, 3000));
}
console.log(`Database type: ${dbType}`);
if (dbType === 'sqlite') {
importSqlite(backupPath);
} else if (dbType === 'postgres') {
importPostgres(backupPath);
} else {
console.error('Unknown DB_TYPE. Use sqlite or postgres.');
process.exit(1);
}
process.exit(0);
}
main().catch((err) => {
console.error('Import failed:', err);
process.exit(1);
});

View File

@@ -1,6 +1,7 @@
import 'dotenv/config'; import 'dotenv/config';
import { db } from './index.js'; import { db, dbAll, events } from './index.js';
import { sql } from 'drizzle-orm'; import { sql, eq } from 'drizzle-orm';
import { uniqueSlug } from '../lib/slugify.js';
const dbType = process.env.DB_TYPE || 'sqlite'; const dbType = process.env.DB_TYPE || 'sqlite';
console.log(`Database type: ${dbType}`); console.log(`Database type: ${dbType}`);
@@ -111,6 +112,23 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`); await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Add slug column to events (backfilled below)
try {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN slug TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`CREATE UNIQUE INDEX IF NOT EXISTS events_slug_unique ON events(slug)`);
} catch (e) { /* index may already exist */ }
// Historical slugs that still resolve (and redirect) to an event's canonical slug
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS event_slug_aliases (
slug TEXT PRIMARY KEY,
event_id TEXT NOT NULL REFERENCES events(id),
created_at TEXT NOT NULL
)
`);
await (db as any).run(sql` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -173,6 +191,11 @@ async function migrate() {
try { try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`); await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Migration: Add is_guest column to tickets
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// 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
@@ -368,6 +391,13 @@ async function migrate() {
) )
`); `);
try {
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TEXT`);
} catch (e) { /* column may already exist */ }
await (db as any).run(sql` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS email_settings ( CREATE TABLE IF NOT EXISTS email_settings (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -526,8 +556,8 @@ async function migrate() {
description_es TEXT, description_es TEXT,
short_description VARCHAR(300), short_description VARCHAR(300),
short_description_es VARCHAR(300), short_description_es VARCHAR(300),
start_datetime TIMESTAMP NOT NULL, start_datetime TIMESTAMPTZ NOT NULL,
end_datetime TIMESTAMP, end_datetime TIMESTAMPTZ,
location VARCHAR(500) NOT NULL, location VARCHAR(500) NOT NULL,
location_url VARCHAR(500), location_url VARCHAR(500),
price DECIMAL(10, 2) NOT NULL DEFAULT 0, price DECIMAL(10, 2) NOT NULL DEFAULT 0,
@@ -558,6 +588,32 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`); await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Migrate event datetime columns from TIMESTAMP to TIMESTAMPTZ for
// unambiguous UTC storage (eliminates pg driver timezone interpretation).
try {
await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN start_datetime TYPE TIMESTAMPTZ USING start_datetime AT TIME ZONE 'UTC'`);
} catch (e) { /* already timestamptz or other issue */ }
try {
await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN end_datetime TYPE TIMESTAMPTZ USING end_datetime AT TIME ZONE 'UTC'`);
} catch (e) { /* already timestamptz or other issue */ }
// Add slug column to events (backfilled below)
try {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN slug VARCHAR(255)`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS events_slug_unique ON events(slug)`);
} catch (e) { /* index may already exist */ }
// Historical slugs that still resolve (and redirect) to an event's canonical slug
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS event_slug_aliases (
slug VARCHAR(255) PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(id),
created_at TIMESTAMP NOT NULL
)
`);
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -592,6 +648,11 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`); await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Migration: Add is_guest column to tickets
try {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
} 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,
@@ -772,6 +833,13 @@ async function migrate() {
) )
`); `);
try {
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TIMESTAMP`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS email_settings ( CREATE TABLE IF NOT EXISTS email_settings (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -862,6 +930,43 @@ async function migrate() {
`); `);
} }
// Backfill slugs for any events that don't have one yet (shared across DB types).
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
(db as any).select().from(events).orderBy((events as any).createdAt)
);
const assignedSlugs: string[] = allEvents
.filter((e) => e.slug)
.map((e) => e.slug as string);
let backfilled = 0;
for (const ev of allEvents) {
if (ev.slug) continue;
const slug = uniqueSlug(ev.title || 'event', assignedSlugs);
assignedSlugs.push(slug);
await (db as any).update(events).set({ slug }).where(eq((events as any).id, ev.id));
backfilled++;
}
if (backfilled > 0) {
console.log(`Backfilled slugs for ${backfilled} event(s).`);
// Bust the frontend cache so the homepage / sitemap pick up the new slugs
// immediately instead of serving stale (pre-slug) data for up to the
// revalidate window. Awaited (not fire-and-forget) so it runs before exit.
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const secret = process.env.REVALIDATE_SECRET;
if (secret) {
try {
const res = await fetch(`${frontendUrl}/api/revalidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
});
console.log(res.ok ? 'Frontend cache revalidated.' : `Frontend revalidation returned ${res.status}.`);
} catch (e: any) {
console.warn('Frontend revalidation skipped (frontend not reachable):', e?.message || e);
}
}
}
console.log('Migrations completed successfully!'); console.log('Migrations completed successfully!');
process.exit(0); process.exit(0);
} }

View File

@@ -62,6 +62,7 @@ export const sqliteInvoices = sqliteTable('invoices', {
export const sqliteEvents = sqliteTable('events', { export const sqliteEvents = sqliteTable('events', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
slug: text('slug').unique(),
title: text('title').notNull(), title: text('title').notNull(),
titleEs: text('title_es'), titleEs: text('title_es'),
description: text('description').notNull(), description: text('description').notNull(),
@@ -75,7 +76,7 @@ export const sqliteEvents = sqliteTable('events', {
price: real('price').notNull().default(0), price: real('price').notNull().default(0),
currency: text('currency').notNull().default('PYG'), currency: text('currency').notNull().default('PYG'),
capacity: integer('capacity').notNull().default(50), capacity: integer('capacity').notNull().default(50),
status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'), status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
bannerUrl: text('banner_url'), bannerUrl: text('banner_url'),
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false), externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
externalBookingUrl: text('external_booking_url'), externalBookingUrl: text('external_booking_url'),
@@ -83,6 +84,13 @@ export const sqliteEvents = sqliteTable('events', {
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
// Historical slugs that still resolve (and redirect) to an event's canonical slug
export const sqliteEventSlugAliases = sqliteTable('event_slug_aliases', {
slug: text('slug').primaryKey(),
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
createdAt: text('created_at').notNull(),
});
export const sqliteTickets = sqliteTable('tickets', { export const sqliteTickets = sqliteTable('tickets', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
bookingId: text('booking_id'), // Groups multiple tickets from same booking bookingId: text('booking_id'), // Groups multiple tickets from same booking
@@ -99,6 +107,7 @@ export const sqliteTickets = sqliteTable('tickets', {
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
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),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
}); });
@@ -243,6 +252,8 @@ export const sqliteEmailLogs = sqliteTable('email_logs', {
sentAt: text('sent_at'), sentAt: text('sent_at'),
sentBy: text('sent_by').references(() => sqliteUsers.id), sentBy: text('sent_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
resendAttempts: integer('resend_attempts').notNull().default(0),
lastResentAt: text('last_resent_at'),
}); });
export const sqliteEmailSettings = sqliteTable('email_settings', { export const sqliteEmailSettings = sqliteTable('email_settings', {
@@ -384,14 +395,15 @@ export const pgInvoices = pgTable('invoices', {
export const pgEvents = pgTable('events', { export const pgEvents = pgTable('events', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
slug: varchar('slug', { length: 255 }).unique(),
title: varchar('title', { length: 255 }).notNull(), title: varchar('title', { length: 255 }).notNull(),
titleEs: varchar('title_es', { length: 255 }), titleEs: varchar('title_es', { length: 255 }),
description: pgText('description').notNull(), description: pgText('description').notNull(),
descriptionEs: pgText('description_es'), descriptionEs: pgText('description_es'),
shortDescription: varchar('short_description', { length: 300 }), shortDescription: varchar('short_description', { length: 300 }),
shortDescriptionEs: varchar('short_description_es', { length: 300 }), shortDescriptionEs: varchar('short_description_es', { length: 300 }),
startDatetime: timestamp('start_datetime').notNull(), startDatetime: timestamp('start_datetime', { withTimezone: true }).notNull(),
endDatetime: timestamp('end_datetime'), endDatetime: timestamp('end_datetime', { withTimezone: true }),
location: varchar('location', { length: 500 }).notNull(), location: varchar('location', { length: 500 }).notNull(),
locationUrl: varchar('location_url', { length: 500 }), locationUrl: varchar('location_url', { length: 500 }),
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'), price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
@@ -405,6 +417,13 @@ export const pgEvents = pgTable('events', {
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
// Historical slugs that still resolve (and redirect) to an event's canonical slug
export const pgEventSlugAliases = pgTable('event_slug_aliases', {
slug: varchar('slug', { length: 255 }).primaryKey(),
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
createdAt: timestamp('created_at').notNull(),
});
export const pgTickets = pgTable('tickets', { export const pgTickets = pgTable('tickets', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
bookingId: uuid('booking_id'), // Groups multiple tickets from same booking bookingId: uuid('booking_id'), // Groups multiple tickets from same booking
@@ -421,6 +440,7 @@ export const pgTickets = pgTable('tickets', {
checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in
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),
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
}); });
@@ -557,6 +577,8 @@ export const pgEmailLogs = pgTable('email_logs', {
sentAt: timestamp('sent_at'), sentAt: timestamp('sent_at'),
sentBy: uuid('sent_by').references(() => pgUsers.id), sentBy: uuid('sent_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
resendAttempts: pgInteger('resend_attempts').notNull().default(0),
lastResentAt: timestamp('last_resent_at'),
}); });
export const pgEmailSettings = pgTable('email_settings', { export const pgEmailSettings = pgTable('email_settings', {
@@ -643,6 +665,7 @@ export const pgSiteSettings = pgTable('site_settings', {
// Export the appropriate schema based on DB_TYPE // Export the appropriate schema based on DB_TYPE
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers; export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents; export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets; export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments; export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts; export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;

View File

@@ -1342,6 +1342,61 @@ export const emailService = {
error: result.error error: result.error
}; };
}, },
/**
* Resend an email from an existing log entry
*/
async resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
const log = await dbGet<any>(
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
);
if (!log) {
return { success: false, error: 'Email log not found' };
}
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
return { success: false, error: 'Email log missing required data to resend' };
}
const result = await sendEmail({
to: log.recipientEmail,
subject: log.subject,
html: log.bodyHtml,
text: undefined,
});
const now = getNow();
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: now,
errorMessage: null,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
error: result.error,
};
},
}; };
// Export the main sendEmail function for direct use // Export the main sendEmail function for direct use

View File

@@ -0,0 +1,22 @@
// Trigger frontend cache revalidation (fire-and-forget)
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
export function revalidateFrontendCache() {
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const secret = process.env.REVALIDATE_SECRET;
if (!secret) {
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
return;
}
fetch(`${frontendUrl}/api/revalidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
})
.then((res) => {
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
else console.log('Frontend revalidation triggered (sitemap + next-event)');
})
.catch((err) => {
console.error('Frontend revalidation error:', err.message);
});
}

View File

@@ -0,0 +1,27 @@
/**
* Convert a title into a URL-safe slug.
* Lowercases, strips accents, replaces non-alphanumerics with hyphens,
* collapses repeated hyphens, and trims leading/trailing hyphens.
*/
export function slugify(title: string): string {
return title
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Generate a slug from a title that does not collide with any of the
* provided existing slugs. Appends -2, -3, ... when needed.
*/
export function uniqueSlug(title: string, existingSlugs: string[]): string {
const base = slugify(title) || 'event';
const taken = new Set(existingSlugs);
if (!taken.has(base)) return base;
let n = 2;
while (taken.has(`${base}-${n}`)) n++;
return `${base}-${n}`;
}

View File

@@ -41,6 +41,49 @@ export function toDbDate(date: Date | string): string | Date {
return getDbType() === 'postgres' ? d : d.toISOString(); return getDbType() === 'postgres' ? d : d.toISOString();
} }
const NAIVE_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
/**
* Parse a datetime string that represents wall-clock time in a given timezone
* and return the corresponding UTC Date.
*
* Naive strings (no "Z" or offset) are interpreted as wall-clock time in
* `timezone`. Strings that already carry a timezone indicator are parsed
* directly so existing UTC ISO values still work.
*/
export function parseEventDatetime(
datetime: string,
timezone: string = 'America/Asuncion',
): Date {
if (!NAIVE_DATETIME_RE.test(datetime)) {
return new Date(datetime);
}
// Treat the digits as UTC so we have a stable reference instant.
const fakeUTC = new Date(datetime + 'Z');
// Ask Intl what that UTC instant looks like in both UTC and the target tz.
const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' });
const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone });
// The gap between the two tells us the tz offset at this point in time.
const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
return new Date(fakeUTC.getTime() + offsetMs);
}
/**
* Convert a datetime string to the appropriate DB format, interpreting naive
* strings as wall-clock time in `timezone` (defaults to America/Asuncion).
*/
export function toDbDateTz(
datetime: string,
timezone: string = 'America/Asuncion',
): string | Date {
const d = parseEventDatetime(datetime, timezone);
return getDbType() === 'postgres' ? d : d.toISOString();
}
/** /**
* Convert a boolean value to the appropriate format for the database type. * Convert a boolean value to the appropriate format for the database type.
* - SQLite: returns boolean (true/false) for mode: 'boolean' * - SQLite: returns boolean (true/false) for mode: 'boolean'

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js'; import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
import { eq, and, 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';
@@ -129,7 +129,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
.where( .where(
and( and(
eq((tickets as any).eventId, event.id), eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed') eq((tickets as any).status, 'confirmed'),
ne((tickets as any).isGuest, 1)
) )
) )
); );
@@ -141,7 +142,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
.where( .where(
and( and(
eq((tickets as any).eventId, event.id), eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'checked_in') eq((tickets as any).status, 'checked_in'),
ne((tickets as any).isGuest, 1)
) )
) )
); );
@@ -222,11 +224,11 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
return c.json({ tickets: enrichedTickets }); return c.json({ tickets: enrichedTickets });
}); });
// Export attendees for a specific event (admin) — CSV/XLSX download // Export attendees for a specific event (admin) — CSV download
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => { adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
const eventId = c.req.param('eventId'); const eventId = c.req.param('eventId');
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
const format = c.req.query('format') || 'csv'; // csv | xlsx const q = c.req.query('q') || '';
// Verify event exists // Verify event exists
const event = await dbGet<any>( const event = await dbGet<any>(
@@ -249,14 +251,28 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) =>
// "all" — include everything // "all" — include everything
} }
const ticketList = await dbAll<any>( let ticketList = await dbAll<any>(
(db as any) (db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(conditions.length === 1 ? conditions[0] : and(...conditions)) .where(conditions.length === 1 ? conditions[0] : and(...conditions))
.orderBy((tickets as any).createdAt) .orderBy(desc((tickets as any).createdAt))
); );
// Apply text search filter in-memory
if (q) {
const query = q.toLowerCase();
ticketList = ticketList.filter((t: any) => {
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
return (
fullName.includes(query) ||
(t.attendeeEmail || '').toLowerCase().includes(query) ||
(t.attendeePhone || '').toLowerCase().includes(query) ||
t.id.toLowerCase().includes(query)
);
});
}
// Enrich each ticket with payment data // Enrich each ticket with payment data
const rows = await Promise.all( const rows = await Promise.all(
ticketList.map(async (ticket: any) => { ticketList.map(async (ticket: any) => {
@@ -274,10 +290,12 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) =>
'Ticket ID': ticket.id, 'Ticket ID': ticket.id,
'Full Name': fullName, 'Full Name': fullName,
'Email': ticket.attendeeEmail || '', 'Email': ticket.attendeeEmail || '',
'Phone': ticket.attendeePhone || '',
'Status': ticket.status, 'Status': ticket.status,
'Checked In': isCheckedIn ? 'true' : 'false', 'Checked In': isCheckedIn ? 'true' : 'false',
'Check-in Time': ticket.checkinAt || '', 'Check-in Time': ticket.checkinAt || '',
'Payment Status': payment?.status || '', 'Payment Status': payment?.status || '',
'Booked At': ticket.createdAt || '',
'Notes': ticket.adminNote || '', 'Notes': ticket.adminNote || '',
}; };
}) })
@@ -294,9 +312,9 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) =>
}; };
const columns = [ const columns = [
'Ticket ID', 'Full Name', 'Email', 'Ticket ID', 'Full Name', 'Email', 'Phone',
'Status', 'Checked In', 'Check-in Time', 'Payment Status', 'Status', 'Checked In', 'Check-in Time', 'Payment Status',
'Notes', 'Booked At', 'Notes',
]; ];
const headerLine = columns.map(csvEscape).join(','); const headerLine = columns.map(csvEscape).join(',');
@@ -319,6 +337,98 @@ adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) =>
return c.body(csvContent); return c.body(csvContent);
}); });
// Legacy alias — keep old path working
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => {
const newUrl = new URL(c.req.url);
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
return c.redirect(newUrl.toString(), 301);
});
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => {
const eventId = c.req.param('eventId');
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
const q = c.req.query('q') || '';
// Verify event exists
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Only confirmed/checked_in for tickets export
let conditions: any[] = [
eq((tickets as any).eventId, eventId),
inArray((tickets as any).status, ['confirmed', 'checked_in']),
];
if (status === 'confirmed') {
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'confirmed')];
} else if (status === 'checked_in') {
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'checked_in')];
}
let ticketList = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(and(...conditions))
.orderBy(desc((tickets as any).createdAt))
);
// Apply text search filter
if (q) {
const query = q.toLowerCase();
ticketList = ticketList.filter((t: any) => {
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
return (
fullName.includes(query) ||
t.id.toLowerCase().includes(query)
);
});
}
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
const rows = ticketList.map((ticket: any) => ({
'Ticket ID': ticket.id,
'Booking ID': ticket.bookingId || '',
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
'Status': ticket.status,
'Check-in Time': ticket.checkinAt || '',
'Booked At': ticket.createdAt || '',
}));
const headerLine = columns.map(csvEscape).join(',');
const dataLines = rows.map((row: any) =>
columns.map((col: string) => csvEscape(row[col])).join(',')
);
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n');
const slug = (event.title || 'event')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const dateStr = new Date().toISOString().split('T')[0];
const filename = `${slug}-tickets-${dateStr}.csv`;
c.header('Content-Type', 'text/csv; charset=utf-8');
c.header('Content-Disposition', `attachment; filename="${filename}"`);
return c.body(csvContent);
});
// Export financial data (admin) // Export financial data (admin)
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => { adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
const startDate = c.req.query('startDate'); const startDate = c.req.query('startDate');

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js'; import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
import { eq, desc, and, sql } from 'drizzle-orm'; import { eq, desc, and, or, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js'; import { getNow, generateId } from '../lib/utils.js';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
@@ -287,6 +287,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');
const status = c.req.query('status'); const status = c.req.query('status');
const search = c.req.query('search');
const limit = parseInt(c.req.query('limit') || '50'); const limit = parseInt(c.req.query('limit') || '50');
const offset = parseInt(c.req.query('offset') || '0'); const offset = parseInt(c.req.query('offset') || '0');
@@ -299,6 +300,14 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
if (status) { if (status) {
conditions.push(eq((emailLogs as any).status, status)); conditions.push(eq((emailLogs as any).status, status));
} }
if (search && search.trim()) {
const term = `%${search.trim().toLowerCase()}%`;
conditions.push(or(
sql`LOWER(${(emailLogs as any).recipientEmail}) LIKE ${term}`,
sql`LOWER(COALESCE(${(emailLogs as any).recipientName}, '')) LIKE ${term}`,
sql`LOWER(${(emailLogs as any).subject}) LIKE ${term}`,
));
}
if (conditions.length > 0) { if (conditions.length > 0) {
query = query.where(and(...conditions)); query = query.where(and(...conditions));
@@ -349,6 +358,23 @@ emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) =>
return c.json({ log }); return c.json({ log });
}); });
// Resend email from log
emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param();
const result = await emailService.resendFromLog(id);
if (!result.success && result.error === 'Email log not found') {
return c.json({ error: 'Email log not found' }, 404);
}
if (!result.success && result.error === 'Email log missing required data to resend') {
return c.json({ error: result.error }, 400);
}
return c.json({ success: result.success, error: result.error });
});
// Get email stats // Get email stats
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');

View File

@@ -1,10 +1,12 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js'; import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js';
import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { eq, desc, and, gte, sql } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
import { slugify, uniqueSlug } from '../lib/slugify.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -15,29 +17,6 @@ interface UserContext {
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>(); const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Trigger frontend cache revalidation (fire-and-forget)
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
function revalidateFrontendCache() {
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const secret = process.env.REVALIDATE_SECRET;
if (!secret) {
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
return;
}
fetch(`${frontendUrl}/api/revalidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
})
.then((res) => {
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
else console.log('Frontend revalidation triggered (sitemap + next-event)');
})
.catch((err) => {
console.error('Frontend revalidation error:', err.message);
});
}
// Helper to normalize event data for API response // Helper to normalize event data for API response
// PostgreSQL decimal returns strings, booleans are stored as integers // PostgreSQL decimal returns strings, booleans are stored as integers
function normalizeEvent(event: any) { function normalizeEvent(event: any) {
@@ -53,6 +32,55 @@ function normalizeEvent(event: any) {
}; };
} }
// Load every slug currently in use (canonical event slugs + historical aliases),
// optionally excluding a given event's own canonical slug + aliases.
async function getAllSlugsInUse(excludeEventId?: string): Promise<string[]> {
const eventRows = await dbAll<any>(
(db as any).select({ id: (events as any).id, slug: (events as any).slug }).from(events)
);
const aliasRows = await dbAll<any>(
(db as any).select({ eventId: (eventSlugAliases as any).eventId, slug: (eventSlugAliases as any).slug }).from(eventSlugAliases)
);
const slugs: string[] = [];
for (const row of eventRows) {
if (row.slug && row.id !== excludeEventId) slugs.push(row.slug);
}
for (const row of aliasRows) {
if (row.slug && row.eventId !== excludeEventId) slugs.push(row.slug);
}
return slugs;
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Resolve an event by canonical slug, primary id, or a historical slug alias.
// Slug is checked first because Postgres rejects non-UUID strings when comparing
// against the uuid `id` column, so id lookups are guarded behind a UUID check there.
async function resolveEventByParam(param: string): Promise<any | null> {
let event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).slug, param))
);
if (!event && (!isPostgres() || UUID_PATTERN.test(param))) {
event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, param))
);
}
if (!event) {
const alias = await dbGet<any>(
(db as any).select().from(eventSlugAliases).where(eq((eventSlugAliases as any).slug, param))
);
if (alias) {
event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, alias.eventId))
);
}
}
return event || null;
}
// Custom validation error handler // Custom validation error handler
const validationHook = (result: any, c: any) => { const validationHook = (result: any, c: any) => {
if (!result.success) { if (!result.success) {
@@ -85,6 +113,7 @@ const normalizeBoolean = (val: unknown): boolean => {
const baseEventSchema = z.object({ const baseEventSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
titleEs: z.string().optional().nullable(), titleEs: z.string().optional().nullable(),
slug: z.string().optional(),
description: z.string().min(1), description: z.string().min(1),
descriptionEs: z.string().optional().nullable(), descriptionEs: z.string().optional().nullable(),
shortDescription: z.string().max(300).optional().nullable(), shortDescription: z.string().max(300).optional().nullable(),
@@ -97,7 +126,7 @@ const baseEventSchema = z.object({
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0), price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
currency: z.string().default('PYG'), currency: z.string().default('PYG'),
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50), capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'), status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
// Accept relative paths (/uploads/...) or full URLs // Accept relative paths (/uploads/...) or full URLs
bannerUrl: z.string().optional().nullable().or(z.literal('')), bannerUrl: z.string().optional().nullable().or(z.literal('')),
// External booking support - accept boolean or number (0/1 from DB) // External booking support - accept boolean or number (0/1 from DB)
@@ -186,13 +215,10 @@ eventsRouter.get('/', async (c) => {
return c.json({ events: eventsWithCounts }); return c.json({ events: eventsWithCounts });
}); });
// Get single event (public) // Get single event (public) - resolves by id, canonical slug, or historical alias
eventsRouter.get('/:id', async (c) => { eventsRouter.get('/:id', async (c) => {
const id = c.req.param('id'); const param = c.req.param('id');
const event = await resolveEventByParam(param);
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
@@ -206,7 +232,7 @@ eventsRouter.get('/:id', async (c) => {
.from(tickets) .from(tickets)
.where( .where(
and( and(
eq((tickets as any).eventId, id), eq((tickets as any).eventId, event.id),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
) )
) )
@@ -223,6 +249,13 @@ eventsRouter.get('/:id', async (c) => {
}); });
}); });
async function getSiteTimezone(): Promise<string> {
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
return settings?.timezone || 'America/Asuncion';
}
// Helper function to get ticket count for an event // Helper function to get ticket count for an event
async function getEventTicketCount(eventId: string): Promise<number> { async function getEventTicketCount(eventId: string): Promise<number> {
const ticketCount = await dbGet<any>( const ticketCount = await dbGet<any>(
@@ -242,6 +275,7 @@ async function getEventTicketCount(eventId: string): Promise<number> {
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming // Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
eventsRouter.get('/next/upcoming', async (c) => { eventsRouter.get('/next/upcoming', async (c) => {
const now = getNow(); const now = getNow();
const nowMs = Date.now();
// First, check if there's a featured event in site settings // First, check if there's a featured event in site settings
const settings = await dbGet<any>( const settings = await dbGet<any>(
@@ -252,7 +286,6 @@ eventsRouter.get('/next/upcoming', async (c) => {
let shouldUnsetFeatured = false; let shouldUnsetFeatured = false;
if (settings?.featuredEventId) { if (settings?.featuredEventId) {
// Get the featured event
featuredEvent = await dbGet<any>( featuredEvent = await dbGet<any>(
(db as any) (db as any)
.select() .select()
@@ -261,37 +294,30 @@ eventsRouter.get('/next/upcoming', async (c) => {
); );
if (featuredEvent) { if (featuredEvent) {
// Check if featured event is still valid:
// 1. Must be published
// 2. Must not have ended (endDatetime >= now, or startDatetime >= now if no endDatetime)
const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime; const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime;
const isPublished = featuredEvent.status === 'published'; const isPublished = featuredEvent.status === 'published';
const hasNotEnded = eventEndTime >= now; const hasNotEnded = new Date(eventEndTime).getTime() > nowMs;
if (!isPublished || !hasNotEnded) { if (!isPublished || !hasNotEnded) {
// Featured event is no longer valid - mark for unsetting
shouldUnsetFeatured = true; shouldUnsetFeatured = true;
featuredEvent = null; featuredEvent = null;
} }
} else { } else {
// Featured event no longer exists
shouldUnsetFeatured = true; shouldUnsetFeatured = true;
} }
} }
// If we need to unset the featured event, do it asynchronously
if (shouldUnsetFeatured && settings) { if (shouldUnsetFeatured && settings) {
// Unset featured event in background (don't await to avoid blocking response) try {
(db as any) await (db as any)
.update(siteSettings) .update(siteSettings)
.set({ featuredEventId: null, updatedAt: now }) .set({ featuredEventId: null, updatedAt: now })
.where(eq((siteSettings as any).id, settings.id)) .where(eq((siteSettings as any).id, settings.id));
.then(() => { console.log('Featured event auto-cleared (event ended or unpublished)');
console.log('Featured event auto-cleared (event ended or unpublished)'); revalidateFrontendCache();
}) } catch (err: any) {
.catch((err: any) => { console.error('Failed to clear featured event:', err);
console.error('Failed to clear featured event:', err); }
});
} }
// If we have a valid featured event, return it // If we have a valid featured event, return it
@@ -345,15 +371,21 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
const user = c.get('user'); const user = c.get('user');
const now = getNow(); const now = getNow();
const id = generateId(); const id = generateId();
const tz = await getSiteTimezone();
// Convert data for database compatibility // Convert data for database compatibility
const dbData = convertBooleansForDb(data); const dbData = convertBooleansForDb(data);
// Generate a unique slug from the title (manual slug is honored on update, not create)
const existingSlugs = await getAllSlugsInUse();
const slug = uniqueSlug(data.title, existingSlugs);
const newEvent = { const newEvent = {
id, id,
...dbData, ...dbData,
startDatetime: toDbDate(data.startDatetime), slug,
endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null, startDatetime: toDbDateTz(data.startDatetime, tz),
endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -372,7 +404,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
const id = c.req.param('id'); const id = c.req.param('id');
const data = c.req.valid('json'); const data = c.req.valid('json');
const existing = await dbGet( const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id)) (db as any).select().from(events).where(eq((events as any).id, id))
); );
if (!existing) { if (!existing) {
@@ -380,14 +412,51 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
} }
const now = getNow(); const now = getNow();
const tz = await getSiteTimezone();
// Convert data for database compatibility // Convert data for database compatibility
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now }; const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
// Slug changes are handled explicitly below to manage aliases
delete updateData.slug;
// Convert datetime fields if present // Convert datetime fields if present
if (data.startDatetime) { if (data.startDatetime) {
updateData.startDatetime = toDbDate(data.startDatetime); updateData.startDatetime = toDbDateTz(data.startDatetime, tz);
} }
if (data.endDatetime !== undefined) { if (data.endDatetime !== undefined) {
updateData.endDatetime = data.endDatetime ? toDbDate(data.endDatetime) : null; updateData.endDatetime = data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null;
}
// Resolve slug: explicit admin edit takes priority, then title-derived regeneration
const oldSlug: string | null = existing.slug || null;
let newSlug: string | null = oldSlug;
if (typeof data.slug === 'string' && data.slug.trim() !== '') {
const normalized = slugify(data.slug);
if (!normalized) {
return c.json({ error: 'Invalid slug' }, 400);
}
if (normalized !== oldSlug) {
const taken = await getAllSlugsInUse(id);
if (taken.includes(normalized)) {
return c.json({ error: 'Slug already in use' }, 400);
}
newSlug = normalized;
}
} else if (data.title && slugify(data.title) !== slugify(existing.title || '')) {
const taken = await getAllSlugsInUse(id);
newSlug = uniqueSlug(data.title, taken);
}
if (newSlug && newSlug !== oldSlug) {
// If this slug was previously one of THIS event's aliases, reclaim it as canonical
await (db as any)
.delete(eventSlugAliases)
.where(and(eq((eventSlugAliases as any).slug, newSlug), eq((eventSlugAliases as any).eventId, id)));
// Preserve the old slug as an alias so existing shared links keep redirecting
if (oldSlug) {
try {
await (db as any).insert(eventSlugAliases).values({ slug: oldSlug, eventId: id, createdAt: now });
} catch (e) { /* alias may already exist */ }
}
updateData.slug = newSlug;
} }
await (db as any) await (db as any)
@@ -449,6 +518,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
// Delete event payment overrides // Delete event payment overrides
await (db as any).delete(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, id)); await (db as any).delete(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, id));
// Delete slug aliases for this event
await (db as any).delete(eventSlugAliases).where(eq((eventSlugAliases as any).eventId, id));
// Set eventId to null on email logs (they reference this event but can exist without it) // Set eventId to null on email logs (they reference this event but can exist without it)
await (db as any) await (db as any)
.update(emailLogs) .update(emailLogs)
@@ -491,11 +563,15 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
const now = getNow(); const now = getNow();
const newId = generateId(); const newId = generateId();
const duplicatedTitle = `${existing.title} (Copy)`;
const existingSlugs = await getAllSlugsInUse();
const slug = uniqueSlug(duplicatedTitle, existingSlugs);
// Create a copy with modified title and draft status // Create a copy with modified title and draft status
const duplicatedEvent = { const duplicatedEvent = {
id: newId, id: newId,
title: `${existing.title} (Copy)`, slug,
title: duplicatedTitle,
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null, titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
description: existing.description, description: existing.description,
descriptionEs: existing.descriptionEs, descriptionEs: existing.descriptionEs,
@@ -521,4 +597,44 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201); return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
}); });
// List slug aliases for an event (admin/organizer only)
eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
const aliases = await dbAll<any>(
(db as any)
.select({ slug: (eventSlugAliases as any).slug, createdAt: (eventSlugAliases as any).createdAt })
.from(eventSlugAliases)
.where(eq((eventSlugAliases as any).eventId, id))
);
return c.json({ aliases });
});
// Remove a slug alias from an event (admin/organizer only)
eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const slug = c.req.param('slug');
const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) {
return c.json({ error: 'Event not found' }, 404);
}
await (db as any)
.delete(eventSlugAliases)
.where(and(eq((eventSlugAliases as any).eventId, id), eq((eventSlugAliases as any).slug, slug)));
return c.json({ message: 'Alias removed' });
});
export default eventsRouter; export default eventsRouter;

View File

@@ -30,6 +30,8 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const status = c.req.query('status'); const status = c.req.query('status');
const provider = c.req.query('provider'); const provider = c.req.query('provider');
const pendingApproval = c.req.query('pendingApproval'); const pendingApproval = c.req.query('pendingApproval');
const eventId = c.req.query('eventId');
const eventIds = c.req.query('eventIds');
// Get all payments with their associated tickets // Get all payments with their associated tickets
let allPayments = await dbAll<any>( let allPayments = await dbAll<any>(
@@ -55,7 +57,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
} }
// Enrich with ticket and event data // Enrich with ticket and event data
const enrichedPayments = await Promise.all( let enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => { allPayments.map(async (payment: any) => {
const ticket = await dbGet<any>( const ticket = await dbGet<any>(
(db as any) (db as any)
@@ -94,6 +96,16 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
}) })
); );
// Filter by event(s)
if (eventId) {
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
} else if (eventIds) {
const ids = eventIds.split(',').map((s: string) => s.trim()).filter(Boolean);
if (ids.length > 0) {
enrichedPayments = enrichedPayments.filter((p: any) => p.event && ids.includes(p.event.id));
}
}
return c.json({ payments: enrichedPayments }); return c.json({ payments: enrichedPayments });
}); });

View File

@@ -5,6 +5,7 @@ import { db, dbGet, siteSettings, events } from '../db/index.js';
import { eq, and, gte } from 'drizzle-orm'; import { eq, and, gte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -172,6 +173,11 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id)) (db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
); );
// Revalidate frontend cache if featured event changed
if (data.featuredEventId !== undefined) {
revalidateFrontendCache();
}
return c.json({ settings: updated, message: 'Settings updated successfully' }); return c.json({ settings: updated, message: 'Settings updated successfully' });
}); });
@@ -194,8 +200,15 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
if (event.status !== 'published') { if (event.status !== 'published') {
return c.json({ error: 'Event must be published to be featured' }, 400); return c.json({ error: 'Event must be published to be featured' }, 400);
} }
const eventEndTime = event.endDatetime || event.startDatetime;
if (new Date(eventEndTime).getTime() <= Date.now()) {
return c.json(
{ error: 'Cannot feature an event that has already ended' },
400
);
}
} }
// Get or create settings // Get or create settings
const existing = await dbGet<any>( const existing = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1) (db as any).select().from(siteSettings).limit(1)
@@ -216,6 +229,9 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
await (db as any).insert(siteSettings).values(newSettings); await (db as any).insert(siteSettings).values(newSettings);
// Revalidate frontend cache so homepage shows the updated featured event
revalidateFrontendCache();
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' }); return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
} }
@@ -229,6 +245,9 @@ siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('js
}) })
.where(eq((siteSettings as any).id, existing.id)); .where(eq((siteSettings as any).id, existing.id));
// Revalidate frontend cache so homepage shows the updated featured event
revalidateFrontendCache();
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' }); return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
}); });

View File

@@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
import { eq, and, or, sql } from 'drizzle-orm'; import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js'; import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
@@ -69,7 +69,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
if (event.status !== 'published') { if (!['published', 'unlisted'].includes(event.status)) {
return c.json({ error: 'Event is not available for booking' }, 400); return c.json({ error: 'Event is not available for booking' }, 400);
} }
@@ -1394,7 +1394,143 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
}, 201); }, 201);
}); });
// Get all tickets (admin) // 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>(
(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);
}
}
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: data.email && data.email.trim() ? data.email.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: 'confirmed',
isGuest: 1,
qrCode,
checkinAt: null,
adminNote: data.adminNote || null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Create a $0 payment record to track the invite
const paymentId = generateId();
const newPayment = {
id: paymentId,
ticketId,
provider: 'cash',
amount: 0,
currency: event.currency,
status: 'paid',
reference: 'Guest invite',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
// Send booking confirmation email if a real email was provided
if (data.email && data.email.trim()) {
emailService.sendBookingConfirmation(ticketId).then(result => {
if (result.success) {
console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
} else {
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending booking confirmation for guest ticket:', err);
});
}
return c.json({
ticket: {
...newTicket,
event: {
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
},
},
payment: newPayment,
message: 'Guest ticket created successfully',
}, 201);
});
// Get all tickets (admin) - includes payment for each ticket
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');
const status = c.req.query('status'); const status = c.req.query('status');
@@ -1413,9 +1549,25 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(and(...conditions)); query = query.where(and(...conditions));
} }
const result = await dbAll(query); const ticketsList = await dbAll(query);
const ticketIds = ticketsList.map((t: any) => t.id);
return c.json({ tickets: result });
let paymentByTicketId: Record<string, any> = {};
if (ticketIds.length > 0) {
const paymentsList = await dbAll(
(db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))
);
for (const p of paymentsList as any[]) {
paymentByTicketId[p.ticketId] = p;
}
}
const ticketsWithPayment = ticketsList.map((t: any) => ({
...t,
payment: paymentByTicketId[t.id] || null,
}));
return c.json({ tickets: ticketsWithPayment });
}); });
export default ticketsRouter; export default ticketsRouter;

View File

@@ -8,9 +8,9 @@ Type=simple
User=spanglish User=spanglish
Group=spanglish Group=spanglish
WorkingDirectory=/home/spanglish/Spanglish/backend WorkingDirectory=/home/spanglish/Spanglish/backend
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
Environment=NODE_ENV=production Environment=NODE_ENV=production
Environment=PORT=3018 Environment=PORT=3018
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
ExecStart=/usr/bin/node dist/index.js ExecStart=/usr/bin/node dist/index.js
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10

View File

@@ -25,6 +25,9 @@ NEXT_PUBLIC_TIKTOK=spanglishsocialpy
# Must match the REVALIDATE_SECRET in backend/.env # Must match the REVALIDATE_SECRET in backend/.env
REVALIDATE_SECRET=change-me-to-a-random-secret REVALIDATE_SECRET=change-me-to-a-random-secret
# Next event cache revalidation (seconds) - homepage metadata/social preview refresh interval. Default: 3600
NEXT_EVENT_REVALIDATE_SECONDS=3600
# Plausible Analytics (optional - leave empty to disable tracking) # Plausible Analytics (optional - leave empty to disable tracking)
NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -110,6 +110,10 @@ export default function BookingPage() {
const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({});
// Terms & Privacy agreement (not persisted across page loads)
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [termsError, setTermsError] = useState<string | null>(null);
const rucPattern = /^\d{6,10}$/; const rucPattern = /^\d{6,10}$/;
// Format RUC input: digits only, max 10 // Format RUC input: digits only, max 10
@@ -145,7 +149,7 @@ export default function BookingPage() {
paymentOptionsApi.getForEvent(params.eventId as string), paymentOptionsApi.getForEvent(params.eventId as string),
]) ])
.then(([eventRes, paymentRes]) => { .then(([eventRes, paymentRes]) => {
if (!eventRes.event || eventRes.event.status !== 'published') { if (!eventRes.event || !['published', 'unlisted'].includes(eventRes.event.status)) {
toast.error('Event is not available for booking'); toast.error('Event is not available for booking');
router.push('/events'); router.push('/events');
return; return;
@@ -162,7 +166,7 @@ export default function BookingPage() {
const soldOut = bookedCount >= capacity; const soldOut = bookedCount >= capacity;
if (soldOut) { if (soldOut) {
toast.error(t('events.details.soldOut')); toast.error(t('events.details.soldOut'));
router.push(`/events/${eventRes.event.id}`); router.push(`/events/${eventRes.event.slug}`);
return; return;
} }
@@ -217,6 +221,19 @@ export default function BookingPage() {
} }
}, [user]); }, [user]);
// Clear the terms error as soon as the user agrees
useEffect(() => {
if (agreedToTerms && termsError) {
setTermsError(null);
}
}, [agreedToTerms, termsError]);
// Scroll to top when moving between booking steps (esp. mobile, where
// the submit button sits at the bottom of a long form)
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [step]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es'); const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
@@ -261,7 +278,20 @@ export default function BookingPage() {
setErrors(newErrors); setErrors(newErrors);
setAttendeeErrors(newAttendeeErrors); setAttendeeErrors(newAttendeeErrors);
return Object.keys(newErrors).length === 0 && Object.keys(newAttendeeErrors).length === 0;
let termsOk = true;
if (!agreedToTerms) {
setTermsError(t('booking.form.errors.termsRequired'));
termsOk = false;
} else {
setTermsError(null);
}
return (
Object.keys(newErrors).length === 0 &&
Object.keys(newAttendeeErrors).length === 0 &&
termsOk
);
}; };
// Connect to SSE for real-time payment updates // Connect to SSE for real-time payment updates
@@ -376,6 +406,10 @@ export default function BookingPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!agreedToTerms) {
setTermsError(t('booking.form.errors.termsRequired'));
return;
}
if (!event || !validateForm()) return; if (!event || !validateForm()) return;
setSubmitting(true); setSubmitting(true);
@@ -1015,7 +1049,7 @@ export default function BookingPage() {
<div className="section-padding bg-secondary-gray min-h-screen"> <div className="section-padding bg-secondary-gray min-h-screen">
<div className="container-page max-w-2xl"> <div className="container-page max-w-2xl">
<Link <Link
href={`/events/${event.id}`} href={`/events/${event.slug}`}
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6" className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
> >
<ArrowLeftIcon className="w-4 h-4" /> <ArrowLeftIcon className="w-4 h-4" />
@@ -1323,13 +1357,58 @@ export default function BookingPage() {
</div> </div>
</Card> </Card>
{/* Terms & Privacy agreement */}
<Card className="mb-6 p-6">
<div className="flex items-start gap-3">
<input
id="booking-terms-agree"
type="checkbox"
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
aria-required="true"
aria-invalid={termsError ? true : undefined}
aria-describedby={termsError ? 'booking-terms-error' : undefined}
className="h-5 w-5 mt-0.5 flex-shrink-0 accent-primary-yellow rounded focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 cursor-pointer"
/>
<label
htmlFor="booking-terms-agree"
className="text-sm text-gray-500 leading-relaxed cursor-pointer select-none"
>
{t('booking.form.termsAgreePart1')}
<Link
href={`/legal/terms-policy${locale === 'es' ? '?locale=es' : ''}`}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline"
>
{t('booking.form.termsOfService')}
</Link>
{t('booking.form.termsAgreePart2')}
<Link
href={`/legal/privacy-policy${locale === 'es' ? '?locale=es' : ''}`}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline"
>
{t('booking.form.privacyPolicy')}
</Link>
{t('booking.form.termsAgreePart3')}
</label>
</div>
{termsError && (
<p id="booking-terms-error" className="mt-1.5 text-sm text-red-600">
{termsError}
</p>
)}
</Card>
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
className="w-full" className="w-full"
isLoading={submitting} isLoading={submitting}
disabled={paymentMethods.length === 0} disabled={paymentMethods.length === 0 || !agreedToTerms}
> >
{formData.paymentMethod === 'cash' {formData.paymentMethod === 'cash'
? t('booking.form.reserveSpot') ? t('booking.form.reserveSpot')
@@ -1338,10 +1417,6 @@ export default function BookingPage() {
: locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment' : locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment'
} }
</Button> </Button>
<p className="text-center text-sm text-gray-500 mt-4">
{t('booking.form.termsNote')}
</p>
</form> </form>
)} )}
</div> </div>

View File

@@ -5,9 +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 Button from '@/components/ui/Button'; import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
import Card from '@/components/ui/Card';
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
interface NextEventSectionProps { interface NextEventSectionProps {
initialEvent?: Event | null; initialEvent?: Event | null;
@@ -16,11 +14,24 @@ interface NextEventSectionProps {
export default function NextEventSection({ initialEvent }: NextEventSectionProps) { export default function NextEventSection({ initialEvent }: NextEventSectionProps) {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null); const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
const [loading, setLoading] = useState(!initialEvent); const [loading, setLoading] = useState(initialEvent === undefined);
useEffect(() => { useEffect(() => {
// Skip fetch if we already have server-provided data if (initialEvent !== undefined) {
if (initialEvent !== undefined) return; if (initialEvent) {
const endTime = initialEvent.endDatetime || initialEvent.startDatetime;
if (new Date(endTime).getTime() <= Date.now()) {
setNextEvent(null);
setLoading(true);
eventsApi.getNextUpcoming()
.then(({ event }) => setNextEvent(event))
.catch(console.error)
.finally(() => setLoading(false));
return;
}
}
return;
}
eventsApi.getNextUpcoming() eventsApi.getNextUpcoming()
.then(({ event }) => setNextEvent(event)) .then(({ event }) => setNextEvent(event))
.catch(console.error) .catch(console.error)
@@ -30,6 +41,15 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es'); const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const title = nextEvent
? (locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title)
: '';
const description = nextEvent
? (locale === 'es'
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
: (nextEvent.shortDescription || nextEvent.description))
: '';
if (loading) { if (loading) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
@@ -49,56 +69,72 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
} }
return ( return (
<Link href={`/events/${nextEvent.id}`} className="block"> <Link href={`/events/${nextEvent.slug}`} className="block group">
<Card variant="elevated" className="p-8 cursor-pointer hover:shadow-lg transition-shadow"> <div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg transition-all duration-300 hover:shadow-2xl hover:scale-[1.01]">
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row">
<div className="flex-1"> {/* Banner */}
<h3 className="text-2xl font-bold text-primary-dark"> {nextEvent.bannerUrl ? (
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title} <div className="relative w-full md:w-2/5 flex-shrink-0">
</h3> <img
<p className="mt-3 text-gray-600 whitespace-pre-line"> src={nextEvent.bannerUrl}
{locale === 'es' alt={title}
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description) className="w-full h-48 md:h-full object-cover"
: (nextEvent.shortDescription || nextEvent.description)} />
</p>
<div className="mt-6 space-y-3">
<div className="flex items-center gap-3 text-gray-700">
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
<span>{formatDate(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-3 text-gray-700">
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
</span>
<span>{fmtTime(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-3 text-gray-700">
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
<span>{nextEvent.location}</span>
</div>
</div> </div>
</div> ) : (
<div className="w-full md:w-2/5 flex-shrink-0 h-48 md:h-auto bg-gradient-to-br from-primary-yellow/20 to-secondary-gray flex items-center justify-center">
<div className="flex flex-col justify-between items-start md:items-end"> <CalendarIcon className="w-16 h-16 text-gray-300" />
<div className="text-right"> </div>
<span className="text-3xl font-bold text-primary-dark"> )}
{nextEvent.price === 0
? t('events.details.free') {/* Info */}
: formatPrice(nextEvent.price, nextEvent.currency)} <div className="flex-1 p-5 md:p-8 flex flex-col justify-between">
</span> <div>
{!nextEvent.externalBookingEnabled && ( <h3 className="text-xl md:text-2xl font-bold text-primary-dark group-hover:text-brand-navy transition-colors">
<p className="text-sm text-gray-500 mt-1"> {title}
{nextEvent.availableSeats} {t('events.details.spotsLeft')} </h3>
{description && (
<p className="mt-2 text-sm md:text-base text-gray-600 line-clamp-2">
{description}
</p> </p>
)} )}
<div className="mt-4 md:mt-5 space-y-2">
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span suppressHydrationWarning>{formatDate(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
<ClockIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span suppressHydrationWarning>{fmtTime(nextEvent.startDatetime)}</span>
</div>
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span>{nextEvent.location}</span>
</div>
</div>
</div>
<div className="mt-5 md:mt-6 flex items-center justify-between gap-4">
<div>
<span className="text-2xl md:text-3xl font-bold text-primary-dark">
{nextEvent.price === 0
? t('events.details.free')
: formatPrice(nextEvent.price, nextEvent.currency)}
</span>
{!nextEvent.externalBookingEnabled && nextEvent.availableSeats != null && (
<p className="text-xs text-gray-500 mt-0.5">
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
</p>
)}
</div>
<span className="inline-flex items-center bg-primary-yellow text-primary-dark font-semibold py-2.5 px-5 rounded-xl text-sm transition-all duration-200 group-hover:bg-yellow-400 flex-shrink-0">
{t('common.moreInfo')}
</span>
</div> </div>
<Button size="lg" className="mt-6">
{t('common.moreInfo')}
</Button>
</div> </div>
</div> </div>
</Card> </div>
</Link> </Link>
); );
} }

View File

@@ -17,7 +17,7 @@ export default function NextEventSectionWrapper({ initialEvent }: NextEventSecti
<h2 className="section-title text-center"> <h2 className="section-title text-center">
{t('home.nextEvent.title')} {t('home.nextEvent.title')}
</h2> </h2>
<div className="mt-12 max-w-3xl mx-auto"> <div className="mt-12 max-w-4xl mx-auto">
<NextEventSection initialEvent={initialEvent} /> <NextEventSection initialEvent={initialEvent} />
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
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 { UserPayment } from '@/lib/api'; import { UserPayment } from '@/lib/api';
import { parseDate } from '@/lib/utils';
interface PaymentsTabProps { interface PaymentsTabProps {
payments: UserPayment[]; payments: UserPayment[];
@@ -21,7 +22,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
}); });
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { dashboardApi, UserProfile } from '@/lib/api'; import { dashboardApi, UserProfile } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
interface ProfileTabProps { interface ProfileTabProps {
@@ -116,7 +117,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
</span> </span>
<span className="font-medium"> <span className="font-medium">
{profile?.memberSince {profile?.memberSince
? new Date(profile.memberSince).toLocaleDateString( ? parseDate(profile.memberSince).toLocaleDateString(
language === 'es' ? 'es-ES' : 'en-US', language === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' } { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
) )

View File

@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api'; import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
export default function SecurityTab() { export default function SecurityTab() {
@@ -147,7 +148,7 @@ export default function SecurityTab() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
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 { UserTicket } from '@/lib/api'; import { UserTicket } from '@/lib/api';
import { parseDate } from '@/lib/utils';
interface TicketsTabProps { interface TicketsTabProps {
tickets: UserTicket[]; tickets: UserTicket[];
@@ -26,7 +27,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
}); });
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -23,6 +23,8 @@ interface EventDetailClientProps {
initialEvent: Event; initialEvent: Event;
} }
const MAX_TICKETS_PER_PERSON = 5;
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) { export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [event, setEvent] = useState<Event>(initialEvent); const [event, setEvent] = useState<Event>(initialEvent);
@@ -44,7 +46,13 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
// Spots left: never negative; sold out when confirmed >= capacity // Spots left: never negative; sold out when confirmed >= capacity
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
const maxTickets = isSoldOut ? 0 : Math.max(1, spotsLeft); const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
useEffect(() => {
if (maxTickets > 0) {
setTicketQuantity((q) => Math.min(q, maxTickets));
}
}, [maxTickets]);
const decreaseQuantity = () => { const decreaseQuantity = () => {
setTicketQuantity(prev => Math.max(1, prev - 1)); setTicketQuantity(prev => Math.max(1, prev - 1));
@@ -60,7 +68,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const isCancelled = event.status === 'cancelled'; const isCancelled = event.status === 'cancelled';
// Only calculate isPastEvent after mount to avoid hydration mismatch // Only calculate isPastEvent after mount to avoid hydration mismatch
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false; const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published'; const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted');
// Booking card content - reused for mobile and desktop positions // Booking card content - reused for mobile and desktop positions
const BookingCardContent = () => ( const BookingCardContent = () => (

View File

@@ -1,5 +1,5 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound, permanentRedirect } from 'next/navigation';
import EventDetailClient from './EventDetailClient'; import EventDetailClient from './EventDetailClient';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
@@ -7,6 +7,7 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface Event { interface Event {
id: string; id: string;
slug: string;
title: string; title: string;
titleEs?: string; titleEs?: string;
description: string; description: string;
@@ -20,7 +21,7 @@ interface Event {
price: number; price: number;
currency: string; currency: string;
capacity: number; capacity: number;
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived'; status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string; bannerUrl?: string;
availableSeats?: number; availableSeats?: number;
bookedCount?: number; bookedCount?: number;
@@ -68,7 +69,7 @@ export async function generateMetadata({ params }: { params: { id: string } }):
title, title,
description, description,
type: 'website', type: 'website',
url: `${siteUrl}/events/${event.id}`, url: `${siteUrl}/events/${event.slug}`,
images: [{ url: imageUrl, width: 1200, height: 630, alt: event.title }], images: [{ url: imageUrl, width: 1200, height: 630, alt: event.title }],
}, },
twitter: { twitter: {
@@ -78,7 +79,7 @@ export async function generateMetadata({ params }: { params: { id: string } }):
images: [imageUrl], images: [imageUrl],
}, },
alternates: { alternates: {
canonical: `${siteUrl}/events/${event.id}`, canonical: `${siteUrl}/events/${event.slug}`,
}, },
}; };
} }
@@ -119,11 +120,11 @@ function generateEventJsonLd(event: Event) {
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0 availability: 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.id}`, url: `${siteUrl}/events/${event.slug}`,
validFrom: new Date().toISOString(), validFrom: new Date().toISOString(),
}, },
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`, image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.id}`, url: `${siteUrl}/events/${event.slug}`,
}; };
} }
@@ -134,6 +135,11 @@ export default async function EventDetailPage({ params }: { params: { id: string
notFound(); notFound();
} }
// Redirect legacy UUID/alias URLs to the canonical slug (HTTP 308 permanent)
if (event.slug && params.id !== event.slug) {
permanentRedirect(`/events/${event.slug}`);
}
const jsonLd = generateEventJsonLd(event); const jsonLd = generateEventJsonLd(event);
return ( return (
@@ -142,7 +148,7 @@ export default async function EventDetailPage({ params }: { params: { id: string
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
<EventDetailClient eventId={params.id} initialEvent={event} /> <EventDetailClient eventId={event.slug} initialEvent={event} />
</> </>
); );
} }

View File

@@ -91,7 +91,7 @@ export default function EventsPage() {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedEvents.map((event) => ( {displayedEvents.map((event) => (
<Link key={event.id} href={`/events/${event.id}`} className="block"> <Link key={event.id} href={`/events/${event.slug}`} className="block">
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full"> <Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
{/* Event banner */} {/* Event banner */}
{event.bannerUrl ? ( {event.bannerUrl ? (

View File

@@ -68,6 +68,8 @@ export default async function LegalPage({ params, searchParams }: PageProps) {
return ( return (
<LegalPageLayout <LegalPageLayout
slug={resolvedParams.slug}
initialLocale={locale}
title={legalPage.title} title={legalPage.title}
content={legalPage.content} content={legalPage.content}
lastUpdated={legalPage.lastUpdated} lastUpdated={legalPage.lastUpdated}

View File

@@ -3,6 +3,7 @@ import HeroSection from './components/HeroSection';
import NextEventSectionWrapper from './components/NextEventSectionWrapper'; import NextEventSectionWrapper from './components/NextEventSectionWrapper';
import AboutSection from './components/AboutSection'; import AboutSection from './components/AboutSection';
import MediaCarouselSection from './components/MediaCarouselSection'; import MediaCarouselSection from './components/MediaCarouselSection';
import { parseDate } from '@/lib/utils';
import NewsletterSection from './components/NewsletterSection'; import NewsletterSection from './components/NewsletterSection';
import HomepageFaqSection from './components/HomepageFaqSection'; import HomepageFaqSection from './components/HomepageFaqSection';
import { getCarouselImages } from '@/lib/carouselImages'; import { getCarouselImages } from '@/lib/carouselImages';
@@ -12,6 +13,7 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface NextEvent { interface NextEvent {
id: string; id: string;
slug: string;
title: string; title: string;
titleEs?: string; titleEs?: string;
description: string; description: string;
@@ -38,8 +40,10 @@ interface NextEvent {
async function getNextUpcomingEvent(): Promise<NextEvent | null> { async function getNextUpcomingEvent(): Promise<NextEvent | null> {
try { try {
const revalidateSeconds =
parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600;
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, { const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
next: { tags: ['next-event'] }, next: { tags: ['next-event'], revalidate: revalidateSeconds },
}); });
if (!response.ok) return null; if (!response.ok) return null;
const data = await response.json(); const data = await response.json();
@@ -61,7 +65,7 @@ export async function generateMetadata(): Promise<Metadata> {
}; };
} }
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', { const eventDate = parseDate(event.startDatetime).toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -136,10 +140,10 @@ function generateNextEventJsonLd(event: NextEvent) {
(event.availableSeats ?? 0) > 0 (event.availableSeats ?? 0) > 0
? 'https://schema.org/InStock' ? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut', : 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.id}`, url: `${siteUrl}/events/${event.slug}`,
}, },
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`, image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.id}`, url: `${siteUrl}/events/${event.slug}`,
}; };
} }

View File

@@ -3,8 +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, Ticket, Event } from '@/lib/api'; import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
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 { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { import {
TicketIcon, TicketIcon,
CheckCircleIcon, CheckCircleIcon,
@@ -14,8 +16,10 @@ import {
EnvelopeIcon, EnvelopeIcon,
PhoneIcon, PhoneIcon,
FunnelIcon, FunnelIcon,
MagnifyingGlassIcon,
} 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';
interface TicketWithDetails extends Omit<Ticket, 'payment'> { interface TicketWithDetails extends Omit<Ticket, 'payment'> {
bookingId?: string; bookingId?: string;
@@ -40,10 +44,11 @@ export default function AdminBookingsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState<string | null>(null); const [processing, setProcessing] = useState<string | null>(null);
// Filters
const [selectedEvent, setSelectedEvent] = useState<string>(''); const [selectedEvent, setSelectedEvent] = useState<string>('');
const [selectedStatus, setSelectedStatus] = useState<string>(''); const [selectedStatus, setSelectedStatus] = useState<string>('');
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>(''); const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -55,20 +60,13 @@ export default function AdminBookingsPage() {
ticketsApi.getAll(), ticketsApi.getAll(),
eventsApi.getAll(), eventsApi.getAll(),
]); ]);
// Fetch full ticket details with payment info const ticketsWithEvent = ticketsRes.tickets.map((ticket) => ({
const ticketsWithDetails = await Promise.all( ...ticket,
ticketsRes.tickets.map(async (ticket) => { event: eventsRes.events.find((e) => e.id === ticket.eventId),
try { }));
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id);
return fullTicket; setTickets(ticketsWithEvent);
} catch {
return ticket;
}
})
);
setTickets(ticketsWithDetails);
setEvents(eventsRes.events); setEvents(eventsRes.events);
} catch (error) { } catch (error) {
toast.error('Failed to load bookings'); toast.error('Failed to load bookings');
@@ -119,7 +117,7 @@ export default function AdminBookingsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -131,62 +129,62 @@ export default function AdminBookingsPage() {
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'confirmed': case 'confirmed': return 'bg-green-100 text-green-800';
return 'bg-green-100 text-green-800'; case 'pending': return 'bg-yellow-100 text-yellow-800';
case 'pending': case 'cancelled': return 'bg-red-100 text-red-800';
return 'bg-yellow-100 text-yellow-800'; case 'checked_in': return 'bg-blue-100 text-blue-800';
case 'cancelled': default: return 'bg-gray-100 text-gray-800';
return 'bg-red-100 text-red-800';
case 'checked_in':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
} }
}; };
const getPaymentStatusColor = (status: string) => { const getPaymentStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'paid': case 'paid': return 'bg-green-100 text-green-800';
return 'bg-green-100 text-green-800'; case 'pending': return 'bg-yellow-100 text-yellow-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed': case 'failed':
case 'cancelled': case 'cancelled': return 'bg-red-100 text-red-800';
return 'bg-red-100 text-red-800'; case 'refunded': return 'bg-purple-100 text-purple-800';
case 'refunded': default: return 'bg-gray-100 text-gray-800';
return 'bg-purple-100 text-purple-800';
default:
return 'bg-gray-100 text-gray-800';
} }
}; };
const getPaymentMethodLabel = (provider: string) => { const getPaymentMethodLabel = (provider: string | null) => {
switch (provider) { if (provider == null) return '—';
case 'bancard': const labels: Record<string, string> = {
return 'TPago / Card'; cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
case 'lightning': bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
return 'Bitcoin Lightning'; lightning: 'Lightning',
case 'cash': tpago: 'TPago',
return 'Cash at Event'; bancard: 'Bancard',
default: };
return provider; return labels[provider] || provider;
} };
const getDisplayProvider = (ticket: TicketWithDetails): string | null => {
if (ticket.payment?.provider) return ticket.payment.provider;
if (ticket.bookingId) {
const sibling = tickets.find((t) => t.bookingId === ticket.bookingId && t.payment?.provider);
return sibling?.payment?.provider ?? null;
}
return null;
}; };
// Filter tickets
const filteredTickets = tickets.filter((ticket) => { const filteredTickets = tickets.filter((ticket) => {
if (selectedEvent && ticket.eventId !== selectedEvent) return false; if (selectedEvent && ticket.eventId !== selectedEvent) return false;
if (selectedStatus && ticket.status !== selectedStatus) return false; if (selectedStatus && ticket.status !== selectedStatus) return false;
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false; if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
const name = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.toLowerCase();
return name.includes(q) || (ticket.attendeeEmail?.toLowerCase().includes(q) || false);
}
return true; return true;
}); });
// Sort by created date (newest first)
const sortedTickets = [...filteredTickets].sort( const sortedTickets = [...filteredTickets].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
); );
// Stats
const stats = { const stats = {
total: tickets.length, total: tickets.length,
pending: tickets.filter(t => t.status === 'pending').length, pending: tickets.filter(t => t.status === 'pending').length,
@@ -196,23 +194,36 @@ export default function AdminBookingsPage() {
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length, pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
}; };
// Helper to get booking info for a ticket (ticket count and total)
const getBookingInfo = (ticket: TicketWithDetails) => { const getBookingInfo = (ticket: TicketWithDetails) => {
if (!ticket.bookingId) { if (!ticket.bookingId) {
return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) }; return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) };
} }
const bookingTickets = tickets.filter(t => t.bookingId === ticket.bookingId);
// Count all tickets with the same bookingId
const bookingTickets = tickets.filter(
t => t.bookingId === ticket.bookingId
);
return { return {
ticketCount: bookingTickets.length, ticketCount: bookingTickets.length,
bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 0), bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 0),
}; };
}; };
const hasActiveFilters = selectedEvent || selectedStatus || selectedPaymentStatus || searchQuery;
const clearFilters = () => {
setSelectedEvent('');
setSelectedStatus('');
setSelectedPaymentStatus('');
setSearchQuery('');
};
const getPrimaryAction = (ticket: TicketWithDetails) => {
if (ticket.status === 'pending' && ticket.payment?.status === 'pending') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
}
if (ticket.status === 'confirmed') {
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' };
}
return null;
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -224,51 +235,61 @@ export default function AdminBookingsPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">Manage Bookings</h1> <h1 className="text-xl md:text-2xl font-bold text-primary-dark">Manage Bookings</h1>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6"> <div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-6 gap-2 md:gap-4 mb-6">
<Card className="p-4 text-center"> <Card className="p-3 md:p-4 text-center">
<p className="text-2xl font-bold text-primary-dark">{stats.total}</p> <p className="text-xl md:text-2xl font-bold text-primary-dark">{stats.total}</p>
<p className="text-sm text-gray-500">Total</p> <p className="text-xs md:text-sm text-gray-500">Total</p>
</Card> </Card>
<Card className="p-4 text-center border-l-4 border-yellow-400"> <Card className="p-3 md:p-4 text-center border-l-4 border-yellow-400">
<p className="text-2xl font-bold text-yellow-600">{stats.pending}</p> <p className="text-xl md:text-2xl font-bold text-yellow-600">{stats.pending}</p>
<p className="text-sm text-gray-500">Pending</p> <p className="text-xs md:text-sm text-gray-500">Pending</p>
</Card> </Card>
<Card className="p-4 text-center border-l-4 border-green-400"> <Card className="p-3 md:p-4 text-center border-l-4 border-green-400">
<p className="text-2xl font-bold text-green-600">{stats.confirmed}</p> <p className="text-xl md:text-2xl font-bold text-green-600">{stats.confirmed}</p>
<p className="text-sm text-gray-500">Confirmed</p> <p className="text-xs md:text-sm text-gray-500">Confirmed</p>
</Card> </Card>
<Card className="p-4 text-center border-l-4 border-blue-400"> <Card className="p-3 md:p-4 text-center border-l-4 border-blue-400">
<p className="text-2xl font-bold text-blue-600">{stats.checkedIn}</p> <p className="text-xl md:text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
<p className="text-sm text-gray-500">Checked In</p> <p className="text-xs md:text-sm text-gray-500">Checked In</p>
</Card> </Card>
<Card className="p-4 text-center border-l-4 border-red-400"> <Card className="p-3 md:p-4 text-center border-l-4 border-red-400">
<p className="text-2xl font-bold text-red-600">{stats.cancelled}</p> <p className="text-xl md:text-2xl font-bold text-red-600">{stats.cancelled}</p>
<p className="text-sm text-gray-500">Cancelled</p> <p className="text-xs md:text-sm text-gray-500">Cancelled</p>
</Card> </Card>
<Card className="p-4 text-center border-l-4 border-orange-400"> <Card className="p-3 md:p-4 text-center border-l-4 border-orange-400">
<p className="text-2xl font-bold text-orange-600">{stats.pendingPayment}</p> <p className="text-xl md:text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
<p className="text-sm text-gray-500">Pending Payment</p> <p className="text-xs md:text-sm text-gray-500">Pending Pay</p>
</Card> </Card>
</div> </div>
{/* Filters */} {/* Desktop Filters */}
<Card className="p-4 mb-6"> <Card className="p-4 mb-6 hidden md:block">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<FunnelIcon className="w-5 h-5 text-gray-500" /> <FunnelIcon className="w-5 h-5 text-gray-500" />
<span className="font-medium">Filters</span> <span className="font-medium">Filters</span>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label> <label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select <select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
value={selectedEvent} className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
onChange={(e) => setSelectedEvent(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Events</option> <option value="">All Events</option>
{events.map((event) => ( {events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option> <option key={event.id} value={event.id}>{event.title}</option>
@@ -277,11 +298,8 @@ export default function AdminBookingsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label> <label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
<select <select value={selectedStatus} onChange={(e) => setSelectedStatus(e.target.value)}
value={selectedStatus} className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
onChange={(e) => setSelectedStatus(e.target.value)}
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="confirmed">Confirmed</option> <option value="confirmed">Confirmed</option>
@@ -291,12 +309,9 @@ export default function AdminBookingsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label> <label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
<select <select value={selectedPaymentStatus} onChange={(e) => setSelectedPaymentStatus(e.target.value)}
value={selectedPaymentStatus} className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
onChange={(e) => setSelectedPaymentStatus(e.target.value)} <option value="">All Payments</option>
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">All Payment Statuses</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="paid">Paid</option> <option value="paid">Paid</option>
<option value="refunded">Refunded</option> <option value="refunded">Refunded</option>
@@ -304,26 +319,66 @@ export default function AdminBookingsPage() {
</select> </select>
</div> </div>
</div> </div>
{hasActiveFilters && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span>Showing {sortedTickets.length} of {tickets.length}</span>
<button onClick={clearFilters} className="text-primary-yellow hover:underline">Clear</button>
</div>
)}
</Card> </Card>
{/* Bookings List */} {/* Mobile Toolbar */}
<Card className="overflow-hidden"> <div className="md:hidden space-y-2 mb-4">
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
hasActiveFilters
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
: 'border-secondary-light-gray text-gray-600'
)}
>
<FunnelIcon className="w-4 h-4" />
Filters
{hasActiveFilters && <span className="text-xs">({sortedTickets.length})</span>}
</button>
{hasActiveFilters && (
<button onClick={clearFilters} className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center">
Clear
</button>
)}
</div>
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Payment</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Payment</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th> <th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{sortedTickets.length === 0 ? ( {sortedTickets.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500"> <td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">
No bookings found. No bookings found.
</td> </td>
</tr> </tr>
@@ -331,123 +386,69 @@ export default function AdminBookingsPage() {
sortedTickets.map((ticket) => { sortedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket); const bookingInfo = getBookingInfo(ticket);
return ( return (
<tr key={ticket.id} className="hover:bg-gray-50"> <tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4"> <td className="px-4 py-3">
<div className="space-y-1"> <p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
<div className="flex items-center gap-2"> <p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
<UserIcon className="w-4 h-4 text-gray-400" /> {ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
<span className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</span> </td>
</div> <td className="px-4 py-3">
<div className="flex items-center gap-2 text-sm text-gray-500"> <span className="text-sm truncate max-w-[150px] block">
<EnvelopeIcon className="w-4 h-4" /> {ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
<span>{ticket.attendeeEmail || 'N/A'}</span> </span>
</div> </td>
<div className="flex items-center gap-2 text-sm text-gray-500"> <td className="px-4 py-3">
<PhoneIcon className="w-4 h-4" /> <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
<span>{ticket.attendeePhone || 'N/A'}</span>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className="text-sm">
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
</span>
</td>
<td className="px-6 py-4">
<div className="space-y-1">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
{ticket.payment?.status || 'pending'} {ticket.payment?.status || 'pending'}
</span> </span>
<p className="text-sm text-gray-500"> <p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(getDisplayProvider(ticket))}</p>
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
</p>
{ticket.payment && ( {ticket.payment && (
<div> <p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
<p className="text-sm font-medium">
{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}
</p>
{bookingInfo.ticketCount > 1 && (
<p className="text-xs text-purple-600 mt-1">
📦 {bookingInfo.ticketCount} × {Number(ticket.payment.amount).toLocaleString()} {ticket.payment.currency}
</p>
)}
</div>
)} )}
</div> </td>
</td> <td className="px-4 py-3">
<td className="px-6 py-4"> <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}> {ticket.status.replace('_', ' ')}
{ticket.status} </span>
</span> {ticket.bookingId && (
{ticket.qrCode && ( <p className="text-[10px] text-purple-600 mt-0.5">Group Booking</p>
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
)}
{ticket.bookingId && (
<p className="text-xs text-purple-600 mt-1" title="Part of multi-ticket booking">
📦 Group Booking
</p>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(ticket.createdAt)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{/* Mark as Paid (for pending payments) */}
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
<Button
size="sm"
variant="ghost"
onClick={() => handleMarkPaid(ticket.id)}
isLoading={processing === ticket.id}
className="text-green-600 hover:bg-green-50"
>
<CurrencyDollarIcon className="w-4 h-4 mr-1" />
Mark Paid
</Button>
)} )}
</td>
{/* Check-in (for confirmed tickets) */} <td className="px-4 py-3 text-xs text-gray-500">
{ticket.status === 'confirmed' && ( {formatDate(ticket.createdAt)}
<Button </td>
size="sm" <td className="px-4 py-3">
variant="ghost" <div className="flex items-center justify-end gap-1">
onClick={() => handleCheckin(ticket.id)} {ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
isLoading={processing === ticket.id} <Button size="sm" variant="outline" onClick={() => handleMarkPaid(ticket.id)}
className="text-blue-600 hover:bg-blue-50" isLoading={processing === ticket.id} className="text-xs px-2 py-1">
> Mark Paid
<CheckCircleIcon className="w-4 h-4 mr-1" /> </Button>
Check In )}
</Button> {ticket.status === 'confirmed' && (
)} <Button size="sm" onClick={() => handleCheckin(ticket.id)}
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
{/* Cancel (for pending/confirmed) */} Check In
{(ticket.status === 'pending' || ticket.status === 'confirmed') && ( </Button>
<Button )}
size="sm" {(ticket.status === 'pending' || ticket.status === 'confirmed') && (
variant="ghost" <MoreMenu>
onClick={() => handleCancel(ticket.id)} <DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
isLoading={processing === ticket.id} <XCircleIcon className="w-4 h-4 mr-2" /> Cancel
className="text-red-600 hover:bg-red-50" </DropdownItem>
> </MoreMenu>
<XCircleIcon className="w-4 h-4 mr-1" /> )}
Cancel {ticket.status === 'checked_in' && (
</Button> <span className="text-xs text-green-600 flex items-center gap-1">
)} <CheckCircleIcon className="w-4 h-4" /> Attended
</span>
{ticket.status === 'checked_in' && ( )}
<span className="text-sm text-green-600 flex items-center gap-1"> {ticket.status === 'cancelled' && (
<CheckCircleIcon className="w-4 h-4" /> <span className="text-xs text-gray-400">Cancelled</span>
Attended )}
</span> </div>
)} </td>
</tr>
{ticket.status === 'cancelled' && (
<span className="text-sm text-gray-400">Cancelled</span>
)}
</div>
</td>
</tr>
); );
}) })
)} )}
@@ -455,6 +456,158 @@ export default function AdminBookingsPage() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{sortedTickets.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">
No bookings found.
</div>
) : (
sortedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket);
const primary = getPrimaryAction(ticket);
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
return (
<Card key={ticket.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail || 'N/A'}</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className={clsx('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', getStatusColor(ticket.status))}>
{ticket.status.replace('_', ' ')}
</span>
</div>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span className="truncate">{eventTitle}</span>
<span className="text-gray-300">|</span>
<span className={clsx('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', getPaymentStatusColor(ticket.payment?.status || 'pending'))}>
{ticket.payment?.status || 'pending'}
</span>
{ticket.payment && (
<>
<span className="text-gray-300">|</span>
<span className="font-medium text-gray-700">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</span>
</>
)}
</div>
{ticket.bookingId && (
<p className="text-[10px] text-purple-600 mt-1">{bookingInfo.ticketCount} tickets - Group Booking</p>
)}
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">{formatDate(ticket.createdAt)}</p>
<div className="flex items-center gap-1">
{primary && (
<Button size="sm" variant={ticket.status === 'confirmed' ? 'primary' : 'outline'}
onClick={primary.onClick} isLoading={processing === ticket.id}
className="text-xs px-2.5 py-1.5 min-h-[36px]">
{primary.label}
</Button>
)}
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
<MoreMenu>
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && !primary && (
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
<CurrencyDollarIcon className="w-4 h-4 mr-2" /> Mark Paid
</DropdownItem>
)}
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel Booking
</DropdownItem>
</MoreMenu>
)}
{ticket.status === 'checked_in' && (
<span className="text-[10px] text-green-600 flex items-center gap-1">
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
</span>
)}
{ticket.status === 'cancelled' && (
<span className="text-[10px] text-gray-400">Cancelled</span>
)}
</div>
</div>
</Card>
);
})
)}
</div>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
<div className="space-y-1">
{[
{ value: '', label: 'All Statuses' },
{ value: 'pending', label: `Pending (${stats.pending})` },
{ value: 'confirmed', label: `Confirmed (${stats.confirmed})` },
{ value: 'checked_in', label: `Checked In (${stats.checkedIn})` },
{ value: 'cancelled', label: `Cancelled (${stats.cancelled})` },
].map((opt) => (
<button
key={opt.value}
onClick={() => setSelectedStatus(opt.value)}
className={clsx(
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
selectedStatus === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}
>
{opt.label}
{selectedStatus === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
<div className="space-y-1">
{[
{ value: '', label: 'All Payments' },
{ value: 'pending', label: 'Pending' },
{ value: 'paid', label: 'Paid' },
{ value: 'refunded', label: 'Refunded' },
{ value: 'failed', label: 'Failed' },
].map((opt) => (
<button
key={opt.value}
onClick={() => setSelectedPaymentStatus(opt.value)}
className={clsx(
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
selectedPaymentStatus === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}
>
{opt.label}
{selectedPaymentStatus === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => { clearFilters(); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">
Clear All
</Button>
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">
Apply
</Button>
</div>
</div>
</BottomSheet>
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
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';
export default function AdminContactsPage() { export default function AdminContactsPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
@@ -44,7 +45,7 @@ export default function AdminContactsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',

View File

@@ -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 { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api'; import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
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 Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { import {
EnvelopeIcon, EnvelopeIcon,
PencilIcon, PencilIcon,
@@ -18,6 +20,9 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
XMarkIcon,
ArrowPathIcon,
MagnifyingGlassIcon,
} 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';
@@ -50,6 +55,11 @@ export default function AdminEmailsPage() {
const [logs, setLogs] = useState<EmailLog[]>([]); const [logs, setLogs] = useState<EmailLog[]>([]);
const [logsOffset, setLogsOffset] = useState(0); const [logsOffset, setLogsOffset] = useState(0);
const [logsTotal, setLogsTotal] = useState(0); const [logsTotal, setLogsTotal] = useState(0);
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
const [logsSearch, setLogsSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [logsEventFilter, setLogsEventFilter] = useState('');
const [resendingLogId, setResendingLogId] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null); const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
// Stats state // Stats state
@@ -208,11 +218,20 @@ export default function AdminEmailsPage() {
} }
}; };
useEffect(() => {
const handle = setTimeout(() => setDebouncedSearch(logsSearch), 300);
return () => clearTimeout(handle);
}, [logsSearch]);
useEffect(() => {
setLogsOffset(0);
}, [debouncedSearch, logsEventFilter]);
useEffect(() => { useEffect(() => {
if (activeTab === 'logs') { if (activeTab === 'logs') {
loadLogs(); loadLogs();
} }
}, [activeTab, logsOffset]); }, [activeTab, logsOffset, logsSubTab, debouncedSearch, logsEventFilter]);
const loadData = async () => { const loadData = async () => {
try { try {
@@ -231,7 +250,13 @@ export default function AdminEmailsPage() {
const loadLogs = async () => { const loadLogs = async () => {
try { try {
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset }); const res = await emailsApi.getLogs({
limit: 20,
offset: logsOffset,
...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(logsEventFilter ? { eventId: logsEventFilter } : {}),
});
setLogs(res.logs); setLogs(res.logs);
setLogsTotal(res.pagination.total); setLogsTotal(res.pagination.total);
} catch (error) { } catch (error) {
@@ -239,6 +264,27 @@ export default function AdminEmailsPage() {
} }
}; };
const handleResend = async (log: EmailLog) => {
setResendingLogId(log.id);
try {
const res = await emailsApi.resendLog(log.id);
if (res.success) {
toast.success('Email re-sent successfully');
} else {
toast.error(res.error || 'Failed to re-send email');
}
await loadLogs();
if (selectedLog?.id === log.id) {
const { log: updatedLog } = await emailsApi.getLog(log.id);
setSelectedLog(updatedLog);
}
} catch (error: any) {
toast.error(error.message || 'Failed to re-send email');
} finally {
setResendingLogId(null);
}
};
const resetTemplateForm = () => { const resetTemplateForm = () => {
setTemplateForm({ setTemplateForm({
name: '', name: '',
@@ -361,7 +407,7 @@ export default function AdminEmailsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -382,7 +428,7 @@ export default function AdminEmailsPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">Email Center</h1> <h1 className="text-xl md:text-2xl font-bold text-primary-dark">Email Center</h1>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
@@ -436,18 +482,15 @@ export default function AdminEmailsPage() {
)} )}
{/* Tabs */} {/* Tabs */}
<div className="border-b border-secondary-light-gray mb-6"> <div className="border-b border-secondary-light-gray mb-6 overflow-x-auto scrollbar-hide">
<nav className="flex gap-6"> <nav className="flex gap-4 md:gap-6 min-w-max">
{(['templates', 'compose', 'logs'] as TabType[]).map((tab) => ( {(['templates', 'compose', 'logs'] as TabType[]).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className={clsx( className={clsx(
'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative', 'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative whitespace-nowrap min-h-[44px]',
{ activeTab === tab ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
'border-primary-yellow text-primary-dark': activeTab === tab,
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab,
}
)} )}
> >
{tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'} {tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'}
@@ -499,30 +542,35 @@ export default function AdminEmailsPage() {
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<button <button onClick={() => handlePreviewTemplate(template)}
onClick={() => handlePreviewTemplate(template)} className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" title="Preview">
className="p-2 hover:bg-gray-100 rounded-btn"
title="Preview"
>
<EyeIcon className="w-5 h-5" /> <EyeIcon className="w-5 h-5" />
</button> </button>
<button <button onClick={() => handleEditTemplate(template)}
onClick={() => handleEditTemplate(template)} className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center hidden md:flex" title="Edit">
className="p-2 hover:bg-gray-100 rounded-btn"
title="Edit"
>
<PencilIcon className="w-5 h-5" /> <PencilIcon className="w-5 h-5" />
</button> </button>
{!template.isSystem && ( <div className="hidden md:block">
<button {!template.isSystem && (
onClick={() => handleDeleteTemplate(template.id)} <button onClick={() => handleDeleteTemplate(template.id)}
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" className="p-2 hover:bg-red-100 text-red-600 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" title="Delete">
title="Delete" <XCircleIcon className="w-5 h-5" />
> </button>
<XCircleIcon className="w-5 h-5" /> )}
</button> </div>
)} <div className="md:hidden">
<MoreMenu>
<DropdownItem onClick={() => handleEditTemplate(template)}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit
</DropdownItem>
{!template.isSystem && (
<DropdownItem onClick={() => handleDeleteTemplate(template.id)} className="text-red-600">
<XCircleIcon className="w-4 h-4 mr-2" /> Delete
</DropdownItem>
)}
</MoreMenu>
</div>
</div> </div>
</div> </div>
</Card> </Card>
@@ -540,7 +588,7 @@ export default function AdminEmailsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasDraft && ( {hasDraft && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''} Draft saved {composeForm.savedAt ? parseDate(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''}
</span> </span>
)} )}
<Button variant="outline" size="sm" onClick={saveDraft}> <Button variant="outline" size="sm" onClick={saveDraft}>
@@ -564,9 +612,9 @@ export default function AdminEmailsPage() {
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
> >
<option value="">Choose an event</option> <option value="">Choose an event</option>
{events.filter(e => e.status === 'published').map((event) => ( {events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
<option key={event.id} value={event.id}> <option key={event.id} value={event.id}>
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })} {event.title} - {parseDate(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
</option> </option>
))} ))}
</select> </select>
@@ -635,13 +683,17 @@ export default function AdminEmailsPage() {
{/* Recipient Preview Modal */} {/* Recipient Preview Modal */}
{showRecipientPreview && ( {showRecipientPreview && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center 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">
<Card className="w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"> <Card className="w-full md:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card">
<div className="p-4 border-b border-secondary-light-gray"> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<h2 className="text-lg font-bold">Recipient Preview</h2> <div>
<p className="text-sm text-gray-500"> <h2 className="text-base font-bold">Recipient Preview</h2>
{previewRecipients.length} recipient(s) will receive this email <p className="text-xs text-gray-500">{previewRecipients.length} recipient(s)</p>
</p> </div>
<button onClick={() => setShowRecipientPreview(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> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
@@ -675,14 +727,10 @@ export default function AdminEmailsPage() {
</div> </div>
<div className="p-4 border-t border-secondary-light-gray flex gap-3"> <div className="p-4 border-t border-secondary-light-gray flex gap-3">
<Button <Button onClick={handleSendEmail} isLoading={sending} disabled={previewRecipients.length === 0} className="flex-1 min-h-[44px]">
onClick={handleSendEmail} Send to {previewRecipients.length}
isLoading={sending}
disabled={previewRecipients.length === 0}
>
Send to {previewRecipients.length} Recipients
</Button> </Button>
<Button variant="outline" onClick={() => setShowRecipientPreview(false)}> <Button variant="outline" onClick={() => setShowRecipientPreview(false)} className="flex-1 min-h-[44px]">
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -695,51 +743,114 @@ export default function AdminEmailsPage() {
{/* Logs Tab */} {/* Logs Tab */}
{activeTab === 'logs' && ( {activeTab === 'logs' && (
<div> <div>
<Card className="overflow-hidden"> {/* Sub-tabs: All | Failed */}
<div className="border-b border-secondary-light-gray mb-4">
<nav className="flex gap-4">
<button
onClick={() => { setLogsSubTab('all'); setLogsOffset(0); }}
className={clsx(
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
logsSubTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
All
</button>
<button
onClick={() => { setLogsSubTab('failed'); setLogsOffset(0); }}
className={clsx(
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
logsSubTab === 'failed' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
Failed
{stats && stats.failed > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700">
{stats.failed}
</span>
)}
</button>
</nav>
</div>
{/* Filters: search + event */}
<div className="flex flex-col md:flex-row gap-3 mb-4">
<div className="relative flex-1">
<MagnifyingGlassIcon className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={logsSearch}
onChange={(e) => setLogsSearch(e.target.value)}
placeholder="Search by recipient or subject..."
className="w-full pl-10 pr-10 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
{logsSearch && (
<button
onClick={() => setLogsSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-btn"
title="Clear search"
>
<XMarkIcon className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
<select
value={logsEventFilter}
onChange={(e) => setLogsEventFilter(e.target.value)}
className="w-full md:w-64 px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="">All events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{event.title}
</option>
))}
</select>
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Recipient</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Recipient</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Subject</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Subject</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Sent</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Sent</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th> <th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{logs.length === 0 ? ( {logs.length === 0 ? (
<tr> <tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No emails sent yet
</td>
</tr>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50"> <tr key={log.id} className="hover:bg-gray-50">
<td className="px-6 py-4"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
{getStatusIcon(log.status)} <div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div>
<span className="capitalize text-sm">{log.status}</span> {(log.resendAttempts ?? 0) > 0 && (
<span className="text-xs text-gray-500">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</span>
)}
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-4 py-3">
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p> <p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
<p className="text-sm text-gray-500">{log.recipientEmail}</p> <p className="text-xs text-gray-500">{log.recipientEmail}</p>
</td> </td>
<td className="px-6 py-4 max-w-xs"> <td className="px-4 py-3 max-w-xs"><p className="text-sm truncate">{log.subject}</p></td>
<p className="text-sm truncate">{log.subject}</p> <td className="px-4 py-3 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td>
</td> <td className="px-4 py-3">
<td className="px-6 py-4 text-sm text-gray-600"> <div className="flex items-center justify-end gap-1">
{formatDate(log.sentAt || log.createdAt)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
<button <button
onClick={() => setSelectedLog(log)} onClick={() => handleResend(log)}
className="p-2 hover:bg-gray-100 rounded-btn" disabled={resendingLogId === log.id}
title="View Email" className="p-2 hover:bg-gray-100 rounded-btn disabled:opacity-50"
title="Re-send"
> >
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === log.id && 'animate-spin')} />
</button>
<button onClick={() => setSelectedLog(log)} className="p-2 hover:bg-gray-100 rounded-btn" title="View">
<EyeIcon className="w-4 h-4" /> <EyeIcon className="w-4 h-4" />
</button> </button>
</div> </div>
@@ -750,46 +861,80 @@ export default function AdminEmailsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination */}
{logsTotal > 20 && ( {logsTotal > 20 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary-light-gray"> <div className="flex items-center justify-between px-4 py-3 border-t border-secondary-light-gray">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}</p>
Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button variant="outline" size="sm" disabled={logsOffset === 0} onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}>
variant="outline"
size="sm"
disabled={logsOffset === 0}
onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}
>
<ChevronLeftIcon className="w-4 h-4" /> <ChevronLeftIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button variant="outline" size="sm" disabled={logsOffset + 20 >= logsTotal} onClick={() => setLogsOffset(logsOffset + 20)}>
variant="outline"
size="sm"
disabled={logsOffset + 20 >= logsTotal}
onClick={() => setLogsOffset(logsOffset + 20)}
>
<ChevronRightIcon className="w-4 h-4" /> <ChevronRightIcon className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div> </div>
)} )}
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{logs.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
) : (
logs.map((log) => (
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(log.status)}</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{log.subject}</p>
<p className="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} &lt;{log.recipientEmail}&gt;</p>
<p className="text-[10px] text-gray-400 mt-1">{formatDate(log.sentAt || log.createdAt)}</p>
{(log.resendAttempts ?? 0) > 0 && (
<p className="text-[10px] text-gray-500 mt-0.5">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</p>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); handleResend(log); }}
disabled={resendingLogId === log.id}
className="p-2 hover:bg-gray-100 rounded-btn flex-shrink-0 disabled:opacity-50"
title="Re-send"
>
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === log.id && 'animate-spin')} />
</button>
</div>
</Card>
))
)}
{logsTotal > 20 && (
<div className="flex items-center justify-between py-3">
<p className="text-xs text-gray-500">{logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={logsOffset === 0} onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))} className="min-h-[44px]">
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" disabled={logsOffset + 20 >= logsTotal} onClick={() => setLogsOffset(logsOffset + 20)} className="min-h-[44px]">
<ChevronRightIcon className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
</div> </div>
)} )}
{/* Template Form Modal */} {/* Template Form Modal */}
{showTemplateForm && ( {showTemplateForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center 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">
<Card className="w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6"> <Card className="w-full md:max-w-4xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<h2 className="text-xl font-bold mb-6"> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
{editingTemplate ? 'Edit Template' : 'Create Template'} <h2 className="text-base font-bold">{editingTemplate ? 'Edit Template' : 'Create Template'}</h2>
</h2> <button onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
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={handleSaveTemplate} className="space-y-4"> <form onSubmit={handleSaveTemplate} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input <Input
label="Template Name" label="Template Name"
@@ -873,14 +1018,10 @@ export default function AdminEmailsPage() {
</div> </div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving}> <Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">
{editingTemplate ? 'Update Template' : 'Create Template'} {editingTemplate ? 'Update' : 'Create'}
</Button> </Button>
<Button <Button type="button" variant="outline" onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }} className="flex-1 min-h-[44px]">
type="button"
variant="outline"
onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -891,16 +1032,17 @@ export default function AdminEmailsPage() {
{/* Preview Modal */} {/* Preview Modal */}
{previewHtml && ( {previewHtml && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center 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">
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col"> <Card className="w-full md:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray"> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<div> <div className="min-w-0">
<h2 className="text-lg font-bold">Email Preview</h2> <h2 className="text-base font-bold">Email Preview</h2>
<p className="text-sm text-gray-500">Subject: {previewSubject}</p> <p className="text-xs text-gray-500 truncate">Subject: {previewSubject}</p>
</div> </div>
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)}> <button onClick={() => setPreviewHtml(null)}
Close className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center flex-shrink-0">
</Button> <XMarkIcon className="w-5 h-5" />
</button>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<iframe <iframe
@@ -914,23 +1056,40 @@ export default function AdminEmailsPage() {
)} )}
{/* Log Detail Modal */} {/* Log Detail Modal */}
<AdminMobileStyles />
{selectedLog && ( {selectedLog && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center 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">
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col"> <Card className="w-full md:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray"> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<div> <div className="min-w-0">
<h2 className="text-lg font-bold">Email Details</h2> <h2 className="text-base font-bold">Email Details</h2>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1 flex-wrap">
{getStatusIcon(selectedLog.status)} {getStatusIcon(selectedLog.status)}
<span className="capitalize text-sm">{selectedLog.status}</span> <span className="capitalize text-sm">{selectedLog.status}</span>
{selectedLog.errorMessage && ( {selectedLog.errorMessage && (
<span className="text-sm text-red-500">- {selectedLog.errorMessage}</span> <span className="text-xs text-red-500">- {selectedLog.errorMessage}</span>
)}
{(selectedLog.resendAttempts ?? 0) > 0 && (
<span className="text-xs text-gray-500">Re-sent {selectedLog.resendAttempts} time{(selectedLog.resendAttempts ?? 0) !== 1 ? 's' : ''}{selectedLog.lastResentAt ? ` (${formatDate(selectedLog.lastResentAt)})` : ''}</span>
)} )}
</div> </div>
</div> </div>
<Button variant="outline" size="sm" onClick={() => setSelectedLog(null)}> <div className="flex items-center gap-1 flex-shrink-0">
Close <button
</Button> onClick={() => handleResend(selectedLog)}
disabled={resendingLogId === selectedLog.id}
className="p-2 hover:bg-gray-100 rounded-btn disabled:opacity-50 flex items-center gap-1.5 text-sm"
title="Re-send"
>
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === selectedLog.id && 'animate-spin')} />
Re-send
</button>
<button onClick={() => setSelectedLog(null)}
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>
</div> </div>
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50"> <div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
<p><strong>To:</strong> {selectedLog.recipientName} &lt;{selectedLog.recipientEmail}&gt;</p> <p><strong>To:</strong> {selectedLog.recipientName} &lt;{selectedLog.recipientEmail}&gt;</p>

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,24 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext'; 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 Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import MediaPicker from '@/components/MediaPicker'; import MediaPicker from '@/components/MediaPicker';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon } from '@heroicons/react/24/outline'; import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, XMarkIcon, LinkIcon } from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
export default function AdminEventsPage() { export default function AdminEventsPage() {
const router = useRouter();
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const searchParams = useSearchParams();
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -23,9 +28,11 @@ export default function AdminEventsPage() {
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null); const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
const [settingFeatured, setSettingFeatured] = useState<string | null>(null); const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
title: string; title: string;
titleEs: string; titleEs: string;
slug: string;
description: string; description: string;
descriptionEs: string; descriptionEs: string;
shortDescription: string; shortDescription: string;
@@ -37,13 +44,14 @@ export default function AdminEventsPage() {
price: number; price: number;
currency: string; currency: string;
capacity: number; capacity: number;
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived'; status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
bannerUrl: string; bannerUrl: string;
externalBookingEnabled: boolean; externalBookingEnabled: boolean;
externalBookingUrl: string; externalBookingUrl: string;
}>({ }>({
title: '', title: '',
titleEs: '', titleEs: '',
slug: '',
description: '', description: '',
descriptionEs: '', descriptionEs: '',
shortDescription: '', shortDescription: '',
@@ -66,6 +74,14 @@ export default function AdminEventsPage() {
loadFeaturedEvent(); loadFeaturedEvent();
}, []); }, []);
useEffect(() => {
const editId = searchParams.get('edit');
if (editId && events.length > 0) {
const event = events.find(e => e.id === editId);
if (event) handleEdit(event);
}
}, [searchParams, events]);
const loadEvents = async () => { const loadEvents = async () => {
try { try {
const { events } = await eventsApi.getAll(); const { events } = await eventsApi.getAll();
@@ -82,7 +98,7 @@ export default function AdminEventsPage() {
const { settings } = await siteSettingsApi.get(); const { settings } = await siteSettingsApi.get();
setFeaturedEventId(settings.featuredEventId || null); setFeaturedEventId(settings.featuredEventId || null);
} catch (error) { } catch (error) {
// Ignore error - settings may not exist yet // Ignore - settings may not exist yet
} }
}; };
@@ -100,69 +116,76 @@ export default function AdminEventsPage() {
}; };
const resetForm = () => { const resetForm = () => {
setSlugAliases([]);
setFormData({ setFormData({
title: '', title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
titleEs: '', shortDescription: '', shortDescriptionEs: '',
description: '', startDatetime: '', endDatetime: '', location: '', locationUrl: '',
descriptionEs: '', price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
shortDescription: '', bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
shortDescriptionEs: '',
startDatetime: '',
endDatetime: '',
location: '',
locationUrl: '',
price: 0,
currency: 'PYG',
capacity: 50,
status: 'draft' as const,
bannerUrl: '',
externalBookingEnabled: false,
externalBookingUrl: '',
}); });
setEditingEvent(null); setEditingEvent(null);
}; };
// Convert ISO UTC string to local datetime-local format (YYYY-MM-DDTHH:MM)
const isoToLocalDatetime = (isoString: string): string => { const isoToLocalDatetime = (isoString: string): string => {
const date = new Date(isoString); const date = parseDate(isoString);
const year = date.getFullYear(); const parts = new Intl.DateTimeFormat('en-US', {
const month = String(date.getMonth() + 1).padStart(2, '0'); timeZone: EVENT_TIMEZONE,
const day = String(date.getDate()).padStart(2, '0'); year: 'numeric',
const hours = String(date.getHours()).padStart(2, '0'); month: '2-digit',
const minutes = String(date.getMinutes()).padStart(2, '0'); day: '2-digit',
return `${year}-${month}-${day}T${hours}:${minutes}`; hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const get = (type: string) => parts.find(p => p.type === type)!.value;
const h = get('hour') === '24' ? '00' : get('hour');
return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`;
};
const loadSlugAliases = async (eventId: string) => {
try {
const { aliases } = await eventsApi.getSlugAliases(eventId);
setSlugAliases(aliases);
} catch (error) {
setSlugAliases([]);
}
};
const handleRemoveAlias = async (slug: string) => {
if (!editingEvent) return;
if (!confirm(`Remove alias "${slug}"? The old URL /events/${slug} will stop working.`)) return;
try {
await eventsApi.deleteSlugAlias(editingEvent.id, slug);
toast.success('Alias removed');
setSlugAliases((prev) => prev.filter((a) => a.slug !== slug));
} catch (error: any) {
toast.error(error.message || 'Failed to remove alias');
}
}; };
const handleEdit = (event: Event) => { const handleEdit = (event: Event) => {
setFormData({ setFormData({
title: event.title, title: event.title, titleEs: event.titleEs || '', slug: event.slug || '',
titleEs: event.titleEs || '', description: event.description, descriptionEs: event.descriptionEs || '',
description: event.description, shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
descriptionEs: event.descriptionEs || '',
shortDescription: event.shortDescription || '',
shortDescriptionEs: event.shortDescriptionEs || '',
startDatetime: isoToLocalDatetime(event.startDatetime), startDatetime: isoToLocalDatetime(event.startDatetime),
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '', endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
location: event.location, location: event.location, locationUrl: event.locationUrl || '',
locationUrl: event.locationUrl || '', price: event.price, currency: event.currency, capacity: event.capacity,
price: event.price, status: event.status, bannerUrl: event.bannerUrl || '',
currency: event.currency,
capacity: event.capacity,
status: event.status,
bannerUrl: event.bannerUrl || '',
externalBookingEnabled: event.externalBookingEnabled || false, externalBookingEnabled: event.externalBookingEnabled || false,
externalBookingUrl: event.externalBookingUrl || '', externalBookingUrl: event.externalBookingUrl || '',
}); });
setEditingEvent(event); setEditingEvent(event);
setShowForm(true); setShowForm(true);
loadSlugAliases(event.id);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
try { try {
// Validate external booking URL if enabled
if (formData.externalBookingEnabled && !formData.externalBookingUrl) { if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
toast.error('External booking URL is required when external booking is enabled'); toast.error('External booking URL is required when external booking is enabled');
setSaving(false); setSaving(false);
@@ -173,35 +196,27 @@ export default function AdminEventsPage() {
setSaving(false); setSaving(false);
return; return;
} }
const eventData: Partial<Event> = {
const eventData = { title: formData.title, titleEs: formData.titleEs || undefined,
title: formData.title, description: formData.description, descriptionEs: formData.descriptionEs || undefined,
titleEs: formData.titleEs || undefined, shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
description: formData.description, startDatetime: formData.startDatetime,
descriptionEs: formData.descriptionEs || undefined, endDatetime: formData.endDatetime || undefined,
shortDescription: formData.shortDescription || undefined, location: formData.location, locationUrl: formData.locationUrl || undefined,
shortDescriptionEs: formData.shortDescriptionEs || undefined, price: formData.price, currency: formData.currency, capacity: formData.capacity,
startDatetime: new Date(formData.startDatetime).toISOString(), status: formData.status, bannerUrl: formData.bannerUrl || undefined,
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
location: formData.location,
locationUrl: formData.locationUrl || undefined,
price: formData.price,
currency: formData.currency,
capacity: formData.capacity,
status: formData.status,
bannerUrl: formData.bannerUrl || undefined,
externalBookingEnabled: formData.externalBookingEnabled, externalBookingEnabled: formData.externalBookingEnabled,
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined, externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
}; };
if (editingEvent) { if (editingEvent) {
// Only send slug when editing so creates still auto-generate from title
eventData.slug = formData.slug || undefined;
await eventsApi.update(editingEvent.id, eventData); await eventsApi.update(editingEvent.id, eventData);
toast.success('Event updated'); toast.success('Event updated');
} else { } else {
await eventsApi.create(eventData); await eventsApi.create(eventData);
toast.success('Event created'); toast.success('Event created');
} }
setShowForm(false); setShowForm(false);
resetForm(); resetForm();
loadEvents(); loadEvents();
@@ -214,7 +229,6 @@ export default function AdminEventsPage() {
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this event?')) return; if (!confirm('Are you sure you want to delete this event?')) return;
try { try {
await eventsApi.delete(id); await eventsApi.delete(id);
toast.success('Event deleted'); toast.success('Event deleted');
@@ -234,23 +248,21 @@ export default function AdminEventsPage() {
} }
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
day: 'numeric',
year: 'numeric',
timeZone: 'America/Asuncion',
}); });
}; };
const isEventOver = (event: Event) => {
const refDate = event.endDatetime || event.startDatetime;
return new Date(refDate) < new Date();
};
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
const styles: Record<string, string> = { const styles: Record<string, string> = {
draft: 'badge-gray', draft: 'badge-gray', published: 'badge-success', unlisted: 'badge-warning',
published: 'badge-success', cancelled: 'badge-danger', completed: 'badge-info', archived: 'badge-gray',
cancelled: 'badge-danger',
completed: 'badge-info',
archived: 'badge-gray',
}; };
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>; return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
}; };
@@ -286,8 +298,8 @@ export default function AdminEventsPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1> <h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
<Button onClick={() => { resetForm(); setShowForm(true); }}> <Button onClick={() => { resetForm(); setShowForm(true); }} className="hidden md:flex">
<PlusIcon className="w-5 h-5 mr-2" /> <PlusIcon className="w-5 h-5 mr-2" />
{t('admin.events.create')} {t('admin.events.create')}
</Button> </Button>
@@ -295,221 +307,180 @@ export default function AdminEventsPage() {
{/* Event Form Modal */} {/* Event Form Modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center 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">
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6"> <Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<h2 className="text-xl font-bold mb-6"> <div className="flex items-center justify-between p-4 md:p-6 border-b border-secondary-light-gray flex-shrink-0">
{editingEvent ? t('admin.events.edit') : t('admin.events.create')} <h2 className="text-lg md:text-xl font-bold">
</h2> {editingEvent ? t('admin.events.edit') : t('admin.events.create')}
</h2>
<button onClick={() => { setShowForm(false); resetForm(); }}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="p-4 md:p-6 space-y-4 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input <Input label="Title (English)" value={formData.title}
label="Title (English)" onChange={(e) => setFormData({ ...formData, title: e.target.value })} required />
value={formData.title} <Input label="Title (Spanish)" value={formData.titleEs}
onChange={(e) => setFormData({ ...formData, title: e.target.value })} onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
required
/>
<Input
label="Title (Spanish)"
value={formData.titleEs}
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })}
/>
</div> </div>
{editingEvent && (
<div>
<Input label="URL Slug" value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="auto-generated from title" />
<p className="text-xs text-gray-500 mt-1">
Public URL: <span className="font-mono">/events/{formData.slug || '...'}</span>
. Changing the slug keeps the old one as a redirecting alias.
</p>
{slugAliases.length > 0 && (
<div className="mt-3 rounded-btn border border-secondary-light-gray p-3">
<p className="text-sm font-medium mb-2">URL aliases</p>
<p className="text-xs text-gray-500 mb-2">
Old URLs that still redirect to the current slug. Removing one breaks those links.
</p>
<ul className="space-y-1">
{slugAliases.map((alias) => (
<li key={alias.slug} className="flex items-center justify-between gap-2 text-sm">
<span className="font-mono truncate">/events/{alias.slug}</span>
<button type="button" onClick={() => handleRemoveAlias(alias.slug)}
className="p-1.5 hover:bg-red-50 text-red-600 rounded-btn flex-shrink-0"
title="Remove alias">
<TrashIcon className="w-4 h-4" />
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
<div> <div>
<label className="block text-sm font-medium mb-1">Description (English)</label> <label className="block text-sm font-medium mb-1">Description (English)</label>
<textarea <textarea value={formData.description}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={3} rows={3} required />
required
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Description (Spanish)</label> <label className="block text-sm font-medium mb-1">Description (Spanish)</label>
<textarea <textarea value={formData.descriptionEs}
value={formData.descriptionEs}
onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })} onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={3} rows={3} />
/>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Short Description (English)</label> <label className="block text-sm font-medium mb-1">Short Description (English)</label>
<textarea <textarea value={formData.shortDescription}
value={formData.shortDescription}
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })} onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} rows={2} maxLength={300} placeholder="Brief summary for SEO and cards (max 300 chars)" />
maxLength={300} <p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300</p>
placeholder="Brief summary for SEO and cards (max 300 chars)"
/>
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300 characters</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label> <label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
<textarea <textarea value={formData.shortDescriptionEs}
value={formData.shortDescriptionEs}
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })} onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2} rows={2} maxLength={300} placeholder="Resumen breve (máx 300 caracteres)" />
maxLength={300} <p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300</p>
placeholder="Resumen breve para SEO y tarjetas (máx 300 caracteres)"
/>
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300 characters</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input <Input label="Start Date & Time" type="datetime-local" value={formData.startDatetime}
label="Start Date & Time" onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })} required />
type="datetime-local" <Input label="End Date & Time" type="datetime-local" value={formData.endDatetime}
value={formData.startDatetime} onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })} />
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })}
required
/>
<Input
label="End Date & Time"
type="datetime-local"
value={formData.endDatetime}
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })}
/>
</div> </div>
<Input <Input label="Location" value={formData.location}
label="Location" onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
value={formData.location} <Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, location: e.target.value })} onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
required
/>
<Input <div className="grid grid-cols-3 gap-4">
label="Location URL (Google Maps)" <Input label="Price" type="number" min="0" value={formData.price}
type="url" onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
value={formData.locationUrl}
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
label="Price"
type="number"
min="0"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
/>
<div> <div>
<label className="block text-sm font-medium mb-1">Currency</label> <label className="block text-sm font-medium mb-1">Currency</label>
<select <select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
value={formData.currency} className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="PYG">PYG</option> <option value="PYG">PYG</option>
<option value="USD">USD</option> <option value="USD">USD</option>
</select> </select>
</div> </div>
<Input <Input label="Capacity" type="number" min="1" value={formData.capacity}
label="Capacity" onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
type="number"
min="1"
value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })}
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-sm font-medium mb-1">Status</label>
<select <select value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
value={formData.status} className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
>
<option value="draft">Draft</option> <option value="draft">Draft</option>
<option value="published">Published</option> <option value="published">Published</option>
<option value="unlisted">Unlisted</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="archived">Archived</option> <option value="archived">Archived</option>
</select> </select>
</div> </div>
{/* External Booking Section */}
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4"> <div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<label className="block text-sm font-medium text-gray-700">External Booking</label> <label className="block text-sm font-medium text-gray-700">External Booking</label>
<p className="text-xs text-gray-500">Redirect users to an external booking platform</p> <p className="text-xs text-gray-500">Redirect users to an external platform</p>
</div> </div>
<button <button type="button"
type="button"
onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })} onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 ${ className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200' formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
}`} }`}>
> <span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
<span formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${ }`} />
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button> </button>
</div> </div>
{formData.externalBookingEnabled && ( {formData.externalBookingEnabled && (
<div> <div>
<Input <Input label="External Booking URL" type="url" value={formData.externalBookingUrl}
label="External Booking URL"
type="url"
value={formData.externalBookingUrl}
onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })} onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })}
placeholder="https://example.com/book" placeholder="https://example.com/book" required />
required
/>
<p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p> <p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p>
</div> </div>
)} )}
</div> </div>
{/* Image Upload / Media Picker */} <MediaPicker value={formData.bannerUrl}
<MediaPicker
value={formData.bannerUrl}
onChange={(url) => setFormData({ ...formData, bannerUrl: url })} onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
relatedId={editingEvent?.id} relatedId={editingEvent?.id} relatedType="event" />
relatedType="event"
/>
{/* Featured Event Section - Only show for published events when editing */}
{editingEvent && editingEvent.status === 'published' && ( {editingEvent && editingEvent.status === 'published' && (
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50"> <div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2"> <label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
<StarIcon className="w-5 h-5 text-amber-500" /> <StarIcon className="w-5 h-5 text-amber-500" /> Featured Event
Featured Event
</label> </label>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">Prominently displayed on homepage</p>
Featured events are prominently displayed on the homepage and linktree
</p>
</div> </div>
<button <button type="button" disabled={settingFeatured !== null}
type="button" onClick={() => handleSetFeatured(featuredEventId === editingEvent.id ? null : editingEvent.id)}
disabled={settingFeatured !== null} className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
onClick={() => handleSetFeatured(
featuredEventId === editingEvent.id ? null : editingEvent.id
)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 disabled:opacity-50 ${
featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200' featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200'
}`} }`}>
> <span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
<span featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${ }`} />
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button> </button>
</div> </div>
{featuredEventId && featuredEventId !== editingEvent.id && ( {featuredEventId && featuredEventId !== editingEvent.id && (
@@ -521,14 +492,10 @@ export default function AdminEventsPage() {
)} )}
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving}> <Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">
{editingEvent ? 'Update Event' : 'Create Event'} {editingEvent ? 'Update Event' : 'Create Event'}
</Button> </Button>
<Button <Button type="button" variant="outline" onClick={() => { setShowForm(false); resetForm(); }} className="flex-1 min-h-[44px]">
type="button"
variant="outline"
onClick={() => { setShowForm(false); resetForm(); }}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -537,17 +504,17 @@ export default function AdminEventsPage() {
</div> </div>
)} )}
{/* Events Table */} {/* Desktop: Table */}
<Card className="overflow-hidden"> <Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Date</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Capacity</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Capacity</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th> <th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
@@ -559,110 +526,95 @@ export default function AdminEventsPage() {
</tr> </tr>
) : ( ) : (
events.map((event) => ( events.map((event) => (
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}> <tr
<td className="px-6 py-4"> key={event.id}
onClick={() => router.push(`/admin/events/${event.id}`)}
className={clsx("hover:bg-gray-50 cursor-pointer", featuredEventId === event.id && "bg-amber-50")}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{event.bannerUrl ? ( {event.bannerUrl ? (
<img <img src={event.bannerUrl} alt={event.title}
src={event.bannerUrl} className="w-10 h-10 rounded-lg object-cover flex-shrink-0" />
alt={event.title}
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
/>
) : ( ) : (
<div className="w-12 h-12 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
<PhotoIcon className="w-6 h-6 text-gray-400" /> <PhotoIcon className="w-5 h-5 text-gray-400" />
</div> </div>
)} )}
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium">{event.title}</p> <p className="font-medium text-sm">{event.title}</p>
{featuredEventId === event.id && ( {featuredEventId === event.id && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800"> <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-amber-100 text-amber-800">
<StarIconSolid className="w-3 h-3" /> <StarIconSolid className="w-2.5 h-2.5" /> Featured
Featured
</span> </span>
)} )}
</div> </div>
<p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p> <p className="text-xs text-gray-500 truncate max-w-xs">{event.location}</p>
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-sm text-gray-600"> <td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
{formatDate(event.startDatetime)} <td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
{getStatusBadge(event.status)}
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
)}
</div>
</td> </td>
<td className="px-6 py-4 text-sm"> <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
{event.bookedCount || 0} / {event.capacity} <div className="flex items-center justify-end gap-1">
</td>
<td className="px-6 py-4">
{getStatusBadge(event.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-1">
{event.status === 'draft' && ( {event.status === 'draft' && (
<Button <Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
size="sm"
variant="ghost"
onClick={() => handleStatusChange(event, 'published')}
>
Publish Publish
</Button> </Button>
)} )}
{event.status === 'published' && ( {event.status === 'published' && (
<button <button onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
disabled={settingFeatured !== null} disabled={settingFeatured !== null}
className={clsx( className={clsx("p-2 rounded-btn disabled:opacity-50",
"p-2 rounded-btn disabled:opacity-50", featuredEventId === event.id ? "bg-amber-100 text-amber-600 hover:bg-amber-200" : "hover:bg-amber-100 text-gray-400 hover:text-amber-600")}
featuredEventId === event.id title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}>
? "bg-amber-100 text-amber-600 hover:bg-amber-200" {featuredEventId === event.id ? <StarIconSolid className="w-4 h-4" /> : <StarIcon className="w-4 h-4" />}
: "hover:bg-amber-100 text-gray-400 hover:text-amber-600"
)}
title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}
>
{featuredEventId === event.id ? (
<StarIconSolid className="w-4 h-4" />
) : (
<StarIcon className="w-4 h-4" />
)}
</button> </button>
)} )}
<Link <Link href={`/admin/events/${event.id}`}
href={`/admin/events/${event.id}`} className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn" title="Manage">
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"
title="Manage Event"
>
<EyeIcon className="w-4 h-4" /> <EyeIcon className="w-4 h-4" />
</Link> </Link>
<button <button onClick={() => handleEdit(event)} className="p-2 hover:bg-gray-100 rounded-btn" title="Edit">
onClick={() => handleEdit(event)}
className="p-2 hover:bg-gray-100 rounded-btn"
title="Edit"
>
<PencilIcon className="w-4 h-4" /> <PencilIcon className="w-4 h-4" />
</button> </button>
<button <MoreMenu>
onClick={() => handleDuplicate(event)} {(event.status === 'draft' || event.status === 'published') && (
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn" <DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
title="Duplicate" <LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
> </DropdownItem>
<DocumentDuplicateIcon className="w-4 h-4" /> )}
</button> {event.status === 'unlisted' && (
{event.status !== 'archived' && ( <DropdownItem onClick={() => handleStatusChange(event, 'published')}>
<button Make Public
onClick={() => handleArchive(event)} </DropdownItem>
className="p-2 hover:bg-gray-100 text-gray-600 rounded-btn" )}
title="Archive" {(event.status === 'published' || event.status === 'unlisted') && (
> <DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
<ArchiveBoxIcon className="w-4 h-4" /> Unpublish
</button> </DropdownItem>
)} )}
<button <DropdownItem onClick={() => handleDuplicate(event)}>
onClick={() => handleDelete(event.id)} <DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" </DropdownItem>
title="Delete" {event.status !== 'archived' && (
> <DropdownItem onClick={() => handleArchive(event)}>
<TrashIcon className="w-4 h-4" /> <ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
</button> </DropdownItem>
)}
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
<TrashIcon className="w-4 h-4 mr-2" /> Delete
</DropdownItem>
</MoreMenu>
</div> </div>
</td> </td>
</tr> </tr>
@@ -672,6 +624,113 @@ export default function AdminEventsPage() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{events.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">
No events found. Create your first event!
</div>
) : (
events.map((event) => (
<Card
key={event.id}
className={clsx("p-3 cursor-pointer", featuredEventId === event.id && "ring-2 ring-amber-300")}
onClick={() => router.push(`/admin/events/${event.id}`)}
>
<div className="flex items-start gap-3">
{event.bannerUrl ? (
<img src={event.bannerUrl} alt={event.title}
className="w-14 h-14 rounded-lg object-cover flex-shrink-0" />
) : (
<div className="w-14 h-14 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
<PhotoIcon className="w-6 h-6 text-gray-400" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{event.title}</p>
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
<p className="text-xs text-gray-400 truncate">{event.location}</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
{getStatusBadge(event.status)}
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
)}
{featuredEventId === event.id && (
<StarIconSolid className="w-4 h-4 text-amber-500" />
)}
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Link href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
<EyeIcon className="w-4 h-4" />
</Link>
<MoreMenu>
<DropdownItem onClick={() => handleEdit(event)}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit
</DropdownItem>
{event.status === 'draft' && (
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
Publish
</DropdownItem>
)}
{(event.status === 'draft' || event.status === 'published') && (
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
</DropdownItem>
)}
{event.status === 'unlisted' && (
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
Make Public
</DropdownItem>
)}
{(event.status === 'published' || event.status === 'unlisted') && (
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
Unpublish
</DropdownItem>
)}
{event.status === 'published' && (
<DropdownItem onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}>
<StarIcon className="w-4 h-4 mr-2" />
{featuredEventId === event.id ? 'Unfeature' : 'Set Featured'}
</DropdownItem>
)}
<DropdownItem onClick={() => handleDuplicate(event)}>
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
</DropdownItem>
{event.status !== 'archived' && (
<DropdownItem onClick={() => handleArchive(event)}>
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
</DropdownItem>
)}
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
<TrashIcon className="w-4 h-4 mr-2" /> Delete
</DropdownItem>
</MoreMenu>
</div>
</div>
</div>
</div>
</Card>
))
)}
</div>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={() => { resetForm(); setShowForm(true); }}
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
<PlusIcon className="w-6 h-6" />
</button>
</div>
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -6,6 +6,7 @@ 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 Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
@@ -15,19 +16,14 @@ import {
Bars3Icon, Bars3Icon,
XMarkIcon, XMarkIcon,
CheckIcon, CheckIcon,
ArrowLeftIcon, ChevronUpIcon,
ChevronDownIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean }; type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean };
const emptyForm: FormState = { const emptyForm: FormState = {
id: null, id: null, question: '', questionEs: '', answer: '', answerEs: '', enabled: true, showOnHomepage: false,
question: '',
questionEs: '',
answer: '',
answerEs: '',
enabled: true,
showOnHomepage: false,
}; };
export default function AdminFaqPage() { export default function AdminFaqPage() {
@@ -40,9 +36,7 @@ export default function AdminFaqPage() {
const [draggedId, setDraggedId] = useState<string | null>(null); const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null); const [dragOverId, setDragOverId] = useState<string | null>(null);
useEffect(() => { useEffect(() => { loadFaqs(); }, []);
loadFaqs();
}, []);
const loadFaqs = async () => { const loadFaqs = async () => {
try { try {
@@ -57,20 +51,12 @@ export default function AdminFaqPage() {
} }
}; };
const handleCreate = () => { const handleCreate = () => { setForm(emptyForm); setShowForm(true); };
setForm(emptyForm);
setShowForm(true);
};
const handleEdit = (faq: FaqItemAdmin) => { const handleEdit = (faq: FaqItemAdmin) => {
setForm({ setForm({
id: faq.id, id: faq.id, question: faq.question, questionEs: faq.questionEs ?? '',
question: faq.question, answer: faq.answer, answerEs: faq.answerEs ?? '', enabled: faq.enabled, showOnHomepage: faq.showOnHomepage,
questionEs: faq.questionEs ?? '',
answer: faq.answer,
answerEs: faq.answerEs ?? '',
enabled: faq.enabled,
showOnHomepage: faq.showOnHomepage,
}); });
setShowForm(true); setShowForm(true);
}; };
@@ -84,22 +70,16 @@ export default function AdminFaqPage() {
setSaving(true); setSaving(true);
if (form.id) { if (form.id) {
await faqApi.update(form.id, { await faqApi.update(form.id, {
question: form.question.trim(), question: form.question.trim(), questionEs: form.questionEs.trim() || null,
questionEs: form.questionEs.trim() || null, answer: form.answer.trim(), answerEs: form.answerEs.trim() || null,
answer: form.answer.trim(), enabled: form.enabled, showOnHomepage: form.showOnHomepage,
answerEs: form.answerEs.trim() || null,
enabled: form.enabled,
showOnHomepage: form.showOnHomepage,
}); });
toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated'); toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated');
} else { } else {
await faqApi.create({ await faqApi.create({
question: form.question.trim(), question: form.question.trim(), questionEs: form.questionEs.trim() || undefined,
questionEs: form.questionEs.trim() || undefined, answer: form.answer.trim(), answerEs: form.answerEs.trim() || undefined,
answer: form.answer.trim(), enabled: form.enabled, showOnHomepage: form.showOnHomepage,
answerEs: form.answerEs.trim() || undefined,
enabled: form.enabled,
showOnHomepage: form.showOnHomepage,
}); });
toast.success(locale === 'es' ? 'FAQ creado' : 'FAQ created'); toast.success(locale === 'es' ? 'FAQ creado' : 'FAQ created');
} }
@@ -143,22 +123,44 @@ export default function AdminFaqPage() {
} }
}; };
const handleMoveUp = async (index: number) => {
if (index === 0) return;
const newOrder = [...faqs];
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
const ids = newOrder.map(f => f.id);
try {
const res = await faqApi.reorder(ids);
setFaqs(res.faqs);
} catch (err: any) {
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
}
};
const handleMoveDown = async (index: number) => {
if (index >= faqs.length - 1) return;
const newOrder = [...faqs];
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
const ids = newOrder.map(f => f.id);
try {
const res = await faqApi.reorder(ids);
setFaqs(res.faqs);
} catch (err: any) {
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
}
};
// Desktop drag handlers
const handleDragStart = (e: React.DragEvent, id: string) => { const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedId(id); setDraggedId(id);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id); e.dataTransfer.setData('text/plain', id);
}; };
const handleDragOver = (e: React.DragEvent, id: string) => { const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
setDragOverId(id); setDragOverId(id);
}; };
const handleDragLeave = () => { setDragOverId(null); };
const handleDragLeave = () => {
setDragOverId(null);
};
const handleDrop = async (e: React.DragEvent, targetId: string) => { const handleDrop = async (e: React.DragEvent, targetId: string) => {
e.preventDefault(); e.preventDefault();
setDragOverId(null); setDragOverId(null);
@@ -180,11 +182,7 @@ export default function AdminFaqPage() {
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder')); toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
} }
}; };
const handleDragEnd = () => { setDraggedId(null); setDragOverId(null); };
const handleDragEnd = () => {
setDraggedId(null);
setDragOverId(null);
};
if (loading) { if (loading) {
return ( return (
@@ -198,179 +196,120 @@ export default function AdminFaqPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4"> <div className="flex items-center justify-between flex-wrap gap-4">
<div> <div>
<h1 className="text-2xl font-bold font-heading"> <h1 className="text-xl md:text-2xl font-bold font-heading">FAQ</h1>
{locale === 'es' ? 'FAQ' : 'FAQ'} <p className="text-gray-500 text-xs md:text-sm mt-1 hidden md:block">
</h1>
<p className="text-gray-500 text-sm mt-1">
{locale === 'es' {locale === 'es'
? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.' ? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.'
: 'Create and edit FAQ questions. Drag to change order.'} : 'Create and edit FAQ questions. Drag to change order.'}
</p> </p>
</div> </div>
<Button onClick={handleCreate}> <Button onClick={handleCreate} className="hidden md:flex">
<PlusIcon className="w-4 h-4 mr-2" /> <PlusIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Nueva pregunta' : 'Add question'} {locale === 'es' ? 'Nueva pregunta' : 'Add question'}
</Button> </Button>
</div> </div>
{/* Form Modal - bottom sheet on mobile */}
{showForm && ( {showForm && (
<Card> <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="p-6 space-y-4"> <Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<div className="flex justify-between items-center"> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-lg font-semibold"> <h2 className="text-base font-semibold">
{form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')} {form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')}
</h2> </h2>
<button <button onClick={() => { setForm(emptyForm); setShowForm(false); }}
onClick={() => { setForm(emptyForm); setShowForm(false); }} className="p-2 hover:bg-gray-100 rounded-full min-h-[44px] min-w-[44px] flex items-center justify-center">
className="p-2 hover:bg-gray-100 rounded-full"
>
<XMarkIcon className="w-5 h-5" /> <XMarkIcon className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
<div> <div className="grid gap-4 sm:grid-cols-2">
<label className="block text-sm font-medium mb-1">Question (EN) *</label> <div>
<Input <label className="block text-sm font-medium mb-1">Question (EN) *</label>
value={form.question} <Input value={form.question} onChange={e => setForm(f => ({ ...f, question: e.target.value }))} placeholder="Question in English" />
onChange={e => setForm(f => ({ ...f, question: e.target.value }))} </div>
placeholder="Question in English" <div>
/> <label className="block text-sm font-medium mb-1">Pregunta (ES)</label>
<Input value={form.questionEs} onChange={e => setForm(f => ({ ...f, questionEs: e.target.value }))} placeholder="Pregunta en español" />
</div>
</div> </div>
<div> <div className="grid gap-4 sm:grid-cols-2">
<label className="block text-sm font-medium mb-1">Pregunta (ES)</label> <div>
<Input <label className="block text-sm font-medium mb-1">Answer (EN) *</label>
value={form.questionEs} <textarea className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
onChange={e => setForm(f => ({ ...f, questionEs: e.target.value }))} value={form.answer} onChange={e => setForm(f => ({ ...f, answer: e.target.value }))} placeholder="Answer in English" />
placeholder="Pregunta en español" </div>
/> <div>
<label className="block text-sm font-medium mb-1">Respuesta (ES)</label>
<textarea className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
value={form.answerEs} onChange={e => setForm(f => ({ ...f, answerEs: e.target.value }))} placeholder="Respuesta en español" />
</div>
</div>
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
<input type="checkbox" checked={form.enabled} onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} className="rounded border-gray-300 w-4 h-4" />
<span className="text-sm">{locale === 'es' ? 'Mostrar en el sitio' : 'Show on site'}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
<input type="checkbox" checked={form.showOnHomepage} onChange={e => setForm(f => ({ ...f, showOnHomepage: e.target.checked }))} className="rounded border-gray-300 w-4 h-4" />
<span className="text-sm">{locale === 'es' ? 'Mostrar en inicio' : 'Show on homepage'}</span>
</label>
</div>
<div className="flex gap-3 pt-2">
<Button onClick={handleSave} isLoading={saving} className="flex-1 min-h-[44px]">
<CheckIcon className="w-4 h-4 mr-1" /> {locale === 'es' ? 'Guardar' : 'Save'}
</Button>
<Button variant="outline" onClick={() => { setForm(emptyForm); setShowForm(false); }} disabled={saving} className="flex-1 min-h-[44px]">
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> </Card>
<div> </div>
<label className="block text-sm font-medium mb-1">Answer (EN) *</label>
<textarea
className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
value={form.answer}
onChange={e => setForm(f => ({ ...f, answer: e.target.value }))}
placeholder="Answer in English"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Respuesta (ES)</label>
<textarea
className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
value={form.answerEs}
onChange={e => setForm(f => ({ ...f, answerEs: e.target.value }))}
placeholder="Respuesta en español"
/>
</div>
</div>
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.enabled}
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))}
className="rounded border-gray-300"
/>
<span className="text-sm">{locale === 'es' ? 'Mostrar en el sitio' : 'Show on site'}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.showOnHomepage}
onChange={e => setForm(f => ({ ...f, showOnHomepage: e.target.checked }))}
className="rounded border-gray-300"
/>
<span className="text-sm">{locale === 'es' ? 'Mostrar en inicio' : 'Show on homepage'}</span>
</label>
</div>
<div className="flex gap-2">
<Button onClick={handleSave} isLoading={saving}>
<CheckIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Guardar' : 'Save'}
</Button>
<Button variant="outline" onClick={() => { setForm(emptyForm); setShowForm(false); }} disabled={saving}>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
</div>
</div>
</Card>
)} )}
<Card> {/* Desktop: Table */}
<Card className="hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="bg-gray-50 border-b border-gray-200">
<tr> <tr>
<th className="w-10 px-4 py-3" /> <th className="w-10 px-4 py-3" />
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase"> <th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase">{locale === 'es' ? 'Pregunta' : 'Question'}</th>
{locale === 'es' ? 'Pregunta' : 'Question'} <th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase w-24">{locale === 'es' ? 'En sitio' : 'On site'}</th>
</th> <th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase w-28">{locale === 'es' ? 'En inicio' : 'Homepage'}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-24"> <th className="px-4 py-2 text-right text-xs font-semibold text-gray-500 uppercase w-32">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
{locale === 'es' ? 'En sitio' : 'On site'}
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-28">
{locale === 'es' ? 'En inicio' : 'Homepage'}
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase w-32">
{locale === 'es' ? 'Acciones' : 'Actions'}
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200">
{faqs.length === 0 ? ( {faqs.length === 0 ? (
<tr> <tr><td colSpan={5} className="px-6 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}</td></tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}
</td>
</tr>
) : ( ) : (
faqs.map((faq) => ( faqs.map((faq) => (
<tr <tr key={faq.id} draggable onDragStart={e => handleDragStart(e, faq.id)}
key={faq.id} onDragOver={e => handleDragOver(e, faq.id)} onDragLeave={handleDragLeave}
draggable onDrop={e => handleDrop(e, faq.id)} onDragEnd={handleDragEnd}
onDragStart={e => handleDragStart(e, faq.id)} className={clsx('hover:bg-gray-50', draggedId === faq.id && 'opacity-50', dragOverId === faq.id && 'bg-primary-yellow/10')}>
onDragOver={e => handleDragOver(e, faq.id)}
onDragLeave={handleDragLeave}
onDrop={e => handleDrop(e, faq.id)}
onDragEnd={handleDragEnd}
className={clsx(
'hover:bg-gray-50',
draggedId === faq.id && 'opacity-50',
dragOverId === faq.id && 'bg-primary-yellow/10'
)}
>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600" title={locale === 'es' ? 'Arrastrar para reordenar' : 'Drag to reorder'}> <span className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600" title={locale === 'es' ? 'Arrastrar para reordenar' : 'Drag to reorder'}>
<Bars3Icon className="w-5 h-5" /> <Bars3Icon className="w-5 h-5" />
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="font-medium text-primary-dark line-clamp-1"> <p className="font-medium text-primary-dark text-sm line-clamp-1">{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}</p>
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</p>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <button onClick={() => handleToggleEnabled(faq)}
onClick={() => handleToggleEnabled(faq)} className={clsx('inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium min-h-[32px]',
className={clsx( faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium', {faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : 'No'}
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
)}
>
{faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : (locale === 'es' ? 'No' : 'No')}
</button> </button>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <button onClick={() => handleToggleShowOnHomepage(faq)}
onClick={() => handleToggleShowOnHomepage(faq)} className={clsx('inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium min-h-[32px]',
className={clsx( faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium', {faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : 'No'}
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
)}
>
{faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : (locale === 'es' ? 'No' : 'No')}
</button> </button>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
@@ -390,6 +329,65 @@ export default function AdminFaqPage() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{faqs.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}</div>
) : (
faqs.map((faq, index) => (
<Card key={faq.id} className="p-3">
<div className="flex items-start gap-2">
<div className="flex flex-col gap-0.5 flex-shrink-0 pt-0.5">
<button onClick={() => handleMoveUp(index)} disabled={index === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 min-h-[28px] min-w-[28px] flex items-center justify-center">
<ChevronUpIcon className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleMoveDown(index)} disabled={index >= faqs.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 min-h-[28px] min-w-[28px] flex items-center justify-center">
<ChevronDownIcon className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-primary-dark line-clamp-2">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</p>
<div className="flex items-center gap-2 mt-1.5">
<button onClick={() => handleToggleEnabled(faq)}
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium min-h-[28px]',
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
{faq.enabled ? (locale === 'es' ? 'Sitio: Sí' : 'Site: Yes') : (locale === 'es' ? 'Sitio: No' : 'Site: No')}
</button>
<button onClick={() => handleToggleShowOnHomepage(faq)}
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium min-h-[28px]',
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
{faq.showOnHomepage ? (locale === 'es' ? 'Inicio: Sí' : 'Home: Yes') : (locale === 'es' ? 'Inicio: No' : 'Home: No')}
</button>
</div>
</div>
<MoreMenu>
<DropdownItem onClick={() => handleEdit(faq)}>
<PencilSquareIcon className="w-4 h-4 mr-2" /> {locale === 'es' ? 'Editar' : 'Edit'}
</DropdownItem>
<DropdownItem onClick={() => handleDelete(faq.id)} className="text-red-600">
<TrashIcon className="w-4 h-4 mr-2" /> {locale === 'es' ? 'Eliminar' : 'Delete'}
</DropdownItem>
</MoreMenu>
</div>
</Card>
))
)}
</div>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={handleCreate}
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
<PlusIcon className="w-6 h-6" />
</button>
</div>
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { mediaApi, Media } from '@/lib/api'; import { mediaApi, Media } from '@/lib/api';
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 { import {
@@ -108,7 +109,7 @@ export default function AdminGalleryPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { legalPagesApi, LegalPage } from '@/lib/api'; import { legalPagesApi, LegalPage } from '@/lib/api';
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 Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
@@ -158,7 +159,7 @@ export default function AdminLegalPagesPage() {
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
try { try {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -15,6 +15,7 @@ import {
UserGroupIcon, UserGroupIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { parseDate } from '@/lib/utils';
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
@@ -30,7 +31,7 @@ export default function AdminDashboardPage() {
}, []); }, []);
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',

View File

@@ -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 Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { import {
CheckCircleIcon, CheckCircleIcon,
ArrowPathIcon, ArrowPathIcon,
@@ -20,8 +22,12 @@ import {
BuildingLibraryIcon, BuildingLibraryIcon,
CreditCardIcon, CreditCardIcon,
EnvelopeIcon, EnvelopeIcon,
FunnelIcon,
MagnifyingGlassIcon,
XMarkIcon,
} 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';
type Tab = 'pending_approval' | 'all'; type Tab = 'pending_approval' | 'all';
@@ -34,6 +40,9 @@ export default function AdminPaymentsPage() {
const [activeTab, setActiveTab] = useState<Tab>('pending_approval'); const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
const [providerFilter, setProviderFilter] = useState<string>(''); const [providerFilter, setProviderFilter] = useState<string>('');
const [eventFilter, setEventFilter] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
// Modal state // Modal state
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null); const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
@@ -54,7 +63,7 @@ export default function AdminPaymentsPage() {
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [statusFilter, providerFilter]); }, [statusFilter, providerFilter, eventFilter]);
const loadData = async () => { const loadData = async () => {
try { try {
@@ -63,7 +72,8 @@ export default function AdminPaymentsPage() {
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,
}), }),
eventsApi.getAll(), eventsApi.getAll(),
]); ]);
@@ -194,7 +204,7 @@ export default function AdminPaymentsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
@@ -271,6 +281,22 @@ export default function AdminPaymentsPage() {
}; };
}; };
// Hide pending-approval payments whose event has already ended.
// Fall back to startDatetime when endDatetime is absent; keep visible when we
// can't classify (event missing from list and no startDatetime on payment.event).
const visiblePendingApprovalPayments = (() => {
const now = new Date();
return pendingApprovalPayments.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) {
@@ -278,7 +304,7 @@ export default function AdminPaymentsPage() {
} }
// Count all pending payments with the same bookingId // Count all pending payments with the same bookingId
const bookingPayments = pendingApprovalPayments.filter( const bookingPayments = visiblePendingApprovalPayments.filter(
p => p.ticket?.bookingId === payment.ticket?.bookingId p => p.ticket?.bookingId === payment.ticket?.bookingId
); );
@@ -316,7 +342,7 @@ export default function AdminPaymentsPage() {
const paidBookingsCount = getUniqueBookingsCount( const paidBookingsCount = getUniqueBookingsCount(
payments.filter(p => p.status === 'paid') payments.filter(p => p.status === 'paid')
); );
const pendingApprovalBookingsCount = getUniqueBookingsCount(pendingApprovalPayments); const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments);
if (loading) { if (loading) {
return ( return (
@@ -329,10 +355,11 @@ export default function AdminPaymentsPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1> <h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
<Button onClick={() => setShowExportModal(true)}> <Button onClick={() => setShowExportModal(true)} size="sm" className="min-h-[44px] md:min-h-0">
<DocumentArrowDownIcon className="w-5 h-5 mr-2" /> <DocumentArrowDownIcon className="w-4 h-4 mr-1.5" />
{locale === 'es' ? 'Exportar Datos' : 'Export Data'} <span className="hidden md:inline">{locale === 'es' ? 'Exportar Datos' : 'Export Data'}</span>
<span className="md:hidden">{locale === 'es' ? 'Exportar' : 'Export'}</span>
</Button> </Button>
</div> </div>
@@ -340,11 +367,18 @@ export default function AdminPaymentsPage() {
{selectedPayment && (() => { {selectedPayment && (() => {
const modalBookingInfo = getBookingInfo(selectedPayment); const modalBookingInfo = getBookingInfo(selectedPayment);
return ( return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center 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">
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6"> <Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<h2 className="text-xl font-bold mb-4"> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'} <h2 className="text-base font-bold">
</h2> {locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
</h2>
<button onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
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>
<div className="p-4 overflow-y-auto flex-1 min-h-0">
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
@@ -442,43 +476,24 @@ export default function AdminPaymentsPage() {
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button <Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
onClick={() => handleApprove(selectedPayment)}
isLoading={processing}
className="flex-1"
>
<CheckCircleIcon className="w-5 h-5 mr-2" /> <CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Aprobar' : 'Approve'} {locale === 'es' ? 'Aprobar' : 'Approve'}
</Button> </Button>
<Button <Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
variant="outline" className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
onClick={() => handleReject(selectedPayment)}
isLoading={processing}
className="flex-1 border-red-300 text-red-600 hover:bg-red-50"
>
<XCircleIcon className="w-5 h-5 mr-2" /> <XCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Rechazar' : 'Reject'} {locale === 'es' ? 'Rechazar' : 'Reject'}
</Button> </Button>
</div> </div>
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<Button <Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
variant="outline"
onClick={() => handleSendReminder(selectedPayment)}
isLoading={sendingReminder}
className="w-full"
>
<EnvelopeIcon className="w-5 h-5 mr-2" /> <EnvelopeIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Enviar recordatorio de pago' : 'Send payment reminder'} {locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'}
</Button> </Button>
</div> </div>
</div>
<button
onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</button>
</Card> </Card>
</div> </div>
); );
@@ -486,9 +501,16 @@ export default function AdminPaymentsPage() {
{/* Export Modal */} {/* Export Modal */}
{showExportModal && ( {showExportModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center 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">
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6"> <Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<h2 className="text-xl font-bold mb-6">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-bold">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
<button onClick={() => { setShowExportModal(false); setExportData(null); }}
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>
<div className="p-4 overflow-y-auto flex-1 min-h-0">
{!exportData ? ( {!exportData ? (
<div className="space-y-4"> <div className="space-y-4">
@@ -522,10 +544,10 @@ export default function AdminPaymentsPage() {
</div> </div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button onClick={handleExport} isLoading={exporting}> <Button onClick={handleExport} isLoading={exporting} className="flex-1 min-h-[44px]">
{locale === 'es' ? 'Generar Reporte' : 'Generate Report'} {locale === 'es' ? 'Generar Reporte' : 'Generate Report'}
</Button> </Button>
<Button variant="outline" onClick={() => setShowExportModal(false)}> <Button variant="outline" onClick={() => setShowExportModal(false)} className="flex-1 min-h-[44px]">
{locale === 'es' ? 'Cancelar' : 'Cancel'} {locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button> </Button>
</div> </div>
@@ -585,20 +607,21 @@ export default function AdminPaymentsPage() {
</div> </div>
</div> </div>
<div className="flex gap-3"> <div className="flex flex-wrap gap-3">
<Button onClick={downloadCSV}> <Button onClick={downloadCSV} className="min-h-[44px]">
<ArrowDownTrayIcon className="w-4 h-4 mr-2" /> <ArrowDownTrayIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'} {locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
</Button> </Button>
<Button variant="outline" onClick={() => setExportData(null)}> <Button variant="outline" onClick={() => setExportData(null)} className="min-h-[44px]">
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'} {locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
</Button> </Button>
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }}> <Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }} className="min-h-[44px]">
{locale === 'es' ? 'Cerrar' : 'Close'} {locale === 'es' ? 'Cerrar' : 'Close'}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
</div>
</Card> </Card>
</div> </div>
)} )}
@@ -613,8 +636,8 @@ export default function AdminPaymentsPage() {
<div> <div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p> <p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p>
<p className="text-xl font-bold text-yellow-600">{pendingApprovalBookingsCount}</p> <p className="text-xl font-bold text-yellow-600">{pendingApprovalBookingsCount}</p>
{pendingApprovalPayments.length !== pendingApprovalBookingsCount && ( {visiblePendingApprovalPayments.length !== pendingApprovalBookingsCount && (
<p className="text-xs text-gray-400">({pendingApprovalPayments.length} tickets)</p> <p className="text-xs text-gray-400">({visiblePendingApprovalPayments.length} tickets)</p>
)} )}
</div> </div>
</div> </div>
@@ -657,31 +680,19 @@ export default function AdminPaymentsPage() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="border-b mb-6"> <div className="border-b mb-6 overflow-x-auto scrollbar-hide">
<nav className="flex gap-4"> <nav className="flex gap-4 min-w-max">
<button <button onClick={() => setActiveTab('pending_approval')}
onClick={() => setActiveTab('pending_approval')} className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${ activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
activeTab === 'pending_approval' {locale === 'es' ? 'Pendientes' : 'Pending Approval'}
? 'border-primary-yellow text-primary-dark' {visiblePendingApprovalPayments.length > 0 && (
: 'border-transparent text-gray-500 hover:text-gray-700' <span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{visiblePendingApprovalPayments.length}</span>
}`}
>
{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}
{pendingApprovalPayments.length > 0 && (
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">
{pendingApprovalPayments.length}
</span>
)} )}
</button> </button>
<button <button onClick={() => setActiveTab('all')}
onClick={() => setActiveTab('all')} className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${ activeTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
activeTab === 'all'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{locale === 'es' ? 'Todos los Pagos' : 'All Payments'} {locale === 'es' ? 'Todos los Pagos' : 'All Payments'}
</button> </button>
</nav> </nav>
@@ -690,7 +701,7 @@ export default function AdminPaymentsPage() {
{/* Pending Approval Tab */} {/* Pending Approval Tab */}
{activeTab === 'pending_approval' && ( {activeTab === 'pending_approval' && (
<> <>
{pendingApprovalPayments.length === 0 ? ( {visiblePendingApprovalPayments.length === 0 ? (
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" /> <CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" />
<p className="text-gray-500"> <p className="text-gray-500">
@@ -701,7 +712,7 @@ export default function AdminPaymentsPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{pendingApprovalPayments.map((payment) => { {visiblePendingApprovalPayments.map((payment) => {
const bookingInfo = getPendingBookingInfo(payment); const bookingInfo = getPendingBookingInfo(payment);
return ( return (
<Card key={payment.id} className="p-4"> <Card key={payment.id} className="p-4">
@@ -748,7 +759,7 @@ export default function AdminPaymentsPage() {
)} )}
</div> </div>
</div> </div>
<Button onClick={() => setSelectedPayment(payment)}> <Button onClick={() => setSelectedPayment(payment)} size="sm" className="min-h-[44px] md:min-h-0 flex-shrink-0">
{locale === 'es' ? 'Revisar' : 'Review'} {locale === 'es' ? 'Revisar' : 'Review'}
</Button> </Button>
</div> </div>
@@ -761,18 +772,44 @@ export default function AdminPaymentsPage() {
)} )}
{/* All Payments Tab */} {/* All Payments Tab */}
{activeTab === 'all' && ( {activeTab === 'all' && (() => {
const q = searchQuery.trim().toLowerCase();
const filteredPayments = q
? payments.filter((p) => {
const name = `${p.ticket?.attendeeFirstName || ''} ${p.ticket?.attendeeLastName || ''}`.trim().toLowerCase();
const email = (p.ticket?.attendeeEmail || '').toLowerCase();
const phone = (p.ticket?.attendeePhone || '').toLowerCase();
const eventTitle = (p.event?.title || '').toLowerCase();
const payerName = (p.payerName || '').toLowerCase();
const reference = (p.reference || '').toLowerCase();
const id = (p.id || '').toLowerCase();
return name.includes(q) || email.includes(q) || phone.includes(q) ||
eventTitle.includes(q) || payerName.includes(q) || reference.includes(q) || id.includes(q);
})
: payments;
return (
<> <>
{/* Filters */} {/* Desktop Filters */}
<Card className="p-4 mb-6"> <Card className="p-4 mb-6 hidden md:block">
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Buscar' : 'Search'}</label>
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder={locale === 'es' ? 'Nombre, email, evento...' : 'Name, email, event...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow min-w-[200px]"
/>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-sm font-medium mb-1">Status</label>
<select <select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
value={statusFilter} className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option> <option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option> <option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option> <option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
@@ -783,119 +820,126 @@ export default function AdminPaymentsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label> <label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
<select <select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)}
value={providerFilter} className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
onChange={(e) => setProviderFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option> <option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
<option value="lightning">Lightning</option> <option value="lightning">Lightning</option>
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option> <option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
<option value="bank_transfer">{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}</option> <option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
<option value="tpago">TPago</option> <option value="tpago">TPago</option>
</select> </select>
</div> </div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Evento(s)' : 'Event(s)'}</label>
<select
value=""
onChange={(e) => {
const id = e.target.value;
if (id && !eventFilter.includes(id)) setEventFilter([...eventFilter, id]);
e.target.value = '';
}}
className="px-4 py-2 rounded-btn border border-secondary-light-gray w-full text-sm"
>
<option value="">{locale === 'es' ? 'Agregar evento...' : 'Add event...'}</option>
{events.filter(e => !eventFilter.includes(e.id)).map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
{eventFilter.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{eventFilter.map((id) => {
const ev = events.find(e => e.id === id);
return (
<span key={id} className="inline-flex items-center gap-1 px-2 py-0.5 bg-primary-yellow/20 rounded text-xs">
{ev?.title || id}
<button type="button" onClick={() => setEventFilter(eventFilter.filter(x => x !== id))} className="hover:text-red-600">×</button>
</span>
);
})}
<button type="button" onClick={() => setEventFilter([])} className="text-xs text-gray-500 hover:text-primary-dark">
{locale === 'es' ? 'Limpiar' : 'Clear'}
</button>
</div>
)}
</div>
</div> </div>
</Card> </Card>
{/* Payments Table */} {/* Mobile Search & Filter Toolbar */}
<Card className="overflow-hidden"> <div className="md:hidden mb-4 space-y-2">
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder={locale === 'es' ? 'Nombre, email, evento...' : 'Name, email, event...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setMobileFilterOpen(true)}
className={clsx('flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
(statusFilter || providerFilter || eventFilter.length > 0) ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600')}>
<FunnelIcon className="w-4 h-4" /> Filters
</button>
{(statusFilter || providerFilter || eventFilter.length > 0 || searchQuery) && (
<button onClick={() => { setStatusFilter(''); setProviderFilter(''); setEventFilter([]); setSearchQuery(''); }}
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
)}
</div>
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Asistente' : 'Attendee'}</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Evento' : 'Event'}</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Evento' : 'Event'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Monto' : 'Amount'}</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Monto' : 'Amount'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Método' : 'Method'}</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Método' : 'Method'}</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Fecha' : 'Date'}</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th> <th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{payments.length === 0 ? ( {filteredPayments.length === 0 ? (
<tr> <tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}
</td>
</tr>
) : ( ) : (
payments.map((payment) => { filteredPayments.map((payment) => {
const bookingInfo = getBookingInfo(payment); const bookingInfo = getBookingInfo(payment);
return ( return (
<tr key={payment.id} className="hover:bg-gray-50"> <tr key={payment.id} className="hover:bg-gray-50">
<td className="px-6 py-4"> <td className="px-4 py-3">
{payment.ticket ? ( {payment.ticket ? (
<div> <div>
<p className="font-medium text-sm"> <p className="font-medium text-sm">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p>
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName} <p className="text-xs text-gray-500 truncate max-w-[180px]">{payment.ticket.attendeeEmail}</p>
</p>
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
{payment.payerName && (
<p className="text-xs text-amber-600 mt-1">
{locale === 'es' ? 'Pagado por:' : 'Paid by:'} {payment.payerName}
</p>
)}
</div> </div>
) : ( ) : <span className="text-gray-400 text-sm">-</span>}
<span className="text-gray-400 text-sm">-</span>
)}
</td> </td>
<td className="px-6 py-4"> <td className="px-4 py-3 text-sm truncate max-w-[150px]">{payment.event?.title || '-'}</td>
{payment.event ? ( <td className="px-4 py-3">
<p className="text-sm">{payment.event.title}</p> <p className="font-medium text-sm">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
) : ( {bookingInfo.ticketCount > 1 && <p className="text-[10px] text-purple-600">{bookingInfo.ticketCount} tickets</p>}
<span className="text-gray-400 text-sm">-</span>
)}
</td> </td>
<td className="px-6 py-4"> <td className="px-4 py-3">
<div> <div className="flex items-center gap-1.5 text-xs text-gray-600">
<p className="font-medium">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p> {getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
{bookingInfo.ticketCount > 1 && (
<p className="text-xs text-purple-600 mt-1">
📦 {bookingInfo.ticketCount} × {formatCurrency(payment.amount, payment.currency)}
</p>
)}
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
<div className="flex items-center gap-2 text-sm text-gray-600"> <td className="px-4 py-3">
{getProviderIcon(payment.provider)} <div className="flex items-center justify-end gap-1">
{getProviderLabel(payment.provider)}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{formatDate(payment.createdAt)}
</td>
<td className="px-6 py-4">
<div className="space-y-1">
{getStatusBadge(payment.status)}
{payment.ticket?.bookingId && (
<p className="text-xs text-purple-600" title="Part of multi-ticket booking">
📦 {locale === 'es' ? 'Grupo' : 'Group'}
</p>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{(payment.status === 'pending' || payment.status === 'pending_approval') && ( {(payment.status === 'pending' || payment.status === 'pending_approval') && (
<Button <Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1">
size="sm"
onClick={() => setSelectedPayment(payment)}
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Revisar' : 'Review'} {locale === 'es' ? 'Revisar' : 'Review'}
</Button> </Button>
)} )}
{payment.status === 'paid' && ( {payment.status === 'paid' && (
<Button <Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2 py-1">
size="sm"
variant="outline"
onClick={() => handleRefund(payment.id)}
>
<ArrowPathIcon className="w-4 h-4 mr-1" />
{t('admin.payments.refund')} {t('admin.payments.refund')}
</Button> </Button>
)} )}
@@ -909,8 +953,117 @@ export default function AdminPaymentsPage() {
</table> </table>
</div> </div>
</Card> </Card>
{(searchQuery || filteredPayments.length !== payments.length) && (
<p className="hidden md:block text-sm text-gray-500 mb-2">
{locale === 'es' ? 'Mostrando' : 'Showing'} {filteredPayments.length} {locale === 'es' ? 'de' : 'of'} {payments.length}
</p>
)}
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{filteredPayments.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</div>
) : (
filteredPayments.map((payment) => {
const bookingInfo = getBookingInfo(payment);
return (
<Card key={payment.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{payment.ticket ? (
<p className="font-medium text-sm truncate">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p>
) : <p className="text-sm text-gray-400">-</p>}
<p className="text-xs text-gray-500 truncate">{payment.event?.title || '-'}</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{getStatusBadge(payment.status)}
</div>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
<span className="text-gray-300">|</span>
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}</span>
{bookingInfo.ticketCount > 1 && (
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
)}
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
<div className="flex items-center gap-1">
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
)}
{payment.status === 'paid' && (
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{t('admin.payments.refund')}
</Button>
)}
</div>
</div>
</Card>
);
})
)}
</div>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title={locale === 'es' ? 'Filtros' : 'Filters'}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{locale === 'es' ? 'Evento(s)' : 'Event(s)'}</label>
<div className="max-h-40 overflow-y-auto border border-secondary-light-gray rounded-btn p-2 space-y-1">
{events.map((event) => (
<label key={event.id} className="flex items-center gap-2 py-1.5 cursor-pointer">
<input
type="checkbox"
checked={eventFilter.includes(event.id)}
onChange={(e) => {
if (e.target.checked) setEventFilter([...eventFilter, event.id]);
else setEventFilter(eventFilter.filter(id => id !== event.id));
}}
className="w-4 h-4 rounded border-gray-300 text-primary-yellow focus:ring-primary-yellow"
/>
<span className="text-sm">{event.title}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
<select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
<option value="lightning">Lightning</option>
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
<option value="tpago">TPago</option>
</select>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => { setStatusFilter(''); setProviderFilter(''); setEventFilter([]); setSearchQuery(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
</div>
</div>
</BottomSheet>
</> </>
)} );
})()}
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -9,6 +9,7 @@ import {
QrCodeIcon, QrCodeIcon,
CheckCircleIcon, CheckCircleIcon,
XCircleIcon, XCircleIcon,
XMarkIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
ArrowPathIcon, ArrowPathIcon,
ClockIcon, ClockIcon,
@@ -17,6 +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 clsx from 'clsx'; import clsx from 'clsx';
// ─── Types ─────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────
@@ -76,86 +78,135 @@ function vibrate(pattern: number | number[]) {
} catch {} } catch {}
} }
// ─── Stop all tracks on a media stream ───────────────────────
function stopAllTracks() {
try {
// Find all video elements and stop their streams
document.querySelectorAll('video').forEach((video) => {
const stream = video.srcObject as MediaStream | null;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
video.srcObject = null;
}
});
} catch {}
}
// ─── QR Scanner Component ──────────────────────────────────── // ─── QR Scanner Component ────────────────────────────────────
// This component fully mounts/unmounts — use a key prop externally
// to force a fresh instance when the scan tab becomes active.
function QRScanner({ function QRScanner({
onScan, onScan,
isActive, onError,
onActiveChange,
}: { }: {
onScan: (code: string) => void; onScan: (code: string) => void;
isActive: boolean; onError: () => void;
onActiveChange: (active: boolean) => void;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const scannerRef = useRef<any>(null); const scannerRef = useRef<any>(null);
const scannerElementId = useRef(`qr-scanner-${Math.random().toString(36).substr(2, 9)}`); const mountedRef = useRef(true);
const elementId = useRef(`qr-scanner-${Date.now()}`);
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment'); const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
const [ready, setReady] = useState(false);
useEffect(() => { // Full cleanup helper
if (containerRef.current && !document.getElementById(scannerElementId.current)) { const destroyScanner = useCallback(async () => {
const scannerDiv = document.createElement('div'); if (scannerRef.current) {
scannerDiv.id = scannerElementId.current; try { await scannerRef.current.stop(); } catch {}
scannerDiv.style.width = '100%'; try { scannerRef.current.clear(); } catch {}
containerRef.current.appendChild(scannerDiv); scannerRef.current = null;
} }
return () => { stopAllTracks();
if (scannerRef.current) {
try { scannerRef.current.stop().catch(() => {}); } catch {}
scannerRef.current = null;
}
};
}, []); }, []);
// Start scanner on mount, destroy on unmount
useEffect(() => { useEffect(() => {
mountedRef.current = true;
let cancelled = false; let cancelled = false;
const startScanner = async () => { const init = async () => {
const elementId = scannerElementId.current; const container = containerRef.current;
const element = document.getElementById(elementId); if (!container) return;
if (!element) return;
// Create a fresh div for the scanner
const id = elementId.current;
container.innerHTML = '';
const div = document.createElement('div');
div.id = id;
div.style.width = '100%';
div.style.height = '100%';
container.appendChild(div);
try { try {
const { Html5Qrcode } = await import('html5-qrcode'); const { Html5Qrcode } = await import('html5-qrcode');
if (cancelled) return; if (cancelled) return;
if (scannerRef.current) {
try { await scannerRef.current.stop(); } catch {}
scannerRef.current = null;
}
if (cancelled) return;
const scanner = new Html5Qrcode(elementId); const scanner = new Html5Qrcode(id);
scannerRef.current = scanner; scannerRef.current = scanner;
await scanner.start( await scanner.start(
{ facingMode }, { facingMode },
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 }, { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 },
(decodedText: string) => onScan(decodedText), (decodedText: string) => {
if (mountedRef.current) onScan(decodedText);
},
() => {} () => {}
); );
if (cancelled) {
await destroyScanner();
return;
}
// Force layout recalculation after camera starts
requestAnimationFrame(() => {
if (container) {
container.style.display = 'none';
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
container.offsetHeight; // force reflow
container.style.display = '';
}
if (mountedRef.current) setReady(true);
});
} catch (error: any) { } catch (error: any) {
console.error('Scanner error:', error); console.error('Scanner error:', error);
if (!cancelled) { if (!cancelled && mountedRef.current) {
toast.error('Failed to start camera. Check permissions.'); toast.error('Failed to start camera. Check permissions.');
onActiveChange(false); onError();
} }
} }
}; };
const stopScanner = async () => { init();
if (scannerRef.current) {
try { await scannerRef.current.stop(); } catch {} return () => {
scannerRef.current = null; cancelled = true;
mountedRef.current = false;
destroyScanner();
};
}, [facingMode]); // restart when camera flips
// Handle browser visibility change (suspend/resume)
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === 'hidden') {
destroyScanner();
} else if (document.visibilityState === 'visible' && mountedRef.current) {
// Re-trigger by flipping facingMode back-and-forth (forces useEffect re-run)
setFacingMode((prev) => {
// Toggle and toggle back to trigger the effect
const temp = prev === 'environment' ? 'user' : 'environment';
setTimeout(() => {
if (mountedRef.current) setFacingMode(prev);
}, 100);
return temp;
});
} }
}; };
if (isActive) { document.addEventListener('visibilitychange', handleVisibility);
startScanner(); return () => document.removeEventListener('visibilitychange', handleVisibility);
} else { }, [destroyScanner]);
stopScanner();
}
return () => { cancelled = true; };
}, [isActive, facingMode, onScan, onActiveChange]);
const switchCamera = () => { const switchCamera = () => {
setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment')); setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'));
@@ -163,8 +214,11 @@ function QRScanner({
return ( return (
<div className="relative w-full bg-black flex-1 min-h-0 overflow-hidden"> <div className="relative w-full bg-black flex-1 min-h-0 overflow-hidden">
<div ref={containerRef} className="w-full h-full [&_video]:!object-cover [&_video]:!h-full" /> <div
{isActive && ( ref={containerRef}
className="w-full h-full [&_video]:!object-cover [&_video]:!h-full [&_video]:!w-full"
/>
{ready && (
<button <button
onClick={switchCamera} onClick={switchCamera}
className="absolute top-3 right-3 z-10 bg-black/50 backdrop-blur-sm text-white p-2.5 rounded-full active:scale-95 transition-transform" className="absolute top-3 right-3 z-10 bg-black/50 backdrop-blur-sm text-white p-2.5 rounded-full active:scale-95 transition-transform"
@@ -173,7 +227,7 @@ function QRScanner({
<VideoCameraIcon className="w-5 h-5" /> <VideoCameraIcon className="w-5 h-5" />
</button> </button>
)} )}
{!isActive && ( {!ready && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400"> <div className="absolute inset-0 flex items-center justify-center text-gray-400">
<div className="text-center"> <div className="text-center">
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" /> <QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
@@ -189,15 +243,27 @@ function QRScanner({
function ValidTicketScreen({ function ValidTicketScreen({
validation, validation,
onConfirmCheckin, onConfirmCheckin,
onClose,
checkingIn, checkingIn,
}: { }: {
validation: TicketValidationResult; validation: TicketValidationResult;
onConfirmCheckin: () => void; onConfirmCheckin: () => void;
onClose: () => void;
checkingIn: boolean; checkingIn: boolean;
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 bg-emerald-600 flex flex-col animate-in fade-in duration-200"> <div className="fixed inset-0 z-50 bg-emerald-600 flex flex-col animate-in fade-in duration-200">
<div className="flex-1 flex flex-col items-center justify-center px-6 text-white"> {/* Close button (dismiss without check-in) */}
<div className="flex justify-end p-4">
<button
onClick={onClose}
className="p-2 rounded-full bg-white/20 text-white active:scale-95 transition-transform"
aria-label="Close"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center px-6 -mt-14 text-white">
<div className="w-24 h-24 rounded-full bg-white/20 flex items-center justify-center mb-6"> <div className="w-24 h-24 rounded-full bg-white/20 flex items-center justify-center mb-6">
<CheckCircleIcon className="w-16 h-16 text-white" /> <CheckCircleIcon className="w-16 h-16 text-white" />
</div> </div>
@@ -259,7 +325,7 @@ function InvalidTicketScreen({
const reasonDetail: Record<InvalidReason, string> = { const reasonDetail: Record<InvalidReason, string> = {
already_checked_in: validation?.ticket?.checkinAt already_checked_in: validation?.ticket?.checkinAt
? `Checked in at ${new Date(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}` ? `Checked in at ${parseDate(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}`
: 'This ticket was already used', : 'This ticket was already used',
cancelled: 'This ticket has been cancelled and is no longer valid.', cancelled: 'This ticket has been cancelled and is no longer valid.',
not_found: error || 'No ticket matching this code was found.', not_found: error || 'No ticket matching this code was found.',
@@ -581,7 +647,7 @@ export default function AdminScannerPage() {
const [activeTab, setActiveTab] = useState<ActiveTab>('scan'); const [activeTab, setActiveTab] = useState<ActiveTab>('scan');
// Scanner state // Scanner state
const [cameraActive, setCameraActive] = useState(false); const [scannerKey, setScannerKey] = useState(0); // increment to force remount
const [scanResult, setScanResult] = useState<ScanResultData>({ state: 'idle' }); const [scanResult, setScanResult] = useState<ScanResultData>({ state: 'idle' });
const [lastScannedCode, setLastScannedCode] = useState(''); const [lastScannedCode, setLastScannedCode] = useState('');
const [checkingIn, setCheckingIn] = useState(false); const [checkingIn, setCheckingIn] = useState(false);
@@ -606,10 +672,11 @@ export default function AdminScannerPage() {
// Load events // Load events
useEffect(() => { useEffect(() => {
eventsApi.getAll({ status: 'published' }) eventsApi.getAll()
.then((res) => { .then((res) => {
setEvents(res.events); const bookable = res.events.filter((e) => e.status === 'published' || e.status === 'unlisted');
const upcoming = res.events.filter((e) => new Date(e.startDatetime) >= new Date()); setEvents(bookable);
const upcoming = bookable.filter((e) => new Date(e.startDatetime) >= new Date());
if (upcoming.length === 1) { if (upcoming.length === 1) {
setSelectedEventId(upcoming[0].id); setSelectedEventId(upcoming[0].id);
} }
@@ -638,21 +705,8 @@ export default function AdminScannerPage() {
loadStats(); loadStats();
}, [selectedEventId]); }, [selectedEventId]);
// Auto-start camera on page load (Scan tab) // When scan tab becomes active again or scan result is dismissed, bump key to remount scanner
useEffect(() => { const scannerActive = activeTab === 'scan' && scanResult.state === 'idle' && !loading;
if (!loading && activeTab === 'scan') {
setCameraActive(true);
}
}, [loading, activeTab]);
// Pause camera when switching away from scan tab
useEffect(() => {
if (activeTab !== 'scan') {
setCameraActive(false);
} else if (scanResult.state === 'idle') {
setCameraActive(true);
}
}, [activeTab, scanResult.state]);
// Validate ticket // Validate ticket
const validateTicket = useCallback(async (code: string) => { const validateTicket = useCallback(async (code: string) => {
@@ -690,7 +744,6 @@ export default function AdminScannerPage() {
if (decodedText === lastScannedCodeRef.current) return; if (decodedText === lastScannedCodeRef.current) return;
lastScannedCodeRef.current = decodedText; lastScannedCodeRef.current = decodedText;
setLastScannedCode(decodedText); setLastScannedCode(decodedText);
setCameraActive(false);
let code = decodedText; let code = decodedText;
const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/); const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/);
@@ -713,7 +766,7 @@ export default function AdminScannerPage() {
setRecentCheckins((prev) => [ setRecentCheckins((prev) => [
{ {
name: result.ticket.attendeeName || 'Guest', name: result.ticket.attendeeName || 'Guest',
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }),
ticketId: scanResult.validation!.ticket!.id, ticketId: scanResult.validation!.ticket!.id,
}, },
...prev.slice(0, 19), ...prev.slice(0, 19),
@@ -744,7 +797,7 @@ export default function AdminScannerPage() {
setRecentCheckins((prev) => [ setRecentCheckins((prev) => [
{ {
name: result.ticket.attendeeName || 'Guest', name: result.ticket.attendeeName || 'Guest',
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }),
ticketId: searchDetailValidation!.ticket!.id, ticketId: searchDetailValidation!.ticket!.id,
}, },
...prev.slice(0, 19), ...prev.slice(0, 19),
@@ -768,12 +821,12 @@ export default function AdminScannerPage() {
} }
}; };
// Reset scan state // Reset scan state — bump key so scanner fully remounts
const resetScan = () => { const resetScan = () => {
setScanResult({ state: 'idle' }); setScanResult({ state: 'idle' });
setLastScannedCode(''); setLastScannedCode('');
lastScannedCodeRef.current = ''; lastScannedCodeRef.current = '';
if (activeTab === 'scan') setCameraActive(true); setScannerKey((k) => k + 1);
}; };
// Get selected event name // Get selected event name
@@ -788,7 +841,7 @@ export default function AdminScannerPage() {
} }
return ( return (
<div className="min-h-screen bg-gray-950 flex flex-col h-screen max-h-screen overflow-hidden"> <div className="bg-gray-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
{/* ── Sticky Header ── */} {/* ── Sticky Header ── */}
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top"> <header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -864,14 +917,17 @@ export default function AdminScannerPage() {
{/* ── Tab Content ── */} {/* ── Tab Content ── */}
<div className="flex-1 min-h-0 flex flex-col overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* SCAN TAB */} {/* SCAN TAB — scanner fully unmounts when not active */}
{activeTab === 'scan' && ( {scannerActive && (
<QRScanner <QRScanner
isActive={cameraActive && scanResult.state === 'idle'} key={scannerKey}
onScan={handleScan} onScan={handleScan}
onActiveChange={setCameraActive} onError={() => {}}
/> />
)} )}
{activeTab === 'scan' && !scannerActive && scanResult.state !== 'idle' && (
<div className="flex-1 bg-black" />
)}
{/* SEARCH TAB */} {/* SEARCH TAB */}
{activeTab === 'search' && ( {activeTab === 'search' && (
@@ -889,6 +945,7 @@ export default function AdminScannerPage() {
<ValidTicketScreen <ValidTicketScreen
validation={scanResult.validation} validation={scanResult.validation}
onConfirmCheckin={handleCheckin} onConfirmCheckin={handleCheckin}
onClose={resetScan}
checkingIn={checkingIn} checkingIn={checkingIn}
/> />
)} )}

View File

@@ -4,6 +4,7 @@ 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 { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api'; import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api';
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 Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
@@ -319,7 +320,7 @@ export default function AdminSettingsPage() {
<div> <div>
<p className="font-medium text-amber-900">{featuredEvent.title}</p> <p className="font-medium text-amber-900">{featuredEvent.title}</p>
<p className="text-sm text-amber-700"> <p className="text-sm text-amber-700">
{new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { {parseDate(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',

View File

@@ -3,10 +3,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api'; import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
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 Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { CheckCircleIcon, XCircleIcon, PlusIcon } from '@heroicons/react/24/outline'; import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { CheckCircleIcon, XCircleIcon, PlusIcon, FunnelIcon, XMarkIcon } 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';
@@ -17,26 +19,17 @@ export default function AdminTicketsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedEvent, setSelectedEvent] = useState<string>(''); const [selectedEvent, setSelectedEvent] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
// Manual ticket creation state
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
eventId: '', eventId: '', firstName: '', lastName: '', email: '', phone: '',
firstName: '', preferredLanguage: 'en' as 'en' | 'es', autoCheckin: false, adminNote: '',
lastName: '',
email: '',
phone: '',
preferredLanguage: 'en' as 'en' | 'es',
autoCheckin: false,
adminNote: '',
}); });
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([ticketsApi.getAll(), eventsApi.getAll()])
ticketsApi.getAll(),
eventsApi.getAll(),
])
.then(([ticketsRes, eventsRes]) => { .then(([ticketsRes, eventsRes]) => {
setTickets(ticketsRes.tickets); setTickets(ticketsRes.tickets);
setEvents(eventsRes.events); setEvents(eventsRes.events);
@@ -58,9 +51,7 @@ export default function AdminTicketsPage() {
}; };
useEffect(() => { useEffect(() => {
if (!loading) { if (!loading) loadTickets();
loadTickets();
}
}, [selectedEvent, statusFilter]); }, [selectedEvent, statusFilter]);
const handleCheckin = async (id: string) => { const handleCheckin = async (id: string) => {
@@ -75,7 +66,6 @@ export default function AdminTicketsPage() {
const handleCancel = async (id: string) => { const handleCancel = async (id: string) => {
if (!confirm('Are you sure you want to cancel this ticket?')) return; if (!confirm('Are you sure you want to cancel this ticket?')) return;
try { try {
await ticketsApi.cancel(id); await ticketsApi.cancel(id);
toast.success('Ticket cancelled'); toast.success('Ticket cancelled');
@@ -97,35 +87,18 @@ export default function AdminTicketsPage() {
const handleCreateTicket = async (e: React.FormEvent) => { const handleCreateTicket = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!createForm.eventId) { if (!createForm.eventId) { toast.error('Please select an event'); return; }
toast.error('Please select an event');
return;
}
setCreating(true); setCreating(true);
try { try {
await ticketsApi.adminCreate({ await ticketsApi.adminCreate({
eventId: createForm.eventId, eventId: createForm.eventId, firstName: createForm.firstName,
firstName: createForm.firstName, lastName: createForm.lastName || undefined, email: createForm.email,
lastName: createForm.lastName || undefined, phone: createForm.phone, preferredLanguage: createForm.preferredLanguage,
email: createForm.email, autoCheckin: createForm.autoCheckin, adminNote: createForm.adminNote || undefined,
phone: createForm.phone,
preferredLanguage: createForm.preferredLanguage,
autoCheckin: createForm.autoCheckin,
adminNote: createForm.adminNote || undefined,
}); });
toast.success('Ticket created successfully'); toast.success('Ticket created successfully');
setShowCreateForm(false); setShowCreateForm(false);
setCreateForm({ setCreateForm({ eventId: '', firstName: '', lastName: '', email: '', phone: '', preferredLanguage: 'en', autoCheckin: false, adminNote: '' });
eventId: '',
firstName: '',
lastName: '',
email: '',
phone: '',
preferredLanguage: 'en',
autoCheckin: false,
adminNote: '',
});
loadTickets(); loadTickets();
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Failed to create ticket'); toast.error(error.message || 'Failed to create ticket');
@@ -135,34 +108,30 @@ export default function AdminTicketsPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
}); });
}; };
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
const styles: Record<string, string> = { const styles: Record<string, string> = {
pending: 'badge-warning', pending: 'badge-warning', confirmed: 'badge-success', cancelled: 'badge-danger', checked_in: 'badge-info',
confirmed: 'badge-success',
cancelled: 'badge-danger',
checked_in: 'badge-info',
}; };
const labels: Record<string, string> = { const labels: Record<string, string> = {
pending: t('admin.tickets.status.pending'), pending: t('admin.tickets.status.pending'), confirmed: t('admin.tickets.status.confirmed'),
confirmed: t('admin.tickets.status.confirmed'), cancelled: t('admin.tickets.status.cancelled'), checked_in: t('admin.tickets.status.checkedIn'),
cancelled: t('admin.tickets.status.cancelled'),
checked_in: t('admin.tickets.status.checkedIn'),
}; };
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>; return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>;
}; };
const getEventName = (eventId: string) => { const getEventName = (eventId: string) => events.find(e => e.id === eventId)?.title || 'Unknown Event';
const event = events.find(e => e.id === eventId);
return event?.title || 'Unknown Event'; const hasActiveFilters = selectedEvent || statusFilter;
const getPrimaryAction = (ticket: Ticket) => {
if (ticket.status === 'pending') return { label: 'Confirm', onClick: () => handleConfirm(ticket.id) };
if (ticket.status === 'confirmed') return { label: t('admin.tickets.checkin'), onClick: () => handleCheckin(ticket.id) };
return null;
}; };
if (loading) { if (loading) {
@@ -176,134 +145,86 @@ export default function AdminTicketsPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1> <h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1>
<Button onClick={() => setShowCreateForm(true)}> <Button onClick={() => setShowCreateForm(true)} className="hidden md:flex">
<PlusIcon className="w-5 h-5 mr-2" /> <PlusIcon className="w-5 h-5 mr-2" /> Create Ticket
Create Ticket
</Button> </Button>
</div> </div>
{/* Manual Ticket Creation Modal */} {/* Create Ticket Modal */}
{showCreateForm && ( {showCreateForm && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center 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">
<Card className="w-full max-w-lg p-6"> <Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<h2 className="text-xl font-bold mb-6">Create Ticket Manually</h2> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<form onSubmit={handleCreateTicket} className="space-y-4"> <h2 className="text-base font-bold">Create Ticket Manually</h2>
<button onClick={() => setShowCreateForm(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={handleCreateTicket} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
<div> <div>
<label className="block text-sm font-medium mb-1">Event *</label> <label className="block text-sm font-medium mb-1">Event *</label>
<select <select value={createForm.eventId}
value={createForm.eventId}
onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })} onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray min-h-[44px]" required>
required
>
<option value="">Select an event</option> <option value="">Select an event</option>
{events.filter(e => e.status === 'published').map((event) => ( {events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
<option key={event.id} value={event.id}> <option key={event.id} value={event.id}>{event.title} ({event.availableSeats} spots left)</option>
{event.title} ({event.availableSeats} spots left)
</option>
))} ))}
</select> </select>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input <Input label="First Name *" value={createForm.firstName}
label="First Name *" onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })} required placeholder="First name" />
value={createForm.firstName} <Input label="Last Name" value={createForm.lastName}
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })} onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })} placeholder="Last name" />
required
placeholder="First name"
/>
<Input
label="Last Name (optional)"
value={createForm.lastName}
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })}
placeholder="Last name"
/>
</div> </div>
<Input label="Email (optional)" type="email" value={createForm.email}
<Input onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })} placeholder="attendee@email.com" />
label="Email (optional)" <Input label="Phone (optional)" value={createForm.phone}
type="email" onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })} placeholder="+595 XXX XXX XXX" />
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
placeholder="attendee@email.com"
/>
<Input
label="Phone (optional)"
value={createForm.phone}
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })}
placeholder="+595 XXX XXX XXX"
/>
<div> <div>
<label className="block text-sm font-medium mb-1">Preferred Language</label> <label className="block text-sm font-medium mb-1">Preferred Language</label>
<select <select value={createForm.preferredLanguage}
value={createForm.preferredLanguage}
onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })} onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray min-h-[44px]">
>
<option value="en">English</option> <option value="en">English</option>
<option value="es">Spanish</option> <option value="es">Spanish</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Admin Note</label> <label className="block text-sm font-medium mb-1">Admin Note</label>
<textarea <textarea value={createForm.adminNote}
value={createForm.adminNote}
onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })} onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" rows={2}
rows={2} placeholder="Internal note (optional)" />
placeholder="Internal note about this booking (optional)"
/>
</div> </div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <input type="checkbox" id="autoCheckin" checked={createForm.autoCheckin}
<input
type="checkbox"
id="autoCheckin"
checked={createForm.autoCheckin}
onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })} onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })}
className="w-4 h-4" className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
/> <label htmlFor="autoCheckin" className="text-sm">Auto check-in immediately</label>
<label htmlFor="autoCheckin" className="text-sm">
Automatically check in (mark as present)
</label>
</div> </div>
<div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800"> <div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800">
Note: This creates a ticket with cash payment marked as paid. Use this for walk-ins at the door. Email and phone are optional for door entries. Creates a ticket with cash payment marked as paid. Use for walk-ins at the door.
</div> </div>
<div className="flex gap-3 pt-2">
<div className="flex gap-3 pt-4"> <Button type="button" variant="outline" onClick={() => setShowCreateForm(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={creating}> <Button type="submit" isLoading={creating} className="flex-1 min-h-[44px]">Create Ticket</Button>
Create Ticket
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowCreateForm(false)}
>
Cancel
</Button>
</div> </div>
</form> </form>
</Card> </Card>
</div> </div>
)} )}
{/* Filters */} {/* Desktop Filters */}
<Card className="p-4 mb-6"> <Card className="p-4 mb-6 hidden md:block">
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Event</label> <label className="block text-sm font-medium mb-1">Event</label>
<select <select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
value={selectedEvent} className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px] text-sm">
onChange={(e) => setSelectedEvent(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px]"
>
<option value="">All Events</option> <option value="">All Events</option>
{events.map((event) => ( {events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option> <option key={event.id} value={event.id}>{event.title}</option>
@@ -312,11 +233,8 @@ export default function AdminTicketsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-sm font-medium mb-1">Status</label>
<select <select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
value={statusFilter} className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="confirmed">Confirmed</option> <option value="confirmed">Confirmed</option>
@@ -327,70 +245,61 @@ export default function AdminTicketsPage() {
</div> </div>
</Card> </Card>
{/* Tickets Table */} {/* Mobile Toolbar */}
<Card className="overflow-hidden"> <div className="md:hidden mb-4 flex items-center gap-2">
<button onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
hasActiveFilters ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
)}>
<FunnelIcon className="w-4 h-4" />
Filters {hasActiveFilters && `(${tickets.length})`}
</button>
{hasActiveFilters && (
<button onClick={() => { setSelectedEvent(''); setStatusFilter(''); }}
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
)}
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Ticket</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Ticket</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th> <th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{tickets.length === 0 ? ( {tickets.length === 0 ? (
<tr> <tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No tickets found</td></tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No tickets found
</td>
</tr>
) : ( ) : (
tickets.map((ticket) => ( tickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50"> <tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4"> <td className="px-4 py-3">
<div> <p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p> <p className="text-[10px] text-gray-400">ID: {ticket.id.slice(0, 8)}...</p>
<p className="text-xs text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
</div>
</td> </td>
<td className="px-6 py-4 text-sm"> <td className="px-4 py-3 text-sm">{getEventName(ticket.eventId)}</td>
{getEventName(ticket.eventId)} <td className="px-4 py-3 text-xs text-gray-500">{formatDate(ticket.createdAt)}</td>
</td> <td className="px-4 py-3">{getStatusBadge(ticket.status)}</td>
<td className="px-6 py-4 text-sm text-gray-600"> <td className="px-4 py-3">
{formatDate(ticket.createdAt)} <div className="flex items-center justify-end gap-1">
</td>
<td className="px-6 py-4">
{getStatusBadge(ticket.status)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{ticket.status === 'pending' && ( {ticket.status === 'pending' && (
<Button <Button size="sm" variant="ghost" onClick={() => handleConfirm(ticket.id)}>Confirm</Button>
size="sm"
variant="ghost"
onClick={() => handleConfirm(ticket.id)}
>
Confirm
</Button>
)} )}
{ticket.status === 'confirmed' && ( {ticket.status === 'confirmed' && (
<Button <Button size="sm" onClick={() => handleCheckin(ticket.id)}>
size="sm" <CheckCircleIcon className="w-4 h-4 mr-1" /> {t('admin.tickets.checkin')}
onClick={() => handleCheckin(ticket.id)}
>
<CheckCircleIcon className="w-4 h-4 mr-1" />
{t('admin.tickets.checkin')}
</Button> </Button>
)} )}
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && ( {ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
<button <button onClick={() => handleCancel(ticket.id)}
onClick={() => handleCancel(ticket.id)} className="p-2 hover:bg-red-100 text-red-600 rounded-btn" title="Cancel">
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Cancel"
>
<XCircleIcon className="w-4 h-4" /> <XCircleIcon className="w-4 h-4" />
</button> </button>
)} )}
@@ -403,6 +312,102 @@ export default function AdminTicketsPage() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{tickets.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">No tickets found</div>
) : (
tickets.map((ticket) => {
const primary = getPrimaryAction(ticket);
return (
<Card key={ticket.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
<p className="text-xs text-gray-500 truncate">{getEventName(ticket.eventId)}</p>
</div>
{getStatusBadge(ticket.status)}
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">{formatDate(ticket.createdAt)}</p>
<div className="flex items-center gap-1">
{primary && (
<Button size="sm" variant={ticket.status === 'confirmed' ? 'primary' : 'outline'}
onClick={primary.onClick} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{primary.label}
</Button>
)}
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
<MoreMenu>
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel Ticket
</DropdownItem>
</MoreMenu>
)}
{ticket.status === 'checked_in' && (
<span className="text-[10px] text-green-600 flex items-center gap-1">
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
</span>
)}
</div>
</div>
</Card>
);
})
)}
</div>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={() => setShowCreateForm(true)}
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
<PlusIcon className="w-6 h-6" />
</button>
</div>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
<option value="">All Events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>{event.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<div className="space-y-1">
{[
{ value: '', label: 'All Statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'checked_in', label: 'Checked In' },
{ value: 'cancelled', label: 'Cancelled' },
].map((opt) => (
<button key={opt.value} onClick={() => setStatusFilter(opt.value)}
className={clsx(
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
statusFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}>
{opt.label}
{statusFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => { setSelectedEvent(''); setStatusFilter(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
</div>
</div>
</BottomSheet>
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -3,11 +3,15 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { usersApi, User } from '@/lib/api'; import { usersApi, User } from '@/lib/api';
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 Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import { TrashIcon, PencilSquareIcon } from '@heroicons/react/24/outline'; import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { CheckCircleIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx';
export default function AdminUsersPage() { export default function AdminUsersPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
@@ -24,6 +28,7 @@ export default function AdminUsersPage() {
accountStatus: '' as string, accountStatus: '' as string,
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
useEffect(() => { useEffect(() => {
loadUsers(); loadUsers();
@@ -52,7 +57,6 @@ export default function AdminUsersPage() {
const handleDelete = async (userId: string) => { const handleDelete = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user?')) return; if (!confirm('Are you sure you want to delete this user?')) return;
try { try {
await usersApi.delete(userId); await usersApi.delete(userId);
toast.success('User deleted'); toast.success('User deleted');
@@ -65,11 +69,8 @@ export default function AdminUsersPage() {
const openEditModal = (user: User) => { const openEditModal = (user: User) => {
setEditingUser(user); setEditingUser(user);
setEditForm({ setEditForm({
name: user.name, name: user.name, email: user.email, phone: user.phone || '',
email: user.email, role: user.role, languagePreference: user.languagePreference || '',
phone: user.phone || '',
role: user.role,
languagePreference: user.languagePreference || '',
accountStatus: user.accountStatus || 'active', accountStatus: user.accountStatus || 'active',
}); });
}; };
@@ -77,7 +78,6 @@ export default function AdminUsersPage() {
const handleEditSubmit = async (e: React.FormEvent) => { const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!editingUser) return; if (!editingUser) return;
if (!editForm.name.trim() || editForm.name.trim().length < 2) { if (!editForm.name.trim() || editForm.name.trim().length < 2) {
toast.error('Name must be at least 2 characters'); toast.error('Name must be at least 2 characters');
return; return;
@@ -86,14 +86,11 @@ export default function AdminUsersPage() {
toast.error('Email is required'); toast.error('Email is required');
return; return;
} }
setSaving(true); setSaving(true);
try { try {
await usersApi.update(editingUser.id, { await usersApi.update(editingUser.id, {
name: editForm.name.trim(), name: editForm.name.trim(), email: editForm.email.trim(),
email: editForm.email.trim(), phone: editForm.phone.trim() || undefined, role: editForm.role,
phone: editForm.phone.trim() || undefined,
role: editForm.role,
languagePreference: editForm.languagePreference || undefined, languagePreference: editForm.languagePreference || undefined,
accountStatus: editForm.accountStatus || undefined, accountStatus: editForm.accountStatus || undefined,
} as Partial<User>); } as Partial<User>);
@@ -108,21 +105,15 @@ export default function AdminUsersPage() {
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion',
month: 'short',
day: 'numeric',
timeZone: 'America/Asuncion',
}); });
}; };
const getRoleBadge = (role: string) => { const getRoleBadge = (role: string) => {
const styles: Record<string, string> = { const styles: Record<string, string> = {
admin: 'badge-danger', admin: 'badge-danger', organizer: 'badge-info', staff: 'badge-warning',
organizer: 'badge-info', marketing: 'badge-success', user: 'badge-gray',
staff: 'badge-warning',
marketing: 'badge-success',
user: 'badge-gray',
}; };
return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>; return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>;
}; };
@@ -138,19 +129,16 @@ export default function AdminUsersPage() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1> <h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1>
</div> </div>
{/* Filters */} {/* Desktop Filters */}
<Card className="p-4 mb-6"> <Card className="p-4 mb-6 hidden md:block">
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label> <label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
<select <select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)}
value={roleFilter} className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
onChange={(e) => setRoleFilter(e.target.value)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
>
<option value="">All Roles</option> <option value="">All Roles</option>
<option value="admin">{t('admin.users.roles.admin')}</option> <option value="admin">{t('admin.users.roles.admin')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option> <option value="organizer">{t('admin.users.roles.organizer')}</option>
@@ -162,51 +150,58 @@ export default function AdminUsersPage() {
</div> </div>
</Card> </Card>
{/* Users Table */} {/* Mobile Toolbar */}
<Card className="overflow-hidden"> <div className="md:hidden mb-4 flex items-center gap-2">
<button onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
roleFilter ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
)}>
<FunnelIcon className="w-4 h-4" />
{roleFilter ? t(`admin.users.roles.${roleFilter}`) : 'Filter by Role'}
</button>
{roleFilter && (
<button onClick={() => setRoleFilter('')} className="text-xs text-primary-yellow min-h-[44px] flex items-center">
Clear
</button>
)}
<span className="text-xs text-gray-500 ml-auto">{users.length} users</span>
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-secondary-gray"> <thead className="bg-secondary-gray">
<tr> <tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">User</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Contact</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Role</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Joined</th> <th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th> <th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{users.length === 0 ? ( {users.length === 0 ? (
<tr> <tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
No users found
</td>
</tr>
) : ( ) : (
users.map((user) => ( users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50"> <tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center"> <div className="w-8 h-8 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
<span className="font-semibold text-primary-dark"> <span className="font-semibold text-sm text-primary-dark">{user.name.charAt(0).toUpperCase()}</span>
{user.name.charAt(0).toUpperCase()}
</span>
</div> </div>
<div> <div>
<p className="font-medium">{user.name}</p> <p className="font-medium text-sm">{user.name}</p>
<p className="text-sm text-gray-500">{user.email}</p> <p className="text-xs text-gray-500">{user.email}</p>
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-sm text-gray-600"> <td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
{user.phone || '-'} <td className="px-4 py-3">
</td> <select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
<td className="px-6 py-4"> className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
<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"
>
<option value="user">{t('admin.users.roles.user')}</option> <option value="user">{t('admin.users.roles.user')}</option>
<option value="staff">{t('admin.users.roles.staff')}</option> <option value="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option> <option value="marketing">{t('admin.users.roles.marketing')}</option>
@@ -214,23 +209,15 @@ export default function AdminUsersPage() {
<option value="admin">{t('admin.users.roles.admin')}</option> <option value="admin">{t('admin.users.roles.admin')}</option>
</select> </select>
</td> </td>
<td className="px-6 py-4 text-sm text-gray-600"> <td className="px-4 py-3 text-xs text-gray-500">{formatDate(user.createdAt)}</td>
{formatDate(user.createdAt)} <td className="px-4 py-3">
</td> <div className="flex items-center justify-end gap-1">
<td className="px-6 py-4"> <button onClick={() => openEditModal(user)}
<div className="flex items-center justify-end gap-2"> className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn" title="Edit">
<button
onClick={() => openEditModal(user)}
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
title="Edit"
>
<PencilSquareIcon className="w-4 h-4" /> <PencilSquareIcon className="w-4 h-4" />
</button> </button>
<button <button onClick={() => handleDelete(user.id)}
onClick={() => handleDelete(user.id)} className="p-2 hover:bg-red-100 text-red-600 rounded-btn" title="Delete">
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
title="Delete"
>
<TrashIcon className="w-4 h-4" /> <TrashIcon className="w-4 h-4" />
</button> </button>
</div> </div>
@@ -243,43 +230,90 @@ export default function AdminUsersPage() {
</div> </div>
</Card> </Card>
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{users.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">No users found</div>
) : (
users.map((user) => (
<Card key={user.id} className="p-3">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
<span className="font-semibold text-primary-dark">{user.name.charAt(0).toUpperCase()}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{user.name}</p>
<p className="text-xs text-gray-500 truncate">{user.email}</p>
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
</div>
{getRoleBadge(user.role)}
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">Joined {formatDate(user.createdAt)}</p>
<MoreMenu>
<DropdownItem onClick={() => openEditModal(user)}>
<PencilSquareIcon className="w-4 h-4 mr-2" /> Edit User
</DropdownItem>
<DropdownItem onClick={() => handleDelete(user.id)} className="text-red-600">
<TrashIcon className="w-4 h-4 mr-2" /> Delete
</DropdownItem>
</MoreMenu>
</div>
</div>
</div>
</Card>
))
)}
</div>
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filter by Role">
<div className="space-y-1">
{[
{ value: '', label: 'All Roles' },
{ value: 'admin', label: t('admin.users.roles.admin') },
{ value: 'organizer', label: t('admin.users.roles.organizer') },
{ value: 'staff', label: t('admin.users.roles.staff') },
{ value: 'marketing', label: t('admin.users.roles.marketing') },
{ value: 'user', label: t('admin.users.roles.user') },
].map((opt) => (
<button key={opt.value}
onClick={() => { setRoleFilter(opt.value); setMobileFilterOpen(false); }}
className={clsx(
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
roleFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}>
{opt.label}
{roleFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</BottomSheet>
{/* Edit User Modal */} {/* Edit User Modal */}
{editingUser && ( {editingUser && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center 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">
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6"> <Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
<h2 className="text-xl font-bold text-primary-dark mb-6">Edit User</h2> <div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-bold">Edit User</h2>
<form onSubmit={handleEditSubmit} className="space-y-4"> <button onClick={() => setEditingUser(null)}
<Input className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
label="Name" <XMarkIcon className="w-5 h-5" />
value={editForm.name} </button>
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} </div>
required <form onSubmit={handleEditSubmit} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
minLength={2} <Input label="Name" value={editForm.name}
/> onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required minLength={2} />
<Input label="Email" type="email" value={editForm.email}
<Input onChange={(e) => setEditForm({ ...editForm, email: e.target.value })} required />
label="Email" <Input label="Phone" value={editForm.phone}
type="email" onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })} placeholder="Optional" />
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
required
/>
<Input
label="Phone"
value={editForm.phone}
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
placeholder="Optional"
/>
<div> <div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label> <label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label>
<select <select value={editForm.role} onChange={(e) => setEditForm({ ...editForm, role: e.target.value as User['role'] })}
value={editForm.role} className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]">
onChange={(e) => setEditForm({ ...editForm, role: e.target.value as User['role'] })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
>
<option value="user">{t('admin.users.roles.user')}</option> <option value="user">{t('admin.users.roles.user')}</option>
<option value="staff">{t('admin.users.roles.staff')}</option> <option value="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option> <option value="marketing">{t('admin.users.roles.marketing')}</option>
@@ -287,49 +321,36 @@ export default function AdminUsersPage() {
<option value="admin">{t('admin.users.roles.admin')}</option> <option value="admin">{t('admin.users.roles.admin')}</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label> <label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label>
<select <select value={editForm.languagePreference}
value={editForm.languagePreference}
onChange={(e) => setEditForm({ ...editForm, languagePreference: e.target.value })} onChange={(e) => setEditForm({ ...editForm, languagePreference: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]">
>
<option value="">Not set</option> <option value="">Not set</option>
<option value="en">English</option> <option value="en">English</option>
<option value="es">Espa&#241;ol</option> <option value="es">Espa&#241;ol</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label> <label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label>
<select <select value={editForm.accountStatus}
value={editForm.accountStatus}
onChange={(e) => setEditForm({ ...editForm, accountStatus: e.target.value })} onChange={(e) => setEditForm({ ...editForm, accountStatus: e.target.value })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent" className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]">
>
<option value="active">Active</option> <option value="active">Active</option>
<option value="unclaimed">Unclaimed</option> <option value="unclaimed">Unclaimed</option>
<option value="suspended">Suspended</option> <option value="suspended">Suspended</option>
</select> </select>
</div> </div>
<div className="flex gap-3 pt-2">
<div className="flex gap-4 justify-end mt-6 pt-4 border-t border-secondary-light-gray"> <Button type="button" variant="outline" onClick={() => setEditingUser(null)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button <Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">Save Changes</Button>
type="button"
variant="outline"
onClick={() => setEditingUser(null)}
>
Cancel
</Button>
<Button type="submit" isLoading={saving}>
Save Changes
</Button>
</div> </div>
</form> </form>
</Card> </Card>
</div> </div>
)} )}
<AdminMobileStyles />
</div> </div>
); );
} }

View File

@@ -127,11 +127,11 @@
} }
.ProseMirror ul { .ProseMirror ul {
@apply list-disc list-inside my-3; @apply list-disc list-outside pl-6 my-3;
} }
.ProseMirror ol { .ProseMirror ol {
@apply list-decimal list-inside my-3; @apply list-decimal list-outside pl-6 my-3;
} }
.ProseMirror li { .ProseMirror li {

View File

@@ -2,13 +2,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
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, formatDateShort, formatTime } from '@/lib/utils'; import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import { import {
CalendarIcon, CalendarIcon,
MapPinIcon, MapPinIcon,
ChatBubbleLeftRightIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
export default function LinktreePage() { export default function LinktreePage() {
@@ -24,7 +24,16 @@ export default function LinktreePage() {
useEffect(() => { useEffect(() => {
eventsApi.getNextUpcoming() eventsApi.getNextUpcoming()
.then(({ event }) => setNextEvent(event)) .then(({ event }) => {
if (event) {
const endTime = event.endDatetime || event.startDatetime;
if (new Date(endTime).getTime() <= Date.now()) {
setNextEvent(null);
return;
}
}
setNextEvent(event);
})
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -50,8 +59,8 @@ export default function LinktreePage() {
<div className="max-w-md mx-auto px-4 py-8 pb-16"> <div className="max-w-md mx-auto px-4 py-8 pb-16">
{/* Profile Header */} {/* Profile Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-24 h-24 mx-auto bg-primary-yellow rounded-full flex items-center justify-center mb-4 shadow-lg"> <div className="w-24 h-24 mx-auto rounded-full overflow-hidden flex items-center justify-center mb-4 shadow-lg bg-white">
<ChatBubbleLeftRightIcon className="w-12 h-12 text-primary-dark" /> <Image src="/images/spanglish-icon.png" alt="Spanglish" width={96} height={96} className="object-contain" />
</div> </div>
<h1 className="text-2xl font-bold text-white">Spanglish</h1> <h1 className="text-2xl font-bold text-white">Spanglish</h1>
<p className="text-gray-400 mt-1">{t('linktree.tagline')}</p> <p className="text-gray-400 mt-1">{t('linktree.tagline')}</p>
@@ -68,7 +77,7 @@ export default function LinktreePage() {
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" /> <div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div> </div>
) : nextEvent ? ( ) : nextEvent ? (
<Link href={`/events/${nextEvent.id}`} className="block group"> <Link href={`/events/${nextEvent.slug}`} className="block group">
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 transition-all duration-300 hover:bg-white/15 hover:scale-[1.02] hover:shadow-xl"> <div className="bg-white/10 backdrop-blur-sm rounded-2xl p-5 border border-white/10 transition-all duration-300 hover:bg-white/15 hover:scale-[1.02] hover:shadow-xl">
<h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors"> <h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors">
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title} {locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}

View File

@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { parseDate } from '@/lib/utils';
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';
@@ -10,6 +11,7 @@ interface LlmsFaq {
interface LlmsEvent { interface LlmsEvent {
id: string; id: string;
slug: string;
title: string; title: string;
titleEs?: string; titleEs?: string;
shortDescription?: string; shortDescription?: string;
@@ -28,7 +30,7 @@ interface LlmsEvent {
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> { async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
try { try {
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, { const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
next: { tags: ['next-event'] }, cache: 'no-store',
}); });
if (!response.ok) return null; if (!response.ok) return null;
const data = await response.json(); const data = await response.json();
@@ -41,7 +43,7 @@ async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
async function getUpcomingEvents(): Promise<LlmsEvent[]> { async function getUpcomingEvents(): Promise<LlmsEvent[]> {
try { try {
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, { const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
next: { tags: ['next-event'] }, cache: 'no-store',
}); });
if (!response.ok) return []; if (!response.ok) return [];
const data = await response.json(); const data = await response.json();
@@ -56,7 +58,7 @@ async function getUpcomingEvents(): Promise<LlmsEvent[]> {
const EVENT_TIMEZONE = 'America/Asuncion'; const EVENT_TIMEZONE = 'America/Asuncion';
function formatEventDate(dateStr: string): string { function formatEventDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', { return parseDate(dateStr).toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -66,7 +68,7 @@ function formatEventDate(dateStr: string): string {
} }
function formatEventTime(dateStr: string): string { function formatEventTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('en-US', { return parseDate(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: true, hour12: true,
@@ -80,7 +82,7 @@ function formatPrice(price: number, currency: string): string {
} }
function formatISODate(dateStr: string): string { function formatISODate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-CA', { return parseDate(dateStr).toLocaleDateString('en-CA', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
@@ -89,7 +91,7 @@ function formatISODate(dateStr: string): string {
} }
function formatISOTime(dateStr: string): string { function formatISOTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('en-GB', { return parseDate(dateStr).toLocaleTimeString('en-GB', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false, hour12: false,
@@ -115,7 +117,7 @@ function getEventStatus(event: LlmsEvent): string {
async function getHomepageFaqs(): Promise<LlmsFaq[]> { async function getHomepageFaqs(): Promise<LlmsFaq[]> {
try { try {
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, { const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
next: { revalidate: 3600 }, cache: 'no-store',
}); });
if (!response.ok) return []; if (!response.ok) return [];
const data = await response.json(); const data = await response.json();
@@ -128,6 +130,8 @@ async function getHomepageFaqs(): Promise<LlmsFaq[]> {
} }
} }
export const dynamic = 'force-dynamic';
export async function GET() { export async function GET() {
const [nextEvent, upcomingEvents, faqs] = await Promise.all([ const [nextEvent, upcomingEvents, faqs] = await Promise.all([
getNextUpcomingEvent(), getNextUpcomingEvent(),
@@ -190,7 +194,7 @@ export async function GET() {
if (nextEvent.availableSeats !== undefined) { if (nextEvent.availableSeats !== undefined) {
lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`); lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`);
} }
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.id}`); lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.slug}`);
if (nextEvent.shortDescription) { if (nextEvent.shortDescription) {
lines.push(`- Description: ${nextEvent.shortDescription}`); lines.push(`- Description: ${nextEvent.shortDescription}`);
} }
@@ -223,7 +227,7 @@ export async function GET() {
if (event.availableSeats !== undefined) { if (event.availableSeats !== undefined) {
lines.push(`- Capacity Remaining: ${event.availableSeats}`); lines.push(`- Capacity Remaining: ${event.availableSeats}`);
} }
lines.push(`- Tickets URL: ${siteUrl}/events/${event.id}`); lines.push(`- Tickets URL: ${siteUrl}/events/${event.slug}`);
lines.push(''); lines.push('');
} }
} }

View File

@@ -5,6 +5,7 @@ const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface SitemapEvent { interface SitemapEvent {
id: string; id: string;
slug: string;
status: string; status: string;
startDatetime: string; startDatetime: string;
updatedAt: string; updatedAt: string;
@@ -100,7 +101,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const eventPages: MetadataRoute.Sitemap = events.map((event) => { const eventPages: MetadataRoute.Sitemap = events.map((event) => {
const isUpcoming = new Date(event.startDatetime) > now; const isUpcoming = new Date(event.startDatetime) > now;
return { return {
url: `${siteUrl}/events/${event.id}`, url: `${siteUrl}/events/${event.slug}`,
lastModified: new Date(event.updatedAt), lastModified: new Date(event.updatedAt),
changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const), changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const),
priority: isUpcoming ? 0.8 : 0.5, priority: isUpcoming ? 0.8 : 0.5,

View File

@@ -0,0 +1,183 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon, EllipsisVerticalIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
// ----- Skeleton loaders -----
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="animate-pulse">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-4 py-3 border-b border-gray-100">
<div className="h-4 bg-gray-200 rounded w-1/4" />
<div className="h-4 bg-gray-200 rounded w-1/5" />
<div className="h-4 bg-gray-200 rounded w-16" />
<div className="h-4 bg-gray-200 rounded w-20" />
<div className="h-4 bg-gray-200 rounded w-16 ml-auto" />
</div>
))}
</div>
);
}
export function CardSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="space-y-3 animate-pulse">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="bg-white rounded-card shadow-card p-4">
<div className="flex items-center justify-between mb-2">
<div className="h-4 bg-gray-200 rounded w-1/3" />
<div className="h-5 bg-gray-200 rounded-full w-16" />
</div>
<div className="h-3 bg-gray-200 rounded w-1/2 mb-2" />
<div className="h-3 bg-gray-200 rounded w-1/4" />
</div>
))}
</div>
);
}
// ----- Dropdown component (portal-based to escape overflow:hidden) -----
export function Dropdown({ trigger, children, open, onOpenChange, align = 'right' }: {
trigger: React.ReactNode;
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
align?: 'left' | 'right';
}) {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
useEffect(() => {
if (open && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const menuWidth = 192;
let left = align === 'right' ? rect.right - menuWidth : rect.left;
left = Math.max(8, Math.min(left, window.innerWidth - menuWidth - 8));
setPos({ top: rect.bottom + 4, left });
}
}, [open, align]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (
triggerRef.current && !triggerRef.current.contains(target) &&
menuRef.current && !menuRef.current.contains(target)
) {
onOpenChange(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open, onOpenChange]);
useEffect(() => {
if (!open) return;
const handler = () => onOpenChange(false);
window.addEventListener('scroll', handler, true);
return () => window.removeEventListener('scroll', handler, true);
}, [open, onOpenChange]);
return (
<>
<div ref={triggerRef} className="inline-block">
<div onClick={() => onOpenChange(!open)}>{trigger}</div>
</div>
{open && pos && createPortal(
<div
ref={menuRef}
className="fixed z-[9999] w-48 bg-white border border-secondary-light-gray rounded-btn shadow-lg py-1"
style={{ top: pos.top, left: pos.left }}
>
{children}
</div>,
document.body
)}
</>
);
}
export function DropdownItem({ onClick, children, className }: { onClick: () => void; children: React.ReactNode; className?: string }) {
return (
<button onClick={onClick} className={clsx('w-full text-left px-4 py-2 text-sm hover:bg-gray-50 min-h-[44px] flex items-center', className)}>
{children}
</button>
);
}
// ----- Bottom Sheet (mobile) -----
export function BottomSheet({ open, onClose, title, children }: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center md:hidden" onClick={onClose}>
<div className="fixed inset-0 bg-black/50" />
<div
className="relative w-full bg-white rounded-t-2xl max-h-[80vh] overflow-auto animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-gray-100 sticky top-0 bg-white z-10">
<h3 className="font-semibold text-base">{title}</h3>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}
// ----- More Menu (per-row) -----
export function MoreMenu({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<Dropdown
open={open}
onOpenChange={setOpen}
trigger={
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<EllipsisVerticalIcon className="w-5 h-5 text-gray-500" />
</button>
}
>
{children}
</Dropdown>
);
}
// ----- Global CSS for animations -----
export function AdminMobileStyles() {
return (
<style jsx global>{`
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.25s ease-out;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
`}</style>
);
}

View File

@@ -29,6 +29,7 @@ export default function Footer() {
width={140} width={140}
height={40} height={40}
className="h-10 w-auto" className="h-10 w-auto"
style={{ width: 'auto' }}
/> />
</Link> </Link>
<p className="mt-3 max-w-md" style={{ color: '#002F44' }}> <p className="mt-3 max-w-md" style={{ color: '#002F44' }}>
@@ -107,7 +108,7 @@ export default function Footer() {
{legalLinks.map((link) => ( {legalLinks.map((link) => (
<Link <Link
key={link.slug} key={link.slug}
href={`/legal/${link.slug}`} href={`/legal/${link.slug}${locale === 'es' ? '?locale=es' : ''}`}
className="hover:opacity-70 transition-colors text-sm" className="hover:opacity-70 transition-colors text-sm"
style={{ color: '#002F44' }} style={{ color: '#002F44' }}
> >

View File

@@ -124,6 +124,7 @@ export default function Header() {
width={140} width={140}
height={40} height={40}
className="h-10 w-auto" className="h-10 w-auto"
style={{ width: 'auto' }}
priority priority
/> />
</Link> </Link>
@@ -219,6 +220,7 @@ export default function Header() {
width={100} width={100}
height={28} height={28}
className="h-7 w-auto" className="h-7 w-auto"
style={{ width: 'auto' }}
/> />
<button <button
className="p-2 rounded-lg hover:bg-gray-100 transition-colors" className="p-2 rounded-lg hover:bg-gray-100 transition-colors"

View File

@@ -1,17 +1,74 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/24/outline'; import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import { useLanguage } from '@/context/LanguageContext';
import { legalPagesApi } from '@/lib/api';
interface LegalPageLayoutProps { interface LegalPageLayoutProps {
slug: string;
initialLocale: 'en' | 'es';
title: string; title: string;
content: string; content: string;
lastUpdated?: string; lastUpdated?: string;
} }
export default function LegalPageLayout({ title, content, lastUpdated }: LegalPageLayoutProps) { function extractLastUpdated(contentMarkdown: string, updatedAt?: string): string | undefined {
const match = contentMarkdown?.match(/Last updated:\s*(.+)/i);
return match ? match[1].trim() : updatedAt;
}
export default function LegalPageLayout({
slug,
initialLocale,
title: initialTitle,
content: initialContent,
lastUpdated: initialLastUpdated,
}: LegalPageLayoutProps) {
const { locale, t } = useLanguage();
const [title, setTitle] = useState(initialTitle);
const [content, setContent] = useState(initialContent);
const [lastUpdated, setLastUpdated] = useState(initialLastUpdated);
const [loadedLocale, setLoadedLocale] = useState(initialLocale);
useEffect(() => {
if (locale === loadedLocale) {
return;
}
// Returning to the server-rendered language: restore SSR content without a fetch
if (locale === initialLocale) {
setTitle(initialTitle);
setContent(initialContent);
setLastUpdated(initialLastUpdated);
setLoadedLocale(initialLocale);
return;
}
let cancelled = false;
legalPagesApi
.getBySlug(slug, locale)
.then(({ page }) => {
if (cancelled || !page) {
return;
}
setTitle(page.title);
setContent(page.contentMarkdown);
setLastUpdated(extractLastUpdated(page.contentMarkdown, page.updatedAt));
setLoadedLocale(locale);
})
.catch(() => {
// Keep the server-rendered content if the re-fetch fails
});
return () => {
cancelled = true;
};
}, [locale, loadedLocale, initialLocale, slug, initialTitle, initialContent, initialLastUpdated]);
return ( return (
<div className="section-padding"> <div className="section-padding">
<div className="container-page max-w-4xl"> <div className="container-page max-w-4xl">
@@ -21,7 +78,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
className="inline-flex items-center text-gray-600 hover:text-primary-dark transition-colors mb-8" className="inline-flex items-center text-gray-600 hover:text-primary-dark transition-colors mb-8"
> >
<ArrowLeftIcon className="w-4 h-4 mr-2" /> <ArrowLeftIcon className="w-4 h-4 mr-2" />
Back to Home {t('legalPage.backToHome')}
</Link> </Link>
{/* Title */} {/* Title */}
@@ -31,7 +88,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
</h1> </h1>
{lastUpdated && lastUpdated !== '[Insert Date]' && ( {lastUpdated && lastUpdated !== '[Insert Date]' && (
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Last updated: {lastUpdated} {t('legalPage.lastUpdated', { date: lastUpdated })}
</p> </p>
)} )}
</div> </div>
@@ -70,12 +127,12 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
), ),
// Style lists // Style lists
ul: ({ children }) => ( ul: ({ children }) => (
<ul className="list-disc list-inside space-y-2 mb-4 text-gray-700 ml-4"> <ul className="list-disc list-outside space-y-2 mb-4 text-gray-700 pl-6">
{children} {children}
</ul> </ul>
), ),
ol: ({ children }) => ( ol: ({ children }) => (
<ol className="list-decimal list-inside space-y-2 mb-4 text-gray-700 ml-4"> <ol className="list-decimal list-outside space-y-2 mb-4 text-gray-700 pl-6">
{children} {children}
</ol> </ol>
), ),
@@ -182,7 +239,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="text-gray-500 hover:text-primary-dark transition-colors text-sm" className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
> >
Back to top {t('legalPage.backToTop')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,41 @@
'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;
}

View File

@@ -117,7 +117,11 @@
"rucOptional": "Optional - for invoice", "rucOptional": "Optional - for invoice",
"reserveSpot": "Reserve My Spot", "reserveSpot": "Reserve My Spot",
"proceedPayment": "Proceed to Payment", "proceedPayment": "Proceed to Payment",
"termsNote": "By booking, you agree to our terms and conditions.", "termsAgreePart1": "I agree to the ",
"termsOfService": "Terms of Service",
"termsAgreePart2": " and ",
"privacyPolicy": "Privacy Policy",
"termsAgreePart3": ".",
"soldOutMessage": "This event is fully booked. Check back later or browse other events.", "soldOutMessage": "This event is fully booked. Check back later or browse other events.",
"errors": { "errors": {
"nameRequired": "Please enter your full name", "nameRequired": "Please enter your full name",
@@ -129,7 +133,8 @@
"phoneRequired": "Phone number is required", "phoneRequired": "Phone number is required",
"bookingFailed": "Booking failed. Please try again.", "bookingFailed": "Booking failed. Please try again.",
"rucInvalidFormat": "Invalid format. Example: 12345678-9", "rucInvalidFormat": "Invalid format. Example: 12345678-9",
"rucInvalidCheckDigit": "Invalid RUC. Please verify the number." "rucInvalidCheckDigit": "Invalid RUC. Please verify the number.",
"termsRequired": "You must agree to the Terms of Service and Privacy Policy to continue."
} }
}, },
"summary": { "summary": {
@@ -317,6 +322,11 @@
"refund": "Refund Policy" "refund": "Refund Policy"
} }
}, },
"legalPage": {
"backToHome": "Back to Home",
"lastUpdated": "Last updated: {date}",
"backToTop": "Back to top"
},
"linktree": { "linktree": {
"tagline": "Language Exchange Community", "tagline": "Language Exchange Community",
"nextEvent": "Next Event", "nextEvent": "Next Event",

View File

@@ -117,7 +117,11 @@
"rucOptional": "Opcional - para facturación", "rucOptional": "Opcional - para facturación",
"reserveSpot": "Reservar Mi Lugar", "reserveSpot": "Reservar Mi Lugar",
"proceedPayment": "Proceder al Pago", "proceedPayment": "Proceder al Pago",
"termsNote": "Al reservar, aceptas nuestros términos y condiciones.", "termsAgreePart1": "Acepto los ",
"termsOfService": "Términos de Servicio",
"termsAgreePart2": " y la ",
"privacyPolicy": "Política de Privacidad",
"termsAgreePart3": ".",
"soldOutMessage": "Este evento está lleno. Vuelve más tarde o explora otros eventos.", "soldOutMessage": "Este evento está lleno. Vuelve más tarde o explora otros eventos.",
"errors": { "errors": {
"nameRequired": "Por favor ingresa tu nombre completo", "nameRequired": "Por favor ingresa tu nombre completo",
@@ -129,7 +133,8 @@
"phoneRequired": "El número de teléfono es requerido", "phoneRequired": "El número de teléfono es requerido",
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.", "bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
"rucInvalidFormat": "Formato inválido. Ej: 12345678-9", "rucInvalidFormat": "Formato inválido. Ej: 12345678-9",
"rucInvalidCheckDigit": "RUC inválido. Verifique el número." "rucInvalidCheckDigit": "RUC inválido. Verifique el número.",
"termsRequired": "Debes aceptar los Términos de Servicio y la Política de Privacidad para continuar."
} }
}, },
"summary": { "summary": {
@@ -317,6 +322,11 @@
"refund": "Política de Reembolso" "refund": "Política de Reembolso"
} }
}, },
"legalPage": {
"backToHome": "Volver al inicio",
"lastUpdated": "Última actualización: {date}",
"backToTop": "Volver arriba"
},
"linktree": { "linktree": {
"tagline": "Comunidad de Intercambio de Idiomas", "tagline": "Comunidad de Intercambio de Idiomas",
"nextEvent": "Próximo Evento", "nextEvent": "Próximo Evento",

View File

@@ -67,6 +67,12 @@ export const eventsApi = {
duplicate: (id: string) => duplicate: (id: string) =>
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }), fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }),
getSlugAliases: (id: string) =>
fetchApi<{ aliases: { slug: string; createdAt: string }[] }>(`/api/events/${id}/slug-aliases`),
deleteSlugAlias: (id: string, slug: string) =>
fetchApi<{ message: string }>(`/api/events/${id}/slug-aliases/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
}; };
// Tickets API // Tickets API
@@ -179,6 +185,20 @@ export const ticketsApi = {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
guestCreate: (data: {
eventId: string;
firstName: string;
lastName?: string;
email?: string;
phone?: string;
preferredLanguage?: 'en' | 'es';
adminNote?: string;
}) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/guest', {
method: 'POST',
body: JSON.stringify(data),
}),
checkPaymentStatus: (ticketId: string) => checkPaymentStatus: (ticketId: string) =>
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>( fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
@@ -236,11 +256,13 @@ export const usersApi = {
// Payments API // Payments API
export const paymentsApi = { export const paymentsApi = {
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean }) => { getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.status) query.set('status', params.status); if (params?.status) query.set('status', params.status);
if (params?.provider) query.set('provider', params.provider); if (params?.provider) query.set('provider', params.provider);
if (params?.pendingApproval) query.set('pendingApproval', 'true'); if (params?.pendingApproval) query.set('pendingApproval', 'true');
if (params?.eventId) query.set('eventId', params.eventId);
if (params?.eventIds && params.eventIds.length > 0) query.set('eventIds', params.eventIds.join(','));
return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`); return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`);
}, },
@@ -373,16 +395,17 @@ export const adminApi = {
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`); return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
}, },
/** Download attendee export as a file (CSV). Returns a Blob. */ /** Download attendee export as a file (CSV). Returns a Blob. */
exportAttendees: async (eventId: string, params?: { status?: string; format?: string }) => { exportAttendees: async (eventId: string, params?: { status?: string; format?: string; q?: string }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.status) query.set('status', params.status); if (params?.status) query.set('status', params.status);
if (params?.format) query.set('format', params.format); if (params?.format) query.set('format', params.format);
if (params?.q) query.set('q', params.q);
const token = typeof window !== 'undefined' const token = typeof window !== 'undefined'
? localStorage.getItem('spanglish-token') ? localStorage.getItem('spanglish-token')
: null; : null;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`; if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/export?${query}`, { headers }); const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/attendees/export?${query}`, { headers });
if (!res.ok) { if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Export failed' })); const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
throw new Error(errorData.error || 'Export failed'); throw new Error(errorData.error || 'Export failed');
@@ -393,6 +416,27 @@ export const adminApi = {
const blob = await res.blob(); const blob = await res.blob();
return { blob, filename }; return { blob, filename };
}, },
/** Download tickets export as CSV. Returns a Blob. */
exportTicketsCSV: async (eventId: string, params?: { status?: string; q?: string }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.q) query.set('q', params.q);
const token = typeof window !== 'undefined'
? localStorage.getItem('spanglish-token')
: null;
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/admin/events/${eventId}/tickets/export?${query}`, { headers });
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
throw new Error(errorData.error || 'Export failed');
}
const disposition = res.headers.get('Content-Disposition') || '';
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
const filename = filenameMatch ? filenameMatch[1] : `tickets-${new Date().toISOString().split('T')[0]}.csv`;
const blob = await res.blob();
return { blob, filename };
},
}; };
// Emails API // Emails API
@@ -458,17 +502,23 @@ export const emailsApi = {
}), }),
// Logs // Logs
getLogs: (params?: { eventId?: string; status?: string; limit?: number; offset?: number }) => { getLogs: (params?: { eventId?: string; status?: string; search?: string; limit?: number; offset?: number }) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params?.eventId) query.set('eventId', params.eventId); if (params?.eventId) query.set('eventId', params.eventId);
if (params?.status) query.set('status', params.status); if (params?.status) query.set('status', params.status);
if (params?.search) query.set('search', params.search);
if (params?.limit) query.set('limit', params.limit.toString()); if (params?.limit) query.set('limit', params.limit.toString());
if (params?.offset) query.set('offset', params.offset.toString()); if (params?.offset) query.set('offset', params.offset.toString());
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`); return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
}, },
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`), getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
resendLog: (id: string) =>
fetchApi<{ success: boolean; error?: string }>(`/api/emails/logs/${id}/resend`, {
method: 'POST',
}),
getStats: (eventId?: string) => { getStats: (eventId?: string) => {
const query = eventId ? `?eventId=${eventId}` : ''; const query = eventId ? `?eventId=${eventId}` : '';
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`); return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
@@ -481,6 +531,7 @@ export const emailsApi = {
// Types // Types
export interface Event { export interface Event {
id: string; id: string;
slug: string;
title: string; title: string;
titleEs?: string; titleEs?: string;
description: string; description: string;
@@ -494,7 +545,7 @@ export interface Event {
price: number; price: number;
currency: string; currency: string;
capacity: number; capacity: number;
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived'; status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string; bannerUrl?: string;
externalBookingEnabled?: boolean; externalBookingEnabled?: boolean;
externalBookingUrl?: string; externalBookingUrl?: string;
@@ -521,6 +572,7 @@ export interface Ticket {
checkedInByAdminId?: string; checkedInByAdminId?: string;
qrCode: string; qrCode: string;
adminNote?: string; adminNote?: string;
isGuest?: boolean;
createdAt: string; createdAt: string;
event?: Event; event?: Event;
payment?: Payment; payment?: Payment;
@@ -768,6 +820,8 @@ export interface EmailLog {
sentAt?: string; sentAt?: string;
sentBy?: string; sentBy?: string;
createdAt: string; createdAt: string;
resendAttempts?: number;
lastResentAt?: string;
} }
export interface EmailStats { export interface EmailStats {

View File

@@ -4,9 +4,14 @@
// All helpers pin the timezone to America/Asuncion so the output is identical // All helpers pin the timezone to America/Asuncion so the output is identical
// on the server (often UTC) and the client (user's local TZ). This prevents // on the server (often UTC) and the client (user's local TZ). This prevents
// React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client). // React hydration mismatches like "07:20 PM" (server) vs "04:20 PM" (client).
//
// IMPORTANT — parseDate() must be used instead of raw `new Date(str)` so that
// ISO-like strings without a timezone suffix (e.g. "2026-04-02T14:00:00") are
// always treated as UTC. Without this, the same string produces a different
// instant on server (Node TZ) vs client (browser / DevTools TZ).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const EVENT_TIMEZONE = 'America/Asuncion'; export const EVENT_TIMEZONE = 'America/Asuncion';
type Locale = 'en' | 'es'; type Locale = 'en' | 'es';
@@ -14,11 +19,29 @@ function pickLocale(locale: Locale): string {
return locale === 'es' ? 'es-ES' : 'en-US'; return locale === 'es' ? 'es-ES' : 'en-US';
} }
// Matches ISO-like strings that have NO timezone indicator (Z, +HH:MM, etc.)
const NAIVE_ISO_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
/**
* Parse a date string into a deterministic Date object.
*
* If the string looks like an ISO datetime but lacks a timezone suffix it is
* ambiguous — `new Date()` would interpret it in the environment's local
* timezone which differs between Node (SSR) and the browser (hydration).
* We normalise by appending "Z" so parsing always targets UTC.
*/
export function parseDate(dateStr: string): Date {
if (NAIVE_ISO_RE.test(dateStr)) {
return new Date(dateStr + 'Z');
}
return new Date(dateStr);
}
/** /**
* "Sat, Feb 14" / "sáb, 14 feb" * "Sat, Feb 14" / "sáb, 14 feb"
*/ */
export function formatDateShort(dateStr: string, locale: Locale = 'en'): string { export function formatDateShort(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -30,7 +53,7 @@ export function formatDateShort(dateStr: string, locale: Locale = 'en'): string
* "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026" * "Saturday, February 14, 2026" / "sábado, 14 de febrero de 2026"
*/ */
export function formatDateLong(dateStr: string, locale: Locale = 'en'): string { export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -43,7 +66,7 @@ export function formatDateLong(dateStr: string, locale: Locale = 'en'): string {
* "February 14, 2026" / "14 de febrero de 2026" (no weekday) * "February 14, 2026" / "14 de febrero de 2026" (no weekday)
*/ */
export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string { export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
@@ -55,7 +78,7 @@ export function formatDateMedium(dateStr: string, locale: Locale = 'en'): string
* "Feb 14, 2026" / "14 feb 2026" * "Feb 14, 2026" / "14 feb 2026"
*/ */
export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string { export function formatDateCompact(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleDateString(pickLocale(locale), { return parseDate(dateStr).toLocaleDateString(pickLocale(locale), {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -67,7 +90,7 @@ export function formatDateCompact(dateStr: string, locale: Locale = 'en'): strin
* "04:30 PM" / "16:30" * "04:30 PM" / "16:30"
*/ */
export function formatTime(dateStr: string, locale: Locale = 'en'): string { export function formatTime(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleTimeString(pickLocale(locale), { return parseDate(dateStr).toLocaleTimeString(pickLocale(locale), {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: EVENT_TIMEZONE, timeZone: EVENT_TIMEZONE,
@@ -78,7 +101,7 @@ export function formatTime(dateStr: string, locale: Locale = 'en'): string {
* "Feb 14, 2026, 04:30 PM" — compact date + time combined * "Feb 14, 2026, 04:30 PM" — compact date + time combined
*/ */
export function formatDateTime(dateStr: string, locale: Locale = 'en'): string { export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleString(pickLocale(locale), { return parseDate(dateStr).toLocaleString(pickLocale(locale), {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -92,7 +115,7 @@ export function formatDateTime(dateStr: string, locale: Locale = 'en'): string {
* "Sat, Feb 14, 04:30 PM" — short date + time combined * "Sat, Feb 14, 04:30 PM" — short date + time combined
*/ */
export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string { export function formatDateTimeShort(dateStr: string, locale: Locale = 'en'): string {
return new Date(dateStr).toLocaleString(pickLocale(locale), { return parseDate(dateStr).toLocaleString(pickLocale(locale), {
weekday: 'short', weekday: 'short',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -15,7 +15,9 @@
"start:frontend": "npm run start --workspace=frontend", "start:frontend": "npm run start --workspace=frontend",
"db:generate": "npm run db:generate --workspace=backend", "db:generate": "npm run db:generate --workspace=backend",
"db:migrate": "npm run db:migrate --workspace=backend", "db:migrate": "npm run db:migrate --workspace=backend",
"db:studio": "npm run db:studio --workspace=backend" "db:studio": "npm run db:studio --workspace=backend",
"db:export": "npm run db:export --workspace=backend --",
"db:import": "npm run db:import --workspace=backend --"
}, },
"workspaces": [ "workspaces": [
"backend", "backend",