26 Commits

Author SHA1 Message Date
b5f14335c4 Merge pull request 'Mobile scanner redesign + backend live search' (#7) from dev into main
Reviewed-on: #7
2026-02-14 04:28:44 +00:00
Michilis
62bf048680 Mobile scanner redesign + backend live search
- Scanner page: fullscreen mobile-first layout, Scan/Search/Recent tabs
- Scan tab: auto-start camera, switch camera, vibration/sound feedback
- Valid/invalid fullscreen states, confirm check-in, auto-return to camera
- Search tab: live backend search (300ms debounce), tap card for detail + check-in
- Recent tab: last 20 check-ins, session counter
- Backend: GET /api/tickets/search (live search), GET /api/tickets/stats/checkin
- Admin layout: hide sidebar on scanner page; fix hooks order (no early return before useEffect)
- Back button to dashboard/events (staff → events, others → admin)
- API: searchLive, getCheckinStats, LiveSearchResult; PostgreSQL LOWER cast for UUID

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 04:26:44 +00:00
d44ac949b5 Merge pull request 'Email queue + async sending; legal settings and placeholders' (#6) from dev into main
Reviewed-on: #6
2026-02-12 21:04:58 +00:00
Michilis
b9f46b02cc Email queue + async sending; legal settings and placeholders
- Add in-memory email queue with rate limiting (MAX_EMAILS_PER_HOUR)
- Bulk send to event attendees now queues and returns immediately
- Frontend shows 'Emails are being sent in the background'
- Legal pages, settings, and placeholders updates

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 21:03:49 +00:00
a5e939221d Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-02-12 07:56:37 +00:00
Michilis
18254c566e SEO: robots.txt, sitemap, Organization & Event schema; dashboard fmtTime fix; frontend updates
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 07:55:43 +00:00
Michilis
95ee5a5dec Improve llms.txt for AI: metadata, ISO dates, explicit status, structured events, update policy, AI summary; fix social links
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 07:12:51 +00:00
833e3e5a9c Merge pull request 'Fix llms.txt event times: format in America/Asuncion timezone' (#4) from dev into main
Reviewed-on: #4
2026-02-12 06:28:51 +00:00
Michilis
77e92e5d96 Fix llms.txt event times: format in America/Asuncion timezone
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 05:17:47 +00:00
ba1975dd6d Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-02-12 04:55:39 +00:00
Michilis
07ba357194 feat: FAQ management from admin, public /faq, homepage section, llms.txt
- Backend: faq_questions table (schema + migration), CRUD + reorder API, Swagger docs
- Admin: FAQ page with create/edit, enable/disable, show on homepage, drag reorder
- Public /faq page fetches enabled FAQs from API; layout builds dynamic JSON-LD
- Homepage: FAQ section under Stay updated (homepage-enabled only) with See full FAQ link
- llms.txt: FAQ section uses homepage FAQs from API
- i18n: home.faq title/seeFull, admin FAQ nav

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 04:49:16 +00:00
Michilis
5885044369 Make next event visible to AI crawlers (SSR, JSON-LD, meta, llms.txt)
- SSR next event on homepage; pass initialEvent from server to avoid client-only content
- Add schema.org Event JSON-LD on homepage when next event exists
- Dynamic homepage metadata (description, OG, Twitter) with next event date
- Add dynamic /llms.txt route for AI-friendly plain-text event info
- Revalidation: support next-event tag; backend revalidates sitemap + next-event on event CUD
- Allow /llms.txt in robots.txt

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 04:10:49 +00:00
Michilis
af94c99fd2 feat: auto-update sitemap when events are added/updated/removed
- Use tag-based cache for sitemap event list (events-sitemap)
- Add POST /api/revalidate endpoint (secret-protected) to trigger revalidation
- Backend calls revalidation after event create/update/delete
- Add REVALIDATE_SECRET to .env.example (frontend + backend)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 03:51:00 +00:00
Michilis
74464b0a7a linktree: next event links to single event page, button 'More info'
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 03:25:09 +00:00
3025ef3d21 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-02-12 03:19:06 +00:00
Michilis
6a807a7cc6 Add edit user details on admin users page
- Backend: extend PUT /api/users/:id with email and accountStatus; admin-only for role/email/accountStatus; return isClaimed, rucNumber, accountStatus in user responses
- Frontend: add Edit button and modal on /admin/users to edit name, email, phone, role, language preference, account status

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 03:17:30 +00:00
Michilis
fe75912f23 Fix event capacity: no negative availability, sold-out enforcement, admin override
- Backend: use calculateAvailableSeats so availableSeats is never negative
- Backend: reject public booking when confirmed >= capacity; admin create/manual bypass capacity
- Frontend: spotsLeft = max(0, capacity - bookedCount), isSoldOut when bookedCount >= capacity
- Frontend: sold-out redirect on booking page, cap quantity by spotsLeft, never show negative

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 03:01:58 +00:00
8564f8af83 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-02-12 02:18:08 +00:00
Michilis
8315029091 fix: resolve ShareButtons hydration error by deferring native share check to client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 02:17:01 +00:00
Michilis
2b2f2cc4ed Admin event: Manual ticket + Tickets tab
- Backend: POST /api/tickets/admin/manual - creates ticket and sends confirmation + ticket email
- Frontend: Manual Ticket button and modal (email required, sends confirmation + ticket)
- New Tickets tab between Attendees and Send Email: confirmed tickets table with search (name/ticket ID), status filter, check-in actions

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 02:44:26 +00:00
Michilis
23d0325d8d feat: add payment management improvements and reminder emails
- Add option to approve/reject payments without sending notification emails
  (checkbox in review popup, default enabled)
- Add payment reminder email template and send functionality
- Track when reminder emails are sent (reminderSentAt field)
- Display reminder sent timestamp in payment review popup
- Make payment review popup scrollable for better UX
- Add payment-reminder template to email system (available in admin emails)
2026-02-05 04:13:42 +00:00
Michilis
0c142884c7 feat: add featured event with automatic fallback
- Add featured_event_id to site_settings (schema + migration)
- Backend: featured event logic in /events/next/upcoming with auto-unset when event ends
- Site settings: PUT supports featuredEventId, add PUT /featured-event for admin
- Admin events: Set as featured checkbox in editor, star toggle in list, featured badge
- Admin settings: Featured Event section with current event and remove/change links
- API: siteSettingsApi.setFeaturedEvent(), Event.isFeatured, SiteSettings.featuredEventId
- Homepage/linktree unchanged: still use getNextUpcoming (now returns featured or fallback)
2026-02-03 19:24:00 +00:00
Michilis
0fd8172e04 Use admin timezone for emails and ticket PDFs
- Email: formatDate/formatTime use site timezone from settings
- PDF tickets: date/time formatted in site timezone
- Tickets routes: fetch timezone and pass to PDF generation
2026-02-03 18:40:39 +00:00
Michilis
9090d7bad2 Add .npm-cache to gitignore; users route and mobile header updates 2026-02-02 21:16:50 +00:00
Michilis
4a84ad22c7 Backend and frontend updates: auth, email, payments, events, tickets; carrousel images; mobile event detail layout; i18n 2026-02-02 20:58:21 +00:00
Michilis
bafd1425c4 Add PostgreSQL support with SQLite/Postgres database compatibility layer
- Add dbGet/dbAll helper functions for database-agnostic queries
- Add toDbBool/convertBooleansForDb for boolean type conversion
- Add toDbDate/getNow for timestamp type handling
- Add generateId that returns UUID for Postgres, nanoid for SQLite
- Update all routes to use compatibility helpers
- Add normalizeEvent to return clean number types from Postgres decimal
- Add formatPrice utility for consistent price display
- Add legal pages admin interface with RichTextEditor
- Update carousel images
- Add drizzle migration files for PostgreSQL
2026-02-02 03:46:35 +00:00
105 changed files with 12349 additions and 2606 deletions

1
.gitignore vendored
View File

@@ -37,6 +37,7 @@ backend/uploads/
# Tooling # Tooling
.turbo/ .turbo/
.cursor/ .cursor/
.npm-cache/
# OS # OS
.DS_Store .DS_Store

View File

@@ -21,6 +21,10 @@ PORT=3001
API_URL=http://localhost:3001 API_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3002 FRONTEND_URL=http://localhost:3002
# Revalidation secret (shared with frontend for on-demand cache revalidation)
# Must match the REVALIDATE_SECRET in frontend/.env
REVALIDATE_SECRET=change-me-to-a-random-secret
# Payment Providers (optional) # Payment Providers (optional)
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
@@ -63,3 +67,9 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
# SendGrid: SMTP_HOST=smtp.sendgrid.net, SMTP_PORT=587, SMTP_USER=apikey, SMTP_PASS=your_api_key # SendGrid: SMTP_HOST=smtp.sendgrid.net, SMTP_PORT=587, SMTP_USER=apikey, SMTP_PASS=your_api_key
# Mailgun: SMTP_HOST=smtp.mailgun.org, SMTP_PORT=587 # Mailgun: SMTP_HOST=smtp.mailgun.org, SMTP_PORT=587
# Amazon SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com, SMTP_PORT=587 # Amazon SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com, SMTP_PORT=587
# Email Queue Rate Limiting
# Maximum number of emails that can be sent per hour (default: 30)
# If the limit is reached, queued emails will pause and resume automatically
MAX_EMAILS_PER_HOUR=30

View File

@@ -0,0 +1,270 @@
CREATE TABLE `audit_logs` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text,
`action` text NOT NULL,
`target` text,
`target_id` text,
`details` text,
`timestamp` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `contacts` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`message` text NOT NULL,
`status` text DEFAULT 'new' NOT NULL,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `email_logs` (
`id` text PRIMARY KEY NOT NULL,
`template_id` text,
`event_id` text,
`recipient_email` text NOT NULL,
`recipient_name` text,
`subject` text NOT NULL,
`body_html` text,
`status` text DEFAULT 'pending' NOT NULL,
`error_message` text,
`sent_at` text,
`sent_by` text,
`created_at` text NOT NULL,
FOREIGN KEY (`template_id`) REFERENCES `email_templates`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`event_id`) REFERENCES `events`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`sent_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `email_settings` (
`id` text PRIMARY KEY NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `email_subscribers` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`name` text,
`status` text DEFAULT 'active' NOT NULL,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `email_templates` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`slug` text NOT NULL,
`subject` text NOT NULL,
`subject_es` text,
`body_html` text NOT NULL,
`body_html_es` text,
`body_text` text,
`body_text_es` text,
`description` text,
`variables` text,
`is_system` integer DEFAULT false NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `event_payment_overrides` (
`id` text PRIMARY KEY NOT NULL,
`event_id` text NOT NULL,
`tpago_enabled` integer,
`tpago_link` text,
`tpago_instructions` text,
`tpago_instructions_es` text,
`bank_transfer_enabled` integer,
`bank_name` text,
`bank_account_holder` text,
`bank_account_number` text,
`bank_alias` text,
`bank_phone` text,
`bank_notes` text,
`bank_notes_es` text,
`lightning_enabled` integer,
`cash_enabled` integer,
`cash_instructions` text,
`cash_instructions_es` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`event_id`) REFERENCES `events`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `events` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`title_es` text,
`description` text NOT NULL,
`description_es` text,
`start_datetime` text NOT NULL,
`end_datetime` text,
`location` text NOT NULL,
`location_url` text,
`price` real DEFAULT 0 NOT NULL,
`currency` text DEFAULT 'PYG' NOT NULL,
`capacity` integer DEFAULT 50 NOT NULL,
`status` text DEFAULT 'draft' NOT NULL,
`banner_url` text,
`external_booking_enabled` integer DEFAULT false NOT NULL,
`external_booking_url` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `invoices` (
`id` text PRIMARY KEY NOT NULL,
`payment_id` text NOT NULL,
`user_id` text NOT NULL,
`invoice_number` text NOT NULL,
`ruc_number` text,
`legal_name` text,
`amount` real NOT NULL,
`currency` text DEFAULT 'PYG' NOT NULL,
`pdf_url` text,
`status` text DEFAULT 'generated' NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`payment_id`) REFERENCES `payments`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `magic_link_tokens` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`token` text NOT NULL,
`type` text NOT NULL,
`expires_at` text NOT NULL,
`used_at` text,
`created_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `media` (
`id` text PRIMARY KEY NOT NULL,
`file_url` text NOT NULL,
`type` text NOT NULL,
`related_id` text,
`related_type` text,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `payment_options` (
`id` text PRIMARY KEY NOT NULL,
`tpago_enabled` integer DEFAULT false NOT NULL,
`tpago_link` text,
`tpago_instructions` text,
`tpago_instructions_es` text,
`bank_transfer_enabled` integer DEFAULT false NOT NULL,
`bank_name` text,
`bank_account_holder` text,
`bank_account_number` text,
`bank_alias` text,
`bank_phone` text,
`bank_notes` text,
`bank_notes_es` text,
`lightning_enabled` integer DEFAULT true NOT NULL,
`cash_enabled` integer DEFAULT true NOT NULL,
`cash_instructions` text,
`cash_instructions_es` text,
`allow_duplicate_bookings` integer DEFAULT false NOT NULL,
`updated_at` text NOT NULL,
`updated_by` text,
FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `payments` (
`id` text PRIMARY KEY NOT NULL,
`ticket_id` text NOT NULL,
`provider` text NOT NULL,
`amount` real NOT NULL,
`currency` text DEFAULT 'PYG' NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`reference` text,
`user_marked_paid_at` text,
`paid_at` text,
`paid_by_admin_id` text,
`admin_note` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`ticket_id`) REFERENCES `tickets`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `site_settings` (
`id` text PRIMARY KEY NOT NULL,
`timezone` text DEFAULT 'America/Asuncion' NOT NULL,
`site_name` text DEFAULT 'Spanglish' NOT NULL,
`site_description` text,
`site_description_es` text,
`contact_email` text,
`contact_phone` text,
`facebook_url` text,
`instagram_url` text,
`twitter_url` text,
`linkedin_url` text,
`maintenance_mode` integer DEFAULT false NOT NULL,
`maintenance_message` text,
`maintenance_message_es` text,
`updated_at` text NOT NULL,
`updated_by` text,
FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `tickets` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`event_id` text NOT NULL,
`attendee_first_name` text NOT NULL,
`attendee_last_name` text,
`attendee_email` text,
`attendee_phone` text,
`attendee_ruc` text,
`preferred_language` text,
`status` text DEFAULT 'pending' NOT NULL,
`checkin_at` text,
`checked_in_by_admin_id` text,
`qr_code` text,
`admin_note` text,
`created_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`event_id`) REFERENCES `events`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`checked_in_by_admin_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user_sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`token` text NOT NULL,
`user_agent` text,
`ip_address` text,
`last_active_at` text NOT NULL,
`expires_at` text NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password` text,
`name` text NOT NULL,
`phone` text,
`role` text DEFAULT 'user' NOT NULL,
`language_preference` text,
`is_claimed` integer DEFAULT true NOT NULL,
`google_id` text,
`ruc_number` text,
`account_status` text DEFAULT 'active' NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `email_settings_key_unique` ON `email_settings` (`key`);--> statement-breakpoint
CREATE UNIQUE INDEX `email_subscribers_email_unique` ON `email_subscribers` (`email`);--> statement-breakpoint
CREATE UNIQUE INDEX `email_templates_name_unique` ON `email_templates` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `email_templates_slug_unique` ON `email_templates` (`slug`);--> statement-breakpoint
CREATE UNIQUE INDEX `invoices_invoice_number_unique` ON `invoices` (`invoice_number`);--> statement-breakpoint
CREATE UNIQUE INDEX `magic_link_tokens_token_unique` ON `magic_link_tokens` (`token`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_sessions_token_unique` ON `user_sessions` (`token`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1769975033554,
"tag": "0000_steady_wendell_vaughn",
"breakpoints": true
}
]
}

View File

@@ -1,3 +1,4 @@
import 'dotenv/config';
import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3'; import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3';
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
@@ -29,5 +30,51 @@ if (dbType === 'postgres') {
db = drizzleSqlite(sqlite, { schema }); db = drizzleSqlite(sqlite, { schema });
} }
export { db }; // ==================== Database Compatibility Helpers ====================
// These functions abstract the differences between SQLite and PostgreSQL Drizzle drivers:
// - SQLite uses .get() for single result, .all() for multiple
// - PostgreSQL returns arrays directly (no .get()/.all() methods)
/**
* Get a single result from a query (works with both SQLite and PostgreSQL)
* @param query - A Drizzle query builder (e.g., db.select().from(table).where(...))
* @returns The first result or null
*/
export async function dbGet<T>(query: any): Promise<T | null> {
if (dbType === 'postgres') {
const results = await query;
return results[0] || null;
}
// SQLite - use .get()
return query.get() || null;
}
/**
* Get all results from a query (works with both SQLite and PostgreSQL)
* @param query - A Drizzle query builder (e.g., db.select().from(table).where(...))
* @returns Array of results
*/
export async function dbAll<T>(query: any): Promise<T[]> {
if (dbType === 'postgres') {
return await query;
}
// SQLite - use .all()
return query.all();
}
/**
* Check if using PostgreSQL
*/
export function isPostgres(): boolean {
return dbType === 'postgres';
}
/**
* Check if using SQLite
*/
export function isSqlite(): boolean {
return dbType === 'sqlite';
}
export { db, dbType };
export * from './schema.js'; export * from './schema.js';

View File

@@ -1,7 +1,10 @@
import 'dotenv/config';
import { db } from './index.js'; import { db } from './index.js';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
const dbType = process.env.DB_TYPE || 'sqlite'; const dbType = process.env.DB_TYPE || 'sqlite';
console.log(`Database type: ${dbType}`);
console.log(`Database URL: ${process.env.DATABASE_URL?.substring(0, 30)}...`);
async function migrate() { async function migrate() {
console.log('Running migrations...'); console.log('Running migrations...');
@@ -166,6 +169,11 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id TEXT REFERENCES users(id)`); await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id TEXT REFERENCES users(id)`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Migration: Add booking_id column to tickets for multi-ticket bookings
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`);
} 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
@@ -198,6 +206,12 @@ async function migrate() {
try { try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN admin_note TEXT`); await (db as any).run(sql`ALTER TABLE payments ADD COLUMN admin_note TEXT`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN payer_name TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`);
} catch (e) { /* column may already exist */ }
// Invoices table // Invoices table
await (db as any).run(sql` await (db as any).run(sql`
@@ -377,6 +391,7 @@ async function migrate() {
instagram_url TEXT, instagram_url TEXT,
twitter_url TEXT, twitter_url TEXT,
linkedin_url TEXT, linkedin_url TEXT,
featured_event_id TEXT REFERENCES events(id),
maintenance_mode INTEGER NOT NULL DEFAULT 0, maintenance_mode INTEGER NOT NULL DEFAULT 0,
maintenance_message TEXT, maintenance_message TEXT,
maintenance_message_es TEXT, maintenance_message_es TEXT,
@@ -384,6 +399,63 @@ async function migrate() {
updated_by TEXT REFERENCES users(id) updated_by TEXT REFERENCES users(id)
) )
`); `);
// Add featured_event_id column to site_settings if it doesn't exist
try {
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`);
} catch (e) { /* column may already exist */ }
// Legal pages table for admin-editable legal content
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS legal_pages (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
title_es TEXT,
content_text TEXT NOT NULL,
content_text_es TEXT,
content_markdown TEXT NOT NULL,
content_markdown_es TEXT,
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL
)
`);
// FAQ questions table
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS faq_questions (
id TEXT PRIMARY KEY,
question TEXT NOT NULL,
question_es TEXT,
answer TEXT NOT NULL,
answer_es TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
show_on_homepage INTEGER NOT NULL DEFAULT 0,
rank INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`);
// Legal settings table for legal page placeholder values
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS legal_settings (
id TEXT PRIMARY KEY,
company_name TEXT,
legal_entity_name TEXT,
ruc_number TEXT,
company_address TEXT,
company_city TEXT,
company_country TEXT,
support_email TEXT,
legal_email TEXT,
governing_law TEXT,
jurisdiction_city TEXT,
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id)
)
`);
} else { } else {
// PostgreSQL migrations // PostgreSQL migrations
await (db as any).execute(sql` await (db as any).execute(sql`
@@ -515,6 +587,11 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id UUID REFERENCES users(id)`); await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id UUID REFERENCES users(id)`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
// Migration: Add booking_id column to tickets for multi-ticket bookings
try {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`);
} 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,
@@ -525,6 +602,7 @@ async function migrate() {
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
reference VARCHAR(255), reference VARCHAR(255),
user_marked_paid_at TIMESTAMP, user_marked_paid_at TIMESTAMP,
payer_name VARCHAR(255),
paid_at TIMESTAMP, paid_at TIMESTAMP,
paid_by_admin_id UUID, paid_by_admin_id UUID,
admin_note TEXT, admin_note TEXT,
@@ -533,6 +611,14 @@ async function migrate() {
) )
`); `);
// Add payer_name column if it doesn't exist
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN payer_name VARCHAR(255)`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
} catch (e) { /* column may already exist */ }
// Invoices table // Invoices table
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS invoices ( CREATE TABLE IF NOT EXISTS invoices (
@@ -709,6 +795,7 @@ async function migrate() {
instagram_url VARCHAR(500), instagram_url VARCHAR(500),
twitter_url VARCHAR(500), twitter_url VARCHAR(500),
linkedin_url VARCHAR(500), linkedin_url VARCHAR(500),
featured_event_id UUID REFERENCES events(id),
maintenance_mode INTEGER NOT NULL DEFAULT 0, maintenance_mode INTEGER NOT NULL DEFAULT 0,
maintenance_message TEXT, maintenance_message TEXT,
maintenance_message_es TEXT, maintenance_message_es TEXT,
@@ -716,6 +803,63 @@ async function migrate() {
updated_by UUID REFERENCES users(id) updated_by UUID REFERENCES users(id)
) )
`); `);
// Add featured_event_id column to site_settings if it doesn't exist
try {
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`);
} catch (e) { /* column may already exist */ }
// Legal pages table for admin-editable legal content
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS legal_pages (
id UUID PRIMARY KEY,
slug VARCHAR(100) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
title_es VARCHAR(255),
content_text TEXT NOT NULL,
content_text_es TEXT,
content_markdown TEXT NOT NULL,
content_markdown_es TEXT,
updated_at TIMESTAMP NOT NULL,
updated_by UUID REFERENCES users(id),
created_at TIMESTAMP NOT NULL
)
`);
// FAQ questions table
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS faq_questions (
id UUID PRIMARY KEY,
question TEXT NOT NULL,
question_es TEXT,
answer TEXT NOT NULL,
answer_es TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
show_on_homepage INTEGER NOT NULL DEFAULT 0,
rank INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Legal settings table for legal page placeholder values
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS legal_settings (
id UUID PRIMARY KEY,
company_name VARCHAR(255),
legal_entity_name VARCHAR(255),
ruc_number VARCHAR(50),
company_address TEXT,
company_city VARCHAR(100),
company_country VARCHAR(100),
support_email VARCHAR(255),
legal_email VARCHAR(255),
governing_law VARCHAR(255),
jurisdiction_city VARCHAR(100),
updated_at TIMESTAMP NOT NULL,
updated_by UUID REFERENCES users(id)
)
`);
} }
console.log('Migrations completed successfully!'); console.log('Migrations completed successfully!');

View File

@@ -85,6 +85,7 @@ export const sqliteEvents = sqliteTable('events', {
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
userId: text('user_id').notNull().references(() => sqliteUsers.id), userId: text('user_id').notNull().references(() => sqliteUsers.id),
eventId: text('event_id').notNull().references(() => sqliteEvents.id), eventId: text('event_id').notNull().references(() => sqliteEvents.id),
attendeeFirstName: text('attendee_first_name').notNull(), attendeeFirstName: text('attendee_first_name').notNull(),
@@ -110,9 +111,11 @@ export const sqlitePayments = sqliteTable('payments', {
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'), status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
reference: text('reference'), reference: text('reference'),
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid" userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
payerName: text('payer_name'), // Name of payer if different from attendee
paidAt: text('paid_at'), paidAt: text('paid_at'),
paidByAdminId: text('paid_by_admin_id'), paidByAdminId: text('paid_by_admin_id'),
adminNote: text('admin_note'), // Internal admin notes adminNote: text('admin_note'), // Internal admin notes
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
@@ -249,6 +252,52 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}); });
// Legal Pages table for admin-editable legal content
export const sqliteLegalPages = sqliteTable('legal_pages', {
id: text('id').primaryKey(),
slug: text('slug').notNull().unique(),
title: text('title').notNull(), // English title
titleEs: text('title_es'), // Spanish title
contentText: text('content_text').notNull(), // Plain text edited by admin (English)
contentTextEs: text('content_text_es'), // Plain text edited by admin (Spanish)
contentMarkdown: text('content_markdown').notNull(), // Generated markdown for public display (English)
contentMarkdownEs: text('content_markdown_es'), // Generated markdown for public display (Spanish)
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(),
});
// FAQ questions table (admin-managed, shown on /faq and optionally on homepage)
export const sqliteFaqQuestions = sqliteTable('faq_questions', {
id: text('id').primaryKey(),
question: text('question').notNull(),
questionEs: text('question_es'),
answer: text('answer').notNull(),
answerEs: text('answer_es'),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
showOnHomepage: integer('show_on_homepage', { mode: 'boolean' }).notNull().default(false),
rank: integer('rank').notNull().default(0),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Legal Settings table for legal page placeholder values
export const sqliteLegalSettings = sqliteTable('legal_settings', {
id: text('id').primaryKey(),
companyName: text('company_name'),
legalEntityName: text('legal_entity_name'),
rucNumber: text('ruc_number'),
companyAddress: text('company_address'),
companyCity: text('company_city'),
companyCountry: text('company_country'),
supportEmail: text('support_email'),
legalEmail: text('legal_email'),
governingLaw: text('governing_law'),
jurisdictionCity: text('jurisdiction_city'),
updatedAt: text('updated_at').notNull(),
updatedBy: text('updated_by').references(() => sqliteUsers.id),
});
// Site Settings table for global website configuration // Site Settings table for global website configuration
export const sqliteSiteSettings = sqliteTable('site_settings', { export const sqliteSiteSettings = sqliteTable('site_settings', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -266,6 +315,8 @@ export const sqliteSiteSettings = sqliteTable('site_settings', {
instagramUrl: text('instagram_url'), instagramUrl: text('instagram_url'),
twitterUrl: text('twitter_url'), twitterUrl: text('twitter_url'),
linkedinUrl: text('linkedin_url'), linkedinUrl: text('linkedin_url'),
// Featured event - manually promoted event shown on homepage/linktree
featuredEventId: text('featured_event_id').references(() => sqliteEvents.id),
// Other settings // Other settings
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false), maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
maintenanceMessage: text('maintenance_message'), maintenanceMessage: text('maintenance_message'),
@@ -356,6 +407,7 @@ export const pgEvents = pgTable('events', {
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
userId: uuid('user_id').notNull().references(() => pgUsers.id), userId: uuid('user_id').notNull().references(() => pgUsers.id),
eventId: uuid('event_id').notNull().references(() => pgEvents.id), eventId: uuid('event_id').notNull().references(() => pgEvents.id),
attendeeFirstName: varchar('attendee_first_name', { length: 255 }).notNull(), attendeeFirstName: varchar('attendee_first_name', { length: 255 }).notNull(),
@@ -381,9 +433,11 @@ export const pgPayments = pgTable('payments', {
status: varchar('status', { length: 20 }).notNull().default('pending'), status: varchar('status', { length: 20 }).notNull().default('pending'),
reference: varchar('reference', { length: 255 }), reference: varchar('reference', { length: 255 }),
userMarkedPaidAt: timestamp('user_marked_paid_at'), userMarkedPaidAt: timestamp('user_marked_paid_at'),
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
paidAt: timestamp('paid_at'), paidAt: timestamp('paid_at'),
paidByAdminId: uuid('paid_by_admin_id'), paidByAdminId: uuid('paid_by_admin_id'),
adminNote: pgText('admin_note'), adminNote: pgText('admin_note'),
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
@@ -512,6 +566,52 @@ export const pgEmailSettings = pgTable('email_settings', {
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
}); });
// Legal Pages table for admin-editable legal content
export const pgLegalPages = pgTable('legal_pages', {
id: uuid('id').primaryKey(),
slug: varchar('slug', { length: 100 }).notNull().unique(),
title: varchar('title', { length: 255 }).notNull(), // English title
titleEs: varchar('title_es', { length: 255 }), // Spanish title
contentText: pgText('content_text').notNull(), // Plain text edited by admin (English)
contentTextEs: pgText('content_text_es'), // Plain text edited by admin (Spanish)
contentMarkdown: pgText('content_markdown').notNull(), // Generated markdown for public display (English)
contentMarkdownEs: pgText('content_markdown_es'), // Generated markdown for public display (Spanish)
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(),
});
// FAQ questions table (admin-managed)
export const pgFaqQuestions = pgTable('faq_questions', {
id: uuid('id').primaryKey(),
question: pgText('question').notNull(),
questionEs: pgText('question_es'),
answer: pgText('answer').notNull(),
answerEs: pgText('answer_es'),
enabled: pgInteger('enabled').notNull().default(1),
showOnHomepage: pgInteger('show_on_homepage').notNull().default(0),
rank: pgInteger('rank').notNull().default(0),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
// Legal Settings table for legal page placeholder values
export const pgLegalSettings = pgTable('legal_settings', {
id: uuid('id').primaryKey(),
companyName: varchar('company_name', { length: 255 }),
legalEntityName: varchar('legal_entity_name', { length: 255 }),
rucNumber: varchar('ruc_number', { length: 50 }),
companyAddress: pgText('company_address'),
companyCity: varchar('company_city', { length: 100 }),
companyCountry: varchar('company_country', { length: 100 }),
supportEmail: varchar('support_email', { length: 255 }),
legalEmail: varchar('legal_email', { length: 255 }),
governingLaw: varchar('governing_law', { length: 255 }),
jurisdictionCity: varchar('jurisdiction_city', { length: 100 }),
updatedAt: timestamp('updated_at').notNull(),
updatedBy: uuid('updated_by').references(() => pgUsers.id),
});
// Site Settings table for global website configuration // Site Settings table for global website configuration
export const pgSiteSettings = pgTable('site_settings', { export const pgSiteSettings = pgTable('site_settings', {
id: uuid('id').primaryKey(), id: uuid('id').primaryKey(),
@@ -529,6 +629,8 @@ export const pgSiteSettings = pgTable('site_settings', {
instagramUrl: varchar('instagram_url', { length: 500 }), instagramUrl: varchar('instagram_url', { length: 500 }),
twitterUrl: varchar('twitter_url', { length: 500 }), twitterUrl: varchar('twitter_url', { length: 500 }),
linkedinUrl: varchar('linkedin_url', { length: 500 }), linkedinUrl: varchar('linkedin_url', { length: 500 }),
// Featured event - manually promoted event shown on homepage/linktree
featuredEventId: uuid('featured_event_id').references(() => pgEvents.id),
// Other settings // Other settings
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0), maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
maintenanceMessage: pgText('maintenance_message'), maintenanceMessage: pgText('maintenance_message'),
@@ -555,7 +657,10 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens; export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions; export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices; export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings;
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings; export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
// Type exports // Type exports
export type User = typeof sqliteUsers.$inferSelect; export type User = typeof sqliteUsers.$inferSelect;
@@ -584,3 +689,9 @@ export type Invoice = typeof sqliteInvoices.$inferSelect;
export type NewInvoice = typeof sqliteInvoices.$inferInsert; export type NewInvoice = typeof sqliteInvoices.$inferInsert;
export type SiteSettings = typeof sqliteSiteSettings.$inferSelect; export type SiteSettings = typeof sqliteSiteSettings.$inferSelect;
export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert; export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert;
export type LegalPage = typeof sqliteLegalPages.$inferSelect;
export type NewLegalPage = typeof sqliteLegalPages.$inferInsert;
export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;

View File

@@ -20,7 +20,11 @@ import emailsRoutes from './routes/emails.js';
import paymentOptionsRoutes from './routes/payment-options.js'; import paymentOptionsRoutes from './routes/payment-options.js';
import dashboardRoutes from './routes/dashboard.js'; import dashboardRoutes from './routes/dashboard.js';
import siteSettingsRoutes from './routes/site-settings.js'; import siteSettingsRoutes from './routes/site-settings.js';
import legalPagesRoutes from './routes/legal-pages.js';
import legalSettingsRoutes from './routes/legal-settings.js';
import faqRoutes from './routes/faq.js';
import emailService from './lib/email.js'; import emailService from './lib/email.js';
import { initEmailQueue } from './lib/emailQueue.js';
const app = new Hono(); const app = new Hono();
@@ -83,6 +87,7 @@ const openApiSpec = {
{ name: 'Media', description: 'File uploads and media management' }, { name: 'Media', description: 'File uploads and media management' },
{ name: 'Lightning', description: 'Lightning/Bitcoin payments via LNBits' }, { name: 'Lightning', description: 'Lightning/Bitcoin payments via LNBits' },
{ name: 'Admin', description: 'Admin dashboard and analytics' }, { name: 'Admin', description: 'Admin dashboard and analytics' },
{ name: 'FAQ', description: 'FAQ questions (public and admin)' },
], ],
paths: { paths: {
// ==================== Auth Endpoints ==================== // ==================== Auth Endpoints ====================
@@ -1586,6 +1591,144 @@ const openApiSpec = {
}, },
}, },
}, },
// ==================== FAQ Endpoints ====================
'/api/faq': {
get: {
tags: ['FAQ'],
summary: 'Get FAQ list (public)',
description: 'Returns enabled FAQ questions, ordered by rank. Use ?homepage=true to get only questions enabled for homepage.',
parameters: [
{ name: 'homepage', in: 'query', schema: { type: 'boolean' }, description: 'If true, only return questions with showOnHomepage' },
],
responses: {
200: { description: 'List of FAQ items (id, question, questionEs, answer, answerEs, rank)' },
},
},
},
'/api/faq/admin/list': {
get: {
tags: ['FAQ'],
summary: 'Get all FAQ questions (admin)',
description: 'Returns all FAQ questions for management, ordered by rank.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'List of all FAQ questions' },
401: { description: 'Unauthorized' },
},
},
},
'/api/faq/admin/:id': {
get: {
tags: ['FAQ'],
summary: 'Get FAQ by ID (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'FAQ details' },
404: { description: 'FAQ not found' },
},
},
put: {
tags: ['FAQ'],
summary: 'Update FAQ (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
question: { type: 'string' },
questionEs: { type: 'string' },
answer: { type: 'string' },
answerEs: { type: 'string' },
enabled: { type: 'boolean' },
showOnHomepage: { type: 'boolean' },
},
},
},
},
},
responses: {
200: { description: 'FAQ updated' },
404: { description: 'FAQ not found' },
},
},
delete: {
tags: ['FAQ'],
summary: 'Delete FAQ (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'FAQ deleted' },
404: { description: 'FAQ not found' },
},
},
},
'/api/faq/admin': {
post: {
tags: ['FAQ'],
summary: 'Create FAQ (admin)',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['question', 'answer'],
properties: {
question: { type: 'string' },
questionEs: { type: 'string' },
answer: { type: 'string' },
answerEs: { type: 'string' },
enabled: { type: 'boolean', default: true },
showOnHomepage: { type: 'boolean', default: false },
},
},
},
},
},
responses: {
201: { description: 'FAQ created' },
400: { description: 'Validation error' },
},
},
},
'/api/faq/admin/reorder': {
post: {
tags: ['FAQ'],
summary: 'Reorder FAQ questions (admin)',
description: 'Set order by sending an ordered array of FAQ ids.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['ids'],
properties: {
ids: { type: 'array', items: { type: 'string' } },
},
},
},
},
},
responses: {
200: { description: 'Order updated, returns full FAQ list' },
400: { description: 'ids array required' },
},
},
},
}, },
components: { components: {
securitySchemes: { securitySchemes: {
@@ -1714,6 +1857,9 @@ app.route('/api/emails', emailsRoutes);
app.route('/api/payment-options', paymentOptionsRoutes); app.route('/api/payment-options', paymentOptionsRoutes);
app.route('/api/dashboard', dashboardRoutes); app.route('/api/dashboard', dashboardRoutes);
app.route('/api/site-settings', siteSettingsRoutes); app.route('/api/site-settings', siteSettingsRoutes);
app.route('/api/legal-pages', legalPagesRoutes);
app.route('/api/legal-settings', legalSettingsRoutes);
app.route('/api/faq', faqRoutes);
// 404 handler // 404 handler
app.notFound((c) => { app.notFound((c) => {
@@ -1728,6 +1874,9 @@ app.onError((err, c) => {
const port = parseInt(process.env.PORT || '3001'); const port = parseInt(process.env.PORT || '3001');
// Initialize email queue with the email service reference
initEmailQueue(emailService);
// Initialize email templates on startup // Initialize email templates on startup
emailService.seedDefaultTemplates().catch(err => { emailService.seedDefaultTemplates().catch(err => {
console.error('[Email] Failed to seed templates:', err); console.error('[Email] Failed to seed templates:', err);

View File

@@ -3,9 +3,9 @@ import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import crypto from 'crypto'; import crypto from 'crypto';
import { Context } from 'hono'; import { Context } from 'hono';
import { db, users, magicLinkTokens, userSessions } from '../db/index.js'; import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
import { eq, and, gt } from 'drizzle-orm'; import { eq, and, gt } from 'drizzle-orm';
import { generateId, getNow } from './utils.js'; import { generateId, getNow, toDbDate } from './utils.js';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production'); const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
const JWT_ISSUER = 'spanglish'; const JWT_ISSUER = 'spanglish';
@@ -51,7 +51,7 @@ export async function createMagicLinkToken(
): Promise<string> { ): Promise<string> {
const token = generateSecureToken(); const token = generateSecureToken();
const now = getNow(); const now = getNow();
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString(); const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
await (db as any).insert(magicLinkTokens).values({ await (db as any).insert(magicLinkTokens).values({
id: generateId(), id: generateId(),
@@ -72,7 +72,8 @@ export async function verifyMagicLinkToken(
): Promise<{ valid: boolean; userId?: string; error?: string }> { ): Promise<{ valid: boolean; userId?: string; error?: string }> {
const now = getNow(); const now = getNow();
const tokenRecord = await (db as any) const tokenRecord = await dbGet<any>(
(db as any)
.select() .select()
.from(magicLinkTokens) .from(magicLinkTokens)
.where( .where(
@@ -81,7 +82,7 @@ export async function verifyMagicLinkToken(
eq((magicLinkTokens as any).type, type) eq((magicLinkTokens as any).type, type)
) )
) )
.get(); );
if (!tokenRecord) { if (!tokenRecord) {
return { valid: false, error: 'Invalid token' }; return { valid: false, error: 'Invalid token' };
@@ -112,7 +113,7 @@ export async function createUserSession(
): Promise<string> { ): Promise<string> {
const sessionToken = generateSecureToken(); const sessionToken = generateSecureToken();
const now = getNow(); const now = getNow();
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days
await (db as any).insert(userSessions).values({ await (db as any).insert(userSessions).values({
id: generateId(), id: generateId(),
@@ -132,7 +133,8 @@ export async function createUserSession(
export async function getUserSessions(userId: string) { export async function getUserSessions(userId: string) {
const now = getNow(); const now = getNow();
return (db as any) return dbAll(
(db as any)
.select() .select()
.from(userSessions) .from(userSessions)
.where( .where(
@@ -141,7 +143,7 @@ export async function getUserSessions(userId: string) {
gt((userSessions as any).expiresAt, now) gt((userSessions as any).expiresAt, now)
) )
) )
.all(); );
} }
// Invalidate a specific session // Invalidate a specific session
@@ -208,7 +210,7 @@ export async function verifyToken(token: string): Promise<JWTPayload | null> {
} }
} }
export async function getAuthUser(c: Context) { export async function getAuthUser(c: Context): Promise<any | null> {
const authHeader = c.req.header('Authorization'); const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
return null; return null;
@@ -221,7 +223,9 @@ export async function getAuthUser(c: Context) {
return null; return null;
} }
const user = await (db as any).select().from(users).where(eq((users as any).id, payload.sub)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, payload.sub))
);
return user || null; return user || null;
} }
@@ -243,6 +247,8 @@ export function requireAuth(roles?: string[]) {
} }
export async function isFirstUser(): Promise<boolean> { export async function isFirstUser(): Promise<boolean> {
const result = await (db as any).select().from(users).limit(1).all(); const result = await dbAll(
(db as any).select().from(users).limit(1)
);
return !result || result.length === 0; return !result || result.length === 0;
} }

View File

@@ -1,16 +1,16 @@
// Email service for Spanglish platform // Email service for Spanglish platform
// Supports multiple email providers: Resend, SMTP (Nodemailer) // Supports multiple email providers: Resend, SMTP (Nodemailer)
import { db, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides } from '../db/index.js'; import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides, siteSettings } from '../db/index.js';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid'; import { getNow, generateId } from './utils.js';
import { getNow } from './utils.js';
import { import {
replaceTemplateVariables, replaceTemplateVariables,
wrapInBaseTemplate, wrapInBaseTemplate,
defaultTemplates, defaultTemplates,
type DefaultTemplate type DefaultTemplate
} from './emailTemplates.js'; } from './emailTemplates.js';
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer'; import type { Transporter } from 'nodemailer';
@@ -325,26 +325,38 @@ export const emailService = {
}, },
/** /**
* Format date for emails * Get the site timezone from settings (cached for performance)
*/ */
formatDate(dateStr: string, locale: string = 'en'): string { async getSiteTimezone(): Promise<string> {
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
return settings?.timezone || 'America/Asuncion';
},
/**
* Format date for emails using site timezone
*/
formatDate(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
timeZone: timezone,
}); });
}, },
/** /**
* Format time for emails * Format time for emails using site timezone
*/ */
formatTime(dateStr: string, locale: string = 'en'): string { formatTime(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: timezone,
}); });
}, },
@@ -362,11 +374,12 @@ export const emailService = {
* Get a template by slug * Get a template by slug
*/ */
async getTemplate(slug: string): Promise<any | null> { async getTemplate(slug: string): Promise<any | null> {
const template = await (db as any) const template = await dbGet(
(db as any)
.select() .select()
.from(emailTemplates) .from(emailTemplates)
.where(eq((emailTemplates as any).slug, slug)) .where(eq((emailTemplates as any).slug, slug))
.get(); );
return template || null; return template || null;
}, },
@@ -385,7 +398,7 @@ export const emailService = {
console.log(`[Email] Creating template: ${template.name}`); console.log(`[Email] Creating template: ${template.name}`);
await (db as any).insert(emailTemplates).values({ await (db as any).insert(emailTemplates).values({
id: nanoid(), id: generateId(),
name: template.name, name: template.name,
slug: template.slug, slug: template.slug,
subject: template.subject, subject: template.subject,
@@ -470,7 +483,7 @@ export const emailService = {
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined; const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
// Create log entry // Create log entry
const logId = nanoid(); const logId = generateId();
const now = getNow(); const now = getNow();
await (db as any).insert(emailLogs).values({ await (db as any).insert(emailLogs).values({
@@ -522,37 +535,66 @@ export const emailService = {
/** /**
* Send booking confirmation email * Send booking confirmation email
* Supports multi-ticket bookings - includes all tickets in the booking
*/ */
async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> { async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket with event info // Get ticket with event info
const ticket = await (db as any) const ticket = await dbGet<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, ticketId)) .where(eq((tickets as any).id, ticketId))
.get(); );
if (!ticket) { if (!ticket) {
return { success: false, error: 'Ticket not found' }; return { success: false, error: 'Ticket not found' };
} }
const event = await (db as any) const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
if (!event) { if (!event) {
return { success: false, error: 'Event not found' }; return { success: false, error: 'Event not found' };
} }
// Get all tickets in this booking (if multi-ticket)
let allTickets: any[] = [ticket];
if (ticket.bookingId) {
allTickets = await dbAll(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
const ticketCount = allTickets.length;
const locale = ticket.preferredLanguage || 'en'; const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
// Generate ticket PDF URL // Generate ticket PDF URL (primary ticket, or use combined endpoint for multi)
const apiUrl = process.env.API_URL || 'http://localhost:3001'; const apiUrl = process.env.API_URL || 'http://localhost:3001';
const ticketPdfUrl = `${apiUrl}/api/tickets/${ticket.id}/pdf`; const ticketPdfUrl = ticketCount > 1 && ticket.bookingId
? `${apiUrl}/api/tickets/booking/${ticket.bookingId}/pdf`
: `${apiUrl}/api/tickets/${ticket.id}/pdf`;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Build attendee list for multi-ticket emails
const attendeeNames = allTickets.map(t =>
`${t.attendeeFirstName} ${t.attendeeLastName || ''}`.trim()
).join(', ');
// Calculate total price for multi-ticket bookings
const totalPrice = event.price * ticketCount;
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
return this.sendTemplateEmail({ return this.sendTemplateEmail({
templateSlug: 'booking-confirmation', templateSlug: 'booking-confirmation',
to: ticket.attendeeEmail, to: ticket.attendeeEmail,
@@ -563,14 +605,20 @@ export const emailService = {
attendeeName: attendeeFullName, attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id, ticketId: ticket.id,
bookingId: ticket.bookingId || ticket.id,
qrCode: ticket.qrCode || '', qrCode: ticket.qrCode || '',
ticketPdfUrl, ticketPdfUrl,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale), eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location, eventLocation: event.location,
eventLocationUrl: event.locationUrl || '', eventLocationUrl: event.locationUrl || '',
eventPrice: this.formatCurrency(event.price, event.currency), eventPrice: this.formatCurrency(event.price, event.currency),
// Multi-ticket specific variables
ticketCount: ticketCount.toString(),
totalPrice: this.formatCurrency(totalPrice, event.currency),
attendeeNames,
isMultiTicket: ticketCount > 1 ? 'true' : 'false',
}, },
}); });
}, },
@@ -580,45 +628,84 @@ export const emailService = {
*/ */
async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> { async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment with ticket and event info // Get payment with ticket and event info
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, paymentId)) .where(eq((payments as any).id, paymentId))
.get(); );
if (!payment) { if (!payment) {
return { success: false, error: 'Payment not found' }; return { success: false, error: 'Payment not found' };
} }
const ticket = await (db as any) const ticket = await dbGet<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, payment.ticketId)) .where(eq((tickets as any).id, payment.ticketId))
.get(); );
if (!ticket) { if (!ticket) {
return { success: false, error: 'Ticket not found' }; return { success: false, error: 'Ticket not found' };
} }
const event = await (db as any) const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
if (!event) { if (!event) {
return { success: false, error: 'Event not found' }; return { success: false, error: 'Event not found' };
} }
// Calculate total amount for multi-ticket bookings
let totalAmount = payment.amount;
let ticketCount = 1;
if (ticket.bookingId) {
// Get all payments for this booking
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
// Sum up all payment amounts for the booking
const bookingPayments = await Promise.all(
bookingTickets.map((t: any) =>
dbGet<any>((db as any).select().from(payments).where(eq((payments as any).ticketId, t.id)))
)
);
totalAmount = bookingPayments
.filter((p: any) => p)
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
}
const locale = ticket.preferredLanguage || 'en'; const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const paymentMethodNames: Record<string, Record<string, string>> = { const paymentMethodNames: Record<string, Record<string, string>> = {
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash' }, en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo' }, es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
}; };
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${this.formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)`
: this.formatCurrency(totalAmount, payment.currency);
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
return this.sendTemplateEmail({ return this.sendTemplateEmail({
templateSlug: 'payment-receipt', templateSlug: 'payment-receipt',
to: ticket.attendeeEmail, to: ticket.attendeeEmail,
@@ -627,13 +714,13 @@ export const emailService = {
eventId: event.id, eventId: event.id,
variables: { variables: {
attendeeName: receiptFullName, attendeeName: receiptFullName,
ticketId: ticket.id, ticketId: ticket.bookingId || ticket.id,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale, timezone),
paymentAmount: this.formatCurrency(payment.amount, payment.currency), paymentAmount: amountDisplay,
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider, paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
paymentReference: payment.reference || payment.id, paymentReference: payment.reference || payment.id,
paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale), paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale, timezone),
}, },
}); });
}, },
@@ -643,17 +730,19 @@ export const emailService = {
*/ */
async getPaymentConfig(eventId: string): Promise<Record<string, any>> { async getPaymentConfig(eventId: string): Promise<Record<string, any>> {
// Get global options // Get global options
const globalOptions = await (db as any) const globalOptions = await dbGet<any>(
(db as any)
.select() .select()
.from(paymentOptions) .from(paymentOptions)
.get(); );
// Get event overrides // Get event overrides
const overrides = await (db as any) const overrides = await dbGet<any>(
(db as any)
.select() .select()
.from(eventPaymentOverrides) .from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId)) .where(eq((eventPaymentOverrides as any).eventId, eventId))
.get(); );
// Defaults // Defaults
const defaults = { const defaults = {
@@ -696,33 +785,36 @@ export const emailService = {
*/ */
async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> { async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket // Get ticket
const ticket = await (db as any) const ticket = await dbGet<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, ticketId)) .where(eq((tickets as any).id, ticketId))
.get(); );
if (!ticket) { if (!ticket) {
return { success: false, error: 'Ticket not found' }; return { success: false, error: 'Ticket not found' };
} }
// Get event // Get event
const event = await (db as any) const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
if (!event) { if (!event) {
return { success: false, error: 'Event not found' }; return { success: false, error: 'Event not found' };
} }
// Get payment // Get payment
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).ticketId, ticketId)) .where(eq((payments as any).ticketId, ticketId))
.get(); );
if (!payment) { if (!payment) {
return { success: false, error: 'Payment not found' }; return { success: false, error: 'Payment not found' };
@@ -740,8 +832,24 @@ export const emailService = {
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Generate a payment reference using ticket ID // Calculate total price for multi-ticket bookings
const paymentReference = `SPG-${ticket.id.substring(0, 8).toUpperCase()}`; let totalPrice = event.price;
let ticketCount = 1;
if (ticket.bookingId) {
// Count all tickets in this booking
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
totalPrice = event.price * ticketCount;
}
// Generate a payment reference using booking ID or ticket ID
const paymentReference = `SPG-${(ticket.bookingId || ticket.id).substring(0, 8).toUpperCase()}`;
// Generate the booking URL for returning to payment page // Generate the booking URL for returning to payment page
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com'; const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
@@ -752,17 +860,25 @@ export const emailService = {
? 'payment-instructions-tpago' ? 'payment-instructions-tpago'
: 'payment-instructions-bank-transfer'; : 'payment-instructions-bank-transfer';
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
: this.formatCurrency(totalPrice, event.currency);
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
// Build variables based on payment method // Build variables based on payment method
const variables: Record<string, any> = { const variables: Record<string, any> = {
attendeeName: attendeeFullName, attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id, ticketId: ticket.bookingId || ticket.id,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale), eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location, eventLocation: event.location,
eventLocationUrl: event.locationUrl || '', eventLocationUrl: event.locationUrl || '',
paymentAmount: this.formatCurrency(event.price, event.currency), paymentAmount: amountDisplay,
paymentReference, paymentReference,
bookingUrl, bookingUrl,
}; };
@@ -797,33 +913,36 @@ export const emailService = {
*/ */
async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> { async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment // Get payment
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, paymentId)) .where(eq((payments as any).id, paymentId))
.get(); );
if (!payment) { if (!payment) {
return { success: false, error: 'Payment not found' }; return { success: false, error: 'Payment not found' };
} }
// Get ticket // Get ticket
const ticket = await (db as any) const ticket = await dbGet<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, payment.ticketId)) .where(eq((tickets as any).id, payment.ticketId))
.get(); );
if (!ticket) { if (!ticket) {
return { success: false, error: 'Ticket not found' }; return { success: false, error: 'Ticket not found' };
} }
// Get event // Get event
const event = await (db as any) const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
if (!event) { if (!event) {
return { success: false, error: 'Event not found' }; return { success: false, error: 'Event not found' };
@@ -837,6 +956,9 @@ export const emailService = {
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com'; const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const newBookingUrl = `${frontendUrl}/book/${event.id}`; const newBookingUrl = `${frontendUrl}/book/${event.id}`;
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`); console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`);
return this.sendTemplateEmail({ return this.sendTemplateEmail({
@@ -850,8 +972,8 @@ export const emailService = {
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id, ticketId: ticket.id,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale), eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location, eventLocation: event.location,
eventLocationUrl: event.locationUrl || '', eventLocationUrl: event.locationUrl || '',
newBookingUrl, newBookingUrl,
@@ -859,6 +981,106 @@ export const emailService = {
}); });
}, },
/**
* Send payment reminder email
* This email is sent when admin wants to remind attendee about pending payment
*/
async sendPaymentReminder(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Only send for pending/pending_approval payments
if (!['pending', 'pending_approval'].includes(payment.status)) {
return { success: false, error: 'Payment reminder can only be sent for pending payments' };
}
// Get ticket
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Calculate total price for multi-ticket bookings
let totalPrice = event.price;
let ticketCount = 1;
if (ticket.bookingId) {
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
totalPrice = event.price * ticketCount;
}
// Generate the booking URL for returning to payment page
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
: this.formatCurrency(totalPrice, event.currency);
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
console.log(`[Email] Sending payment reminder email to ${ticket.attendeeEmail}`);
return this.sendTemplateEmail({
templateSlug: 'payment-reminder',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.bookingId || ticket.id,
eventTitle,
eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
paymentAmount: amountDisplay,
bookingUrl,
},
});
},
/** /**
* Send custom email to event attendees * Send custom email to event attendees
*/ */
@@ -872,11 +1094,12 @@ export const emailService = {
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params; const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
// Get event // Get event
const event = await (db as any) const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, eventId)) .where(eq((events as any).id, eventId))
.get(); );
if (!event) { if (!event) {
return { success: false, sentCount: 0, failedCount: 0, errors: ['Event not found'] }; return { success: false, sentCount: 0, failedCount: 0, errors: ['Event not found'] };
@@ -897,12 +1120,15 @@ export const emailService = {
); );
} }
const eventTickets = await ticketQuery.all(); const eventTickets = await dbAll<any>(ticketQuery);
if (eventTickets.length === 0) { if (eventTickets.length === 0) {
return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] }; return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] };
} }
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
let sentCount = 0; let sentCount = 0;
let failedCount = 0; let failedCount = 0;
const errors: string[] = []; const errors: string[] = [];
@@ -925,8 +1151,8 @@ export const emailService = {
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id, ticketId: ticket.id,
eventTitle, eventTitle,
eventDate: this.formatDate(event.startDatetime, locale), eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale), eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location, eventLocation: event.location,
eventLocationUrl: event.locationUrl || '', eventLocationUrl: event.locationUrl || '',
...customVariables, ...customVariables,
@@ -949,6 +1175,100 @@ export const emailService = {
}; };
}, },
/**
* Queue emails for event attendees (non-blocking).
* Adds all matching recipients to the background email queue and returns immediately.
* Rate limiting and actual sending is handled by the email queue.
*/
async queueEventEmails(params: {
eventId: string;
templateSlug: string;
customVariables?: Record<string, any>;
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
sentBy: string;
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
// Validate event exists
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, eventId))
);
if (!event) {
return { success: false, queuedCount: 0, error: 'Event not found' };
}
// Validate template exists
const template = await this.getTemplate(templateSlug);
if (!template) {
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
}
// Get tickets based on filter
let ticketQuery = (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).eventId, eventId));
if (recipientFilter !== 'all') {
ticketQuery = ticketQuery.where(
and(
eq((tickets as any).eventId, eventId),
eq((tickets as any).status, recipientFilter)
)
);
}
const eventTickets = await dbAll<any>(ticketQuery);
if (eventTickets.length === 0) {
return { success: true, queuedCount: 0, error: 'No recipients found' };
}
// Get site timezone for proper date/time formatting
const timezone = await this.getSiteTimezone();
// Build individual email jobs for the queue
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
return {
templateSlug,
to: ticket.attendeeEmail,
toName: fullName,
locale,
eventId: event.id,
sentBy,
variables: {
attendeeName: fullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: this.formatDate(event.startDatetime, locale, timezone),
eventTime: this.formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
...customVariables,
},
};
});
// Enqueue all emails for background processing
enqueueBulkEmails(jobs);
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
return {
success: true,
queuedCount: jobs.length,
};
},
/** /**
* Send a custom email (not from template) * Send a custom email (not from template)
*/ */
@@ -958,10 +1278,11 @@ export const emailService = {
subject: string; subject: string;
bodyHtml: string; bodyHtml: string;
bodyText?: string; bodyText?: string;
replyTo?: string;
eventId?: string; eventId?: string;
sentBy: string; sentBy?: string | null;
}): Promise<{ success: boolean; logId?: string; error?: string }> { }): Promise<{ success: boolean; logId?: string; error?: string }> {
const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params; const { to, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params;
const allVariables = { const allVariables = {
...this.getCommonVariables(), ...this.getCommonVariables(),
@@ -971,7 +1292,7 @@ export const emailService = {
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables); const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
// Create log entry // Create log entry
const logId = nanoid(); const logId = generateId();
const now = getNow(); const now = getNow();
await (db as any).insert(emailLogs).values({ await (db as any).insert(emailLogs).values({
@@ -983,7 +1304,7 @@ export const emailService = {
subject, subject,
bodyHtml: finalBodyHtml, bodyHtml: finalBodyHtml,
status: 'pending', status: 'pending',
sentBy, sentBy: sentBy || null,
createdAt: now, createdAt: now,
}); });
@@ -993,6 +1314,7 @@ export const emailService = {
subject, subject,
html: finalBodyHtml, html: finalBodyHtml,
text: bodyText, text: bodyText,
replyTo,
}); });
// Update log // Update log

View File

@@ -0,0 +1,194 @@
// In-memory email queue with rate limiting
// Processes emails asynchronously in the background without blocking the request thread
import { generateId } from './utils.js';
// ==================== Types ====================
export interface EmailJob {
id: string;
type: 'template';
params: TemplateEmailJobParams;
addedAt: number;
}
export interface TemplateEmailJobParams {
templateSlug: string;
to: string;
toName?: string;
variables: Record<string, any>;
locale?: string;
eventId?: string;
sentBy?: string;
}
export interface QueueStatus {
queued: number;
processing: boolean;
sentInLastHour: number;
maxPerHour: number;
}
// ==================== Queue State ====================
const queue: EmailJob[] = [];
const sentTimestamps: number[] = [];
let processing = false;
let processTimer: ReturnType<typeof setTimeout> | null = null;
// Lazy reference to emailService to avoid circular imports
let _emailService: any = null;
function getEmailService() {
if (!_emailService) {
// Dynamic import to avoid circular dependency
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
}
return _emailService;
}
/**
* Initialize the email queue with a reference to the email service.
* Must be called once at startup.
*/
export function initEmailQueue(emailService: any): void {
_emailService = emailService;
console.log('[EmailQueue] Initialized');
}
// ==================== Rate Limiting ====================
function getMaxPerHour(): number {
return parseInt(process.env.MAX_EMAILS_PER_HOUR || '30', 10);
}
/**
* Clean up timestamps older than 1 hour
*/
function cleanOldTimestamps(): void {
const oneHourAgo = Date.now() - 3_600_000;
while (sentTimestamps.length > 0 && sentTimestamps[0] <= oneHourAgo) {
sentTimestamps.shift();
}
}
// ==================== Queue Operations ====================
/**
* Add a single email job to the queue.
* Returns the job ID.
*/
export function enqueueEmail(params: TemplateEmailJobParams): string {
const id = generateId();
queue.push({
id,
type: 'template',
params,
addedAt: Date.now(),
});
scheduleProcessing();
return id;
}
/**
* Add multiple email jobs to the queue at once.
* Returns array of job IDs.
*/
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
const ids: string[] = [];
for (const params of paramsList) {
const id = generateId();
queue.push({
id,
type: 'template',
params,
addedAt: Date.now(),
});
ids.push(id);
}
if (ids.length > 0) {
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
scheduleProcessing();
}
return ids;
}
/**
* Get current queue status
*/
export function getQueueStatus(): QueueStatus {
cleanOldTimestamps();
return {
queued: queue.length,
processing,
sentInLastHour: sentTimestamps.length,
maxPerHour: getMaxPerHour(),
};
}
// ==================== Processing ====================
function scheduleProcessing(): void {
if (processing) return;
processing = true;
// Start processing on next tick to not block the caller
setImmediate(() => processNext());
}
async function processNext(): Promise<void> {
if (queue.length === 0) {
processing = false;
console.log('[EmailQueue] Queue empty. Processing stopped.');
return;
}
// Rate limit check
cleanOldTimestamps();
const maxPerHour = getMaxPerHour();
if (sentTimestamps.length >= maxPerHour) {
// Calculate when the oldest timestamp in the window expires
const waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
console.log(
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
);
processTimer = setTimeout(() => processNext(), waitMs);
return;
}
// Dequeue and process
const job = queue.shift()!;
try {
const emailService = getEmailService();
await emailService.sendTemplateEmail(job.params);
sentTimestamps.push(Date.now());
console.log(
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
);
} catch (error: any) {
console.error(
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
error?.message || error
);
// The sendTemplateEmail method already logs the failure in the email_logs table,
// so we don't need to retry here. The error is logged and we move on.
}
// Small delay between sends to be gentle on the email server
processTimer = setTimeout(() => processNext(), 200);
}
/**
* Stop processing (for graceful shutdown)
*/
export function stopQueue(): void {
if (processTimer) {
clearTimeout(processTimer);
processTimer = null;
}
processing = false;
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
}

View File

@@ -991,6 +991,118 @@ Spanglish`,
], ],
isSystem: true, isSystem: true,
}, },
{
name: 'Payment Reminder',
slug: 'payment-reminder',
subject: 'Reminder: Complete your payment for Spanglish',
subjectEs: 'Recordatorio: Completa tu pago para Spanglish',
bodyHtml: `
<h2>Payment Reminder</h2>
<p>Hi {{attendeeName}},</p>
<p>We wanted to follow up on your booking for <strong>{{eventTitle}}</strong>.</p>
<p>We haven't been able to locate your payment yet. To receive your ticket and confirm your spot, please complete your payment.</p>
<div class="event-card">
<h3>📅 Event Details</h3>
<div class="event-detail"><strong>Event:</strong> {{eventTitle}}</div>
<div class="event-detail"><strong>Date:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>Time:</strong> {{eventTime}}</div>
<div class="event-detail"><strong>Location:</strong> {{eventLocation}}</div>
<div class="event-detail"><strong>Amount Due:</strong> {{paymentAmount}}</div>
</div>
<p style="text-align: center; margin: 24px 0;">
<a href="{{bookingUrl}}" class="btn">Complete Payment</a>
</p>
<div class="note">
<strong>Already paid?</strong><br>
If you have already completed your payment and believe this is an error, please reply to this email with your payment details (date, amount, and method used) and we'll be happy to look into it.
</div>
<p>We hope to see you at the event!</p>
<p>The Spanglish Team</p>
`,
bodyHtmlEs: `
<h2>Recordatorio de Pago</h2>
<p>Hola {{attendeeName}},</p>
<p>Queríamos dar seguimiento a tu reserva para <strong>{{eventTitle}}</strong>.</p>
<p>Aún no hemos podido localizar tu pago. Para recibir tu entrada y confirmar tu lugar, por favor completa tu pago.</p>
<div class="event-card">
<h3>📅 Detalles del Evento</h3>
<div class="event-detail"><strong>Evento:</strong> {{eventTitle}}</div>
<div class="event-detail"><strong>Fecha:</strong> {{eventDate}}</div>
<div class="event-detail"><strong>Hora:</strong> {{eventTime}}</div>
<div class="event-detail"><strong>Ubicación:</strong> {{eventLocation}}</div>
<div class="event-detail"><strong>Monto a Pagar:</strong> {{paymentAmount}}</div>
</div>
<p style="text-align: center; margin: 24px 0;">
<a href="{{bookingUrl}}" class="btn">Completar Pago</a>
</p>
<div class="note">
<strong>¿Ya pagaste?</strong><br>
Si ya completaste tu pago y crees que esto es un error, por favor responde a este correo con los detalles de tu pago (fecha, monto y método utilizado) y con gusto lo revisaremos.
</div>
<p>¡Esperamos verte en el evento!</p>
<p>El Equipo de Spanglish</p>
`,
bodyText: `Payment Reminder
Hi {{attendeeName}},
We wanted to follow up on your booking for {{eventTitle}}.
We haven't been able to locate your payment yet. To receive your ticket and confirm your spot, please complete your payment.
Event Details:
- Event: {{eventTitle}}
- Date: {{eventDate}}
- Time: {{eventTime}}
- Location: {{eventLocation}}
- Amount Due: {{paymentAmount}}
Complete your payment here: {{bookingUrl}}
Already paid?
If you have already completed your payment and believe this is an error, please reply to this email with your payment details (date, amount, and method used) and we'll be happy to look into it.
We hope to see you at the event!
The Spanglish Team`,
bodyTextEs: `Recordatorio de Pago
Hola {{attendeeName}},
Queríamos dar seguimiento a tu reserva para {{eventTitle}}.
Aún no hemos podido localizar tu pago. Para recibir tu entrada y confirmar tu lugar, por favor completa tu pago.
Detalles del Evento:
- Evento: {{eventTitle}}
- Fecha: {{eventDate}}
- Hora: {{eventTime}}
- Ubicación: {{eventLocation}}
- Monto a Pagar: {{paymentAmount}}
Completa tu pago aquí: {{bookingUrl}}
¿Ya pagaste?
Si ya completaste tu pago y crees que esto es un error, por favor responde a este correo con los detalles de tu pago (fecha, monto y método utilizado) y con gusto lo revisaremos.
¡Esperamos verte en el evento!
El Equipo de Spanglish`,
description: 'Sent to remind attendees to complete their pending payment',
variables: [
...commonVariables,
...bookingVariables,
{ name: 'paymentAmount', description: 'Payment amount with currency', example: '50,000 PYG' },
{ name: 'bookingUrl', description: 'URL to complete payment', example: 'https://spanglish.com/booking/abc123?step=payment' },
],
isSystem: true,
},
{ {
name: 'Payment Rejected', name: 'Payment Rejected',
slug: 'payment-rejected', slug: 'payment-rejected',

View File

@@ -0,0 +1,80 @@
import { getLegalSettingsValues } from '../routes/legal-settings.js';
/**
* Strict whitelist of supported placeholders.
* Only these placeholders will be replaced in legal page content.
* Unknown placeholders remain unchanged.
*/
const SUPPORTED_PLACEHOLDERS = new Set([
'COMPANY_NAME',
'LEGAL_ENTITY_NAME',
'RUC_NUMBER',
'COMPANY_ADDRESS',
'COMPANY_CITY',
'COMPANY_COUNTRY',
'SUPPORT_EMAIL',
'LEGAL_EMAIL',
'GOVERNING_LAW',
'JURISDICTION_CITY',
'CURRENT_YEAR',
'LAST_UPDATED_DATE',
]);
/**
* Replace legal placeholders in content using strict whitelist mapping.
*
* Rules:
* - Only supported placeholders are replaced
* - Unknown placeholders remain unchanged
* - Missing values are replaced with empty string
* - No code execution or dynamic evaluation
* - Replacement is pure string substitution
*
* @param content - The markdown/text content containing {{PLACEHOLDER}} tokens
* @param updatedAt - The page's updated_at timestamp (for LAST_UPDATED_DATE)
* @returns Content with placeholders replaced
*/
export async function replaceLegalPlaceholders(
content: string,
updatedAt?: string
): Promise<string> {
if (!content) return content;
// Fetch legal settings values from DB
const settingsValues = await getLegalSettingsValues();
// Build the full replacement map
const replacements: Record<string, string> = { ...settingsValues };
// Dynamic values
replacements['CURRENT_YEAR'] = new Date().getFullYear().toString();
if (updatedAt) {
try {
const date = new Date(updatedAt);
if (!isNaN(date.getTime())) {
replacements['LAST_UPDATED_DATE'] = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
} else {
replacements['LAST_UPDATED_DATE'] = updatedAt;
}
} catch {
replacements['LAST_UPDATED_DATE'] = updatedAt;
}
}
// Replace only whitelisted placeholders using a single regex pass
// Matches {{PLACEHOLDER_NAME}} where PLACEHOLDER_NAME is uppercase letters and underscores
return content.replace(/\{\{([A-Z_]+)\}\}/g, (match, placeholderName) => {
// Only replace if the placeholder is in the whitelist
if (!SUPPORTED_PLACEHOLDERS.has(placeholderName)) {
return match; // Unknown placeholder - leave unchanged
}
// Return the value or empty string if missing
return replacements[placeholderName] ?? '';
});
}

View File

@@ -14,6 +14,7 @@ interface TicketData {
location: string; location: string;
locationUrl?: string; locationUrl?: string;
}; };
timezone?: string;
} }
/** /**
@@ -29,27 +30,29 @@ async function generateQRCode(data: string): Promise<Buffer> {
} }
/** /**
* Format date for display * Format date for display using site timezone
*/ */
function formatDate(dateStr: string): string { function formatDate(dateStr: string, timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
timeZone: timezone,
}); });
} }
/** /**
* Format time for display * Format time for display using site timezone
*/ */
function formatTime(dateStr: string): string { function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleTimeString('en-US', { return date.toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: true, hour12: true,
timeZone: timezone,
}); });
} }
@@ -89,12 +92,13 @@ export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' }); doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5); doc.moveDown(0.5);
// Date and time // Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333'); doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime), { align: 'center' }); doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime); const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null; const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime; const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' }); doc.text(timeRange, { align: 'center' });
@@ -184,11 +188,13 @@ export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' }); doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5); doc.moveDown(0.5);
// Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333'); doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime), { align: 'center' }); doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime); const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null; const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime; const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' }); doc.text(timeRange, { align: 'center' });

View File

@@ -1,15 +1,71 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { randomUUID } from 'crypto';
/**
* Get database type (reads env var each time to handle module loading order)
*/
function getDbType(): string {
return process.env.DB_TYPE || 'sqlite';
}
/**
* Generate a unique ID appropriate for the database type.
* - SQLite: returns nanoid (21-char alphanumeric)
* - PostgreSQL: returns UUID v4
*/
export function generateId(): string { export function generateId(): string {
return nanoid(21); return getDbType() === 'postgres' ? randomUUID() : nanoid(21);
} }
export function generateTicketCode(): string { export function generateTicketCode(): string {
return `TKT-${nanoid(8).toUpperCase()}`; return `TKT-${nanoid(8).toUpperCase()}`;
} }
export function getNow(): string { /**
return new Date().toISOString(); * Get current timestamp in the format appropriate for the database type.
* - SQLite: returns ISO string
* - PostgreSQL: returns Date object
*/
export function getNow(): string | Date {
const now = new Date();
return getDbType() === 'postgres' ? now : now.toISOString();
}
/**
* Convert a date value to the appropriate format for the database type.
* - SQLite: returns ISO string
* - PostgreSQL: returns Date object
*/
export function toDbDate(date: Date | string): string | Date {
const d = typeof date === 'string' ? new Date(date) : date;
return getDbType() === 'postgres' ? d : d.toISOString();
}
/**
* Convert a boolean value to the appropriate format for the database type.
* - SQLite: returns boolean (true/false) for mode: 'boolean'
* - PostgreSQL: returns integer (1/0) for pgInteger columns
*/
export function toDbBool(value: boolean): boolean | number {
return getDbType() === 'postgres' ? (value ? 1 : 0) : value;
}
/**
* Convert all boolean values in an object to the appropriate database format.
* Useful for converting request data before database insert/update.
*/
export function convertBooleansForDb<T extends Record<string, any>>(obj: T): T {
if (getDbType() !== 'postgres') {
return obj; // SQLite handles booleans automatically
}
const result = { ...obj };
for (const key in result) {
if (typeof result[key] === 'boolean') {
(result as any)[key] = result[key] ? 1 : 0;
}
}
return result;
} }
export function formatCurrency(amount: number, currency: string = 'PYG'): string { export function formatCurrency(amount: number, currency: string = 'PYG'): string {

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, 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 } from 'drizzle-orm'; import { eq, and, gte, sql, desc } 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';
@@ -11,7 +11,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
const now = getNow(); const now = getNow();
// Get upcoming events // Get upcoming events
const upcomingEvents = await (db as any) const upcomingEvents = await dbAll(
(db as any)
.select() .select()
.from(events) .from(events)
.where( .where(
@@ -22,63 +23,72 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
) )
.orderBy((events as any).startDatetime) .orderBy((events as any).startDatetime)
.limit(5) .limit(5)
.all(); );
// Get recent tickets // Get recent tickets
const recentTickets = await (db as any) const recentTickets = await dbAll(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.orderBy(desc((tickets as any).createdAt)) .orderBy(desc((tickets as any).createdAt))
.limit(10) .limit(10)
.all(); );
// Get total stats // Get total stats
const totalUsers = await (db as any) const totalUsers = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(users) .from(users)
.get(); );
const totalEvents = await (db as any) const totalEvents = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(events) .from(events)
.get(); );
const totalTickets = await (db as any) const totalTickets = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(tickets) .from(tickets)
.get(); );
const confirmedTickets = await (db as any) const confirmedTickets = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(tickets) .from(tickets)
.where(eq((tickets as any).status, 'confirmed')) .where(eq((tickets as any).status, 'confirmed'))
.get(); );
const pendingPayments = await (db as any) const pendingPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(payments) .from(payments)
.where(eq((payments as any).status, 'pending')) .where(eq((payments as any).status, 'pending'))
.get(); );
const paidPayments = await (db as any) const paidPayments = await dbAll<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).status, 'paid')) .where(eq((payments as any).status, 'paid'))
.all(); );
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + (p.amount || 0), 0); const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
const newContacts = await (db as any) const newContacts = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(contacts) .from(contacts)
.where(eq((contacts as any).status, 'new')) .where(eq((contacts as any).status, 'new'))
.get(); );
const totalSubscribers = await (db as any) const totalSubscribers = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(emailSubscribers) .from(emailSubscribers)
.where(eq((emailSubscribers as any).status, 'active')) .where(eq((emailSubscribers as any).status, 'active'))
.get(); );
return c.json({ return c.json({
dashboard: { dashboard: {
@@ -101,17 +111,19 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
// Get analytics data (admin) // Get analytics data (admin)
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
// Get events with ticket counts // Get events with ticket counts
const allEvents = await (db as any).select().from(events).all(); const allEvents = await dbAll<any>((db as any).select().from(events));
const eventStats = await Promise.all( const eventStats = await Promise.all(
allEvents.map(async (event: any) => { allEvents.map(async (event: any) => {
const ticketCount = await (db as any) const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(tickets) .from(tickets)
.where(eq((tickets as any).eventId, event.id)) .where(eq((tickets as any).eventId, event.id))
.get(); );
const confirmedCount = await (db as any) const confirmedCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(tickets) .from(tickets)
.where( .where(
@@ -120,9 +132,10 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
eq((tickets as any).status, 'confirmed') eq((tickets as any).status, 'confirmed')
) )
) )
.get(); );
const checkedInCount = await (db as any) const checkedInCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(tickets) .from(tickets)
.where( .where(
@@ -131,7 +144,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
eq((tickets as any).status, 'checked_in') eq((tickets as any).status, 'checked_in')
) )
) )
.get(); );
return { return {
id: event.id, id: event.id,
@@ -163,28 +176,31 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
query = query.where(eq((tickets as any).eventId, eventId)); query = query.where(eq((tickets as any).eventId, eventId));
} }
const ticketList = await query.all(); const ticketList = await dbAll<any>(query);
// Get user and event details for each ticket // Get user and event details for each ticket
const enrichedTickets = await Promise.all( const enrichedTickets = await Promise.all(
ticketList.map(async (ticket: any) => { ticketList.map(async (ticket: any) => {
const user = await (db as any) const user = await dbGet<any>(
(db as any)
.select() .select()
.from(users) .from(users)
.where(eq((users as any).id, ticket.userId)) .where(eq((users as any).id, ticket.userId))
.get(); );
const event = await (db as any) const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).ticketId, ticket.id)) .where(eq((payments as any).ticketId, ticket.id))
.get(); );
return { return {
ticketId: ticket.id, ticketId: ticket.id,
@@ -215,24 +231,26 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
// Get all payments // Get all payments
let query = (db as any).select().from(payments); let query = (db as any).select().from(payments);
const allPayments = await query.all(); const allPayments = await dbAll<any>(query);
// Enrich with event and ticket data // Enrich with event and ticket data
const enrichedPayments = await Promise.all( const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => { allPayments.map(async (payment: any) => {
const ticket = await (db as any) const ticket = await dbGet<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, payment.ticketId)) .where(eq((tickets as any).id, payment.ticketId))
.get(); );
if (!ticket) return null; if (!ticket) return null;
const event = await (db as any) const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
// Apply filters // Apply filters
if (eventId && ticket.eventId !== eventId) return null; if (eventId && ticket.eventId !== eventId) return null;

View File

@@ -1,7 +1,7 @@
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, users, magicLinkTokens, User } from '../db/index.js'; import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { import {
hashPassword, hashPassword,
@@ -16,7 +16,7 @@ import {
invalidateAllUserSessions, invalidateAllUserSessions,
requireAuth, requireAuth,
} from '../lib/auth.js'; } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js'; import { sendEmail } from '../lib/email.js';
// User type that includes all fields (some added in schema updates) // User type that includes all fields (some added in schema updates)
@@ -121,7 +121,9 @@ auth.post('/register', zValidator('json', registerSchema), async (c) => {
} }
// Check if email exists // Check if email exists
const existing = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get(); const existing = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (existing) { if (existing) {
// If user exists but is unclaimed, allow claiming // If user exists but is unclaimed, allow claiming
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') { if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
@@ -149,7 +151,7 @@ auth.post('/register', zValidator('json', registerSchema), async (c) => {
phone: data.phone || null, phone: data.phone || null,
role: firstUser ? 'admin' : 'user', role: firstUser ? 'admin' : 'user',
languagePreference: data.languagePreference || null, languagePreference: data.languagePreference || null,
isClaimed: true, isClaimed: toDbBool(true),
googleId: null, googleId: null,
rucNumber: null, rucNumber: null,
accountStatus: 'active', accountStatus: 'active',
@@ -189,7 +191,9 @@ auth.post('/login', zValidator('json', loginSchema), async (c) => {
}, 429); }, 429);
} }
const user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (!user) { if (!user) {
recordFailedAttempt(data.email); recordFailedAttempt(data.email);
return c.json({ error: 'Invalid credentials' }, 401); return c.json({ error: 'Invalid credentials' }, 401);
@@ -243,7 +247,9 @@ auth.post('/login', zValidator('json', loginSchema), async (c) => {
auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => { auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) { if (!user) {
// Don't reveal if email exists // Don't reveal if email exists
@@ -288,7 +294,9 @@ auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async
return c.json({ error: verification.error }, 400); return c.json({ error: verification.error }, 400);
} }
const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
if (!user || user.accountStatus === 'suspended') { if (!user || user.accountStatus === 'suspended') {
return c.json({ error: 'Invalid token' }, 400); return c.json({ error: 'Invalid token' }, 400);
@@ -317,7 +325,9 @@ auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async
auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => { auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => {
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) { if (!user) {
// Don't reveal if email exists // Don't reveal if email exists
@@ -389,7 +399,9 @@ auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), as
auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => { auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
const user = await (db as any).select().from(users).where(eq((users as any).email, email)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) { if (!user) {
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' }); return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
@@ -439,7 +451,7 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn
const now = getNow(); const now = getNow();
const updates: Record<string, any> = { const updates: Record<string, any> = {
isClaimed: true, isClaimed: toDbBool(true),
accountStatus: 'active', accountStatus: 'active',
updatedAt: now, updatedAt: now,
}; };
@@ -461,7 +473,9 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn
.set(updates) .set(updates)
.where(eq((users as any).id, verification.userId)); .where(eq((users as any).id, verification.userId));
const user = await (db as any).select().from(users).where(eq((users as any).id, verification.userId)).get(); const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
const authToken = await createToken(user.id, user.email, user.role); const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id); const refreshToken = await createRefreshToken(user.id);
@@ -510,11 +524,15 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
const { sub: googleId, email, name } = googleData; const { sub: googleId, email, name } = googleData;
// Check if user exists by email or google_id // Check if user exists by email or google_id
let user = await (db as any).select().from(users).where(eq((users as any).email, email)).get(); let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) { if (!user) {
// Check by google_id // Check by google_id
user = await (db as any).select().from(users).where(eq((users as any).googleId, googleId)).get(); user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).googleId, googleId))
);
} }
const now = getNow(); const now = getNow();
@@ -530,7 +548,7 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
.update(users) .update(users)
.set({ .set({
googleId, googleId,
isClaimed: true, isClaimed: toDbBool(true),
accountStatus: 'active', accountStatus: 'active',
updatedAt: now, updatedAt: now,
}) })
@@ -538,7 +556,9 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
} }
// Refresh user data // Refresh user data
user = await (db as any).select().from(users).where(eq((users as any).id, user.id)).get(); user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
} else { } else {
// Create new user // Create new user
const firstUser = await isFirstUser(); const firstUser = await isFirstUser();
@@ -552,7 +572,7 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
phone: null, phone: null,
role: firstUser ? 'admin' : 'user', role: firstUser ? 'admin' : 'user',
languagePreference: null, languagePreference: null,
isClaimed: true, isClaimed: toDbBool(true),
googleId, googleId,
rucNumber: null, rucNumber: null,
accountStatus: 'active', accountStatus: 'active',

View File

@@ -1,13 +1,37 @@
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, contacts, emailSubscribers } from '../db/index.js'; import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
import { eq, desc } from 'drizzle-orm'; import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow } from '../lib/utils.js';
import { emailService } from '../lib/email.js';
const contactsRouter = new Hono(); const contactsRouter = new Hono();
// ==================== Sanitization Helpers ====================
/**
* Sanitize a string to prevent HTML injection
* Escapes HTML special characters
*/
function sanitizeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
/**
* Sanitize email header values to prevent email header injection
* Strips newlines and carriage returns that could be used to inject headers
*/
function sanitizeHeaderValue(str: string): string {
return str.replace(/[\r\n]/g, '').trim();
}
const createContactSchema = z.object({ const createContactSchema = z.object({
name: z.string().min(2), name: z.string().min(2),
email: z.string().email(), email: z.string().email(),
@@ -29,17 +53,74 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
const now = getNow(); const now = getNow();
const id = generateId(); const id = generateId();
// Sanitize header-sensitive values to prevent email header injection
const sanitizedEmail = sanitizeHeaderValue(data.email);
const sanitizedName = sanitizeHeaderValue(data.name);
const newContact = { const newContact = {
id, id,
name: data.name, name: sanitizedName,
email: data.email, email: sanitizedEmail,
message: data.message, message: data.message,
status: 'new' as const, status: 'new' as const,
createdAt: now, createdAt: now,
}; };
// Always store the message in admin, regardless of email outcome
await (db as any).insert(contacts).values(newContact); await (db as any).insert(contacts).values(newContact);
// Send email notification to support email (non-blocking)
try {
// Retrieve support_email from legal_settings
const settings = await dbGet<any>(
(db as any).select().from(legalSettings).limit(1)
);
const supportEmail = settings?.supportEmail;
if (supportEmail) {
const websiteUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
// Sanitize all values for HTML display
const safeName = sanitizeHtml(sanitizedName);
const safeEmail = sanitizeHtml(sanitizedEmail);
const safeMessage = sanitizeHtml(data.message);
const subject = `New Contact Form Message ${websiteUrl}`;
const bodyHtml = `
<p><strong>${safeName}</strong> (${safeEmail}) sent a message:</p>
<div style="padding: 16px 20px; background-color: #f8fafc; border-left: 4px solid #3b82f6; margin: 16px 0; white-space: pre-wrap; font-size: 15px; line-height: 1.6;">${safeMessage}</div>
<p style="color: #64748b; font-size: 13px;">Reply directly to this email to respond to ${safeName}.</p>
`;
const bodyText = [
`${sanitizedName} (${sanitizedEmail}) sent a message:`,
'',
data.message,
'',
`Reply directly to this email to respond to ${sanitizedName}.`,
].join('\n');
const emailResult = await emailService.sendCustomEmail({
to: supportEmail,
subject,
bodyHtml,
bodyText,
replyTo: sanitizedEmail,
});
if (!emailResult.success) {
console.error('[Contact Form] Failed to send email notification:', emailResult.error);
}
} else {
console.warn('[Contact Form] No support email configured in legal settings skipping email notification');
}
} catch (emailError: any) {
// Log the error but do NOT break the contact form UX
console.error('[Contact Form] Error sending email notification:', emailError?.message || emailError);
}
return c.json({ message: 'Message sent successfully' }, 201); return c.json({ message: 'Message sent successfully' }, 201);
}); });
@@ -48,11 +129,12 @@ contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c)
const data = c.req.valid('json'); const data = c.req.valid('json');
// Check if already subscribed // Check if already subscribed
const existing = await (db as any) const existing = await dbGet<any>(
(db as any)
.select() .select()
.from(emailSubscribers) .from(emailSubscribers)
.where(eq((emailSubscribers as any).email, data.email)) .where(eq((emailSubscribers as any).email, data.email))
.get(); );
if (existing) { if (existing) {
if (existing.status === 'unsubscribed') { if (existing.status === 'unsubscribed') {
@@ -87,11 +169,9 @@ contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c)
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => { contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
.from(emailSubscribers) );
.where(eq((emailSubscribers as any).email, email))
.get();
if (!existing) { if (!existing) {
return c.json({ error: 'Email not found' }, 404); return c.json({ error: 'Email not found' }, 404);
@@ -115,7 +195,7 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(eq((contacts as any).status, status)); query = query.where(eq((contacts as any).status, status));
} }
const result = await query.orderBy(desc((contacts as any).createdAt)).all(); const result = await dbAll(query.orderBy(desc((contacts as any).createdAt)));
return c.json({ contacts: result }); return c.json({ contacts: result });
}); });
@@ -124,11 +204,12 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
contactsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => { contactsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const contact = await (db as any) const contact = await dbGet(
(db as any)
.select() .select()
.from(contacts) .from(contacts)
.where(eq((contacts as any).id, id)) .where(eq((contacts as any).id, id))
.get(); );
if (!contact) { if (!contact) {
return c.json({ error: 'Contact not found' }, 404); return c.json({ error: 'Contact not found' }, 404);
@@ -142,11 +223,9 @@ contactsRouter.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 (db as any) const existing = await dbGet<any>(
.select() (db as any).select().from(contacts).where(eq((contacts as any).id, id))
.from(contacts) );
.where(eq((contacts as any).id, id))
.get();
if (!existing) { if (!existing) {
return c.json({ error: 'Contact not found' }, 404); return c.json({ error: 'Contact not found' }, 404);
@@ -157,11 +236,9 @@ contactsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
.set({ status: data.status }) .set({ status: data.status })
.where(eq((contacts as any).id, id)); .where(eq((contacts as any).id, id));
const updated = await (db as any) const updated = await dbGet<any>(
.select() (db as any).select().from(contacts).where(eq((contacts as any).id, id))
.from(contacts) );
.where(eq((contacts as any).id, id))
.get();
return c.json({ contact: updated }); return c.json({ contact: updated });
}); });
@@ -185,7 +262,7 @@ contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), asy
query = query.where(eq((emailSubscribers as any).status, status)); query = query.where(eq((emailSubscribers as any).status, status));
} }
const result = await query.orderBy(desc((emailSubscribers as any).createdAt)).all(); const result = await dbAll(query.orderBy(desc((emailSubscribers as any).createdAt)));
return c.json({ subscribers: result }); return c.json({ subscribers: result });
}); });

View File

@@ -1,7 +1,7 @@
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, users, tickets, payments, events, invoices, User } from '../db/index.js'; import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
import { eq, desc, and, gt, sql } from 'drizzle-orm'; import { eq, desc, and, gt, sql } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js'; import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow } from '../lib/utils.js';
@@ -70,11 +70,12 @@ dashboard.put('/profile', zValidator('json', updateProfileSchema), async (c) =>
}) })
.where(eq((users as any).id, user.id)); .where(eq((users as any).id, user.id));
const updatedUser = await (db as any) const updatedUser = await dbGet<any>(
(db as any)
.select() .select()
.from(users) .from(users)
.where(eq((users as any).id, user.id)) .where(eq((users as any).id, user.id))
.get(); );
return c.json({ return c.json({
profile: { profile: {
@@ -95,36 +96,40 @@ dashboard.put('/profile', zValidator('json', updateProfileSchema), async (c) =>
dashboard.get('/tickets', async (c) => { dashboard.get('/tickets', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
const userTickets = await (db as any) const userTickets = await dbAll<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).userId, user.id)) .where(eq((tickets as any).userId, user.id))
.orderBy(desc((tickets as any).createdAt)) .orderBy(desc((tickets as any).createdAt))
.all(); );
// Get event details for each ticket // Get event details for each ticket
const ticketsWithEvents = await Promise.all( const ticketsWithEvents = await Promise.all(
userTickets.map(async (ticket: any) => { userTickets.map(async (ticket: any) => {
const event = await (db as any) const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).ticketId, ticket.id)) .where(eq((payments as any).ticketId, ticket.id))
.get(); );
// Check for invoice // Check for invoice
let invoice = null; let invoice: any = null;
if (payment && payment.status === 'paid') { if (payment && payment.status === 'paid') {
invoice = await (db as any) invoice = await dbGet<any>(
(db as any)
.select() .select()
.from(invoices) .from(invoices)
.where(eq((invoices as any).paymentId, payment.id)) .where(eq((invoices as any).paymentId, payment.id))
.get(); );
} }
return { return {
@@ -168,7 +173,8 @@ dashboard.get('/tickets/:id', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
const ticketId = c.req.param('id'); const ticketId = c.req.param('id');
const ticket = await (db as any) const ticket = await dbGet<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where( .where(
@@ -177,31 +183,34 @@ dashboard.get('/tickets/:id', async (c) => {
eq((tickets as any).userId, user.id) eq((tickets as any).userId, user.id)
) )
) )
.get(); );
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
} }
const event = await (db as any) const event = await dbGet(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).ticketId, ticket.id)) .where(eq((payments as any).ticketId, ticket.id))
.get(); );
let invoice = null; let invoice = null;
if (payment && payment.status === 'paid') { if (payment && payment.status === 'paid') {
invoice = await (db as any) invoice = await dbGet(
(db as any)
.select() .select()
.from(invoices) .from(invoices)
.where(eq((invoices as any).paymentId, payment.id)) .where(eq((invoices as any).paymentId, payment.id))
.get(); );
} }
return c.json({ return c.json({
@@ -222,11 +231,12 @@ dashboard.get('/next-event', async (c) => {
const now = getNow(); const now = getNow();
// Get user's tickets for upcoming events // Get user's tickets for upcoming events
const userTickets = await (db as any) const userTickets = await dbAll<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).userId, user.id)) .where(eq((tickets as any).userId, user.id))
.all(); );
if (userTickets.length === 0) { if (userTickets.length === 0) {
return c.json({ nextEvent: null }); return c.json({ nextEvent: null });
@@ -240,11 +250,12 @@ dashboard.get('/next-event', async (c) => {
for (const ticket of userTickets) { for (const ticket of userTickets) {
if (ticket.status === 'cancelled') continue; if (ticket.status === 'cancelled') continue;
const event = await (db as any) const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
if (!event) continue; if (!event) continue;
@@ -253,11 +264,12 @@ dashboard.get('/next-event', async (c) => {
if (!nextEvent || new Date(event.startDatetime) < new Date(nextEvent.startDatetime)) { if (!nextEvent || new Date(event.startDatetime) < new Date(nextEvent.startDatetime)) {
nextEvent = event; nextEvent = event;
nextTicket = ticket; nextTicket = ticket;
nextPayment = await (db as any) nextPayment = await dbGet(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).ticketId, ticket.id)) .where(eq((payments as any).ticketId, ticket.id))
.get(); );
} }
} }
} }
@@ -282,11 +294,12 @@ dashboard.get('/payments', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
// Get all user's tickets first // Get all user's tickets first
const userTickets = await (db as any) const userTickets = await dbAll<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).userId, user.id)) .where(eq((tickets as any).userId, user.id))
.all(); );
const ticketIds = userTickets.map((t: any) => t.id); const ticketIds = userTickets.map((t: any) => t.id);
@@ -297,29 +310,32 @@ dashboard.get('/payments', async (c) => {
// Get all payments for user's tickets // Get all payments for user's tickets
const allPayments = []; const allPayments = [];
for (const ticketId of ticketIds) { for (const ticketId of ticketIds) {
const ticketPayments = await (db as any) const ticketPayments = await dbAll<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).ticketId, ticketId)) .where(eq((payments as any).ticketId, ticketId))
.all(); );
for (const payment of ticketPayments) { for (const payment of ticketPayments) {
const ticket = userTickets.find((t: any) => t.id === payment.ticketId); const ticket = userTickets.find((t: any) => t.id === payment.ticketId);
const event = ticket const event = ticket
? await (db as any) ? await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get() )
: null; : null;
let invoice = null; let invoice: any = null;
if (payment.status === 'paid') { if (payment.status === 'paid') {
invoice = await (db as any) invoice = await dbGet<any>(
(db as any)
.select() .select()
.from(invoices) .from(invoices)
.where(eq((invoices as any).paymentId, payment.id)) .where(eq((invoices as any).paymentId, payment.id))
.get(); );
} }
allPayments.push({ allPayments.push({
@@ -355,36 +371,40 @@ dashboard.get('/payments', async (c) => {
dashboard.get('/invoices', async (c) => { dashboard.get('/invoices', async (c) => {
const user = (c as any).get('user') as AuthUser; const user = (c as any).get('user') as AuthUser;
const userInvoices = await (db as any) const userInvoices = await dbAll<any>(
(db as any)
.select() .select()
.from(invoices) .from(invoices)
.where(eq((invoices as any).userId, user.id)) .where(eq((invoices as any).userId, user.id))
.orderBy(desc((invoices as any).createdAt)) .orderBy(desc((invoices as any).createdAt))
.all(); );
// Get payment and event details for each invoice // Get payment and event details for each invoice
const invoicesWithDetails = await Promise.all( const invoicesWithDetails = await Promise.all(
userInvoices.map(async (invoice: any) => { userInvoices.map(async (invoice: any) => {
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, invoice.paymentId)) .where(eq((payments as any).id, invoice.paymentId))
.get(); );
let event = null; let event: any = null;
if (payment) { if (payment) {
const ticket = await (db as any) const ticket = await dbGet<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, payment.ticketId)) .where(eq((tickets as any).id, payment.ticketId))
.get(); );
if (ticket) { if (ticket) {
event = await (db as any) event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
} }
} }
@@ -511,11 +531,12 @@ dashboard.get('/summary', async (c) => {
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24)); const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
// Get ticket count // Get ticket count
const userTickets = await (db as any) const userTickets = await dbAll<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).userId, user.id)) .where(eq((tickets as any).userId, user.id))
.all(); );
const totalTickets = userTickets.length; const totalTickets = userTickets.length;
const confirmedTickets = userTickets.filter((t: any) => t.status === 'confirmed' || t.status === 'checked_in').length; const confirmedTickets = userTickets.filter((t: any) => t.status === 'confirmed' || t.status === 'checked_in').length;
@@ -524,11 +545,12 @@ dashboard.get('/summary', async (c) => {
for (const ticket of userTickets) { for (const ticket of userTickets) {
if (ticket.status === 'cancelled') continue; if (ticket.status === 'cancelled') continue;
const event = await (db as any) const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
if (event && new Date(event.startDatetime) > now) { if (event && new Date(event.startDatetime) > now) {
upcomingTickets.push({ ticket, event }); upcomingTickets.push({ ticket, event });
@@ -540,7 +562,8 @@ dashboard.get('/summary', async (c) => {
let pendingPayments = 0; let pendingPayments = 0;
for (const ticketId of ticketIds) { for (const ticketId of ticketIds) {
const payment = await (db as any) const payment = await dbGet(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where( .where(
@@ -549,7 +572,7 @@ dashboard.get('/summary', async (c) => {
eq((payments as any).status, 'pending_approval') eq((payments as any).status, 'pending_approval')
) )
) )
.get(); );
if (payment) pendingPayments++; if (payment) pendingPayments++;
} }

View File

@@ -1,11 +1,11 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, 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, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js'; import { getNow, generateId } from '../lib/utils.js';
import { nanoid } from 'nanoid';
import emailService from '../lib/email.js'; import emailService from '../lib/email.js';
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js'; import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
import { getQueueStatus } from '../lib/emailQueue.js';
const emailsRouter = new Hono(); const emailsRouter = new Hono();
@@ -13,11 +13,9 @@ const emailsRouter = new Hono();
// Get all email templates // Get all email templates
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
const templates = await (db as any) const templates = await dbAll<any>(
.select() (db as any).select().from(emailTemplates).orderBy(desc((emailTemplates as any).createdAt))
.from(emailTemplates) );
.orderBy(desc((emailTemplates as any).createdAt))
.all();
// Parse variables JSON for each template // Parse variables JSON for each template
const parsedTemplates = templates.map((t: any) => ({ const parsedTemplates = templates.map((t: any) => ({
@@ -34,11 +32,12 @@ emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) =>
emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param(); const { id } = c.req.param();
const template = await (db as any) const template = await dbGet<any>(
(db as any)
.select() .select()
.from(emailTemplates) .from(emailTemplates)
.where(eq((emailTemplates as any).id, id)) .where(eq((emailTemplates as any).id, id))
.get(); );
if (!template) { if (!template) {
return c.json({ error: 'Template not found' }, 404); return c.json({ error: 'Template not found' }, 404);
@@ -64,11 +63,9 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
} }
// Check if slug already exists // Check if slug already exists
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
.from(emailTemplates) );
.where(eq((emailTemplates as any).slug, slug))
.get();
if (existing) { if (existing) {
return c.json({ error: 'Template with this slug already exists' }, 400); return c.json({ error: 'Template with this slug already exists' }, 400);
@@ -76,7 +73,7 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
const now = getNow(); const now = getNow();
const template = { const template = {
id: nanoid(), id: generateId(),
name, name,
slug, slug,
subject, subject,
@@ -111,11 +108,12 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param(); const { id } = c.req.param();
const body = await c.req.json(); const body = await c.req.json();
const existing = await (db as any) const existing = await dbGet<any>(
(db as any)
.select() .select()
.from(emailTemplates) .from(emailTemplates)
.where(eq((emailTemplates as any).id, id)) .where(eq((emailTemplates as any).id, id))
.get(); );
if (!existing) { if (!existing) {
return c.json({ error: 'Template not found' }, 404); return c.json({ error: 'Template not found' }, 404);
@@ -148,11 +146,12 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
.set(updateData) .set(updateData)
.where(eq((emailTemplates as any).id, id)); .where(eq((emailTemplates as any).id, id));
const updated = await (db as any) const updated = await dbGet<any>(
(db as any)
.select() .select()
.from(emailTemplates) .from(emailTemplates)
.where(eq((emailTemplates as any).id, id)) .where(eq((emailTemplates as any).id, id))
.get(); );
return c.json({ return c.json({
template: { template: {
@@ -169,11 +168,9 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
emailsRouter.delete('/templates/:id', requireAuth(['admin']), async (c) => { emailsRouter.delete('/templates/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param(); const { id } = c.req.param();
const template = await (db as any) const template = await dbGet<any>(
.select() (db as any).select().from(emailTemplates).where(eq((emailTemplates as any).id, id))
.from(emailTemplates) );
.where(eq((emailTemplates as any).id, id))
.get();
if (!template) { if (!template) {
return c.json({ error: 'Template not found' }, 404); return c.json({ error: 'Template not found' }, 404);
@@ -199,7 +196,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
// ==================== Email Sending Routes ==================== // ==================== Email Sending Routes ====================
// Send email using template to event attendees // Send email using template to event attendees (non-blocking, queued)
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
const { eventId } = c.req.param(); const { eventId } = c.req.param();
const user = (c as any).get('user'); const user = (c as any).get('user');
@@ -210,7 +207,8 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
return c.json({ error: 'Template slug is required' }, 400); return c.json({ error: 'Template slug is required' }, 400);
} }
const result = await emailService.sendToEventAttendees({ // Queue emails for background processing instead of sending synchronously
const result = await emailService.queueEventEmails({
eventId, eventId,
templateSlug, templateSlug,
customVariables, customVariables,
@@ -306,11 +304,12 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(and(...conditions)); query = query.where(and(...conditions));
} }
const logs = await query const logs = await dbAll(
query
.orderBy(desc((emailLogs as any).createdAt)) .orderBy(desc((emailLogs as any).createdAt))
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
.all(); );
// Get total count // Get total count
let countQuery = (db as any) let countQuery = (db as any)
@@ -321,7 +320,7 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
countQuery = countQuery.where(and(...conditions)); countQuery = countQuery.where(and(...conditions));
} }
const totalResult = await countQuery.get(); const totalResult = await dbGet<any>(countQuery);
const total = totalResult?.count || 0; const total = totalResult?.count || 0;
return c.json({ return c.json({
@@ -339,11 +338,9 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param(); const { id } = c.req.param();
const log = await (db as any) const log = await dbGet<any>(
.select() (db as any).select().from(emailLogs).where(eq((emailLogs as any).id, id))
.from(emailLogs) );
.where(eq((emailLogs as any).id, id))
.get();
if (!log) { if (!log) {
return c.json({ error: 'Email log not found' }, 404); return c.json({ error: 'Email log not found' }, 404);
@@ -362,22 +359,22 @@ emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
? (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(baseCondition) ? (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(baseCondition)
: (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs); : (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs);
const total = (await totalQuery.get())?.count || 0; const total = (await dbGet<any>(totalQuery))?.count || 0;
const sentCondition = baseCondition const sentCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'sent')) ? and(baseCondition, eq((emailLogs as any).status, 'sent'))
: eq((emailLogs as any).status, 'sent'); : eq((emailLogs as any).status, 'sent');
const sent = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(sentCondition).get())?.count || 0; const sent = (await dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(sentCondition)))?.count || 0;
const failedCondition = baseCondition const failedCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'failed')) ? and(baseCondition, eq((emailLogs as any).status, 'failed'))
: eq((emailLogs as any).status, 'failed'); : eq((emailLogs as any).status, 'failed');
const failed = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(failedCondition).get())?.count || 0; const failed = (await dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(failedCondition)))?.count || 0;
const pendingCondition = baseCondition const pendingCondition = baseCondition
? and(baseCondition, eq((emailLogs as any).status, 'pending')) ? and(baseCondition, eq((emailLogs as any).status, 'pending'))
: eq((emailLogs as any).status, 'pending'); : eq((emailLogs as any).status, 'pending');
const pending = (await (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(pendingCondition).get())?.count || 0; const pending = (await dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(pendingCondition)))?.count || 0;
return c.json({ return c.json({
stats: { stats: {
@@ -416,4 +413,10 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
return c.json(result); return c.json(result);
}); });
// Get email queue status
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
const status = getQueueStatus();
return c.json({ status });
});
export default emailsRouter; export default emailsRouter;

View File

@@ -1,10 +1,10 @@
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, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices } from '../db/index.js'; import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } 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 } from '../lib/utils.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -15,6 +15,44 @@ 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
// PostgreSQL decimal returns strings, booleans are stored as integers
function normalizeEvent(event: any) {
if (!event) return event;
return {
...event,
// Convert price from string/decimal to clean number
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
// Convert capacity from string to number if needed
capacity: typeof event.capacity === 'string' ? parseInt(event.capacity, 10) : Number(event.capacity),
// Convert boolean integers to actual booleans for frontend
externalBookingEnabled: Boolean(event.externalBookingEnabled),
};
}
// 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) {
@@ -23,6 +61,27 @@ const validationHook = (result: any, c: any) => {
} }
}; };
// Helper to parse price from string (handles both "45000" and "41,44" formats)
const parsePrice = (val: unknown): number => {
if (typeof val === 'number') return val;
if (typeof val === 'string') {
// Replace comma with dot for decimal parsing (European format)
const normalized = val.replace(',', '.');
const parsed = parseFloat(normalized);
return isNaN(parsed) ? 0 : parsed;
}
return 0;
};
// Helper to normalize boolean (handles true/false and 0/1)
const normalizeBoolean = (val: unknown): boolean => {
if (typeof val === 'boolean') return val;
if (typeof val === 'number') return val !== 0;
if (val === 'true') return true;
if (val === 'false') return false;
return false;
};
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(),
@@ -34,14 +93,15 @@ const baseEventSchema = z.object({
endDatetime: z.string().optional().nullable(), endDatetime: z.string().optional().nullable(),
location: z.string().min(1), location: z.string().min(1),
locationUrl: z.string().url().optional().nullable().or(z.literal('')), locationUrl: z.string().url().optional().nullable().or(z.literal('')),
price: z.number().min(0).default(0), // Accept price as number or string (handles "45000" and "41,44" formats)
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.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', '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 // External booking support - accept boolean or number (0/1 from DB)
externalBookingEnabled: z.boolean().default(false), externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')), externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
}); });
@@ -94,26 +154,31 @@ eventsRouter.get('/', async (c) => {
); );
} }
const result = await query.orderBy(desc((events as any).startDatetime)).all(); const result = await dbAll(query.orderBy(desc((events as any).startDatetime)));
// Get ticket counts for each event // Get ticket counts for each event
const eventsWithCounts = await Promise.all( const eventsWithCounts = await Promise.all(
result.map(async (event: any) => { result.map(async (event: any) => {
const ticketCount = await (db as any) // Count confirmed AND checked_in tickets (checked_in were previously confirmed)
// This ensures check-in doesn't affect capacity/spots_left
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(tickets) .from(tickets)
.where( .where(
and( and(
eq((tickets as any).eventId, event.id), eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed') sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
) )
) )
.get(); );
const normalized = normalizeEvent(event);
const bookedCount = ticketCount?.count || 0;
return { return {
...event, ...normalized,
bookedCount: ticketCount?.count || 0, bookedCount,
availableSeats: event.capacity - (ticketCount?.count || 0), availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
}; };
}) })
); );
@@ -125,38 +190,127 @@ eventsRouter.get('/', async (c) => {
eventsRouter.get('/:id', async (c) => { eventsRouter.get('/:id', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const event = await (db as any).select().from(events).where(eq((events as any).id, id)).get(); 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);
} }
// Get ticket count // Count confirmed AND checked_in tickets (checked_in were previously confirmed)
const ticketCount = await (db as any) // This ensures check-in doesn't affect capacity/spots_left
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(tickets) .from(tickets)
.where( .where(
and( and(
eq((tickets as any).eventId, id), eq((tickets as any).eventId, id),
eq((tickets as any).status, 'confirmed') sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
) )
) )
.get(); );
const normalized = normalizeEvent(event);
const bookedCount = ticketCount?.count || 0;
return c.json({ return c.json({
event: { event: {
...event, ...normalized,
bookedCount: ticketCount?.count || 0, bookedCount,
availableSeats: event.capacity - (ticketCount?.count || 0), availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
}, },
}); });
}); });
// Get next upcoming event (public) // Helper function to get ticket count for an event
async function getEventTicketCount(eventId: string): Promise<number> {
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
return ticketCount?.count || 0;
}
// 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 event = await (db as any) // First, check if there's a featured event in site settings
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
let featuredEvent = null;
let shouldUnsetFeatured = false;
if (settings?.featuredEventId) {
// Get the featured event
featuredEvent = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, settings.featuredEventId))
);
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 isPublished = featuredEvent.status === 'published';
const hasNotEnded = eventEndTime >= now;
if (!isPublished || !hasNotEnded) {
// Featured event is no longer valid - mark for unsetting
shouldUnsetFeatured = true;
featuredEvent = null;
}
} else {
// Featured event no longer exists
shouldUnsetFeatured = true;
}
}
// If we need to unset the featured event, do it asynchronously
if (shouldUnsetFeatured && settings) {
// Unset featured event in background (don't await to avoid blocking response)
(db as any)
.update(siteSettings)
.set({ featuredEventId: null, updatedAt: now })
.where(eq((siteSettings as any).id, settings.id))
.then(() => {
console.log('Featured event auto-cleared (event ended or unpublished)');
})
.catch((err: any) => {
console.error('Failed to clear featured event:', err);
});
}
// If we have a valid featured event, return it
if (featuredEvent) {
const bookedCount = await getEventTicketCount(featuredEvent.id);
const normalized = normalizeEvent(featuredEvent);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
isFeatured: true,
},
});
}
// Fallback: get the next upcoming published event
const event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where( .where(
@@ -167,28 +321,20 @@ eventsRouter.get('/next/upcoming', async (c) => {
) )
.orderBy((events as any).startDatetime) .orderBy((events as any).startDatetime)
.limit(1) .limit(1)
.get(); );
if (!event) { if (!event) {
return c.json({ event: null }); return c.json({ event: null });
} }
const ticketCount = await (db as any) const bookedCount = await getEventTicketCount(event.id);
.select({ count: sql<number>`count(*)` }) const normalized = normalizeEvent(event);
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed')
)
)
.get();
return c.json({ return c.json({
event: { event: {
...event, ...normalized,
bookedCount: ticketCount?.count || 0, bookedCount,
availableSeats: event.capacity - (ticketCount?.count || 0), availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
isFeatured: false,
}, },
}); });
}); });
@@ -200,16 +346,25 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
const now = getNow(); const now = getNow();
const id = generateId(); const id = generateId();
// Convert data for database compatibility
const dbData = convertBooleansForDb(data);
const newEvent = { const newEvent = {
id, id,
...data, ...dbData,
startDatetime: toDbDate(data.startDatetime),
endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
await (db as any).insert(events).values(newEvent); await (db as any).insert(events).values(newEvent);
return c.json({ event: newEvent }, 201); // Revalidate sitemap when a new event is created
revalidateFrontendCache();
// Return normalized event data
return c.json({ event: normalizeEvent(newEvent) }, 201);
}); });
// Update event (admin/organizer only) // Update event (admin/organizer only)
@@ -217,46 +372,67 @@ 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 (db as any).select().from(events).where(eq((events as any).id, id)).get(); const existing = await dbGet(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
const now = getNow(); const now = getNow();
// Convert data for database compatibility
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
// Convert datetime fields if present
if (data.startDatetime) {
updateData.startDatetime = toDbDate(data.startDatetime);
}
if (data.endDatetime !== undefined) {
updateData.endDatetime = data.endDatetime ? toDbDate(data.endDatetime) : null;
}
await (db as any) await (db as any)
.update(events) .update(events)
.set({ ...data, updatedAt: now }) .set(updateData)
.where(eq((events as any).id, id)); .where(eq((events as any).id, id));
const updated = await (db as any).select().from(events).where(eq((events as any).id, id)).get(); const updated = await dbGet(
(db as any).select().from(events).where(eq((events as any).id, id))
);
return c.json({ event: updated }); // Revalidate sitemap when an event is updated (status/dates may have changed)
revalidateFrontendCache();
return c.json({ event: normalizeEvent(updated) });
}); });
// Delete event (admin only) // Delete event (admin only)
eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => { eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get(); const existing = await dbGet(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
// Get all tickets for this event // Get all tickets for this event
const eventTickets = await (db as any) const eventTickets = await dbAll<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).eventId, id)) .where(eq((tickets as any).eventId, id))
.all(); );
// Delete invoices and payments for all tickets of this event // Delete invoices and payments for all tickets of this event
for (const ticket of eventTickets) { for (const ticket of eventTickets) {
// Get payments for this ticket // Get payments for this ticket
const ticketPayments = await (db as any) const ticketPayments = await dbAll<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).ticketId, ticket.id)) .where(eq((payments as any).ticketId, ticket.id))
.all(); );
// Delete invoices for each payment // Delete invoices for each payment
for (const payment of ticketPayments) { for (const payment of ticketPayments) {
@@ -282,6 +458,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
// Finally delete the event // Finally delete the event
await (db as any).delete(events).where(eq((events as any).id, id)); await (db as any).delete(events).where(eq((events as any).id, id));
// Revalidate sitemap when an event is deleted
revalidateFrontendCache();
return c.json({ message: 'Event deleted successfully' }); return c.json({ message: 'Event deleted successfully' });
}); });
@@ -289,11 +468,12 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => { eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const attendees = await (db as any) const attendees = await dbAll(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).eventId, id)) .where(eq((tickets as any).eventId, id))
.all(); );
return c.json({ attendees }); return c.json({ attendees });
}); });
@@ -302,7 +482,9 @@ eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']),
eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (c) => { eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const existing = await (db as any).select().from(events).where(eq((events as any).id, id)).get(); const existing = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
@@ -319,7 +501,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
descriptionEs: existing.descriptionEs, descriptionEs: existing.descriptionEs,
shortDescription: existing.shortDescription, shortDescription: existing.shortDescription,
shortDescriptionEs: existing.shortDescriptionEs, shortDescriptionEs: existing.shortDescriptionEs,
startDatetime: existing.startDatetime, startDatetime: existing.startDatetime, // Already in DB format from existing record
endDatetime: existing.endDatetime, endDatetime: existing.endDatetime,
location: existing.location, location: existing.location,
locationUrl: existing.locationUrl, locationUrl: existing.locationUrl,
@@ -328,7 +510,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
capacity: existing.capacity, capacity: existing.capacity,
status: 'draft', status: 'draft',
bannerUrl: existing.bannerUrl, bannerUrl: existing.bannerUrl,
externalBookingEnabled: existing.externalBookingEnabled || false, externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1)
externalBookingUrl: existing.externalBookingUrl, externalBookingUrl: existing.externalBookingUrl,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -336,7 +518,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
await (db as any).insert(events).values(duplicatedEvent); await (db as any).insert(events).values(duplicatedEvent);
return c.json({ event: duplicatedEvent, message: 'Event duplicated successfully' }, 201); return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
}); });
export default eventsRouter; export default eventsRouter;

242
backend/src/routes/faq.ts Normal file
View File

@@ -0,0 +1,242 @@
import { Hono } from 'hono';
import { db, dbGet, dbAll, faqQuestions } from '../db/index.js';
import { eq, asc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js';
const faqRouter = new Hono();
// ==================== Public Routes ====================
// Get FAQ list for public (only enabled; optional filter for homepage)
faqRouter.get('/', async (c) => {
const homepage = c.req.query('homepage') === 'true';
let query = (db as any)
.select()
.from(faqQuestions)
.where(eq((faqQuestions as any).enabled, 1))
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt));
const rows = await dbAll<any>(query);
let items = rows;
if (homepage) {
items = rows.filter((r: any) => r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1);
}
return c.json({
faqs: items.map((r: any) => ({
id: r.id,
question: r.question,
questionEs: r.questionEs ?? r.question_es ?? null,
answer: r.answer,
answerEs: r.answerEs ?? r.answer_es ?? null,
rank: r.rank ?? 0,
})),
});
});
// ==================== Admin Routes ====================
// Get all FAQ questions for admin (all, ordered by rank)
faqRouter.get('/admin/list', requireAuth(['admin']), async (c) => {
const rows = await dbAll<any>(
(db as any)
.select()
.from(faqQuestions)
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt))
);
const list = rows.map((r: any) => ({
id: r.id,
question: r.question,
questionEs: r.questionEs ?? r.question_es ?? null,
answer: r.answer,
answerEs: r.answerEs ?? r.answer_es ?? null,
enabled: r.enabled === true || r.enabled === 1,
showOnHomepage: r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1,
rank: r.rank ?? 0,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}));
return c.json({ faqs: list });
});
// Get one FAQ by id (admin)
faqRouter.get('/admin/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param();
const row = await dbGet<any>(
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
);
if (!row) {
return c.json({ error: 'FAQ not found' }, 404);
}
return c.json({
faq: {
id: row.id,
question: row.question,
questionEs: row.questionEs ?? row.question_es ?? null,
answer: row.answer,
answerEs: row.answerEs ?? row.answer_es ?? null,
enabled: row.enabled === true || row.enabled === 1,
showOnHomepage: row.showOnHomepage === true || row.showOnHomepage === 1 || row.show_on_homepage === true || row.show_on_homepage === 1,
rank: row.rank ?? 0,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
},
});
});
// Create FAQ (admin)
faqRouter.post('/admin', requireAuth(['admin']), async (c) => {
const body = await c.req.json();
const { question, questionEs, answer, answerEs, enabled, showOnHomepage } = body;
if (!question || typeof question !== 'string' || !answer || typeof answer !== 'string') {
return c.json({ error: 'Question and answer (EN) are required' }, 400);
}
const now = getNow();
const id = generateId();
const allForRank = await dbAll<any>(
(db as any).select({ rank: (faqQuestions as any).rank }).from(faqQuestions)
);
const maxRank = allForRank.length
? Math.max(...allForRank.map((r: any) => Number(r.rank ?? 0)))
: 0;
const nextRank = maxRank + 1;
await (db as any).insert(faqQuestions).values({
id,
question: String(question).trim(),
questionEs: questionEs != null ? String(questionEs).trim() : null,
answer: String(answer).trim(),
answerEs: answerEs != null ? String(answerEs).trim() : null,
enabled: enabled !== false ? 1 : 0,
showOnHomepage: showOnHomepage === true ? 1 : 0,
rank: nextRank,
createdAt: now,
updatedAt: now,
});
const created = await dbGet<any>(
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
);
return c.json(
{
faq: {
id: created.id,
question: created.question,
questionEs: created.questionEs ?? created.question_es ?? null,
answer: created.answer,
answerEs: created.answerEs ?? created.answer_es ?? null,
enabled: created.enabled === true || created.enabled === 1,
showOnHomepage: created.showOnHomepage === true || created.showOnHomepage === 1 || created.show_on_homepage === true || created.show_on_homepage === 1,
rank: created.rank ?? 0,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
},
},
201
);
});
// Update FAQ (admin)
faqRouter.put('/admin/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param();
const body = await c.req.json();
const { question, questionEs, answer, answerEs, enabled, showOnHomepage } = body;
const existing = await dbGet<any>(
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
);
if (!existing) {
return c.json({ error: 'FAQ not found' }, 404);
}
const updateData: Record<string, unknown> = {
updatedAt: getNow(),
};
if (question !== undefined) updateData.question = String(question).trim();
if (questionEs !== undefined) updateData.questionEs = questionEs == null ? null : String(questionEs).trim();
if (answer !== undefined) updateData.answer = String(answer).trim();
if (answerEs !== undefined) updateData.answerEs = answerEs == null ? null : String(answerEs).trim();
if (typeof enabled === 'boolean') updateData.enabled = enabled ? 1 : 0;
if (typeof showOnHomepage === 'boolean') updateData.showOnHomepage = showOnHomepage ? 1 : 0;
await (db as any).update(faqQuestions).set(updateData).where(eq((faqQuestions as any).id, id));
const updated = await dbGet<any>(
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
);
return c.json({
faq: {
id: updated.id,
question: updated.question,
questionEs: updated.questionEs ?? updated.question_es ?? null,
answer: updated.answer,
answerEs: updated.answerEs ?? updated.answer_es ?? null,
enabled: updated.enabled === true || updated.enabled === 1,
showOnHomepage: updated.showOnHomepage === true || updated.showOnHomepage === 1 || updated.show_on_homepage === true || updated.show_on_homepage === 1,
rank: updated.rank ?? 0,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
},
});
});
// Delete FAQ (admin)
faqRouter.delete('/admin/:id', requireAuth(['admin']), async (c) => {
const { id } = c.req.param();
const existing = await dbGet<any>(
(db as any).select().from(faqQuestions).where(eq((faqQuestions as any).id, id))
);
if (!existing) {
return c.json({ error: 'FAQ not found' }, 404);
}
await (db as any).delete(faqQuestions).where(eq((faqQuestions as any).id, id));
return c.json({ message: 'FAQ deleted' });
});
// Reorder FAQs (admin) body: { ids: string[] } (ordered list of ids)
faqRouter.post('/admin/reorder', requireAuth(['admin']), async (c) => {
const body = await c.req.json();
const { ids } = body;
if (!Array.isArray(ids) || ids.length === 0) {
return c.json({ error: 'ids array is required' }, 400);
}
const now = getNow();
for (let i = 0; i < ids.length; i++) {
await (db as any)
.update(faqQuestions)
.set({ rank: i, updatedAt: now })
.where(eq((faqQuestions as any).id, ids[i]));
}
const rows = await dbAll<any>(
(db as any)
.select()
.from(faqQuestions)
.orderBy(asc((faqQuestions as any).rank), asc((faqQuestions as any).createdAt))
);
const list = rows.map((r: any) => ({
id: r.id,
question: r.question,
questionEs: r.questionEs ?? r.question_es ?? null,
answer: r.answer,
answerEs: r.answerEs ?? r.answer_es ?? null,
enabled: r.enabled === true || r.enabled === 1,
showOnHomepage: r.showOnHomepage === true || r.showOnHomepage === 1 || r.show_on_homepage === true || r.show_on_homepage === 1,
rank: r.rank ?? 0,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}));
return c.json({ faqs: list });
});
export default faqRouter;

View File

@@ -0,0 +1,400 @@
import { Hono } from 'hono';
import { db, dbGet, dbAll, legalPages } from '../db/index.js';
import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js';
import { replaceLegalPlaceholders } from '../lib/legal-placeholders.js';
import fs from 'fs';
import path from 'path';
const legalPagesRouter = new Hono();
// Helper: Convert plain text to simple markdown
// Preserves paragraphs and line breaks, nothing fancy
function textToMarkdown(text: string): string {
if (!text) return '';
// Split into paragraphs (double newlines)
const paragraphs = text.split(/\n\s*\n/);
// Process each paragraph
const processed = paragraphs.map(para => {
// Replace single newlines with double spaces + newline for markdown line breaks
return para.trim().replace(/\n/g, ' \n');
});
// Join paragraphs with double newlines
return processed.join('\n\n');
}
// Helper: Convert markdown to plain text for editing
function markdownToText(markdown: string): string {
if (!markdown) return '';
let text = markdown;
// Remove markdown heading markers (# ## ###)
text = text.replace(/^#{1,6}\s+/gm, '');
// Remove horizontal rules
text = text.replace(/^---+$/gm, '');
// Remove bold/italic markers
text = text.replace(/\*\*([^*]+)\*\*/g, '$1');
text = text.replace(/\*([^*]+)\*/g, '$1');
text = text.replace(/__([^_]+)__/g, '$1');
text = text.replace(/_([^_]+)_/g, '$1');
// Remove list markers (preserve text)
text = text.replace(/^\s*[\*\-\+]\s+/gm, '');
text = text.replace(/^\s*\d+\.\s+/gm, '');
// Remove link formatting [text](url) -> text
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
// Remove double-space line breaks
text = text.replace(/ \n/g, '\n');
// Normalize multiple newlines
text = text.replace(/\n{3,}/g, '\n\n');
return text.trim();
}
// Helper: Extract title from markdown content
function extractTitleFromMarkdown(content: string): string {
if (!content) return 'Untitled';
const match = content.match(/^#\s+(.+?)(?:\s*[-]\s*.+)?$/m);
if (match) {
return match[1].trim();
}
// Fallback to first line
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
return firstLine || 'Untitled';
}
// Helper: Get legal directory path
function getLegalDir(): string {
// When running from backend, legal folder is in frontend
const possiblePaths = [
path.join(process.cwd(), '../frontend/legal'),
path.join(process.cwd(), 'frontend/legal'),
path.join(process.cwd(), 'legal'),
];
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
return p;
}
}
return possiblePaths[0]; // Default
}
// Helper: Convert filename to slug
function fileNameToSlug(fileName: string): string {
return fileName.replace('.md', '').replace(/_/g, '-');
}
// Title map for localization
const titleMap: Record<string, { en: string; es: string }> = {
'privacy-policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
'terms-policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
'refund-cancelation-policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
};
// Helper: Get localized content with fallback
// If requested locale content is missing, fallback to the other locale
function getLocalizedContent(page: any, locale: string = 'en'): { title: string; contentMarkdown: string } {
const isSpanish = locale === 'es';
// Title: prefer requested locale, fallback to other
let title: string;
if (isSpanish) {
title = page.titleEs || page.title;
} else {
title = page.title || page.titleEs;
}
// Content: prefer requested locale, fallback to other
let contentMarkdown: string;
if (isSpanish) {
contentMarkdown = page.contentMarkdownEs || page.contentMarkdown;
} else {
contentMarkdown = page.contentMarkdown || page.contentMarkdownEs;
}
return { title, contentMarkdown };
}
// ==================== Public Routes ====================
// Get all legal pages (public, for footer/navigation)
legalPagesRouter.get('/', async (c) => {
const locale = c.req.query('locale') || 'en';
const pages = await dbAll<any>(
(db as any)
.select({
id: (legalPages as any).id,
slug: (legalPages as any).slug,
title: (legalPages as any).title,
titleEs: (legalPages as any).titleEs,
updatedAt: (legalPages as any).updatedAt,
})
.from(legalPages)
.orderBy((legalPages as any).slug)
);
// Return pages with localized title
const localizedPages = pages.map((page: any) => ({
id: page.id,
slug: page.slug,
title: locale === 'es' ? (page.titleEs || page.title) : (page.title || page.titleEs),
updatedAt: page.updatedAt,
}));
return c.json({ pages: localizedPages });
});
// Get single legal page (public, for rendering)
legalPagesRouter.get('/:slug', async (c) => {
const { slug } = c.req.param();
const locale = c.req.query('locale') || 'en';
// First try to get from database
const page = await dbGet<any>(
(db as any).select().from(legalPages).where(eq((legalPages as any).slug, slug))
);
if (page) {
// Get localized content with fallback
const { title, contentMarkdown } = getLocalizedContent(page, locale);
// Replace legal placeholders before returning
const processedContent = await replaceLegalPlaceholders(contentMarkdown, page.updatedAt);
return c.json({
page: {
id: page.id,
slug: page.slug,
title,
contentMarkdown: processedContent,
updatedAt: page.updatedAt,
source: 'database',
}
});
}
// Fallback to filesystem
const legalDir = getLegalDir();
const fileName = slug.replace(/-/g, '_') + '.md';
const filePath = path.join(legalDir, fileName);
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
const titles = titleMap[slug];
const title = locale === 'es'
? (titles?.es || titles?.en || slug)
: (titles?.en || titles?.es || slug);
// Replace legal placeholders in filesystem content too
const processedContent = await replaceLegalPlaceholders(content);
return c.json({
page: {
slug,
title,
contentMarkdown: processedContent,
source: 'filesystem',
}
});
}
return c.json({ error: 'Legal page not found' }, 404);
});
// ==================== Admin Routes ====================
// Get all legal pages for admin (with full content)
legalPagesRouter.get('/admin/list', requireAuth(['admin']), async (c) => {
const pages = await dbAll<any>(
(db as any)
.select()
.from(legalPages)
.orderBy((legalPages as any).slug)
);
// Add flags to indicate which languages have content
const pagesWithFlags = pages.map((page: any) => ({
...page,
hasEnglish: Boolean(page.contentText),
hasSpanish: Boolean(page.contentTextEs),
}));
return c.json({ pages: pagesWithFlags });
});
// Get single legal page for editing (admin)
legalPagesRouter.get('/admin/:slug', requireAuth(['admin']), async (c) => {
const { slug } = c.req.param();
const page = await dbGet<any>(
(db as any).select().from(legalPages).where(eq((legalPages as any).slug, slug))
);
if (!page) {
return c.json({ error: 'Legal page not found' }, 404);
}
return c.json({
page: {
...page,
hasEnglish: Boolean(page.contentText),
hasSpanish: Boolean(page.contentTextEs),
}
});
});
// Update legal page (admin only)
// Note: No creation or deletion - only updates are allowed
// Accepts markdown content from rich text editor
legalPagesRouter.put('/admin/:slug', requireAuth(['admin']), async (c) => {
const { slug } = c.req.param();
const user = (c as any).get('user');
const body = await c.req.json();
// Accept both contentText (legacy plain text) and contentMarkdown (from rich text editor)
const { contentText, contentTextEs, contentMarkdown, contentMarkdownEs, title, titleEs } = body;
// Determine content - prefer markdown if provided, fall back to contentText
const enContent = contentMarkdown !== undefined ? contentMarkdown : contentText;
const esContent = contentMarkdownEs !== undefined ? contentMarkdownEs : contentTextEs;
// At least one content field is required
if (!enContent && !esContent) {
return c.json({ error: 'At least one language content is required' }, 400);
}
const existing = await dbGet(
(db as any)
.select()
.from(legalPages)
.where(eq((legalPages as any).slug, slug))
);
if (!existing) {
return c.json({ error: 'Legal page not found' }, 404);
}
const updateData: any = {
updatedAt: getNow(),
updatedBy: user?.id || null,
};
// Update English content if provided
if (enContent !== undefined) {
// Store markdown directly (from rich text editor)
updateData.contentMarkdown = enContent;
// Derive plain text from markdown
updateData.contentText = markdownToText(enContent);
}
// Update Spanish content if provided
if (esContent !== undefined) {
updateData.contentMarkdownEs = esContent || null;
updateData.contentTextEs = esContent ? markdownToText(esContent) : null;
}
// Allow updating titles
if (title !== undefined) {
updateData.title = title;
}
if (titleEs !== undefined) {
updateData.titleEs = titleEs || null;
}
await (db as any)
.update(legalPages)
.set(updateData)
.where(eq((legalPages as any).slug, slug));
const updated = await dbGet<any>(
(db as any).select().from(legalPages).where(eq((legalPages as any).slug, slug))
);
return c.json({
page: {
...updated,
hasEnglish: Boolean(updated.contentText),
hasSpanish: Boolean(updated.contentTextEs),
},
message: 'Legal page updated successfully'
});
});
// Seed legal pages from filesystem (admin only)
// This imports markdown files and converts them to plain text for editing
// Files are imported as English content - Spanish can be added manually
legalPagesRouter.post('/admin/seed', requireAuth(['admin']), async (c) => {
const user = (c as any).get('user');
// Check if table already has data
const existingPages = await dbAll(
(db as any)
.select({ id: (legalPages as any).id })
.from(legalPages)
.limit(1)
);
if (existingPages.length > 0) {
return c.json({
message: 'Legal pages already seeded. Use update to modify pages.',
seeded: 0
});
}
const legalDir = getLegalDir();
if (!fs.existsSync(legalDir)) {
return c.json({ error: `Legal directory not found: ${legalDir}` }, 400);
}
const files = fs.readdirSync(legalDir).filter(f => f.endsWith('.md'));
const seededPages: string[] = [];
const now = getNow();
for (const file of files) {
const filePath = path.join(legalDir, file);
const contentMarkdown = fs.readFileSync(filePath, 'utf-8');
const slug = fileNameToSlug(file);
const contentText = markdownToText(contentMarkdown);
const titles = titleMap[slug];
const title = titles?.en || extractTitleFromMarkdown(contentMarkdown);
const titleEs = titles?.es || null;
await (db as any).insert(legalPages).values({
id: generateId(),
slug,
title,
titleEs,
contentText, // English plain text
contentTextEs: null, // Spanish to be added manually
contentMarkdown, // English markdown
contentMarkdownEs: null, // Spanish to be generated when contentTextEs is set
updatedAt: now,
updatedBy: user?.id || null,
createdAt: now,
});
seededPages.push(slug);
}
return c.json({
message: `Successfully seeded ${seededPages.length} legal pages (English content imported, Spanish can be added via editor)`,
seeded: seededPages.length,
pages: seededPages,
});
});
export default legalPagesRouter;

View File

@@ -0,0 +1,146 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, legalSettings } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
interface UserContext {
id: string;
email: string;
name: string;
role: string;
}
const legalSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Validation schema for updating legal settings
const updateLegalSettingsSchema = z.object({
companyName: z.string().optional().nullable(),
legalEntityName: z.string().optional().nullable(),
rucNumber: z.string().optional().nullable(),
companyAddress: z.string().optional().nullable(),
companyCity: z.string().optional().nullable(),
companyCountry: z.string().optional().nullable(),
supportEmail: z.string().email().optional().nullable().or(z.literal('')),
legalEmail: z.string().email().optional().nullable().or(z.literal('')),
governingLaw: z.string().optional().nullable(),
jurisdictionCity: z.string().optional().nullable(),
});
// Get legal settings (admin only)
legalSettingsRouter.get('/', requireAuth(['admin']), async (c) => {
const settings = await dbGet<any>(
(db as any).select().from(legalSettings).limit(1)
);
if (!settings) {
// Return empty defaults
return c.json({
settings: {
companyName: null,
legalEntityName: null,
rucNumber: null,
companyAddress: null,
companyCity: null,
companyCountry: null,
supportEmail: null,
legalEmail: null,
governingLaw: null,
jurisdictionCity: null,
},
});
}
return c.json({ settings });
});
// Internal helper: get legal settings for placeholder replacement (no auth required)
// This is called server-side from legal-pages route, not exposed as HTTP endpoint
export async function getLegalSettingsValues(): Promise<Record<string, string>> {
const settings = await dbGet<any>(
(db as any).select().from(legalSettings).limit(1)
);
if (!settings) {
return {};
}
const values: Record<string, string> = {};
if (settings.companyName) values['COMPANY_NAME'] = settings.companyName;
if (settings.legalEntityName) values['LEGAL_ENTITY_NAME'] = settings.legalEntityName;
if (settings.rucNumber) values['RUC_NUMBER'] = settings.rucNumber;
if (settings.companyAddress) values['COMPANY_ADDRESS'] = settings.companyAddress;
if (settings.companyCity) values['COMPANY_CITY'] = settings.companyCity;
if (settings.companyCountry) values['COMPANY_COUNTRY'] = settings.companyCountry;
if (settings.supportEmail) values['SUPPORT_EMAIL'] = settings.supportEmail;
if (settings.legalEmail) values['LEGAL_EMAIL'] = settings.legalEmail;
if (settings.governingLaw) values['GOVERNING_LAW'] = settings.governingLaw;
if (settings.jurisdictionCity) values['JURISDICTION_CITY'] = settings.jurisdictionCity;
return values;
}
// Update legal settings (admin only)
legalSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateLegalSettingsSchema), async (c) => {
const data = c.req.valid('json');
const user = c.get('user');
const now = getNow();
// Check if settings exist
const existing = await dbGet<any>(
(db as any).select().from(legalSettings).limit(1)
);
if (!existing) {
// Create new settings record
const id = generateId();
const newSettings = {
id,
companyName: data.companyName || null,
legalEntityName: data.legalEntityName || null,
rucNumber: data.rucNumber || null,
companyAddress: data.companyAddress || null,
companyCity: data.companyCity || null,
companyCountry: data.companyCountry || null,
supportEmail: data.supportEmail || null,
legalEmail: data.legalEmail || null,
governingLaw: data.governingLaw || null,
jurisdictionCity: data.jurisdictionCity || null,
updatedAt: now,
updatedBy: user.id,
};
await (db as any).insert(legalSettings).values(newSettings);
return c.json({ settings: newSettings, message: 'Legal settings created successfully' }, 201);
}
// Update existing settings
const updateData: Record<string, any> = {
...data,
updatedAt: now,
updatedBy: user.id,
};
// Normalize empty strings to null
for (const key of Object.keys(updateData)) {
if (updateData[key] === '') {
updateData[key] = null;
}
}
await (db as any)
.update(legalSettings)
.set(updateData)
.where(eq((legalSettings as any).id, existing.id));
const updated = await dbGet(
(db as any).select().from(legalSettings).where(eq((legalSettings as any).id, existing.id))
);
return c.json({ settings: updated, message: 'Legal settings updated successfully' });
});
export default legalSettingsRouter;

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming'; import { streamSSE } from 'hono/streaming';
import { db, tickets, payments } from '../db/index.js'; import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getNow } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js'; import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
@@ -152,27 +152,47 @@ lnbitsRouter.post('/webhook', async (c) => {
/** /**
* Handle successful payment * Handle successful payment
* Supports multi-ticket bookings - confirms all tickets in the booking
*/ */
async function handlePaymentComplete(ticketId: string, paymentHash: string) { async function handlePaymentComplete(ticketId: string, paymentHash: string) {
const now = getNow(); const now = getNow();
// Check if already confirmed to avoid duplicate updates // Get the ticket to check for booking ID
const existingTicket = await (db as any) const existingTicket = await dbGet<any>(
.select() (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
.from(tickets) );
.where(eq((tickets as any).id, ticketId))
.get();
if (existingTicket?.status === 'confirmed') { if (!existingTicket) {
console.error(`Ticket ${ticketId} not found for payment confirmation`);
return;
}
if (existingTicket.status === 'confirmed') {
console.log(`Ticket ${ticketId} already confirmed, skipping update`); console.log(`Ticket ${ticketId} already confirmed, skipping update`);
return; return;
} }
// Get all tickets in this booking (if multi-ticket)
let ticketsToConfirm: any[] = [existingTicket];
if (existingTicket.bookingId) {
// This is a multi-ticket booking - get all tickets with same bookingId
ticketsToConfirm = await dbAll(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, existingTicket.bookingId))
);
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
}
// Confirm all tickets in the booking
for (const ticket of ticketsToConfirm) {
// Update ticket status to confirmed // Update ticket status to confirmed
await (db as any) await (db as any)
.update(tickets) .update(tickets)
.set({ status: 'confirmed' }) .set({ status: 'confirmed' })
.where(eq((tickets as any).id, ticketId)); .where(eq((tickets as any).id, ticket.id));
// Update payment status to paid // Update payment status to paid
await (db as any) await (db as any)
@@ -183,18 +203,21 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
paidAt: now, paidAt: now,
updatedAt: now, updatedAt: now,
}) })
.where(eq((payments as any).ticketId, ticketId)); .where(eq((payments as any).ticketId, ticket.id));
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`); console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
}
// Get payment for sending receipt // Get primary payment for sending receipt
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).ticketId, ticketId)) .where(eq((payments as any).ticketId, ticketId))
.get(); );
// Send confirmation emails asynchronously // Send confirmation emails asynchronously
// For multi-ticket bookings, send email with all ticket info
Promise.all([ Promise.all([
emailService.sendBookingConfirmation(ticketId), emailService.sendBookingConfirmation(ticketId),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(), payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
@@ -211,11 +234,9 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
const ticketId = c.req.param('ticketId'); const ticketId = c.req.param('ticketId');
// Verify ticket exists // Verify ticket exists
const ticket = await (db as any) const ticket = await dbGet<any>(
.select() (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
.from(tickets) );
.where(eq((tickets as any).id, ticketId))
.get();
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
@@ -227,11 +248,9 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
} }
// Get payment to start background checker // Get payment to start background checker
const payment = await (db as any) const payment = await dbGet<any>(
.select() (db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
.from(payments) );
.where(eq((payments as any).ticketId, ticketId))
.get();
// Start background checker if not already running // Start background checker if not already running
if (payment?.reference && !activeCheckers.has(ticketId)) { if (payment?.reference && !activeCheckers.has(ticketId)) {
@@ -290,21 +309,23 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
lnbitsRouter.get('/status/:ticketId', async (c) => { lnbitsRouter.get('/status/:ticketId', async (c) => {
const ticketId = c.req.param('ticketId'); const ticketId = c.req.param('ticketId');
const ticket = await (db as any) const ticket = await dbGet<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, ticketId)) .where(eq((tickets as any).id, ticketId))
.get(); );
if (!ticket) { if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404); return c.json({ error: 'Ticket not found' }, 404);
} }
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).ticketId, ticketId)) .where(eq((payments as any).ticketId, ticketId))
.get(); );
return c.json({ return c.json({
ticketStatus: ticket.status, ticketStatus: ticket.status,

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { db, media } from '../db/index.js'; import { db, dbGet, dbAll, media } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow } from '../lib/utils.js';
@@ -85,11 +85,9 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
mediaRouter.get('/:id', async (c) => { mediaRouter.get('/:id', async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const mediaRecord = await (db as any) const mediaRecord = await dbGet<any>(
.select() (db as any).select().from(media).where(eq((media as any).id, id))
.from(media) );
.where(eq((media as any).id, id))
.get();
if (!mediaRecord) { if (!mediaRecord) {
return c.json({ error: 'Media not found' }, 404); return c.json({ error: 'Media not found' }, 404);
@@ -102,11 +100,9 @@ mediaRouter.get('/:id', async (c) => {
mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => { mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const mediaRecord = await (db as any) const mediaRecord = await dbGet<any>(
.select() (db as any).select().from(media).where(eq((media as any).id, id))
.from(media) );
.where(eq((media as any).id, id))
.get();
if (!mediaRecord) { if (!mediaRecord) {
return c.json({ error: 'Media not found' }, 404); return c.json({ error: 'Media not found' }, 404);
@@ -142,7 +138,7 @@ mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(eq((media as any).relatedId, relatedId)); query = query.where(eq((media as any).relatedId, relatedId));
} }
const result = await query.all(); const result = await dbAll(query);
return c.json({ media: result }); return c.json({ media: result });
}); });

View File

@@ -1,20 +1,26 @@
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, paymentOptions, eventPaymentOverrides, events } from '../db/index.js'; import { db, dbGet, paymentOptions, eventPaymentOverrides, events } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js'; import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
const paymentOptionsRouter = new Hono(); const paymentOptionsRouter = new Hono();
// Helper to normalize boolean (handles true/false and 0/1 from database)
const booleanOrNumber = z.union([z.boolean(), z.number()]).transform((val) => {
if (typeof val === 'boolean') return val;
return val !== 0;
});
// Schema for updating global payment options // Schema for updating global payment options
const updatePaymentOptionsSchema = z.object({ const updatePaymentOptionsSchema = z.object({
tpagoEnabled: z.boolean().optional(), tpagoEnabled: booleanOrNumber.optional(),
tpagoLink: z.string().optional().nullable(), tpagoLink: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(), tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(), tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: z.boolean().optional(), bankTransferEnabled: booleanOrNumber.optional(),
bankName: z.string().optional().nullable(), bankName: z.string().optional().nullable(),
bankAccountHolder: z.string().optional().nullable(), bankAccountHolder: z.string().optional().nullable(),
bankAccountNumber: z.string().optional().nullable(), bankAccountNumber: z.string().optional().nullable(),
@@ -22,21 +28,21 @@ const updatePaymentOptionsSchema = z.object({
bankPhone: z.string().optional().nullable(), bankPhone: z.string().optional().nullable(),
bankNotes: z.string().optional().nullable(), bankNotes: z.string().optional().nullable(),
bankNotesEs: z.string().optional().nullable(), bankNotesEs: z.string().optional().nullable(),
lightningEnabled: z.boolean().optional(), lightningEnabled: booleanOrNumber.optional(),
cashEnabled: z.boolean().optional(), cashEnabled: booleanOrNumber.optional(),
cashInstructions: z.string().optional().nullable(), cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(),
// Booking settings // Booking settings
allowDuplicateBookings: z.boolean().optional(), allowDuplicateBookings: booleanOrNumber.optional(),
}); });
// Schema for event-level overrides // Schema for event-level overrides
const updateEventOverridesSchema = z.object({ const updateEventOverridesSchema = z.object({
tpagoEnabled: z.boolean().optional().nullable(), tpagoEnabled: booleanOrNumber.optional().nullable(),
tpagoLink: z.string().optional().nullable(), tpagoLink: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(), tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(), tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: z.boolean().optional().nullable(), bankTransferEnabled: booleanOrNumber.optional().nullable(),
bankName: z.string().optional().nullable(), bankName: z.string().optional().nullable(),
bankAccountHolder: z.string().optional().nullable(), bankAccountHolder: z.string().optional().nullable(),
bankAccountNumber: z.string().optional().nullable(), bankAccountNumber: z.string().optional().nullable(),
@@ -44,18 +50,17 @@ const updateEventOverridesSchema = z.object({
bankPhone: z.string().optional().nullable(), bankPhone: z.string().optional().nullable(),
bankNotes: z.string().optional().nullable(), bankNotes: z.string().optional().nullable(),
bankNotesEs: z.string().optional().nullable(), bankNotesEs: z.string().optional().nullable(),
lightningEnabled: z.boolean().optional().nullable(), lightningEnabled: booleanOrNumber.optional().nullable(),
cashEnabled: z.boolean().optional().nullable(), cashEnabled: booleanOrNumber.optional().nullable(),
cashInstructions: z.string().optional().nullable(), cashInstructions: z.string().optional().nullable(),
cashInstructionsEs: z.string().optional().nullable(), cashInstructionsEs: z.string().optional().nullable(),
}); });
// Get global payment options // Get global payment options
paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => { paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
const options = await (db as any) const options = await dbGet<any>(
.select() (db as any).select().from(paymentOptions)
.from(paymentOptions) );
.get();
// If no options exist yet, return defaults // If no options exist yet, return defaults
if (!options) { if (!options) {
@@ -92,17 +97,21 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
const now = getNow(); const now = getNow();
// Check if options exist // Check if options exist
const existing = await (db as any) const existing = await dbGet<any>(
(db as any)
.select() .select()
.from(paymentOptions) .from(paymentOptions)
.get(); );
// Convert boolean fields for database compatibility
const dbData = convertBooleansForDb(data);
if (existing) { if (existing) {
// Update existing // Update existing
await (db as any) await (db as any)
.update(paymentOptions) .update(paymentOptions)
.set({ .set({
...data, ...dbData,
updatedAt: now, updatedAt: now,
updatedBy: user.id, updatedBy: user.id,
}) })
@@ -112,16 +121,17 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
const id = generateId(); const id = generateId();
await (db as any).insert(paymentOptions).values({ await (db as any).insert(paymentOptions).values({
id, id,
...data, ...dbData,
updatedAt: now, updatedAt: now,
updatedBy: user.id, updatedBy: user.id,
}); });
} }
const updated = await (db as any) const updated = await dbGet(
(db as any)
.select() .select()
.from(paymentOptions) .from(paymentOptions)
.get(); );
return c.json({ paymentOptions: updated, message: 'Payment options updated successfully' }); return c.json({ paymentOptions: updated, message: 'Payment options updated successfully' });
}); });
@@ -131,28 +141,31 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
const eventId = c.req.param('eventId'); const eventId = c.req.param('eventId');
// Get the event first to verify it exists // Get the event first to verify it exists
const event = await (db as any) const event = await dbGet(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, eventId)) .where(eq((events as any).id, eventId))
.get(); );
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
// Get global options // Get global options
const globalOptions = await (db as any) const globalOptions = await dbGet<any>(
(db as any)
.select() .select()
.from(paymentOptions) .from(paymentOptions)
.get(); );
// Get event overrides // Get event overrides
const overrides = await (db as any) const overrides = await dbGet<any>(
(db as any)
.select() .select()
.from(eventPaymentOverrides) .from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId)) .where(eq((eventPaymentOverrides as any).eventId, eventId))
.get(); );
// Merge global with overrides (override takes precedence if not null) // Merge global with overrides (override takes precedence if not null)
const defaults = { const defaults = {
@@ -206,11 +219,9 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => { paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.param('eventId'); const eventId = c.req.param('eventId');
const overrides = await (db as any) const overrides = await dbGet<any>(
.select() (db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
.from(eventPaymentOverrides) );
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get();
return c.json({ overrides: overrides || null }); return c.json({ overrides: overrides || null });
}); });
@@ -222,28 +233,27 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org
const now = getNow(); const now = getNow();
// Verify event exists // Verify event exists
const event = await (db as any) const event = await dbGet<any>(
.select() (db as any).select().from(events).where(eq((events as any).id, eventId))
.from(events) );
.where(eq((events as any).id, eventId))
.get();
if (!event) { if (!event) {
return c.json({ error: 'Event not found' }, 404); return c.json({ error: 'Event not found' }, 404);
} }
// Check if overrides exist // Check if overrides exist
const existing = await (db as any) const existing = await dbGet<any>(
.select() (db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
.from(eventPaymentOverrides) );
.where(eq((eventPaymentOverrides as any).eventId, eventId))
.get(); // Convert boolean fields for database compatibility
const dbData = convertBooleansForDb(data);
if (existing) { if (existing) {
await (db as any) await (db as any)
.update(eventPaymentOverrides) .update(eventPaymentOverrides)
.set({ .set({
...data, ...dbData,
updatedAt: now, updatedAt: now,
}) })
.where(eq((eventPaymentOverrides as any).id, existing.id)); .where(eq((eventPaymentOverrides as any).id, existing.id));
@@ -252,17 +262,18 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org
await (db as any).insert(eventPaymentOverrides).values({ await (db as any).insert(eventPaymentOverrides).values({
id, id,
eventId, eventId,
...data, ...dbData,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
} }
const updated = await (db as any) const updated = await dbGet(
(db as any)
.select() .select()
.from(eventPaymentOverrides) .from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId)) .where(eq((eventPaymentOverrides as any).eventId, eventId))
.get(); );
return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' }); return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' });
}); });

View File

@@ -1,7 +1,7 @@
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, payments, tickets, events } from '../db/index.js'; import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
import { eq, desc, and, or, sql } from 'drizzle-orm'; import { eq, desc, and, or, sql } 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';
@@ -17,10 +17,12 @@ const updatePaymentSchema = z.object({
const approvePaymentSchema = z.object({ const approvePaymentSchema = z.object({
adminNote: z.string().optional(), adminNote: z.string().optional(),
sendEmail: z.boolean().optional().default(true),
}); });
const rejectPaymentSchema = z.object({ const rejectPaymentSchema = z.object({
adminNote: z.string().optional(), adminNote: z.string().optional(),
sendEmail: z.boolean().optional().default(true),
}); });
// Get all payments (admin) - with ticket and event details // Get all payments (admin) - with ticket and event details
@@ -30,11 +32,12 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const pendingApproval = c.req.query('pendingApproval'); const pendingApproval = c.req.query('pendingApproval');
// Get all payments with their associated tickets // Get all payments with their associated tickets
let allPayments = await (db as any) let allPayments = await dbAll<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.orderBy(desc((payments as any).createdAt)) .orderBy(desc((payments as any).createdAt))
.all(); );
// Filter by status // Filter by status
if (status) { if (status) {
@@ -54,25 +57,28 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
// Enrich with ticket and event data // Enrich with ticket and event data
const enrichedPayments = await Promise.all( const enrichedPayments = await Promise.all(
allPayments.map(async (payment: any) => { allPayments.map(async (payment: any) => {
const ticket = await (db as any) const ticket = await dbGet<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, payment.ticketId)) .where(eq((tickets as any).id, payment.ticketId))
.get(); );
let event = null; let event: any = null;
if (ticket) { if (ticket) {
event = await (db as any) event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
} }
return { return {
...payment, ...payment,
ticket: ticket ? { ticket: ticket ? {
id: ticket.id, id: ticket.id,
bookingId: ticket.bookingId,
attendeeFirstName: ticket.attendeeFirstName, attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName, attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
@@ -93,35 +99,39 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
// Get payments pending approval (admin dashboard view) // Get payments pending approval (admin dashboard view)
paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), async (c) => { paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), async (c) => {
const pendingPayments = await (db as any) const pendingPayments = await dbAll<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).status, 'pending_approval')) .where(eq((payments as any).status, 'pending_approval'))
.orderBy(desc((payments as any).userMarkedPaidAt)) .orderBy(desc((payments as any).userMarkedPaidAt))
.all(); );
// Enrich with ticket and event data // Enrich with ticket and event data
const enrichedPayments = await Promise.all( const enrichedPayments = await Promise.all(
pendingPayments.map(async (payment: any) => { pendingPayments.map(async (payment: any) => {
const ticket = await (db as any) const ticket = await dbGet<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, payment.ticketId)) .where(eq((tickets as any).id, payment.ticketId))
.get(); );
let event = null; let event: any = null;
if (ticket) { if (ticket) {
event = await (db as any) event = await dbGet<any>(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
} }
return { return {
...payment, ...payment,
ticket: ticket ? { ticket: ticket ? {
id: ticket.id, id: ticket.id,
bookingId: ticket.bookingId,
attendeeFirstName: ticket.attendeeFirstName, attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName, attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
@@ -144,22 +154,24 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => { paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); );
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
} }
// Get associated ticket // Get associated ticket
const ticket = await (db as any) const ticket = await dbGet(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).id, payment.ticketId)) .where(eq((tickets as any).id, payment.ticketId))
.get(); );
return c.json({ payment: { ...payment, ticket } }); return c.json({ payment: { ...payment, ticket } });
}); });
@@ -170,11 +182,12 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
const data = c.req.valid('json'); const data = c.req.valid('json');
const user = (c as any).get('user'); const user = (c as any).get('user');
const existing = await (db as any) const existing = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); );
if (!existing) { if (!existing) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -190,17 +203,42 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
updateData.paidByAdminId = user.id; updateData.paidByAdminId = user.id;
} }
// If payment confirmed, handle multi-ticket booking
if (data.status === 'paid') {
// Get the ticket associated with this payment
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, existing.ticketId))
);
// Check if this is part of a multi-ticket booking
let ticketsToConfirm: any[] = [ticket];
if (ticket?.bookingId) {
// Get all tickets in this booking
ticketsToConfirm = await dbAll(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
console.log(`[Payment] Confirming multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
}
// Update all payments and tickets in the booking
for (const t of ticketsToConfirm) {
await (db as any) await (db as any)
.update(payments) .update(payments)
.set(updateData) .set(updateData)
.where(eq((payments as any).id, id)); .where(eq((payments as any).ticketId, (t as any).id));
// If payment confirmed, update ticket status and send emails
if (data.status === 'paid') {
await (db as any) await (db as any)
.update(tickets) .update(tickets)
.set({ status: 'confirmed' }) .set({ status: 'confirmed' })
.where(eq((tickets as any).id, existing.ticketId)); .where(eq((tickets as any).id, (t as any).id));
}
// Send confirmation emails asynchronously (don't block the response) // Send confirmation emails asynchronously (don't block the response)
Promise.all([ Promise.all([
@@ -209,13 +247,20 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
]).catch(err => { ]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err); console.error('[Email] Failed to send confirmation emails:', err);
}); });
} else {
// For non-paid status updates, just update this payment
await (db as any)
.update(payments)
.set(updateData)
.where(eq((payments as any).id, id));
} }
const updated = await (db as any) const updated = await dbGet(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); );
return c.json({ payment: updated }); return c.json({ payment: updated });
}); });
@@ -223,14 +268,15 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
// Approve payment (admin) - specifically for pending_approval payments // Approve payment (admin) - specifically for pending_approval payments
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => { paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const { adminNote } = c.req.valid('json'); const { adminNote, sendEmail } = c.req.valid('json');
const user = (c as any).get('user'); const user = (c as any).get('user');
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); );
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -243,7 +289,30 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
const now = getNow(); const now = getNow();
// Update payment status to paid // Get the ticket associated with this payment
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
// Check if this is part of a multi-ticket booking
let ticketsToConfirm: any[] = [ticket];
if (ticket?.bookingId) {
// Get all tickets in this booking
ticketsToConfirm = await dbAll(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
}
// Update all payments in the booking to paid
for (const t of ticketsToConfirm) {
await (db as any) await (db as any)
.update(payments) .update(payments)
.set({ .set({
@@ -253,27 +322,33 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
adminNote: adminNote || payment.adminNote, adminNote: adminNote || payment.adminNote,
updatedAt: now, updatedAt: now,
}) })
.where(eq((payments as any).id, id)); .where(eq((payments as any).ticketId, (t as any).id));
// Update ticket status to confirmed // Update ticket status to confirmed
await (db as any) await (db as any)
.update(tickets) .update(tickets)
.set({ status: 'confirmed' }) .set({ status: 'confirmed' })
.where(eq((tickets as any).id, payment.ticketId)); .where(eq((tickets as any).id, (t as any).id));
}
// Send confirmation emails asynchronously // Send confirmation emails asynchronously (if sendEmail is true, which is the default)
if (sendEmail !== false) {
Promise.all([ Promise.all([
emailService.sendBookingConfirmation(payment.ticketId), emailService.sendBookingConfirmation(payment.ticketId),
emailService.sendPaymentReceipt(id), emailService.sendPaymentReceipt(id),
]).catch(err => { ]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err); console.error('[Email] Failed to send confirmation emails:', err);
}); });
} else {
console.log('[Payment] Skipping confirmation emails per admin request');
}
const updated = await (db as any) const updated = await dbGet(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); );
return c.json({ payment: updated, message: 'Payment approved successfully' }); return c.json({ payment: updated, message: 'Payment approved successfully' });
}); });
@@ -281,14 +356,15 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
// Reject payment (admin) // Reject payment (admin)
paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidator('json', rejectPaymentSchema), async (c) => { paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidator('json', rejectPaymentSchema), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const { adminNote } = c.req.valid('json'); const { adminNote, sendEmail } = c.req.valid('json');
const user = (c as any).get('user'); const user = (c as any).get('user');
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); );
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -320,33 +396,82 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
}) })
.where(eq((tickets as any).id, payment.ticketId)); .where(eq((tickets as any).id, payment.ticketId));
// Send rejection email asynchronously (for manual payment methods only) // Send rejection email asynchronously (for manual payment methods only, if sendEmail is true)
if (['bank_transfer', 'tpago'].includes(payment.provider)) { if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
emailService.sendPaymentRejectionEmail(id).catch(err => { emailService.sendPaymentRejectionEmail(id).catch(err => {
console.error('[Email] Failed to send payment rejection email:', err); console.error('[Email] Failed to send payment rejection email:', err);
}); });
} else if (sendEmail === false) {
console.log('[Payment] Skipping rejection email per admin request');
} }
const updated = await (db as any) const updated = await dbGet(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); );
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' }); return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
}); });
// Send payment reminder email
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
);
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
// Only allow sending reminders for pending payments
if (!['pending', 'pending_approval'].includes(payment.status)) {
return c.json({ error: 'Payment reminder can only be sent for pending payments' }, 400);
}
try {
const result = await emailService.sendPaymentReminder(id);
if (result.success) {
const now = getNow();
// Record when reminder was sent
await (db as any)
.update(payments)
.set({
reminderSentAt: now,
updatedAt: now,
})
.where(eq((payments as any).id, id));
return c.json({ message: 'Payment reminder sent successfully', reminderSentAt: now });
} else {
return c.json({ error: result.error || 'Failed to send payment reminder' }, 500);
}
} catch (err: any) {
console.error('[Payment] Failed to send payment reminder:', err);
return c.json({ error: 'Failed to send payment reminder' }, 500);
}
});
// Update admin note // Update admin note
paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c) => { paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const body = await c.req.json(); const body = await c.req.json();
const { adminNote } = body; const { adminNote } = body;
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); );
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -362,11 +487,12 @@ paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c)
}) })
.where(eq((payments as any).id, id)); .where(eq((payments as any).id, id));
const updated = await (db as any) const updated = await dbGet(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); );
return c.json({ payment: updated, message: 'Note updated' }); return c.json({ payment: updated, message: 'Note updated' });
}); });
@@ -375,11 +501,12 @@ paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c)
paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => { paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const payment = await (db as any) const payment = await dbGet<any>(
(db as any)
.select() .select()
.from(payments) .from(payments)
.where(eq((payments as any).id, id)) .where(eq((payments as any).id, id))
.get(); );
if (!payment) { if (!payment) {
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
@@ -426,7 +553,7 @@ paymentsRouter.post('/webhook', async (c) => {
// Get payment statistics (admin) // Get payment statistics (admin)
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const allPayments = await (db as any).select().from(payments).all(); const allPayments = await dbAll<any>((db as any).select().from(payments));
const stats = { const stats = {
total: allPayments.length, total: allPayments.length,
@@ -436,7 +563,7 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
failed: allPayments.filter((p: any) => p.status === 'failed').length, failed: allPayments.filter((p: any) => p.status === 'failed').length,
totalRevenue: allPayments totalRevenue: allPayments
.filter((p: any) => p.status === 'paid') .filter((p: any) => p.status === 'paid')
.reduce((sum: number, p: any) => sum + (p.amount || 0), 0), .reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0),
}; };
return c.json({ stats }); return c.json({ stats });

View File

@@ -1,10 +1,10 @@
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, siteSettings } from '../db/index.js'; import { db, dbGet, siteSettings, events } from '../db/index.js';
import { eq } 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 } from '../lib/utils.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -27,6 +27,7 @@ const updateSiteSettingsSchema = z.object({
instagramUrl: z.string().url().optional().nullable().or(z.literal('')), instagramUrl: z.string().url().optional().nullable().or(z.literal('')),
twitterUrl: z.string().url().optional().nullable().or(z.literal('')), twitterUrl: z.string().url().optional().nullable().or(z.literal('')),
linkedinUrl: z.string().url().optional().nullable().or(z.literal('')), linkedinUrl: z.string().url().optional().nullable().or(z.literal('')),
featuredEventId: z.string().optional().nullable(),
maintenanceMode: z.boolean().optional(), maintenanceMode: z.boolean().optional(),
maintenanceMessage: z.string().optional().nullable(), maintenanceMessage: z.string().optional().nullable(),
maintenanceMessageEs: z.string().optional().nullable(), maintenanceMessageEs: z.string().optional().nullable(),
@@ -34,7 +35,9 @@ const updateSiteSettingsSchema = z.object({
// Get site settings (public - needed for frontend timezone) // Get site settings (public - needed for frontend timezone)
siteSettingsRouter.get('/', async (c) => { siteSettingsRouter.get('/', async (c) => {
const settings = await (db as any).select().from(siteSettings).limit(1).get(); const settings = await dbGet(
(db as any).select().from(siteSettings).limit(1)
);
if (!settings) { if (!settings) {
// Return default settings if none exist // Return default settings if none exist
@@ -50,6 +53,7 @@ siteSettingsRouter.get('/', async (c) => {
instagramUrl: null, instagramUrl: null,
twitterUrl: null, twitterUrl: null,
linkedinUrl: null, linkedinUrl: null,
featuredEventId: null,
maintenanceMode: false, maintenanceMode: false,
maintenanceMessage: null, maintenanceMessage: null,
maintenanceMessageEs: null, maintenanceMessageEs: null,
@@ -95,11 +99,24 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
const now = getNow(); const now = getNow();
// Check if settings exist // Check if settings exist
const existing = await (db as any).select().from(siteSettings).limit(1).get(); const existing = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
if (!existing) { if (!existing) {
// Create new settings record // Create new settings record
const id = generateId(); const id = generateId();
// Validate featured event if provided
if (data.featuredEventId) {
const featuredEvent = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, data.featuredEventId))
);
if (!featuredEvent || featuredEvent.status !== 'published') {
return c.json({ error: 'Featured event must exist and be published' }, 400);
}
}
const newSettings = { const newSettings = {
id, id,
timezone: data.timezone || 'America/Asuncion', timezone: data.timezone || 'America/Asuncion',
@@ -112,7 +129,8 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
instagramUrl: data.instagramUrl || null, instagramUrl: data.instagramUrl || null,
twitterUrl: data.twitterUrl || null, twitterUrl: data.twitterUrl || null,
linkedinUrl: data.linkedinUrl || null, linkedinUrl: data.linkedinUrl || null,
maintenanceMode: data.maintenanceMode || false, featuredEventId: data.featuredEventId || null,
maintenanceMode: toDbBool(data.maintenanceMode || false),
maintenanceMessage: data.maintenanceMessage || null, maintenanceMessage: data.maintenanceMessage || null,
maintenanceMessageEs: data.maintenanceMessageEs || null, maintenanceMessageEs: data.maintenanceMessageEs || null,
updatedAt: now, updatedAt: now,
@@ -124,21 +142,94 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201); return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
} }
// Validate featured event if provided
if (data.featuredEventId) {
const featuredEvent = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, data.featuredEventId))
);
if (!featuredEvent || featuredEvent.status !== 'published') {
return c.json({ error: 'Featured event must exist and be published' }, 400);
}
}
// Update existing settings // Update existing settings
const updateData = { const updateData: Record<string, any> = {
...data, ...data,
updatedAt: now, updatedAt: now,
updatedBy: user.id, updatedBy: user.id,
}; };
// Convert maintenanceMode boolean to appropriate format for database
if (typeof data.maintenanceMode === 'boolean') {
updateData.maintenanceMode = toDbBool(data.maintenanceMode);
}
await (db as any) await (db as any)
.update(siteSettings) .update(siteSettings)
.set(updateData) .set(updateData)
.where(eq((siteSettings as any).id, existing.id)); .where(eq((siteSettings as any).id, existing.id));
const updated = await (db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id)).get(); const updated = await dbGet(
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
);
return c.json({ settings: updated, message: 'Settings updated successfully' }); return c.json({ settings: updated, message: 'Settings updated successfully' });
}); });
// Set featured event (admin only) - convenience endpoint for event editor
siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('json', z.object({
eventId: z.string().nullable(),
})), async (c) => {
const { eventId } = c.req.valid('json');
const user = c.get('user');
const now = getNow();
// Validate event if provided
if (eventId) {
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);
}
if (event.status !== 'published') {
return c.json({ error: 'Event must be published to be featured' }, 400);
}
}
// Get or create settings
const existing = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
if (!existing) {
// Create new settings record with featured event
const id = generateId();
const newSettings = {
id,
timezone: 'America/Asuncion',
siteName: 'Spanglish',
featuredEventId: eventId,
maintenanceMode: 0,
updatedAt: now,
updatedBy: user.id,
};
await (db as any).insert(siteSettings).values(newSettings);
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
}
// Update existing settings
await (db as any)
.update(siteSettings)
.set({
featuredEventId: eventId,
updatedAt: now,
updatedBy: user.id,
})
.where(eq((siteSettings as any).id, existing.id));
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
});
export default siteSettingsRouter; export default siteSettingsRouter;

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
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, users, tickets, events, payments } from '../db/index.js'; import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
import { eq, desc, sql } from 'drizzle-orm'; import { eq, desc, sql } 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';
@@ -17,9 +17,11 @@ const usersRouter = new Hono<{ Variables: { user: UserContext } }>();
const updateUserSchema = z.object({ const updateUserSchema = z.object({
name: z.string().min(2).optional(), name: z.string().min(2).optional(),
email: z.string().email().optional(),
phone: z.string().optional(), phone: z.string().optional(),
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(), role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
languagePreference: z.enum(['en', 'es']).optional(), languagePreference: z.enum(['en', 'es']).optional(),
accountStatus: z.enum(['active', 'unclaimed', 'suspended']).optional(),
}); });
// Get all users (admin only) // Get all users (admin only)
@@ -33,6 +35,9 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
phone: (users as any).phone, phone: (users as any).phone,
role: (users as any).role, role: (users as any).role,
languagePreference: (users as any).languagePreference, languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt, createdAt: (users as any).createdAt,
}).from(users); }).from(users);
@@ -40,7 +45,7 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
query = query.where(eq((users as any).role, role)); query = query.where(eq((users as any).role, role));
} }
const result = await query.orderBy(desc((users as any).createdAt)).all(); const result = await dbAll(query.orderBy(desc((users as any).createdAt)));
return c.json({ users: result }); return c.json({ users: result });
}); });
@@ -55,7 +60,8 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
return c.json({ error: 'Forbidden' }, 403); return c.json({ error: 'Forbidden' }, 403);
} }
const user = await (db as any) const user = await dbGet(
(db as any)
.select({ .select({
id: (users as any).id, id: (users as any).id,
email: (users as any).email, email: (users as any).email,
@@ -63,11 +69,14 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
phone: (users as any).phone, phone: (users as any).phone,
role: (users as any).role, role: (users as any).role,
languagePreference: (users as any).languagePreference, languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt, createdAt: (users as any).createdAt,
}) })
.from(users) .from(users)
.where(eq((users as any).id, id)) .where(eq((users as any).id, id))
.get(); );
if (!user) { if (!user) {
return c.json({ error: 'User not found' }, 404); return c.json({ error: 'User not found' }, 404);
@@ -87,12 +96,20 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
return c.json({ error: 'Forbidden' }, 403); return c.json({ error: 'Forbidden' }, 403);
} }
// Only admin can change roles // Only admin can change roles, email, and account status
if (data.role && currentUser.role !== 'admin') { if (data.role && currentUser.role !== 'admin') {
delete data.role; delete data.role;
} }
if (data.email && currentUser.role !== 'admin') {
delete data.email;
}
if (data.accountStatus && currentUser.role !== 'admin') {
delete data.accountStatus;
}
const existing = await (db as any).select().from(users).where(eq((users as any).id, id)).get(); const existing = await dbGet(
(db as any).select().from(users).where(eq((users as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'User not found' }, 404); return c.json({ error: 'User not found' }, 404);
} }
@@ -102,7 +119,8 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
.set({ ...data, updatedAt: getNow() }) .set({ ...data, updatedAt: getNow() })
.where(eq((users as any).id, id)); .where(eq((users as any).id, id));
const updated = await (db as any) const updated = await dbGet(
(db as any)
.select({ .select({
id: (users as any).id, id: (users as any).id,
email: (users as any).email, email: (users as any).email,
@@ -110,10 +128,14 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
phone: (users as any).phone, phone: (users as any).phone,
role: (users as any).role, role: (users as any).role,
languagePreference: (users as any).languagePreference, languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt,
}) })
.from(users) .from(users)
.where(eq((users as any).id, id)) .where(eq((users as any).id, id))
.get(); );
return c.json({ user: updated }); return c.json({ user: updated });
}); });
@@ -128,21 +150,23 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar
return c.json({ error: 'Forbidden' }, 403); return c.json({ error: 'Forbidden' }, 403);
} }
const userTickets = await (db as any) const userTickets = await dbAll<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).userId, id)) .where(eq((tickets as any).userId, id))
.orderBy(desc((tickets as any).createdAt)) .orderBy(desc((tickets as any).createdAt))
.all(); );
// Get event details for each ticket // Get event details for each ticket
const history = await Promise.all( const history = await Promise.all(
userTickets.map(async (ticket: any) => { userTickets.map(async (ticket: any) => {
const event = await (db as any) const event = await dbGet(
(db as any)
.select() .select()
.from(events) .from(events)
.where(eq((events as any).id, ticket.eventId)) .where(eq((events as any).id, ticket.eventId))
.get(); );
return { return {
...ticket, ...ticket,
@@ -164,7 +188,9 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
return c.json({ error: 'Cannot delete your own account' }, 400); return c.json({ error: 'Cannot delete your own account' }, 400);
} }
const existing = await (db as any).select().from(users).where(eq((users as any).id, id)).get(); const existing = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, id))
);
if (!existing) { if (!existing) {
return c.json({ error: 'User not found' }, 404); return c.json({ error: 'User not found' }, 404);
} }
@@ -176,20 +202,80 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
try { try {
// Get all tickets for this user // Get all tickets for this user
const userTickets = await (db as any) const userTickets = await dbAll<any>(
(db as any)
.select() .select()
.from(tickets) .from(tickets)
.where(eq((tickets as any).userId, id)) .where(eq((tickets as any).userId, id))
.all(); );
// Delete payments associated with user's tickets // Delete invoices associated with user's tickets (invoices reference payments which reference tickets)
for (const ticket of userTickets) { for (const ticket of userTickets) {
// Get payments for this ticket
const ticketPayments = await dbAll<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
);
// Delete invoices for each payment
for (const payment of ticketPayments) {
await (db as any).delete(invoices).where(eq((invoices as any).paymentId, payment.id));
}
// Delete payments for this ticket
await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id)); await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
} }
// Delete invoices directly associated with the user (if any)
await (db as any).delete(invoices).where(eq((invoices as any).userId, id));
// Delete user's tickets // Delete user's tickets
await (db as any).delete(tickets).where(eq((tickets as any).userId, id)); await (db as any).delete(tickets).where(eq((tickets as any).userId, id));
// Delete magic link tokens for the user
await (db as any).delete(magicLinkTokens).where(eq((magicLinkTokens as any).userId, id));
// Delete user sessions
await (db as any).delete(userSessions).where(eq((userSessions as any).userId, id));
// Set userId to null in audit_logs (nullable reference)
await (db as any)
.update(auditLogs)
.set({ userId: null })
.where(eq((auditLogs as any).userId, id));
// Set sentBy to null in email_logs (nullable reference)
await (db as any)
.update(emailLogs)
.set({ sentBy: null })
.where(eq((emailLogs as any).sentBy, id));
// Set updatedBy to null in payment_options (nullable reference)
await (db as any)
.update(paymentOptions)
.set({ updatedBy: null })
.where(eq((paymentOptions as any).updatedBy, id));
// Set updatedBy to null in legal_pages (nullable reference)
await (db as any)
.update(legalPages)
.set({ updatedBy: null })
.where(eq((legalPages as any).updatedBy, id));
// Set updatedBy to null in site_settings (nullable reference)
await (db as any)
.update(siteSettings)
.set({ updatedBy: null })
.where(eq((siteSettings as any).updatedBy, id));
// Clear checkedInByAdminId references in tickets
await (db as any)
.update(tickets)
.set({ checkedInByAdminId: null })
.where(eq((tickets as any).checkedInByAdminId, id));
// Delete the user // Delete the user
await (db as any).delete(users).where(eq((users as any).id, id)); await (db as any).delete(users).where(eq((users as any).id, id));
@@ -202,16 +288,18 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
// Get user statistics (admin) // Get user statistics (admin)
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const totalUsers = await (db as any) const totalUsers = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(users) .from(users)
.get(); );
const adminCount = await (db as any) const adminCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(users) .from(users)
.where(eq((users as any).role, 'admin')) .where(eq((users as any).role, 'admin'))
.get(); );
return c.json({ return c.json({
stats: { stats: {

View File

@@ -21,6 +21,10 @@ NEXT_PUBLIC_EMAIL=hola@spanglish.com.py
NEXT_PUBLIC_TELEGRAM=spanglish_py NEXT_PUBLIC_TELEGRAM=spanglish_py
NEXT_PUBLIC_TIKTOK=spanglishsocialpy NEXT_PUBLIC_TIKTOK=spanglishsocialpy
# Revalidation secret (shared between frontend and backend for on-demand cache revalidation)
# Must match the REVALIDATE_SECRET in backend/.env
REVALIDATE_SECRET=change-me-to-a-random-secret
# 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

View File

@@ -10,6 +10,10 @@
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.1.4", "@heroicons/react": "^2.1.4",
"@tiptap/extension-placeholder": "^3.18.0",
"@tiptap/pm": "^3.18.0",
"@tiptap/react": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"next": "^14.2.4", "next": "^14.2.4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -1,11 +1,12 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
@@ -25,9 +26,17 @@ import {
BuildingLibraryIcon, BuildingLibraryIcon,
ClockIcon, ClockIcon,
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
UserIcon,
ArrowDownTrayIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
// Attendee info for each ticket
interface AttendeeInfo {
firstName: string;
lastName: string;
}
type PaymentMethod = 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago'; type PaymentMethod = 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
interface BookingFormData { interface BookingFormData {
@@ -51,14 +60,19 @@ interface LightningInvoice {
interface BookingResult { interface BookingResult {
ticketId: string; ticketId: string;
ticketIds?: string[]; // For multi-ticket bookings
bookingId?: string;
qrCode: string; qrCode: string;
qrCodes?: string[]; // For multi-ticket bookings
paymentMethod: PaymentMethod; paymentMethod: PaymentMethod;
lightningInvoice?: LightningInvoice; lightningInvoice?: LightningInvoice;
ticketCount?: number;
} }
export default function BookingPage() { export default function BookingPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const { user } = useAuth(); const { user } = useAuth();
const [event, setEvent] = useState<Event | null>(null); const [event, setEvent] = useState<Event | null>(null);
@@ -70,6 +84,20 @@ export default function BookingPage() {
const [paymentPending, setPaymentPending] = useState(false); const [paymentPending, setPaymentPending] = useState(false);
const [markingPaid, setMarkingPaid] = useState(false); const [markingPaid, setMarkingPaid] = useState(false);
// State for payer name (when paid under different name)
const [paidUnderDifferentName, setPaidUnderDifferentName] = useState(false);
const [payerName, setPayerName] = useState('');
// Quantity from URL param (default 1)
const initialQuantity = Math.max(1, parseInt(searchParams.get('qty') || '1', 10));
const [ticketQuantity, setTicketQuantity] = useState(initialQuantity);
// Attendees for multi-ticket bookings (ticket 1 uses main formData)
const [attendees, setAttendees] = useState<AttendeeInfo[]>(() =>
Array(Math.max(0, initialQuantity - 1)).fill(null).map(() => ({ firstName: '', lastName: '' }))
);
const [attendeeErrors, setAttendeeErrors] = useState<{ [key: number]: string }>({});
const [formData, setFormData] = useState<BookingFormData>({ const [formData, setFormData] = useState<BookingFormData>({
firstName: '', firstName: '',
lastName: '', lastName: '',
@@ -82,43 +110,12 @@ export default function BookingPage() {
const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({});
// RUC validation using modulo 11 algorithm const rucPattern = /^\d{6,10}$/;
const validateRucCheckDigit = (ruc: string): boolean => {
const match = ruc.match(/^(\d{6,8})-(\d)$/);
if (!match) return false;
const baseNumber = match[1]; // Format RUC input: digits only, max 10
const checkDigit = parseInt(match[2], 10);
// Modulo 11 algorithm for Paraguayan RUC
const weights = [2, 3, 4, 5, 6, 7, 2, 3];
let sum = 0;
const digits = baseNumber.split('').reverse();
for (let i = 0; i < digits.length; i++) {
sum += parseInt(digits[i], 10) * weights[i];
}
const remainder = sum % 11;
const expectedCheckDigit = remainder < 2 ? 0 : 11 - remainder;
return checkDigit === expectedCheckDigit;
};
// Format RUC input: auto-insert hyphen before last digit
const formatRuc = (value: string): string => { const formatRuc = (value: string): string => {
// Remove non-numeric characters const digits = value.replace(/\D/g, '').slice(0, 10);
const digits = value.replace(/\D/g, ''); return digits;
// Limit to 9 digits (8 base + 1 check)
const limited = digits.slice(0, 9);
// Auto-insert hyphen before last digit if we have more than 6 digits
if (limited.length > 6) {
return `${limited.slice(0, -1)}-${limited.slice(-1)}`;
}
return limited;
}; };
// Handle RUC input change // Handle RUC input change
@@ -132,19 +129,12 @@ export default function BookingPage() {
} }
}; };
// Validate RUC on blur // Validate RUC on blur (optional field: 610 digits)
const handleRucBlur = () => { const handleRucBlur = () => {
if (!formData.ruc) return; // Optional field, no validation if empty if (!formData.ruc) return;
const digits = formData.ruc.replace(/\D/g, '');
const rucPattern = /^[0-9]{6,8}-[0-9]{1}$/; if (digits.length > 0 && !rucPattern.test(digits)) {
if (!rucPattern.test(formData.ruc)) {
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') }); setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
return;
}
if (!validateRucCheckDigit(formData.ruc)) {
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidCheckDigit') });
} }
}; };
@@ -167,7 +157,25 @@ export default function BookingPage() {
return; return;
} }
const bookedCount = eventRes.event.bookedCount ?? 0;
const capacity = eventRes.event.capacity ?? 0;
const soldOut = bookedCount >= capacity;
if (soldOut) {
toast.error(t('events.details.soldOut'));
router.push(`/events/${eventRes.event.id}`);
return;
}
const spotsLeft = Math.max(0, capacity - bookedCount);
setEvent(eventRes.event); setEvent(eventRes.event);
// Cap quantity by available spots (never allow requesting more than spotsLeft)
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
setAttendees((prev) => {
const newQty = Math.min(initialQuantity, Math.max(1, spotsLeft));
const need = Math.max(0, newQty - 1);
if (need === prev.length) return prev;
return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' });
});
setPaymentConfig(paymentRes.paymentOptions); setPaymentConfig(paymentRes.paymentOptions);
// Set default payment method based on what's enabled // Set default payment method based on what's enabled
@@ -209,24 +217,12 @@ export default function BookingPage() {
} }
}, [user]); }, [user]);
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const validateForm = (): boolean => { const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof BookingFormData, string>> = {}; const newErrors: Partial<Record<keyof BookingFormData, string>> = {};
const newAttendeeErrors: { [key: number]: string } = {};
if (!formData.firstName.trim() || formData.firstName.length < 2) { if (!formData.firstName.trim() || formData.firstName.length < 2) {
newErrors.firstName = t('booking.form.errors.firstNameRequired'); newErrors.firstName = t('booking.form.errors.firstNameRequired');
@@ -246,18 +242,26 @@ export default function BookingPage() {
newErrors.phone = t('booking.form.errors.phoneTooShort'); newErrors.phone = t('booking.form.errors.phoneTooShort');
} }
// RUC validation (optional field - only validate if filled) // RUC validation (optional field - 610 digits if filled)
if (formData.ruc.trim()) { if (formData.ruc.trim()) {
const rucPattern = /^[0-9]{6,8}-[0-9]{1}$/; const digits = formData.ruc.replace(/\D/g, '');
if (!rucPattern.test(formData.ruc)) { if (!/^\d{6,10}$/.test(digits)) {
newErrors.ruc = t('booking.form.errors.rucInvalidFormat'); newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
} else if (!validateRucCheckDigit(formData.ruc)) {
newErrors.ruc = t('booking.form.errors.rucInvalidCheckDigit');
} }
} }
// Validate additional attendees (if multi-ticket)
attendees.forEach((attendee, index) => {
if (!attendee.firstName.trim() || attendee.firstName.length < 2) {
newAttendeeErrors[index] = locale === 'es'
? 'Ingresa el nombre del asistente'
: 'Enter attendee name';
}
});
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; setAttendeeErrors(newAttendeeErrors);
return Object.keys(newErrors).length === 0 && Object.keys(newAttendeeErrors).length === 0;
}; };
// Connect to SSE for real-time payment updates // Connect to SSE for real-time payment updates
@@ -345,9 +349,20 @@ export default function BookingPage() {
const handleMarkPaymentSent = async () => { const handleMarkPaymentSent = async () => {
if (!bookingResult) return; if (!bookingResult) return;
// Validate payer name if paid under different name
if (paidUnderDifferentName && !payerName.trim()) {
toast.error(locale === 'es'
? 'Por favor ingresa el nombre del pagador'
: 'Please enter the payer name');
return;
}
setMarkingPaid(true); setMarkingPaid(true);
try { try {
await ticketsApi.markPaymentSent(bookingResult.ticketId); await ticketsApi.markPaymentSent(
bookingResult.ticketId,
paidUnderDifferentName ? payerName.trim() : undefined
);
setStep('pending_approval'); setStep('pending_approval');
toast.success(locale === 'es' toast.success(locale === 'es'
? 'Pago marcado como enviado. Esperando aprobación.' ? 'Pago marcado como enviado. Esperando aprobación.'
@@ -365,6 +380,12 @@ export default function BookingPage() {
setSubmitting(true); setSubmitting(true);
try { try {
// Build attendees array: first attendee from main form, rest from attendees state
const allAttendees = [
{ firstName: formData.firstName, lastName: formData.lastName },
...attendees
];
const response = await ticketsApi.book({ const response = await ticketsApi.book({
eventId: event.id, eventId: event.id,
firstName: formData.firstName, firstName: formData.firstName,
@@ -373,17 +394,25 @@ export default function BookingPage() {
phone: formData.phone, phone: formData.phone,
preferredLanguage: formData.preferredLanguage, preferredLanguage: formData.preferredLanguage,
paymentMethod: formData.paymentMethod, paymentMethod: formData.paymentMethod,
...(formData.ruc.trim() && { ruc: formData.ruc }), ...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
// Include attendees array for multi-ticket bookings
...(allAttendees.length > 1 && { attendees: allAttendees }),
}); });
const { ticket, lightningInvoice } = response as any; const { ticket, tickets: ticketsList, bookingId, lightningInvoice } = response as any;
const ticketCount = ticketsList?.length || 1;
const primaryTicket = ticket || ticketsList?.[0];
// If Lightning payment with invoice, go to paying step // If Lightning payment with invoice, go to paying step
if (formData.paymentMethod === 'lightning' && lightningInvoice?.paymentRequest) { if (formData.paymentMethod === 'lightning' && lightningInvoice?.paymentRequest) {
const result: BookingResult = { const result: BookingResult = {
ticketId: ticket.id, ticketId: primaryTicket.id,
qrCode: ticket.qrCode, ticketIds: ticketsList?.map((t: any) => t.id),
bookingId,
qrCode: primaryTicket.qrCode,
qrCodes: ticketsList?.map((t: any) => t.qrCode),
paymentMethod: formData.paymentMethod as PaymentMethod, paymentMethod: formData.paymentMethod as PaymentMethod,
ticketCount,
lightningInvoice: { lightningInvoice: {
paymentHash: lightningInvoice.paymentHash, paymentHash: lightningInvoice.paymentHash,
paymentRequest: lightningInvoice.paymentRequest, paymentRequest: lightningInvoice.paymentRequest,
@@ -398,21 +427,29 @@ export default function BookingPage() {
setPaymentPending(true); setPaymentPending(true);
// Connect to SSE for real-time payment updates // Connect to SSE for real-time payment updates
connectPaymentStream(ticket.id); connectPaymentStream(primaryTicket.id);
} else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') { } else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') {
// Manual payment methods - show payment details // Manual payment methods - show payment details
setBookingResult({ setBookingResult({
ticketId: ticket.id, ticketId: primaryTicket.id,
qrCode: ticket.qrCode, ticketIds: ticketsList?.map((t: any) => t.id),
bookingId,
qrCode: primaryTicket.qrCode,
qrCodes: ticketsList?.map((t: any) => t.qrCode),
paymentMethod: formData.paymentMethod, paymentMethod: formData.paymentMethod,
ticketCount,
}); });
setStep('manual_payment'); setStep('manual_payment');
} else { } else {
// Cash payment - go straight to success // Cash payment - go straight to success
setBookingResult({ setBookingResult({
ticketId: ticket.id, ticketId: primaryTicket.id,
qrCode: ticket.qrCode, ticketIds: ticketsList?.map((t: any) => t.id),
bookingId,
qrCode: primaryTicket.qrCode,
qrCodes: ticketsList?.map((t: any) => t.qrCode),
paymentMethod: formData.paymentMethod, paymentMethod: formData.paymentMethod,
ticketCount,
}); });
setStep('success'); setStep('success');
toast.success(t('booking.success.message')); toast.success(t('booking.success.message'));
@@ -441,8 +478,8 @@ export default function BookingPage() {
paymentMethods.push({ paymentMethods.push({
id: 'tpago', id: 'tpago',
icon: CreditCardIcon, icon: CreditCardIcon,
label: locale === 'es' ? 'TPago / Tarjeta Internacional' : 'TPago / International Card', label: locale === 'es' ? 'TPago / Tarjetas de Crédito' : 'TPago / Credit Cards',
description: locale === 'es' ? 'Paga con tarjeta de crédito o débito' : 'Pay with credit or debit card', description: locale === 'es' ? 'Pagá con tarjetas de crédito locales o internacionales' : 'Pay with local or international credit cards',
badge: locale === 'es' ? 'Manual' : 'Manual', badge: locale === 'es' ? 'Manual' : 'Manual',
}); });
} }
@@ -451,8 +488,8 @@ export default function BookingPage() {
paymentMethods.push({ paymentMethods.push({
id: 'bank_transfer', id: 'bank_transfer',
icon: BuildingLibraryIcon, icon: BuildingLibraryIcon,
label: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer', label: locale === 'es' ? 'Transferencia Bancaria Local' : 'Local Bank Transfer',
description: locale === 'es' ? 'Transferencia bancaria local' : 'Local bank transfer', description: locale === 'es' ? 'Pago por transferencia bancaria en Paraguay' : 'Pay via Paraguayan bank transfer',
badge: locale === 'es' ? 'Manual' : 'Manual', badge: locale === 'es' ? 'Manual' : 'Manual',
}); });
} }
@@ -481,7 +518,8 @@ export default function BookingPage() {
return null; return null;
} }
const isSoldOut = event.availableSeats === 0; const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
// Get title and description based on payment method // Get title and description based on payment method
const getSuccessContent = () => { const getSuccessContent = () => {
@@ -591,6 +629,8 @@ export default function BookingPage() {
if (step === 'manual_payment' && bookingResult && paymentConfig) { if (step === 'manual_payment' && bookingResult && paymentConfig) {
const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer'; const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer';
const isTpago = bookingResult.paymentMethod === 'tpago'; const isTpago = bookingResult.paymentMethod === 'tpago';
const ticketCount = bookingResult.ticketCount || 1;
const totalAmount = (event?.price || 0) * ticketCount;
return ( return (
<div className="section-padding"> <div className="section-padding">
@@ -620,8 +660,13 @@ export default function BookingPage() {
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'} {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
</p> </p>
<p className="text-2xl font-bold text-primary-dark"> <p className="text-2xl font-bold text-primary-dark">
{event?.price?.toLocaleString()} {event?.currency} {event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''}
</p> </p>
{ticketCount > 1 && (
<p className="text-sm text-gray-500 mt-1">
{ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')}
</p>
)}
</div> </div>
{/* Bank Transfer Details */} {/* Bank Transfer Details */}
@@ -724,6 +769,45 @@ export default function BookingPage() {
</div> </div>
</div> </div>
{/* Paid under different name option */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={paidUnderDifferentName}
onChange={(e) => {
setPaidUnderDifferentName(e.target.checked);
if (!e.target.checked) setPayerName('');
}}
className="mt-1 w-4 h-4 text-primary-yellow border-gray-300 rounded focus:ring-primary-yellow"
/>
<div>
<span className="font-medium text-gray-700">
{locale === 'es'
? 'El pago está a nombre de otra persona'
: 'The payment is under another person\'s name'}
</span>
<p className="text-xs text-gray-500 mt-1">
{locale === 'es'
? 'Marcá esta opción si el pago fue realizado por un familiar o tercero.'
: 'Check this option if the payment was made by a family member or a third party.'}
</p>
</div>
</label>
{paidUnderDifferentName && (
<div className="mt-3 pl-7">
<Input
label={locale === 'es' ? 'Nombre del pagador' : 'Payer name'}
value={payerName}
onChange={(e) => setPayerName(e.target.value)}
placeholder={locale === 'es' ? 'Nombre completo del titular de la cuenta' : 'Full name of account holder'}
required
/>
</div>
)}
</div>
{/* Warning before I Have Paid button */} {/* Warning before I Have Paid button */}
<p className="text-sm text-center text-amber-700 font-medium mb-3"> <p className="text-sm text-center text-amber-700 font-medium mb-3">
{locale === 'es' {locale === 'es'
@@ -737,6 +821,7 @@ export default function BookingPage() {
isLoading={markingPaid} isLoading={markingPaid}
size="lg" size="lg"
className="w-full" className="w-full"
disabled={paidUnderDifferentName && !payerName.trim()}
> >
<CheckCircleIcon className="w-5 h-5 mr-2" /> <CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'} {locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'}
@@ -781,7 +866,7 @@ export default function BookingPage() {
<div className="text-sm text-gray-600 space-y-2"> <div className="text-sm text-gray-600 space-y-2">
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p> <p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p> <p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {event && formatTime(event.startDatetime)}</p> <p><strong>{t('booking.success.time')}:</strong> {event && fmtTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p> <p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
</div> </div>
</div> </div>
@@ -828,15 +913,36 @@ export default function BookingPage() {
</p> </p>
<div className="bg-secondary-gray rounded-lg p-6 mb-6"> <div className="bg-secondary-gray rounded-lg p-6 mb-6">
{/* Multi-ticket indicator */}
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
<div className="mb-4 pb-4 border-b border-gray-300">
<p className="text-lg font-semibold text-primary-dark">
{locale === 'es'
? `${bookingResult.ticketCount} tickets reservados`
: `${bookingResult.ticketCount} tickets booked`}
</p>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Cada asistente recibirá su propio código QR'
: 'Each attendee will receive their own QR code'}
</p>
</div>
)}
<div className="flex items-center justify-center gap-2 mb-4"> <div className="flex items-center justify-center gap-2 mb-4">
<TicketIcon className="w-6 h-6 text-primary-yellow" /> <TicketIcon className="w-6 h-6 text-primary-yellow" />
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span> <span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full">
+{bookingResult.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}
</span>
)}
</div> </div>
<div className="text-sm text-gray-600 space-y-2"> <div className="text-sm text-gray-600 space-y-2">
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p> <p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p> <p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {formatTime(event.startDatetime)}</p> <p><strong>{t('booking.success.time')}:</strong> {fmtTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p> <p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
</div> </div>
</div> </div>
@@ -872,6 +978,25 @@ export default function BookingPage() {
{t('booking.success.emailSent')} {t('booking.success.emailSent')}
</p> </p>
{/* Download Ticket Button - only for instant confirmation (Lightning) */}
{bookingResult.paymentMethod === 'lightning' && (
<div className="mb-6">
<a
href={bookingResult.bookingId
? `/api/tickets/booking/${bookingResult.bookingId}/pdf`
: `/api/tickets/${bookingResult.ticketId}/pdf`
}
download
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
<ArrowDownTrayIcon className="w-5 h-5" />
{locale === 'es'
? (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Descargar Tickets' : 'Descargar Ticket')
: (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Download Tickets' : 'Download Ticket')}
</a>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center"> <div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href="/events"> <Link href="/events">
<Button variant="outline">{t('booking.success.browseEvents')}</Button> <Button variant="outline">{t('booking.success.browseEvents')}</Button>
@@ -907,7 +1032,7 @@ export default function BookingPage() {
<div className="p-4 space-y-2 text-sm"> <div className="p-4 space-y-2 text-sm">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CalendarIcon className="w-5 h-5 text-primary-yellow" /> <CalendarIcon className="w-5 h-5 text-primary-yellow" />
<span>{formatDate(event.startDatetime)} {formatTime(event.startDatetime)}</span> <span>{formatDate(event.startDatetime)} {fmtTime(event.startDatetime)}</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<MapPinIcon className="w-5 h-5 text-primary-yellow" /> <MapPinIcon className="w-5 h-5 text-primary-yellow" />
@@ -916,7 +1041,7 @@ export default function BookingPage() {
{!event.externalBookingEnabled && ( {!event.externalBookingEnabled && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<UserGroupIcon className="w-5 h-5 text-primary-yellow" /> <UserGroupIcon className="w-5 h-5 text-primary-yellow" />
<span>{event.availableSeats} {t('events.details.spotsLeft')}</span> <span>{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}</span>
</div> </div>
)} )}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -924,10 +1049,28 @@ export default function BookingPage() {
<span className="font-bold text-lg"> <span className="font-bold text-lg">
{event.price === 0 {event.price === 0
? t('events.details.free') ? t('events.details.free')
: `${event.price.toLocaleString()} ${event.currency}`} : formatPrice(event.price, event.currency)}
</span>
{event.price > 0 && (
<span className="text-gray-400 text-sm">
{locale === 'es' ? 'por persona' : 'per person'}
</span>
)}
</div>
{/* Ticket quantity and total */}
{ticketQuantity > 1 && (
<div className="mt-3 pt-3 border-t border-secondary-light-gray">
<div className="flex items-center justify-between">
<span className="text-gray-600">
{locale === 'es' ? 'Tickets' : 'Tickets'}: <span className="font-semibold">{ticketQuantity}</span>
</span>
<span className="font-bold text-lg text-primary-dark">
{locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)}
</span> </span>
</div> </div>
</div> </div>
)}
</div>
</Card> </Card>
{isSoldOut ? ( {isSoldOut ? (
@@ -940,8 +1083,18 @@ export default function BookingPage() {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{/* User Information Section */} {/* User Information Section */}
<Card className="mb-6 p-6"> <Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark"> <h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
{attendees.length > 0 && (
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
1
</span>
)}
{t('booking.form.personalInfo')} {t('booking.form.personalInfo')}
{attendees.length > 0 && (
<span className="text-sm font-normal text-gray-500">
({locale === 'es' ? 'Asistente principal' : 'Primary attendee'})
</span>
)}
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
@@ -1039,6 +1192,74 @@ export default function BookingPage() {
</div> </div>
</Card> </Card>
{/* Additional Attendees Section (for multi-ticket bookings) */}
{attendees.length > 0 && (
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
<UserIcon className="w-5 h-5 text-primary-yellow" />
{locale === 'es' ? 'Información de los Otros Asistentes' : 'Other Attendees Information'}
</h3>
<p className="text-sm text-gray-600 mb-4">
{locale === 'es'
? 'Ingresa el nombre de cada asistente adicional. Cada persona recibirá su propio ticket.'
: 'Enter the name for each additional attendee. Each person will receive their own ticket.'}
</p>
<div className="space-y-4">
{attendees.map((attendee, index) => (
<div key={index} className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 mb-3">
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
{index + 2}
</span>
<span className="font-medium text-gray-700">
{locale === 'es' ? `Asistente ${index + 2}` : `Attendee ${index + 2}`}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label={t('booking.form.firstName')}
value={attendee.firstName}
onChange={(e) => {
const newAttendees = [...attendees];
newAttendees[index].firstName = e.target.value;
setAttendees(newAttendees);
if (attendeeErrors[index]) {
const newErrors = { ...attendeeErrors };
delete newErrors[index];
setAttendeeErrors(newErrors);
}
}}
placeholder={t('booking.form.firstNamePlaceholder')}
error={attendeeErrors[index]}
required
/>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.lastName')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
value={attendee.lastName}
onChange={(e) => {
const newAttendees = [...attendees];
newAttendees[index].lastName = e.target.value;
setAttendees(newAttendees);
}}
placeholder={t('booking.form.lastNamePlaceholder')}
/>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{/* Payment Selection Section */} {/* Payment Selection Section */}
<Card className="mb-6 p-6"> <Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark"> <h3 className="font-bold text-lg mb-4 text-primary-dark">
@@ -1097,45 +1318,6 @@ export default function BookingPage() {
</button> </button>
))} ))}
{/* Manual payment instructions - shown when TPago or Bank Transfer is selected */}
{(formData.paymentMethod === 'tpago' || formData.paymentMethod === 'bank_transfer') && (
<div className="mt-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<div className="text-sm text-amber-800">
<p className="font-medium mb-1">
{locale === 'es' ? 'Proceso de pago manual' : 'Manual payment process'}
</p>
<ol className="list-decimal list-inside space-y-1 text-amber-700">
<li>
{locale === 'es'
? 'Por favor completa el pago primero.'
: 'Please complete the payment first.'}
</li>
<li>
{locale === 'es'
? 'Después de pagar, haz clic en "Ya pagué" para notificarnos.'
: 'After you have paid, click "I have paid" to notify us.'}
</li>
<li>
{locale === 'es'
? 'Nuestro equipo verificará el pago manualmente.'
: 'Our team will manually verify the payment.'}
</li>
<li>
{locale === 'es'
? 'Una vez aprobado, recibirás un email confirmando tu reserva.'
: 'Once approved, you will receive an email confirming your booking.'}
</li>
</ol>
</div>
</div>
</div>
)}
</> </>
)} )}
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api'; import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { import {
@@ -151,21 +152,8 @@ export default function BookingPaymentPage() {
} }
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
// Loading state // Loading state
if (step === 'loading') { if (step === 'loading') {
@@ -236,7 +224,7 @@ export default function BookingPaymentPage() {
<div className="text-sm text-gray-600 space-y-2"> <div className="text-sm text-gray-600 space-y-2">
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p> <p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p> <p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p> <p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p> <p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
</div> </div>
)} )}
@@ -285,7 +273,7 @@ export default function BookingPaymentPage() {
<div className="text-sm text-gray-600 space-y-2"> <div className="text-sm text-gray-600 space-y-2">
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p> <p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p> <p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p> <p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p> <p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
</div> </div>
)} )}
@@ -332,7 +320,7 @@ export default function BookingPaymentPage() {
<div className="p-4 space-y-2 text-sm"> <div className="p-4 space-y-2 text-sm">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CalendarIcon className="w-5 h-5 text-primary-yellow" /> <CalendarIcon className="w-5 h-5 text-primary-yellow" />
<span>{formatDate(ticket.event.startDatetime)} - {formatTime(ticket.event.startDatetime)}</span> <span>{formatDate(ticket.event.startDatetime)} - {fmtTime(ticket.event.startDatetime)}</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<MapPinIcon className="w-5 h-5 text-primary-yellow" /> <MapPinIcon className="w-5 h-5 text-primary-yellow" />
@@ -341,7 +329,7 @@ export default function BookingPaymentPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" /> <CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
<span className="font-bold text-lg"> <span className="font-bold text-lg">
{ticket.event.price?.toLocaleString()} {ticket.event.currency} {ticket.event.price !== undefined ? formatPrice(ticket.event.price, ticket.event.currency) : ''}
</span> </span>
</div> </div>
</div> </div>
@@ -374,7 +362,7 @@ export default function BookingPaymentPage() {
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'} {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
</p> </p>
<p className="text-2xl font-bold text-primary-dark"> <p className="text-2xl font-bold text-primary-dark">
{ticket.event?.price?.toLocaleString()} {ticket.event?.currency} {ticket.event?.price !== undefined ? formatPrice(ticket.event.price, ticket.event.currency) : ''}
</p> </p>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, Ticket } from '@/lib/api'; import { ticketsApi, Ticket } from '@/lib/api';
import { formatDateLong, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { import {
@@ -69,21 +70,8 @@ export default function BookingSuccessPage() {
}; };
}, [ticketId]); }, [ticketId]);
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) { if (loading) {
return ( return (
@@ -191,7 +179,7 @@ export default function BookingSuccessPage() {
<> <>
<p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p> <p><strong>{locale === 'es' ? 'Evento' : 'Event'}:</strong> {ticket.event.title}</p>
<p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p> <p><strong>{locale === 'es' ? 'Fecha' : 'Date'}:</strong> {formatDate(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {formatTime(ticket.event.startDatetime)}</p> <p><strong>{locale === 'es' ? 'Hora' : 'Time'}:</strong> {fmtTime(ticket.event.startDatetime)}</p>
<p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p> <p><strong>{locale === 'es' ? 'Ubicación' : 'Location'}:</strong> {ticket.event.location}</p>
</> </>
)} )}
@@ -229,12 +217,15 @@ export default function BookingSuccessPage() {
{isPaid && ( {isPaid && (
<div className="mb-6"> <div className="mb-6">
<a <a
href={`/api/tickets/${ticketId}/pdf`} href={ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${ticketId}/pdf`
}
download download
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
> >
<ArrowDownTrayIcon className="w-5 h-5" /> <ArrowDownTrayIcon className="w-5 h-5" />
{locale === 'es' ? 'Descargar Ticket' : 'Download Ticket'} {locale === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
</a> </a>
</div> </div>
)} )}

View File

@@ -0,0 +1,82 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { faqApi, FaqItem } from '@/lib/api';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import clsx from 'clsx';
export default function HomepageFaqSection() {
const { t, locale } = useLanguage();
const [faqs, setFaqs] = useState<FaqItem[]>([]);
const [loading, setLoading] = useState(true);
const [openIndex, setOpenIndex] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
faqApi.getList(true).then((res) => {
if (!cancelled) setFaqs(res.faqs);
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
if (loading || faqs.length === 0) {
return null;
}
return (
<section className="section-padding bg-secondary-gray" aria-labelledby="homepage-faq-title">
<div className="container-page">
<div className="max-w-2xl mx-auto">
<h2 id="homepage-faq-title" className="text-2xl md:text-3xl font-bold text-primary-dark text-center mb-8">
{t('home.faq.title')}
</h2>
<div className="space-y-3">
{faqs.map((faq, index) => (
<div
key={faq.id}
className="bg-white rounded-btn border border-gray-200 overflow-hidden"
>
<button
onClick={() => setOpenIndex(openIndex === index ? null : index)}
className="w-full px-5 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-primary-dark pr-4 text-sm md:text-base">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</span>
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
openIndex === index && 'transform rotate-180'
)}
/>
</button>
<div
className={clsx(
'overflow-hidden transition-all duration-200',
openIndex === index ? 'max-h-80' : 'max-h-0'
)}
>
<div className="px-5 pb-4 text-gray-600 text-sm md:text-base">
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
</div>
</div>
</div>
))}
</div>
<div className="mt-8 text-center">
<Link
href="/faq"
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
{t('home.faq.seeFull')}
</Link>
</div>
</div>
</div>
</section>
);
}

View File

@@ -4,37 +4,31 @@ 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 { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline'; import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
export default function NextEventSection() { interface NextEventSectionProps {
initialEvent?: Event | null;
}
export default function NextEventSection({ initialEvent }: NextEventSectionProps) {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [nextEvent, setNextEvent] = useState<Event | null>(null); const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(!initialEvent);
useEffect(() => { useEffect(() => {
// Skip fetch if we already have server-provided data
if (initialEvent !== undefined) return;
eventsApi.getNextUpcoming() eventsApi.getNextUpcoming()
.then(({ event }) => setNextEvent(event)) .then(({ event }) => setNextEvent(event))
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, [initialEvent]);
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) { if (loading) {
return ( return (
@@ -77,7 +71,7 @@ export default function NextEventSection() {
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold"> <span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
</span> </span>
<span>{formatTime(nextEvent.startDatetime)}</span> <span>{fmtTime(nextEvent.startDatetime)}</span>
</div> </div>
<div className="flex items-center gap-3 text-gray-700"> <div className="flex items-center gap-3 text-gray-700">
<MapPinIcon className="w-5 h-5 text-primary-yellow" /> <MapPinIcon className="w-5 h-5 text-primary-yellow" />
@@ -91,7 +85,7 @@ export default function NextEventSection() {
<span className="text-3xl font-bold text-primary-dark"> <span className="text-3xl font-bold text-primary-dark">
{nextEvent.price === 0 {nextEvent.price === 0
? t('events.details.free') ? t('events.details.free')
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`} : formatPrice(nextEvent.price, nextEvent.currency)}
</span> </span>
{!nextEvent.externalBookingEnabled && ( {!nextEvent.externalBookingEnabled && (
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">

View File

@@ -2,8 +2,13 @@
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import NextEventSection from './NextEventSection'; import NextEventSection from './NextEventSection';
import { Event } from '@/lib/api';
export default function NextEventSectionWrapper() { interface NextEventSectionWrapperProps {
initialEvent?: Event | null;
}
export default function NextEventSectionWrapper({ initialEvent }: NextEventSectionWrapperProps) {
const { t } = useLanguage(); const { t } = useLanguage();
return ( return (
@@ -13,7 +18,7 @@ export default function NextEventSectionWrapper() {
{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-3xl mx-auto">
<NextEventSection /> <NextEventSection initialEvent={initialEvent} />
</div> </div>
</div> </div>
</section> </section>

View File

@@ -25,6 +25,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
timeZone: 'America/Asuncion',
}); });
}; };

View File

@@ -118,7 +118,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
{profile?.memberSince {profile?.memberSince
? new Date(profile.memberSince).toLocaleDateString( ? new Date(profile.memberSince).toLocaleDateString(
language === 'es' ? 'es-ES' : 'en-US', language === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric' } { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
) )
: '-'} : '-'}
</span> </span>

View File

@@ -153,6 +153,7 @@ export default function SecurityTab() {
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: 'America/Asuncion',
}); });
}; };

View File

@@ -30,6 +30,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
timeZone: 'America/Asuncion',
}); });
}; };
@@ -170,6 +171,20 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
{language === 'es' ? 'Ver Entrada' : 'View Ticket'} {language === 'es' ? 'Ver Entrada' : 'View Ticket'}
</Button> </Button>
</Link> </Link>
{(ticket.status === 'confirmed' || ticket.status === 'checked_in') && (
<a
href={ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${ticket.id}/pdf`
}
download
className="text-center"
>
<Button variant="outline" size="sm" className="w-full">
{language === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
</Button>
</a>
)}
{ticket.invoice && ( {ticket.invoice && (
<a <a
href={ticket.invoice.pdfUrl || '#'} href={ticket.invoice.pdfUrl || '#'}

View File

@@ -7,6 +7,7 @@ import { useAuth } from '@/context/AuthContext';
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 { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api'; import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
import { formatDateLong, formatTime } from '@/lib/utils';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Link from 'next/link'; import Link from 'next/link';
import { import {
@@ -85,21 +86,8 @@ export default function DashboardPage() {
); );
} }
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es');
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es');
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(language === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
return ( return (
<div className="section-padding min-h-[70vh]"> <div className="section-padding min-h-[70vh]">

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import ShareButtons from '@/components/ShareButtons'; import ShareButtons from '@/components/ShareButtons';
@@ -13,6 +14,8 @@ import {
MapPinIcon, MapPinIcon,
UserGroupIcon, UserGroupIcon,
ArrowLeftIcon, ArrowLeftIcon,
MinusIcon,
PlusIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
interface EventDetailClientProps { interface EventDetailClientProps {
@@ -24,6 +27,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [event, setEvent] = useState<Event>(initialEvent); const [event, setEvent] = useState<Event>(initialEvent);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [ticketQuantity, setTicketQuantity] = useState(1);
// Ensure consistent hydration by only rendering dynamic content after mount // Ensure consistent hydration by only rendering dynamic content after mount
useEffect(() => { useEffect(() => {
@@ -37,159 +41,77 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
.catch(console.error); .catch(console.error);
}, [eventId]); }, [eventId]);
const formatDate = (dateStr: string) => { // Spots left: never negative; sold out when confirmed >= capacity
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
weekday: 'long', const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
year: 'numeric', const maxTickets = isSoldOut ? 0 : Math.max(1, spotsLeft);
month: 'long',
day: 'numeric', const decreaseQuantity = () => {
}); setTicketQuantity(prev => Math.max(1, prev - 1));
}; };
const formatTime = (dateStr: string) => { const increaseQuantity = () => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { setTicketQuantity(prev => Math.min(maxTickets, prev + 1));
hour: '2-digit',
minute: '2-digit',
});
}; };
const isSoldOut = event.availableSeats === 0; const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
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';
return ( // Booking card content - reused for mobile and desktop positions
<div className="section-padding"> const BookingCardContent = () => (
<div className="container-page"> <>
<Link <div className="text-center mb-4">
href="/events"
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
>
<ArrowLeftIcon className="w-4 h-4" />
{t('common.back')}
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Event Details */}
<div className="lg:col-span-2">
<Card className="overflow-hidden">
{/* Banner - LCP element, loaded with high priority */}
{/* Using unoptimized for backend-served images via /uploads/ rewrite */}
{event.bannerUrl ? (
<div className="relative h-64 w-full">
<Image
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
fill
className="object-cover"
sizes="(max-width: 1024px) 100vw, 66vw"
priority
unoptimized
/>
</div>
) : (
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
</div>
)}
<div className="p-8">
<div className="flex items-start justify-between gap-4">
<h1 className="text-3xl font-bold text-primary-dark" suppressHydrationWarning>
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h1>
{isCancelled && (
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
)}
{isSoldOut && !isCancelled && (
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
)}
</div>
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="flex items-start gap-3">
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.date')}</p>
<p className="text-gray-600" suppressHydrationWarning>{formatDate(event.startDatetime)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl"></span>
<div>
<p className="font-medium">{t('events.details.time')}</p>
<p className="text-gray-600" suppressHydrationWarning>
{formatTime(event.startDatetime)}
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.location')}</p>
<p className="text-gray-600">{event.location}</p>
{event.locationUrl && (
<a
href={event.locationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:underline text-sm"
>
View on map
</a>
)}
</div>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-start gap-3">
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<div>
<p className="font-medium">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
</p>
</div>
</div>
)}
</div>
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
<h2 className="font-semibold text-lg mb-4">About this event</h2>
<p className="text-gray-700 whitespace-pre-line" suppressHydrationWarning>
{locale === 'es' && event.descriptionEs
? event.descriptionEs
: event.description}
</p>
</div>
{/* Social Sharing */}
<div className="mt-8 pt-8 border-t border-secondary-light-gray" suppressHydrationWarning>
<ShareButtons
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
/>
</div>
</div>
</Card>
</div>
{/* Booking Card */}
<div className="lg:col-span-1">
<Card className="p-6 sticky top-24">
<div className="text-center mb-6">
<p className="text-sm text-gray-500">{t('events.details.price')}</p> <p className="text-sm text-gray-500">{t('events.details.price')}</p>
<p className="text-4xl font-bold text-primary-dark"> <p className="text-4xl font-bold text-primary-dark">
{event.price === 0 {event.price === 0
? t('events.details.free') ? t('events.details.free')
: `${event.price.toLocaleString()} ${event.currency}`} : formatPrice(event.price, event.currency)}
</p> </p>
{event.price > 0 && (
<p className="text-xs text-gray-400 mt-1">
{locale === 'es' ? 'por persona' : 'per person'}
</p>
)}
</div> </div>
{/* Ticket Quantity Selector */}
{canBook && !event.externalBookingEnabled && (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 text-center mb-2">
{locale === 'es' ? 'Cantidad de tickets' : 'Number of tickets'}
</label>
<div className="flex items-center justify-center gap-4">
<button
type="button"
onClick={decreaseQuantity}
disabled={ticketQuantity <= 1}
className="w-10 h-10 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-primary-yellow hover:bg-primary-yellow/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<MinusIcon className="w-5 h-5" />
</button>
<span className="text-2xl font-bold w-12 text-center">{ticketQuantity}</span>
<button
type="button"
onClick={increaseQuantity}
disabled={ticketQuantity >= maxTickets}
className="w-10 h-10 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-primary-yellow hover:bg-primary-yellow/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<PlusIcon className="w-5 h-5" />
</button>
</div>
{ticketQuantity > 1 && event.price > 0 && (
<p className="text-center text-sm text-gray-600 mt-2">
{locale === 'es' ? 'Total' : 'Total'}: <span className="font-bold">{formatPrice(event.price * ticketQuantity, event.currency)}</span>
</p>
)}
</div>
)}
{canBook ? ( {canBook ? (
event.externalBookingEnabled && event.externalBookingUrl ? ( event.externalBookingEnabled && event.externalBookingUrl ? (
<a <a
@@ -202,7 +124,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
</Button> </Button>
</a> </a>
) : ( ) : (
<Link href={`/book/${event.id}`}> <Link href={`/book/${event.id}?qty=${ticketQuantity}`}>
<Button className="w-full" size="lg"> <Button className="w-full" size="lg">
{t('events.booking.join')} {t('events.booking.join')}
</Button> </Button>
@@ -220,9 +142,147 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
{!event.externalBookingEnabled && ( {!event.externalBookingEnabled && (
<p className="mt-4 text-center text-sm text-gray-500"> <p className="mt-4 text-center text-sm text-gray-500">
{event.availableSeats} {t('events.details.spotsLeft')} {spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
</p> </p>
)} )}
</>
);
return (
<div className="section-padding">
<div className="container-page">
<Link
href="/events"
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
>
<ArrowLeftIcon className="w-4 h-4" />
{t('common.back')}
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Event Details */}
<div className="lg:col-span-2 space-y-6">
{/* Top section: Image + Event Info side by side on desktop */}
<Card className="overflow-hidden">
<div className="flex flex-col md:flex-row">
{/* Image - smaller on desktop, side by side */}
{event.bannerUrl ? (
<div className="relative md:w-2/5 flex-shrink-0 bg-gray-100">
<Image
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
width={400}
height={400}
className="w-full h-auto md:h-full object-cover"
sizes="(max-width: 768px) 100vw, 300px"
priority
unoptimized
/>
</div>
) : (
<div className="md:w-2/5 flex-shrink-0 h-48 md:h-auto bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
</div>
)}
{/* Event title and key info */}
<div className="flex-1 p-6">
<div className="flex items-start justify-between gap-4 mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-primary-dark" suppressHydrationWarning>
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h1>
<div className="flex-shrink-0">
{isCancelled && (
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
)}
{isSoldOut && !isCancelled && (
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
)}
</div>
</div>
<div className="space-y-4">
<div className="flex items-start gap-3">
<CalendarIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sm">{t('events.details.date')}</p>
<p className="text-gray-600" suppressHydrationWarning>{formatDate(event.startDatetime)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow text-lg"></span>
<div>
<p className="font-medium text-sm">{t('events.details.time')}</p>
<p className="text-gray-600" suppressHydrationWarning>
{fmtTime(event.startDatetime)}
{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPinIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sm">{t('events.details.location')}</p>
<p className="text-gray-600">{event.location}</p>
{event.locationUrl && (
<a
href={event.locationUrl}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:underline text-sm"
>
View on map
</a>
)}
</div>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-start gap-3">
<UserGroupIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
<p className="text-gray-600">
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</Card>
{/* Mobile Booking Card - shown between event details and description on mobile */}
<Card className="p-6 lg:hidden">
<BookingCardContent />
</Card>
{/* Description section - separate card below */}
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">About this event</h2>
<p className="text-gray-700 whitespace-pre-line" suppressHydrationWarning>
{locale === 'es' && event.descriptionEs
? event.descriptionEs
: event.description}
</p>
{/* Social Sharing */}
<div className="mt-8 pt-6 border-t border-secondary-light-gray" suppressHydrationWarning>
<ShareButtons
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
/>
</div>
</Card>
</div>
{/* Desktop Booking Card - hidden on mobile, shown in sidebar on desktop */}
<div className="hidden lg:block lg:col-span-1">
<Card className="p-6 sticky top-24">
<BookingCardContent />
</Card> </Card>
</div> </div>
</div> </div>

View File

@@ -97,8 +97,6 @@ function generateEventJsonLd(event: Event) {
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode', eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
eventStatus: isCancelled eventStatus: isCancelled
? 'https://schema.org/EventCancelled' ? 'https://schema.org/EventCancelled'
: isPastEvent
? 'https://schema.org/EventPostponed'
: 'https://schema.org/EventScheduled', : 'https://schema.org/EventScheduled',
location: { location: {
'@type': 'Place', '@type': 'Place',
@@ -118,7 +116,7 @@ function generateEventJsonLd(event: Event) {
'@type': 'Offer', '@type': 'Offer',
price: event.price, price: event.price,
priceCurrency: event.currency, priceCurrency: event.currency,
availability: event.availableSeats && event.availableSeats > 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.id}`,

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 { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline'; import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
@@ -32,20 +33,8 @@ export default function EventsPage() {
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents; const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusBadge = (event: Event) => { const getStatusBadge = (event: Event) => {
if (event.status === 'cancelled') { if (event.status === 'cancelled') {
@@ -129,7 +118,7 @@ export default function EventsPage() {
<div className="mt-4 space-y-2 text-sm text-gray-600"> <div className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" /> <CalendarIcon className="w-4 h-4" />
<span>{formatDate(event.startDatetime)} - {formatTime(event.startDatetime)}</span> <span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPinIcon className="w-4 h-4" /> <MapPinIcon className="w-4 h-4" />
@@ -139,7 +128,7 @@ export default function EventsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" /> <UserGroupIcon className="w-4 h-4" />
<span> <span>
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} {Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
</span> </span>
</div> </div>
)} )}
@@ -149,7 +138,7 @@ export default function EventsPage() {
<span className="font-bold text-xl text-primary-dark"> <span className="font-bold text-xl text-primary-dark">
{event.price === 0 {event.price === 0
? t('events.details.free') ? t('events.details.free')
: `${event.price.toLocaleString()} ${event.currency}`} : formatPrice(event.price, event.currency)}
</span> </span>
<Button size="sm"> <Button size="sm">
{t('common.moreInfo')} {t('common.moreInfo')}

View File

@@ -1,44 +1,21 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
// FAQ Page structured data const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const faqSchema = {
'@context': 'https://schema.org', async function getFaqForSchema(): Promise<{ question: string; answer: string }[]> {
'@type': 'FAQPage', try {
mainEntity: [ const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } });
{ if (!res.ok) return [];
'@type': 'Question', const data = await res.json();
name: 'What is Spanglish?', const faqs = data.faqs || [];
acceptedAnswer: { return faqs.map((f: { question: string; questionEs?: string | null; answer: string; answerEs?: string | null }) => ({
'@type': 'Answer', question: f.question,
text: 'Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.', answer: f.answer || '',
}, }));
}, } catch {
{ return [];
'@type': 'Question', }
name: 'Who can attend Spanglish events?', }
acceptedAnswer: {
'@type': 'Answer',
text: 'Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.',
},
},
{
'@type': 'Question',
name: 'How do language exchange events work?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Our events typically last 2-3 hours. You will be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.',
},
},
{
'@type': 'Question',
name: 'Do I need to speak the language already?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice.',
},
},
],
};
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Frequently Asked Questions', title: 'Frequently Asked Questions',
@@ -49,11 +26,25 @@ export const metadata: Metadata = {
}, },
}; };
export default function FAQLayout({ export default async function FAQLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const faqList = await getFaqForSchema();
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqList.map(({ question, answer }) => ({
'@type': 'Question',
name: question,
acceptedAnswer: {
'@type': 'Answer',
text: answer,
},
})),
};
return ( return (
<> <>
<script <script

View File

@@ -1,89 +1,44 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { faqApi, FaqItem } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
interface FAQItem {
question: string;
questionEs: string;
answer: string;
answerEs: string;
}
const faqs: FAQItem[] = [
{
question: "What is Spanglish?",
questionEs: "¿Qué es Spanglish?",
answer: "Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.",
answerEs: "Spanglish es una comunidad de intercambio de idiomas en Asunción, Paraguay. Organizamos eventos mensuales donde hablantes de español e inglés se reúnen para practicar idiomas, conocer gente nueva y divertirse en un ambiente social relajado."
},
{
question: "Who can attend Spanglish events?",
questionEs: "¿Quién puede asistir a los eventos de Spanglish?",
answer: "Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.",
answerEs: "¡Cualquier persona interesada en practicar inglés o español es bienvenida! Aceptamos todos los niveles - desde principiantes hasta hablantes nativos. Nuestros eventos están diseñados para ser inclusivos y acogedores para todos."
},
{
question: "How do events work?",
questionEs: "¿Cómo funcionan los eventos?",
answer: "Our events typically last 2-3 hours. You'll be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.",
answerEs: "Nuestros eventos suelen durar 2-3 horas. Serás emparejado con personas que hablan el idioma que quieres practicar. Rotamos parejas durante la noche para que puedas conocer a varias personas. También hay actividades grupales y tiempo de conversación libre."
},
{
question: "How much does it cost to attend?",
questionEs: "¿Cuánto cuesta asistir?",
answer: "Event prices vary but are always kept affordable. The price covers venue costs and event organization. Check each event page for specific pricing. Some special events may be free!",
answerEs: "Los precios de los eventos varían pero siempre se mantienen accesibles. El precio cubre los costos del local y la organización del evento. Consulta la página de cada evento para precios específicos. ¡Algunos eventos especiales pueden ser gratis!"
},
{
question: "What payment methods do you accept?",
questionEs: "¿Qué métodos de pago aceptan?",
answer: "We accept multiple payment methods: credit/debit cards through Bancard, Bitcoin Lightning for crypto enthusiasts, and cash payment at the event. You can choose your preferred method when booking.",
answerEs: "Aceptamos múltiples métodos de pago: tarjetas de crédito/débito a través de Bancard, Bitcoin Lightning para entusiastas de cripto, y pago en efectivo en el evento. Puedes elegir tu método preferido al reservar."
},
{
question: "Do I need to speak the language already?",
questionEs: "¿Necesito ya hablar el idioma?",
answer: "Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice. It's a judgment-free zone for learning.",
answerEs: "¡Para nada! Damos la bienvenida a principiantes absolutos. Nuestros eventos están estructurados para apoyar todos los niveles. Los hablantes nativos son pacientes y felices de ayudar a los principiantes a practicar. Es una zona libre de juicios para aprender."
},
{
question: "Can I come alone?",
questionEs: "¿Puedo ir solo/a?",
answer: "Absolutely! Most people come alone and that's totally fine. In fact, it's a great way to meet new people. Our events are designed to be social, so you'll quickly find conversation partners.",
answerEs: "¡Absolutamente! La mayoría de las personas vienen solas y eso está totalmente bien. De hecho, es una excelente manera de conocer gente nueva. Nuestros eventos están diseñados para ser sociales, así que encontrarás compañeros de conversación rápidamente."
},
{
question: "What if I can't make it after booking?",
questionEs: "¿Qué pasa si no puedo asistir después de reservar?",
answer: "If you can't attend, please let us know as soon as possible so we can offer your spot to someone on the waitlist. Contact us through the website or WhatsApp group to cancel your booking.",
answerEs: "Si no puedes asistir, por favor avísanos lo antes posible para poder ofrecer tu lugar a alguien en la lista de espera. Contáctanos a través del sitio web o el grupo de WhatsApp para cancelar tu reserva."
},
{
question: "How can I stay updated about events?",
questionEs: "¿Cómo puedo mantenerme actualizado sobre los eventos?",
answer: "Join our WhatsApp group for instant updates, follow us on Instagram for announcements and photos, or subscribe to our newsletter on the website. We typically announce events 2-3 weeks in advance.",
answerEs: "Únete a nuestro grupo de WhatsApp para actualizaciones instantáneas, síguenos en Instagram para anuncios y fotos, o suscríbete a nuestro boletín en el sitio web. Normalmente anunciamos eventos con 2-3 semanas de anticipación."
},
{
question: "Can I volunteer or help organize events?",
questionEs: "¿Puedo ser voluntario o ayudar a organizar eventos?",
answer: "Yes! We're always looking for enthusiastic volunteers. Volunteers help with setup, greeting newcomers, facilitating activities, and more. Contact us through the website if you're interested in getting involved.",
answerEs: "¡Sí! Siempre estamos buscando voluntarios entusiastas. Los voluntarios ayudan con la preparación, saludar a los recién llegados, facilitar actividades y más. Contáctanos a través del sitio web si estás interesado en participar."
}
];
export default function FAQPage() { export default function FAQPage() {
const { t, locale } = useLanguage(); const { locale } = useLanguage();
const [faqs, setFaqs] = useState<FaqItem[]>([]);
const [loading, setLoading] = useState(true);
const [openIndex, setOpenIndex] = useState<number | null>(null); const [openIndex, setOpenIndex] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
faqApi.getList().then((res) => {
if (!cancelled) {
setFaqs(res.faqs);
}
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const toggleFAQ = (index: number) => { const toggleFAQ = (index: number) => {
setOpenIndex(openIndex === index ? null : index); setOpenIndex(openIndex === index ? null : index);
}; };
if (loading) {
return (
<div className="section-padding">
<div className="container-page max-w-3xl flex justify-center py-20">
<div className="animate-spin w-10 h-10 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
</div>
);
}
return ( return (
<div className="section-padding"> <div className="section-padding">
<div className="container-page max-w-3xl"> <div className="container-page max-w-3xl">
@@ -98,15 +53,24 @@ export default function FAQPage() {
</p> </p>
</div> </div>
{faqs.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{locale === 'es'
? 'No hay preguntas frecuentes publicadas en este momento.'
: 'No FAQ questions are published at the moment.'}
</p>
</Card>
) : (
<div className="space-y-4"> <div className="space-y-4">
{faqs.map((faq, index) => ( {faqs.map((faq, index) => (
<Card key={index} className="overflow-hidden"> <Card key={faq.id} className="overflow-hidden">
<button <button
onClick={() => toggleFAQ(index)} onClick={() => toggleFAQ(index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors" className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
> >
<span className="font-semibold text-primary-dark pr-4"> <span className="font-semibold text-primary-dark pr-4">
{locale === 'es' ? faq.questionEs : faq.question} {locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</span> </span>
<ChevronDownIcon <ChevronDownIcon
className={clsx( className={clsx(
@@ -122,12 +86,13 @@ export default function FAQPage() {
)} )}
> >
<div className="px-6 pb-4 text-gray-600"> <div className="px-6 pb-4 text-gray-600">
{locale === 'es' ? faq.answerEs : faq.answer} {locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
</div> </div>
</div> </div>
</Card> </Card>
))} ))}
</div> </div>
)}
<Card className="mt-12 p-8 text-center bg-primary-yellow/10"> <Card className="mt-12 p-8 text-center bg-primary-yellow/10">
<h2 className="text-xl font-semibold text-primary-dark mb-2"> <h2 className="text-xl font-semibold text-primary-dark mb-2">

View File

@@ -20,7 +20,7 @@ export const metadata: Metadata = {
const organizationSchema = { const organizationSchema = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Organization', '@type': 'Organization',
name: 'Spanglish', name: 'Spanglish Community',
url: siteUrl, url: siteUrl,
logo: `${siteUrl}/images/logo.png`, logo: `${siteUrl}/images/logo.png`,
description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.', description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.',
@@ -30,7 +30,7 @@ const organizationSchema = {
addressCountry: 'PY', addressCountry: 'PY',
}, },
sameAs: [ sameAs: [
process.env.NEXT_PUBLIC_INSTAGRAM_URL, 'https://instagram.com/spanglishsocialpy',
process.env.NEXT_PUBLIC_WHATSAPP_URL, process.env.NEXT_PUBLIC_WHATSAPP_URL,
process.env.NEXT_PUBLIC_TELEGRAM_URL, process.env.NEXT_PUBLIC_TELEGRAM_URL,
].filter(Boolean), ].filter(Boolean),

View File

@@ -1,10 +1,11 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getLegalPage, getAllLegalSlugs } from '@/lib/legal'; import { getLegalPageAsync, getAllLegalSlugs } from '@/lib/legal';
import LegalPageLayout from '@/components/layout/LegalPageLayout'; import LegalPageLayout from '@/components/layout/LegalPageLayout';
interface PageProps { interface PageProps {
params: { slug: string }; params: Promise<{ slug: string }>;
searchParams: Promise<{ locale?: string }>;
} }
// Generate static params for all legal pages // Generate static params for all legal pages
@@ -13,11 +14,24 @@ export async function generateStaticParams() {
return slugs.map((slug) => ({ slug })); return slugs.map((slug) => ({ slug }));
} }
// Enable dynamic rendering to always fetch fresh content from DB
export const dynamic = 'force-dynamic';
export const revalidate = 60; // Revalidate every 60 seconds
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
// Validate and normalize locale
function getValidLocale(locale?: string): 'en' | 'es' {
if (locale === 'es') return 'es';
return 'en'; // Default to English
}
// Generate metadata for SEO // Generate metadata for SEO
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
const legalPage = getLegalPage(params.slug); const resolvedParams = await params;
const resolvedSearchParams = await searchParams;
const locale = getValidLocale(resolvedSearchParams.locale);
const legalPage = await getLegalPageAsync(resolvedParams.slug, locale);
if (!legalPage) { if (!legalPage) {
return { return {
@@ -33,13 +47,20 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
follow: true, follow: true,
}, },
alternates: { alternates: {
canonical: `${siteUrl}/legal/${params.slug}`, canonical: `${siteUrl}/legal/${resolvedParams.slug}`,
languages: {
'en': `${siteUrl}/legal/${resolvedParams.slug}`,
'es': `${siteUrl}/legal/${resolvedParams.slug}?locale=es`,
},
}, },
}; };
} }
export default function LegalPage({ params }: PageProps) { export default async function LegalPage({ params, searchParams }: PageProps) {
const legalPage = getLegalPage(params.slug); const resolvedParams = await params;
const resolvedSearchParams = await searchParams;
const locale = getValidLocale(resolvedSearchParams.locale);
const legalPage = await getLegalPageAsync(resolvedParams.slug, locale);
if (!legalPage) { if (!legalPage) {
notFound(); notFound();

View File

@@ -1,20 +1,168 @@
import type { Metadata } from 'next';
import HeroSection from './components/HeroSection'; 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 NewsletterSection from './components/NewsletterSection'; import NewsletterSection from './components/NewsletterSection';
import HomepageFaqSection from './components/HomepageFaqSection';
import { getCarouselImages } from '@/lib/carouselImages'; import { getCarouselImages } from '@/lib/carouselImages';
export default function HomePage() { const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface NextEvent {
id: string;
title: string;
titleEs?: string;
description: string;
descriptionEs?: string;
shortDescription?: string;
shortDescriptionEs?: string;
startDatetime: string;
endDatetime?: string;
location: string;
locationUrl?: string;
price: number;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string;
externalBookingEnabled?: boolean;
externalBookingUrl?: string;
availableSeats?: number;
bookedCount?: number;
isFeatured?: boolean;
createdAt: string;
updatedAt: string;
}
async function getNextUpcomingEvent(): Promise<NextEvent | null> {
try {
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
next: { tags: ['next-event'] },
});
if (!response.ok) return null;
const data = await response.json();
return data.event || null;
} catch {
return null;
}
}
// Dynamic metadata with next event date for AI crawlers and SEO
export async function generateMetadata(): Promise<Metadata> {
const event = await getNextUpcomingEvent();
if (!event) {
return {
title: 'Spanglish Language Exchange Events in Asunción',
description:
'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
};
}
const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'America/Asuncion',
});
const description = `Next event: ${eventDate} ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
return {
title: 'Spanglish Language Exchange Events in Asunción',
description,
openGraph: {
title: 'Spanglish Language Exchange Events in Asunción',
description,
url: siteUrl,
siteName: 'Spanglish',
type: 'website',
images: [
{
url: event.bannerUrl
? event.bannerUrl.startsWith('http')
? event.bannerUrl
: `${siteUrl}${event.bannerUrl}`
: `${siteUrl}/images/og-image.jpg`,
width: 1200,
height: 630,
alt: `Spanglish ${event.title}`,
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Spanglish Language Exchange Events in Asunción',
description,
},
};
}
function generateNextEventJsonLd(event: NextEvent) {
return {
'@context': 'https://schema.org',
'@type': 'Event',
name: event.title,
description: event.shortDescription || event.description,
startDate: event.startDatetime,
endDate: event.endDatetime || event.startDatetime,
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
eventStatus:
event.status === 'cancelled'
? 'https://schema.org/EventCancelled'
: 'https://schema.org/EventScheduled',
location: {
'@type': 'Place',
name: event.location,
address: {
'@type': 'PostalAddress',
addressLocality: 'Asunción',
addressCountry: 'PY',
},
},
organizer: {
'@type': 'Organization',
name: 'Spanglish',
url: siteUrl,
},
offers: {
'@type': 'Offer',
price: event.price,
priceCurrency: event.currency,
availability:
(event.availableSeats ?? 0) > 0
? 'https://schema.org/InStock'
: 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.id}`,
},
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.id}`,
};
}
export default async function HomePage() {
const carouselImages = getCarouselImages(); const carouselImages = getCarouselImages();
const nextEvent = await getNextUpcomingEvent();
return ( return (
<> <>
{nextEvent && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(generateNextEventJsonLd(nextEvent)),
}}
/>
)}
<HeroSection /> <HeroSection />
<NextEventSectionWrapper /> <NextEventSectionWrapper initialEvent={nextEvent} />
<AboutSection /> <AboutSection />
<MediaCarouselSection images={carouselImages} /> <MediaCarouselSection images={carouselImages} />
<NewsletterSection /> <NewsletterSection />
<HomepageFaqSection />
</> </>
); );
} }

View File

@@ -18,6 +18,7 @@ import {
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
interface TicketWithDetails extends Omit<Ticket, 'payment'> { interface TicketWithDetails extends Omit<Ticket, 'payment'> {
bookingId?: string;
event?: Event; event?: Event;
payment?: { payment?: {
id: string; id: string;
@@ -124,6 +125,7 @@ export default function AdminBookingsPage() {
year: 'numeric', year: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: 'America/Asuncion',
}); });
}; };
@@ -194,6 +196,23 @@ 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) => {
if (!ticket.bookingId) {
return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) };
}
// Count all tickets with the same bookingId
const bookingTickets = tickets.filter(
t => t.bookingId === ticket.bookingId
);
return {
ticketCount: bookingTickets.length,
bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 0),
};
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -309,7 +328,9 @@ export default function AdminBookingsPage() {
</td> </td>
</tr> </tr>
) : ( ) : (
sortedTickets.map((ticket) => ( sortedTickets.map((ticket) => {
const bookingInfo = getBookingInfo(ticket);
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-6 py-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -341,9 +362,16 @@ export default function AdminBookingsPage() {
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')} {getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
</p> </p>
{ticket.payment && ( {ticket.payment && (
<div>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{ticket.payment.amount?.toLocaleString()} {ticket.payment.currency} {bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}
</p> </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> </div>
</td> </td>
@@ -354,6 +382,11 @@ export default function AdminBookingsPage() {
{ticket.qrCode && ( {ticket.qrCode && (
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</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>
<td className="px-6 py-4 text-sm text-gray-600"> <td className="px-6 py-4 text-sm text-gray-600">
{formatDate(ticket.createdAt)} {formatDate(ticket.createdAt)}
@@ -415,7 +448,8 @@ export default function AdminBookingsPage() {
</div> </div>
</td> </td>
</tr> </tr>
)) );
})
)} )}
</tbody> </tbody>
</table> </table>

View File

@@ -49,6 +49,7 @@ export default function AdminContactsPage() {
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: 'America/Asuncion',
}); });
}; };

View File

@@ -189,7 +189,6 @@ export default function AdminEmailsPage() {
return; return;
} }
setSending(true);
try { try {
const res = await emailsApi.sendToEvent(composeForm.eventId, { const res = await emailsApi.sendToEvent(composeForm.eventId, {
templateSlug: composeForm.templateSlug, templateSlug: composeForm.templateSlug,
@@ -197,20 +196,15 @@ export default function AdminEmailsPage() {
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined, customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
}); });
if (res.success || res.sentCount > 0) { if (res.success) {
toast.success(`Sent ${res.sentCount} emails successfully`); toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
if (res.failedCount > 0) {
toast.error(`${res.failedCount} emails failed`);
}
clearDraft(); clearDraft();
setShowRecipientPreview(false); setShowRecipientPreview(false);
} else { } else {
toast.error('Failed to send emails'); toast.error(res.error || 'Failed to queue emails');
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Failed to send emails'); toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
} }
}; };
@@ -373,6 +367,7 @@ export default function AdminEmailsPage() {
year: 'numeric', year: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: 'America/Asuncion',
}); });
}; };
@@ -545,7 +540,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() : ''} Draft saved {composeForm.savedAt ? new Date(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}>
@@ -571,7 +566,7 @@ export default function AdminEmailsPage() {
<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').map((event) => (
<option key={event.id} value={event.id}> <option key={event.id} value={event.id}>
{event.title} - {new Date(event.startDatetime).toLocaleDateString()} {event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
</option> </option>
))} ))}
</select> </select>

View File

@@ -20,6 +20,7 @@ import {
EnvelopeIcon, EnvelopeIcon,
PencilIcon, PencilIcon,
EyeIcon, EyeIcon,
EyeSlashIcon,
PaperAirplaneIcon, PaperAirplaneIcon,
UserGroupIcon, UserGroupIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
@@ -37,7 +38,7 @@ import {
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
type TabType = 'overview' | 'attendees' | 'email' | 'payments'; type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
export default function AdminEventDetailPage() { export default function AdminEventDetailPage() {
const params = useParams(); const params = useParams();
@@ -62,6 +63,8 @@ export default function AdminEventDetailPage() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false); const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
const [showStats, setShowStats] = useState(true);
const [showNoteModal, setShowNoteModal] = useState(false); const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null); const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState(''); const [noteText, setNoteText] = useState('');
@@ -73,8 +76,19 @@ export default function AdminEventDetailPage() {
autoCheckin: true, autoCheckin: true,
adminNote: '', adminNote: '',
}); });
const [manualTicketForm, setManualTicketForm] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// Tickets tab state
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
const [ticketStatusFilter, setTicketStatusFilter] = useState<'all' | 'confirmed' | 'checked_in'>('all');
// Payment options state // Payment options state
const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null); const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null);
const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({}); const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({});
@@ -182,6 +196,7 @@ export default function AdminEventDetailPage() {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
timeZone: 'America/Asuncion',
}); });
}; };
@@ -189,6 +204,7 @@ export default function AdminEventDetailPage() {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: 'America/Asuncion',
}); });
}; };
@@ -301,6 +317,30 @@ export default function AdminEventDetailPage() {
} }
}; };
const handleManualTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.manualCreate({
eventId: event.id,
firstName: manualTicketForm.firstName,
lastName: manualTicketForm.lastName || undefined,
email: manualTicketForm.email,
phone: manualTicketForm.phone || undefined,
adminNote: manualTicketForm.adminNote || undefined,
});
toast.success('Manual ticket created — confirmation email sent');
setShowManualTicketModal(false);
setManualTicketForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to create manual ticket');
} finally {
setSubmitting(false);
}
};
// Filtered tickets for attendees tab // Filtered tickets for attendees tab
const filteredTickets = tickets.filter((ticket) => { const filteredTickets = tickets.filter((ticket) => {
// Status filter // Status filter
@@ -321,6 +361,25 @@ export default function AdminEventDetailPage() {
return true; return true;
}); });
// Filtered tickets for the Tickets tab (only confirmed/checked_in)
const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status));
const filteredConfirmedTickets = confirmedTickets.filter((ticket) => {
// Status filter
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) {
return false;
}
// Search filter
if (ticketSearchQuery) {
const query = ticketSearchQuery.toLowerCase();
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
return (
fullName.includes(query) ||
ticket.id.toLowerCase().includes(query)
);
}
return true;
});
const handlePreviewEmail = async () => { const handlePreviewEmail = async () => {
if (!selectedTemplate) { if (!selectedTemplate) {
toast.error('Please select a template'); toast.error('Please select a template');
@@ -366,7 +425,6 @@ export default function AdminEventDetailPage() {
return; return;
} }
setSending(true);
try { try {
const res = await emailsApi.sendToEvent(eventId, { const res = await emailsApi.sendToEvent(eventId, {
templateSlug: selectedTemplate, templateSlug: selectedTemplate,
@@ -375,14 +433,12 @@ export default function AdminEventDetailPage() {
}); });
if (res.success) { if (res.success) {
toast.success(`Email sent to ${res.sentCount} recipients`); toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
} else { } else {
toast.error(`Sent: ${res.sentCount}, Failed: ${res.failedCount}`); toast.error(res.error || 'Failed to queue emails');
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Failed to send emails'); toast.error(error.message || 'Failed to send emails');
} finally {
setSending(false);
} }
}; };
@@ -442,7 +498,9 @@ export default function AdminEventDetailPage() {
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6"> <div className="mb-6 flex items-center justify-between gap-4">
{showStats ? (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 flex-1">
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
@@ -499,11 +557,31 @@ export default function AdminEventDetailPage() {
</div> </div>
</Card> </Card>
</div> </div>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => setShowStats(!showStats)}
className="flex-shrink-0"
>
{showStats ? (
<>
<EyeSlashIcon className="w-4 h-4 mr-2" />
Hide stats
</>
) : (
<>
<EyeIcon className="w-4 h-4 mr-2" />
Show stats
</>
)}
</Button>
</div>
{/* Tabs */} {/* Tabs */}
<div className="border-b border-secondary-light-gray mb-6"> <div className="border-b border-secondary-light-gray mb-6">
<nav className="flex gap-6"> <nav className="flex gap-6">
{(['overview', 'attendees', 'email', 'payments'] as TabType[]).map((tab) => ( {(['overview', 'attendees', 'tickets', 'email', 'payments'] as TabType[]).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
@@ -517,9 +595,10 @@ export default function AdminEventDetailPage() {
> >
{tab === 'overview' && <CalendarIcon className="w-4 h-4" />} {tab === 'overview' && <CalendarIcon className="w-4 h-4" />}
{tab === 'attendees' && <UserGroupIcon className="w-4 h-4" />} {tab === 'attendees' && <UserGroupIcon className="w-4 h-4" />}
{tab === 'tickets' && <TicketIcon className="w-4 h-4" />}
{tab === 'email' && <EnvelopeIcon className="w-4 h-4" />} {tab === 'email' && <EnvelopeIcon className="w-4 h-4" />}
{tab === 'payments' && <CreditCardIcon className="w-4 h-4" />} {tab === 'payments' && <CreditCardIcon className="w-4 h-4" />}
{tab === 'overview' ? 'Overview' : tab === 'attendees' ? `Attendees (${tickets.length})` : tab === 'email' ? 'Send Email' : (locale === 'es' ? 'Pagos' : 'Payments')} {tab === 'overview' ? 'Overview' : tab === 'attendees' ? `Attendees (${tickets.length})` : tab === 'tickets' ? `Tickets (${confirmedTickets.length})` : tab === 'email' ? 'Send Email' : (locale === 'es' ? 'Pagos' : 'Payments')}
</button> </button>
))} ))}
</nav> </nav>
@@ -563,7 +642,7 @@ export default function AdminEventDetailPage() {
<div> <div>
<p className="font-medium">Capacity</p> <p className="font-medium">Capacity</p>
<p className="text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p> <p className="text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
<p className="text-sm text-gray-500">{event.capacity - confirmedCount - checkedInCount} spots remaining</p> <p className="text-sm text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
</div> </div>
</div> </div>
</div> </div>
@@ -629,12 +708,18 @@ export default function AdminEventDetailPage() {
</select> </select>
</div> </div>
</div> </div>
{/* Add at Door Button */} {/* Action Buttons */}
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setShowManualTicketModal(true)}>
<EnvelopeIcon className="w-4 h-4 mr-2" />
Manual Ticket
</Button>
<Button onClick={() => setShowAddAtDoorModal(true)}> <Button onClick={() => setShowAddAtDoorModal(true)}>
<PlusIcon className="w-4 h-4 mr-2" /> <PlusIcon className="w-4 h-4 mr-2" />
Add at Door Add at Door
</Button> </Button>
</div> </div>
</div>
{/* Filter Results Summary */} {/* Filter Results Summary */}
{(searchQuery || statusFilter !== 'all') && ( {(searchQuery || statusFilter !== 'all') && (
<div className="mt-3 text-sm text-gray-500 flex items-center gap-2"> <div className="mt-3 text-sm text-gray-500 flex items-center gap-2">
@@ -678,6 +763,11 @@ export default function AdminEventDetailPage() {
<td className="px-6 py-4"> <td className="px-6 py-4">
<p className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p> <p className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
<p className="text-sm text-gray-500">ID: {ticket.id.slice(0, 8)}...</p> <p className="text-sm text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
{ticket.bookingId && (
<p className="text-xs text-purple-600 mt-1" title={`Booking: ${ticket.bookingId}`}>
📦 {locale === 'es' ? 'Reserva grupal' : 'Group booking'}
</p>
)}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<p className="text-sm">{ticket.attendeeEmail}</p> <p className="text-sm">{ticket.attendeeEmail}</p>
@@ -687,7 +777,7 @@ export default function AdminEventDetailPage() {
{getStatusBadge(ticket.status)} {getStatusBadge(ticket.status)}
{ticket.checkinAt && ( {ticket.checkinAt && (
<p className="text-xs text-gray-400 mt-1"> <p className="text-xs text-gray-400 mt-1">
{new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}
</p> </p>
)} )}
</td> </td>
@@ -701,7 +791,7 @@ export default function AdminEventDetailPage() {
)} )}
</td> </td>
<td className="px-6 py-4 text-sm text-gray-600"> <td className="px-6 py-4 text-sm text-gray-600">
{new Date(ticket.createdAt).toLocaleDateString()} {new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
@@ -753,11 +843,163 @@ export default function AdminEventDetailPage() {
</div> </div>
)} )}
{/* Tickets Tab */}
{activeTab === 'tickets' && (
<div className="space-y-4">
{/* Search & Filter Bar */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex flex-col sm:flex-row gap-3 flex-1 w-full sm:w-auto">
{/* Search */}
<div className="relative flex-1 max-w-md">
<MagnifyingGlassIcon className="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search by name or ticket ID..."
value={ticketSearchQuery}
onChange={(e) => setTicketSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
{/* Status Filter */}
<div className="flex items-center gap-2">
<FunnelIcon className="w-5 h-5 text-gray-400" />
<select
value={ticketStatusFilter}
onChange={(e) => setTicketStatusFilter(e.target.value as any)}
className="px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="all">All ({confirmedTickets.length})</option>
<option value="confirmed">Valid ({getTicketsByStatus('confirmed').length})</option>
<option value="checked_in">Checked In ({getTicketsByStatus('checked_in').length})</option>
</select>
</div>
</div>
</div>
{(ticketSearchQuery || ticketStatusFilter !== 'all') && (
<div className="mt-3 text-sm text-gray-500 flex items-center gap-2">
<span>Showing {filteredConfirmedTickets.length} of {confirmedTickets.length} tickets</span>
<button
onClick={() => { setTicketSearchQuery(''); setTicketStatusFilter('all'); }}
className="text-primary-yellow hover:underline"
>
Clear filters
</button>
</div>
)}
</Card>
{/* Tickets Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-secondary-gray">
<tr>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee Name</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Ticket ID</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booking ID</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Check-in Time</th>
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{filteredConfirmedTickets.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
</td>
</tr>
) : (
filteredConfirmedTickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<p className="font-medium">
{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}
</p>
</td>
<td className="px-6 py-4">
<code className="text-sm bg-gray-100 px-2 py-1 rounded" title={ticket.id}>
{ticket.id.slice(0, 8)}...
</code>
</td>
<td className="px-6 py-4">
{ticket.bookingId ? (
<code className="text-sm bg-purple-50 text-purple-700 px-2 py-1 rounded" title={ticket.bookingId}>
{ticket.bookingId.slice(0, 8)}...
</code>
) : (
<span className="text-sm text-gray-400"></span>
)}
</td>
<td className="px-6 py-4">
{ticket.status === 'confirmed' ? (
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">
Valid
</span>
) : (
<span className="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">
Checked In
</span>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{ticket.checkinAt ? (
new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
})
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
{ticket.status === 'confirmed' && (
<Button
size="sm"
onClick={() => handleCheckin(ticket.id)}
>
Check In
</Button>
)}
{ticket.status === 'checked_in' && (
<Button
size="sm"
variant="outline"
onClick={() => handleRemoveCheckin(ticket.id)}
>
<ArrowUturnLeftIcon className="w-4 h-4 mr-1" />
Undo
</Button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Add at Door Modal */} {/* Add at Door Modal */}
{showAddAtDoorModal && ( {showAddAtDoorModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div
<Card className="w-full max-w-md"> className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray"> onClick={() => setShowAddAtDoorModal(false)}
role="presentation"
>
<Card
className="w-full max-w-md max-h-[90vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-lg font-bold">Add Attendee at Door</h2> <h2 className="text-lg font-bold">Add Attendee at Door</h2>
<button <button
onClick={() => setShowAddAtDoorModal(false)} onClick={() => setShowAddAtDoorModal(false)}
@@ -766,7 +1008,7 @@ export default function AdminEventDetailPage() {
<XMarkIcon className="w-5 h-5" /> <XMarkIcon className="w-5 h-5" />
</button> </button>
</div> </div>
<form onSubmit={handleAddAtDoor} className="p-4 space-y-4"> <form onSubmit={handleAddAtDoor} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium mb-1">First Name *</label> <label className="block text-sm font-medium mb-1">First Name *</label>
@@ -850,6 +1092,118 @@ export default function AdminEventDetailPage() {
</div> </div>
)} )}
{/* Manual Ticket Modal */}
{showManualTicketModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={() => setShowManualTicketModal(false)}
role="presentation"
>
<Card
className="w-full max-w-md max-h-[90vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<div>
<h2 className="text-lg font-bold">Create Manual Ticket</h2>
<p className="text-sm text-gray-500">Attendee will receive a confirmation email with their ticket</p>
</div>
<button
onClick={() => setShowManualTicketModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleManualTicket} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">First Name *</label>
<input
type="text"
required
value={manualTicketForm.firstName}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, firstName: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="First name"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name (optional)</label>
<input
type="text"
value={manualTicketForm.lastName}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="Last name"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email *</label>
<input
type="email"
required
value={manualTicketForm.email}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="email@example.com"
/>
<p className="text-xs text-gray-500 mt-1">
Booking confirmation and ticket will be sent to this email
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone (optional)</label>
<input
type="tel"
value={manualTicketForm.phone}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
placeholder="+595 981 123456"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Admin Note (optional)</label>
<textarea
value={manualTicketForm.adminNote}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
rows={2}
placeholder="Internal note about this attendee..."
/>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<EnvelopeIcon className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium">This will send:</p>
<ul className="list-disc ml-4 mt-1 space-y-0.5">
<li>Booking confirmation email</li>
<li>Ticket with QR code</li>
</ul>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setShowManualTicketModal(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="submit" isLoading={submitting} className="flex-1">
<EnvelopeIcon className="w-4 h-4 mr-2" />
Create & Send
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Note Modal */} {/* Note Modal */}
{showNoteModal && selectedTicket && ( {showNoteModal && selectedTicket && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">

View File

@@ -3,12 +3,13 @@
import { useState, useEffect } from 'react'; 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 { eventsApi, 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 } from '@heroicons/react/24/outline'; import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon } from '@heroicons/react/24/outline';
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';
@@ -19,6 +20,8 @@ export default function AdminEventsPage() {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<Event | null>(null); const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
title: string; title: string;
@@ -60,6 +63,7 @@ export default function AdminEventsPage() {
useEffect(() => { useEffect(() => {
loadEvents(); loadEvents();
loadFeaturedEvent();
}, []); }, []);
const loadEvents = async () => { const loadEvents = async () => {
@@ -73,6 +77,28 @@ export default function AdminEventsPage() {
} }
}; };
const loadFeaturedEvent = async () => {
try {
const { settings } = await siteSettingsApi.get();
setFeaturedEventId(settings.featuredEventId || null);
} catch (error) {
// Ignore error - settings may not exist yet
}
};
const handleSetFeatured = async (eventId: string | null) => {
setSettingFeatured(eventId || 'clearing');
try {
await siteSettingsApi.setFeaturedEvent(eventId);
setFeaturedEventId(eventId);
toast.success(eventId ? 'Event set as featured' : 'Featured event removed');
} catch (error: any) {
toast.error(error.message || 'Failed to update featured event');
} finally {
setSettingFeatured(null);
}
};
const resetForm = () => { const resetForm = () => {
setFormData({ setFormData({
title: '', title: '',
@@ -214,6 +240,7 @@ export default function AdminEventsPage() {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
timeZone: 'America/Asuncion',
}); });
}; };
@@ -455,6 +482,44 @@ export default function AdminEventsPage() {
relatedType="event" relatedType="event"
/> />
{/* Featured Event Section - Only show for published events when editing */}
{editingEvent && editingEvent.status === 'published' && (
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
<StarIcon className="w-5 h-5 text-amber-500" />
Featured Event
</label>
<p className="text-xs text-gray-500">
Featured events are prominently displayed on the homepage and linktree
</p>
</div>
<button
type="button"
disabled={settingFeatured !== null}
onClick={() => handleSetFeatured(
featuredEventId === editingEvent.id ? null : editingEvent.id
)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors 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'
}`}
>
<span
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>
</div>
{featuredEventId && featuredEventId !== editingEvent.id && (
<p className="text-xs text-amber-700 bg-amber-100 p-2 rounded">
Note: Another event is currently featured. Setting this event as featured will replace it.
</p>
)}
</div>
)}
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button type="submit" isLoading={saving}> <Button type="submit" isLoading={saving}>
{editingEvent ? 'Update Event' : 'Create Event'} {editingEvent ? 'Update Event' : 'Create Event'}
@@ -494,7 +559,7 @@ export default function AdminEventsPage() {
</tr> </tr>
) : ( ) : (
events.map((event) => ( events.map((event) => (
<tr key={event.id} className="hover:bg-gray-50"> <tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{event.bannerUrl ? ( {event.bannerUrl ? (
@@ -509,7 +574,15 @@ export default function AdminEventsPage() {
</div> </div>
)} )}
<div> <div>
<div className="flex items-center gap-2">
<p className="font-medium">{event.title}</p> <p className="font-medium">{event.title}</p>
{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">
<StarIconSolid className="w-3 h-3" />
Featured
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p> <p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
</div> </div>
</div> </div>
@@ -534,6 +607,25 @@ export default function AdminEventsPage() {
Publish Publish
</Button> </Button>
)} )}
{event.status === 'published' && (
<button
onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
disabled={settingFeatured !== null}
className={clsx(
"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"
)}
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>
)}
<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" className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"

View File

@@ -0,0 +1,395 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { faqApi, FaqItemAdmin } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import {
PlusIcon,
PencilSquareIcon,
TrashIcon,
Bars3Icon,
XMarkIcon,
CheckIcon,
ArrowLeftIcon,
} from '@heroicons/react/24/outline';
type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean };
const emptyForm: FormState = {
id: null,
question: '',
questionEs: '',
answer: '',
answerEs: '',
enabled: true,
showOnHomepage: false,
};
export default function AdminFaqPage() {
const { locale } = useLanguage();
const [faqs, setFaqs] = useState<FaqItemAdmin[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState<FormState>(emptyForm);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
useEffect(() => {
loadFaqs();
}, []);
const loadFaqs = async () => {
try {
setLoading(true);
const res = await faqApi.getAdminList();
setFaqs(res.faqs);
} catch (e) {
console.error(e);
toast.error(locale === 'es' ? 'Error al cargar FAQs' : 'Failed to load FAQs');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setForm(emptyForm);
setShowForm(true);
};
const handleEdit = (faq: FaqItemAdmin) => {
setForm({
id: faq.id,
question: faq.question,
questionEs: faq.questionEs ?? '',
answer: faq.answer,
answerEs: faq.answerEs ?? '',
enabled: faq.enabled,
showOnHomepage: faq.showOnHomepage,
});
setShowForm(true);
};
const handleSave = async () => {
if (!form.question.trim() || !form.answer.trim()) {
toast.error(locale === 'es' ? 'Pregunta y respuesta (EN) son obligatorios' : 'Question and answer (EN) are required');
return;
}
try {
setSaving(true);
if (form.id) {
await faqApi.update(form.id, {
question: form.question.trim(),
questionEs: form.questionEs.trim() || null,
answer: form.answer.trim(),
answerEs: form.answerEs.trim() || null,
enabled: form.enabled,
showOnHomepage: form.showOnHomepage,
});
toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated');
} else {
await faqApi.create({
question: form.question.trim(),
questionEs: form.questionEs.trim() || undefined,
answer: form.answer.trim(),
answerEs: form.answerEs.trim() || undefined,
enabled: form.enabled,
showOnHomepage: form.showOnHomepage,
});
toast.success(locale === 'es' ? 'FAQ creado' : 'FAQ created');
}
setForm(emptyForm);
setShowForm(false);
await loadFaqs();
} catch (e: any) {
toast.error(e.message || (locale === 'es' ? 'Error al guardar' : 'Failed to save'));
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm(locale === 'es' ? '¿Eliminar esta pregunta?' : 'Delete this question?')) return;
try {
await faqApi.delete(id);
toast.success(locale === 'es' ? 'FAQ eliminado' : 'FAQ deleted');
if (form.id === id) { setForm(emptyForm); setShowForm(false); }
await loadFaqs();
} catch (e: any) {
toast.error(e.message || (locale === 'es' ? 'Error al eliminar' : 'Failed to delete'));
}
};
const handleToggleEnabled = async (faq: FaqItemAdmin) => {
try {
await faqApi.update(faq.id, { enabled: !faq.enabled });
setFaqs(prev => prev.map(f => f.id === faq.id ? { ...f, enabled: !f.enabled } : f));
} catch (e: any) {
toast.error(e.message || (locale === 'es' ? 'Error al actualizar' : 'Failed to update'));
}
};
const handleToggleShowOnHomepage = async (faq: FaqItemAdmin) => {
try {
await faqApi.update(faq.id, { showOnHomepage: !faq.showOnHomepage });
setFaqs(prev => prev.map(f => f.id === faq.id ? { ...f, showOnHomepage: !f.showOnHomepage } : f));
} catch (e: any) {
toast.error(e.message || (locale === 'es' ? 'Error al actualizar' : 'Failed to update'));
}
};
const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedId(id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
};
const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverId(id);
};
const handleDragLeave = () => {
setDragOverId(null);
};
const handleDrop = async (e: React.DragEvent, targetId: string) => {
e.preventDefault();
setDragOverId(null);
setDraggedId(null);
const sourceId = e.dataTransfer.getData('text/plain');
if (!sourceId || sourceId === targetId) return;
const idx = faqs.findIndex(f => f.id === sourceId);
const targetIdx = faqs.findIndex(f => f.id === targetId);
if (idx === -1 || targetIdx === -1) return;
const newOrder = [...faqs];
const [removed] = newOrder.splice(idx, 1);
newOrder.splice(targetIdx, 0, removed);
const ids = newOrder.map(f => f.id);
try {
const res = await faqApi.reorder(ids);
setFaqs(res.faqs);
toast.success(locale === 'es' ? 'Orden actualizado' : 'Order updated');
} catch (err: any) {
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
}
};
const handleDragEnd = () => {
setDraggedId(null);
setDragOverId(null);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl font-bold font-heading">
{locale === 'es' ? 'FAQ' : 'FAQ'}
</h1>
<p className="text-gray-500 text-sm mt-1">
{locale === 'es'
? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.'
: 'Create and edit FAQ questions. Drag to change order.'}
</p>
</div>
<Button onClick={handleCreate}>
<PlusIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Nueva pregunta' : 'Add question'}
</Button>
</div>
{showForm && (
<Card>
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">
{form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')}
</h2>
<button
onClick={() => { setForm(emptyForm); setShowForm(false); }}
className="p-2 hover:bg-gray-100 rounded-full"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-1">Question (EN) *</label>
<Input
value={form.question}
onChange={e => setForm(f => ({ ...f, question: e.target.value }))}
placeholder="Question in English"
/>
</div>
<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 className="grid gap-4 sm:grid-cols-2">
<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>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="w-10 px-4 py-3" />
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">
{locale === 'es' ? 'Pregunta' : 'Question'}
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-24">
{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>
</thead>
<tbody className="divide-y divide-gray-200">
{faqs.length === 0 ? (
<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) => (
<tr
key={faq.id}
draggable
onDragStart={e => handleDragStart(e, faq.id)}
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">
<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" />
</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-primary-dark line-clamp-1">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</p>
</td>
<td className="px-4 py-3">
<button
onClick={() => handleToggleEnabled(faq)}
className={clsx(
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
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>
</td>
<td className="px-4 py-3">
<button
onClick={() => handleToggleShowOnHomepage(faq)}
className={clsx(
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
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>
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" onClick={() => handleEdit(faq)}>
<PencilSquareIcon className="w-4 h-4" />
</Button>
<Button size="sm" variant="outline" onClick={() => handleDelete(faq.id)} className="text-red-600 hover:bg-red-50">
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -112,6 +112,7 @@ export default function AdminGalleryPage() {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
timeZone: 'America/Asuncion',
}); });
}; };

View File

@@ -23,6 +23,8 @@ import {
XMarkIcon, XMarkIcon,
BanknotesIcon, BanknotesIcon,
QrCodeIcon, QrCodeIcon,
DocumentTextIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
@@ -35,14 +37,56 @@ export default function AdminLayout({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const { user, isAdmin, isLoading, logout } = useAuth(); const { user, hasAdminAccess, isLoading, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
const userRole = (user?.role || 'user') as Role;
const navigationWithRoles: { name: string; href: string; icon: typeof HomeIcon; allowedRoles: Role[] }[] = [
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon, allowedRoles: ['admin', 'organizer'] },
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
];
const allowedPathsForRole = new Set(
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href)
);
const defaultAdminRoute =
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
// All hooks must be called unconditionally before any early returns
useEffect(() => { useEffect(() => {
if (!isLoading && (!user || !isAdmin)) { if (!isLoading && (!user || !hasAdminAccess)) {
router.push('/login'); router.push('/login');
} }
}, [user, isAdmin, isLoading, router]); }, [user, hasAdminAccess, isLoading, router]);
useEffect(() => {
if (!user || !hasAdminAccess) return;
if (!pathname.startsWith('/admin')) return;
if (pathname === '/admin' && (userRole === 'staff' || userRole === 'marketing')) {
router.replace(defaultAdminRoute);
return;
}
const isPathAllowed = (path: string) => {
if (allowedPathsForRole.has(path)) return true;
return Array.from(allowedPathsForRole).some((allowed) => path.startsWith(allowed + '/'));
};
if (!isPathAllowed(pathname)) {
router.replace(defaultAdminRoute);
}
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -52,29 +96,29 @@ export default function AdminLayout({
); );
} }
if (!user || !isAdmin) { if (!user || !hasAdminAccess) {
return null; return null;
} }
const navigation = [ const visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole));
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon }, const navigation = visibleNav;
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon },
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
];
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
router.push('/'); router.push('/');
}; };
// Scanner page gets fullscreen layout without sidebar
const isScannerPage = pathname === '/admin/scanner';
if (isScannerPage) {
return (
<div className="min-h-screen bg-gray-950">
{children}
</div>
);
}
return ( return (
<div className="min-h-screen bg-secondary-gray"> <div className="min-h-screen bg-secondary-gray">
{/* Mobile sidebar backdrop */} {/* Mobile sidebar backdrop */}

View File

@@ -0,0 +1,597 @@
'use client';
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { useLanguage } from '@/context/LanguageContext';
import { legalPagesApi, LegalPage } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import {
DocumentTextIcon,
PencilSquareIcon,
ArrowPathIcon,
XMarkIcon,
CheckIcon,
ArrowLeftIcon,
CheckCircleIcon,
ExclamationCircleIcon,
EyeIcon,
PencilIcon,
} from '@heroicons/react/24/outline';
// Dynamically import rich text editor to avoid SSR issues
const RichTextEditor = dynamic(
() => import('@/components/ui/RichTextEditor'),
{
ssr: false,
loading: () => (
<div className="border border-secondary-light-gray rounded-btn bg-gray-50 h-[400px] flex items-center justify-center">
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
</div>
),
}
);
const RichTextPreview = dynamic(
() => import('@/components/ui/RichTextEditor').then(mod => ({ default: mod.RichTextPreview })),
{
ssr: false,
loading: () => (
<div className="border border-secondary-light-gray rounded-btn bg-gray-50 h-[400px] flex items-center justify-center">
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
</div>
),
}
);
type EditLanguage = 'en' | 'es';
type ViewMode = 'edit' | 'preview' | 'split';
export default function AdminLegalPagesPage() {
const { locale } = useLanguage();
const [pages, setPages] = useState<LegalPage[]>([]);
const [loading, setLoading] = useState(true);
const [seeding, setSeeding] = useState(false);
// Editor state
const [editingPage, setEditingPage] = useState<LegalPage | null>(null);
const [editLanguage, setEditLanguage] = useState<EditLanguage>('en');
const [editContentEn, setEditContentEn] = useState('');
const [editContentEs, setEditContentEs] = useState('');
const [editTitleEn, setEditTitleEn] = useState('');
const [editTitleEs, setEditTitleEs] = useState('');
const [saving, setSaving] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('edit');
useEffect(() => {
loadPages();
}, []);
const loadPages = async () => {
try {
setLoading(true);
const response = await legalPagesApi.getAdminList();
setPages(response.pages);
} catch (error) {
console.error('Failed to load legal pages:', error);
toast.error(locale === 'es' ? 'Error al cargar páginas legales' : 'Failed to load legal pages');
} finally {
setLoading(false);
}
};
const handleSeed = async () => {
try {
setSeeding(true);
const response = await legalPagesApi.seed();
toast.success(response.message);
if (response.seeded > 0) {
await loadPages();
}
} catch (error: any) {
console.error('Failed to seed legal pages:', error);
toast.error(error.message || (locale === 'es' ? 'Error al importar páginas' : 'Failed to import pages'));
} finally {
setSeeding(false);
}
};
const handleEdit = (page: LegalPage) => {
setEditingPage(page);
// Load from contentMarkdown to preserve formatting (fallback to contentText)
setEditContentEn(page.contentMarkdown || page.contentText || '');
setEditContentEs(page.contentMarkdownEs || page.contentTextEs || '');
setEditTitleEn(page.title || '');
setEditTitleEs(page.titleEs || '');
// Default to English tab, or Spanish if only Spanish exists
setEditLanguage(page.hasEnglish || !page.hasSpanish ? 'en' : 'es');
};
const handleCancelEdit = () => {
setEditingPage(null);
setEditContentEn('');
setEditContentEs('');
setEditTitleEn('');
setEditTitleEs('');
setEditLanguage('en');
};
const handleSave = async () => {
if (!editingPage) return;
// Validate - at least one language must have content
if (!editContentEn.trim() && !editContentEs.trim()) {
toast.error(locale === 'es'
? 'Al menos una versión de idioma debe tener contenido'
: 'At least one language version must have content'
);
return;
}
try {
setSaving(true);
const response = await legalPagesApi.update(editingPage.slug, {
contentMarkdown: editContentEn.trim() || undefined,
contentMarkdownEs: editContentEs.trim() || undefined,
title: editTitleEn.trim() || undefined,
titleEs: editTitleEs.trim() || undefined,
});
toast.success(response.message || (locale === 'es' ? 'Página actualizada' : 'Page updated successfully'));
// Update local state
setPages(prev => prev.map(p =>
p.slug === editingPage.slug ? response.page : p
));
handleCancelEdit();
} catch (error: any) {
console.error('Failed to save legal page:', error);
toast.error(error.message || (locale === 'es' ? 'Error al guardar' : 'Failed to save'));
} finally {
setSaving(false);
}
};
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
} catch {
return dateStr;
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
// Editor view
if (editingPage) {
const currentContent = editLanguage === 'en' ? editContentEn : editContentEs;
const setCurrentContent = editLanguage === 'en' ? setEditContentEn : setEditContentEs;
const currentTitle = editLanguage === 'en' ? editTitleEn : editTitleEs;
const setCurrentTitle = editLanguage === 'en' ? setEditTitleEn : setEditTitleEs;
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<button
onClick={handleCancelEdit}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
<ArrowLeftIcon className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold font-heading">
{locale === 'es' ? 'Editar Página Legal' : 'Edit Legal Page'}
</h1>
<p className="text-gray-500 text-sm mt-1">
{editingPage.slug}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleCancelEdit}
disabled={saving}
>
<XMarkIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
<Button
onClick={handleSave}
isLoading={saving}
>
<CheckIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Guardar Todo' : 'Save All'}
</Button>
</div>
</div>
<Card>
<div className="p-6 space-y-6">
{/* Language tabs and View mode toggle */}
<div className="flex justify-between items-center border-b border-gray-200 pb-3">
{/* Language tabs */}
<div className="flex gap-4">
<button
onClick={() => setEditLanguage('en')}
className={clsx(
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 -mb-3',
editLanguage === 'en'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
)}
>
English
{editContentEn.trim() ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<ExclamationCircleIcon className="w-4 h-4 text-amber-500" />
)}
</button>
<button
onClick={() => setEditLanguage('es')}
className={clsx(
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 -mb-3',
editLanguage === 'es'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
)}
>
Español (Paraguay)
{editContentEs.trim() ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<ExclamationCircleIcon className="w-4 h-4 text-amber-500" />
)}
</button>
</div>
{/* View mode toggle */}
<div className="flex bg-gray-100 rounded-lg p-1">
<button
onClick={() => setViewMode('edit')}
className={clsx(
'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1.5',
viewMode === 'edit'
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-600 hover:text-gray-900'
)}
>
<PencilIcon className="w-4 h-4" />
{locale === 'es' ? 'Editar' : 'Edit'}
</button>
<button
onClick={() => setViewMode('split')}
className={clsx(
'px-3 py-1.5 text-sm rounded-md transition-colors hidden lg:flex items-center gap-1.5',
viewMode === 'split'
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-600 hover:text-gray-900'
)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
Split
</button>
<button
onClick={() => setViewMode('preview')}
className={clsx(
'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1.5',
viewMode === 'preview'
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-600 hover:text-gray-900'
)}
>
<EyeIcon className="w-4 h-4" />
{locale === 'es' ? 'Vista previa' : 'Preview'}
</button>
</div>
</div>
{/* Title for current language */}
<div>
<label className="block text-sm font-medium mb-2">
{editLanguage === 'en'
? (locale === 'es' ? 'Título (Inglés)' : 'Title (English)')
: (locale === 'es' ? 'Título (Español)' : 'Title (Spanish)')
}
</label>
<Input
value={currentTitle}
onChange={(e) => setCurrentTitle(e.target.value)}
placeholder={editLanguage === 'en' ? 'Title' : 'Título'}
/>
</div>
{/* Content editor and preview */}
<div>
<label className="block text-sm font-medium mb-2">
{editLanguage === 'en'
? (locale === 'es' ? 'Contenido (Inglés)' : 'Content (English)')
: (locale === 'es' ? 'Contenido (Español)' : 'Content (Spanish)')
}
</label>
{viewMode === 'edit' && (
<>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es'
? 'Usa la barra de herramientas para dar formato. Los cambios se guardan como texto plano.'
: 'Use the toolbar to format text. Changes are saved as plain text.'
}
</p>
<RichTextEditor
content={currentContent}
onChange={setCurrentContent}
placeholder={editLanguage === 'en'
? 'Write content here...'
: 'Escribe el contenido aquí...'
}
/>
</>
)}
{viewMode === 'preview' && (
<>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es'
? 'Así se verá el contenido en la página pública.'
: 'This is how the content will look on the public page.'
}
</p>
<div className="border border-secondary-light-gray rounded-btn bg-white">
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200 text-sm text-gray-500">
{locale === 'es' ? 'Vista previa' : 'Preview'}
</div>
<RichTextPreview content={currentContent} />
</div>
</>
)}
{viewMode === 'split' && (
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es' ? 'Editor' : 'Editor'}
</p>
<RichTextEditor
content={currentContent}
onChange={setCurrentContent}
placeholder={editLanguage === 'en'
? 'Write content here...'
: 'Escribe el contenido aquí...'
}
/>
</div>
<div>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es' ? 'Vista previa' : 'Preview'}
</p>
<div className="border border-secondary-light-gray rounded-btn bg-white h-full">
<RichTextPreview content={currentContent} className="h-full" />
</div>
</div>
</div>
)}
</div>
{/* Info */}
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">
<p className="font-medium mb-2">
{locale === 'es' ? 'Nota:' : 'Note:'}
</p>
<ul className="list-disc list-inside space-y-1">
<li>
{locale === 'es'
? 'El slug (URL) no se puede cambiar: '
: 'The slug (URL) cannot be changed: '
}
<code className="bg-gray-200 px-1 rounded">/legal/{editingPage.slug}</code>
</li>
<li>
{locale === 'es'
? 'Usa la barra de herramientas para encabezados, listas, negritas y cursivas.'
: 'Use the toolbar for headings, lists, bold, and italics.'
}
</li>
<li>
{locale === 'es'
? 'Si falta una versión de idioma, se mostrará la otra versión disponible.'
: 'If a language version is missing, the other available version will be shown.'
}
</li>
</ul>
</div>
{/* Available Placeholders */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
<p className="font-medium mb-2">
{locale === 'es' ? 'Marcadores de posición disponibles:' : 'Available placeholders:'}
</p>
<p className="text-blue-700 mb-3">
{locale === 'es'
? 'Puedes usar estos marcadores en el contenido. Se reemplazarán automáticamente con los valores configurados en'
: 'You can use these placeholders in the content. They will be automatically replaced with the values configured in'
}
{' '}
<a href="/admin/settings" className="underline font-medium hover:text-blue-900">
{locale === 'es' ? 'Configuración > Legal' : 'Settings > Legal Settings'}
</a>.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5">
{[
{ placeholder: '{{COMPANY_NAME}}', label: locale === 'es' ? 'Nombre de la empresa' : 'Company name' },
{ placeholder: '{{LEGAL_ENTITY_NAME}}', label: locale === 'es' ? 'Nombre de la entidad legal' : 'Legal entity name' },
{ placeholder: '{{RUC_NUMBER}}', label: locale === 'es' ? 'Número de RUC' : 'RUC number' },
{ placeholder: '{{COMPANY_ADDRESS}}', label: locale === 'es' ? 'Dirección de la empresa' : 'Company address' },
{ placeholder: '{{COMPANY_CITY}}', label: locale === 'es' ? 'Ciudad' : 'City' },
{ placeholder: '{{COMPANY_COUNTRY}}', label: locale === 'es' ? 'País' : 'Country' },
{ placeholder: '{{SUPPORT_EMAIL}}', label: locale === 'es' ? 'Email de soporte' : 'Support email' },
{ placeholder: '{{LEGAL_EMAIL}}', label: locale === 'es' ? 'Email legal' : 'Legal email' },
{ placeholder: '{{GOVERNING_LAW}}', label: locale === 'es' ? 'Ley aplicable' : 'Governing law' },
{ placeholder: '{{JURISDICTION_CITY}}', label: locale === 'es' ? 'Ciudad de jurisdicción' : 'Jurisdiction city' },
{ placeholder: '{{CURRENT_YEAR}}', label: locale === 'es' ? 'Año actual (automático)' : 'Current year (automatic)' },
{ placeholder: '{{LAST_UPDATED_DATE}}', label: locale === 'es' ? 'Fecha de última actualización (automático)' : 'Last updated date (automatic)' },
].map(({ placeholder, label }) => (
<div key={placeholder} className="flex items-center gap-2">
<code className="bg-blue-100 text-blue-900 px-1.5 py-0.5 rounded text-xs font-mono whitespace-nowrap">
{placeholder}
</code>
<span className="text-blue-700 text-xs truncate">{label}</span>
</div>
))}
</div>
</div>
</div>
</Card>
</div>
);
}
// List view
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold font-heading">
{locale === 'es' ? 'Páginas Legales' : 'Legal Pages'}
</h1>
<p className="text-gray-500 text-sm mt-1">
{locale === 'es'
? 'Administra el contenido de las páginas legales del sitio.'
: 'Manage the content of the site\'s legal pages.'
}
</p>
</div>
{pages.length === 0 && (
<Button
onClick={handleSeed}
isLoading={seeding}
variant="outline"
>
<ArrowPathIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Importar desde archivos' : 'Import from files'}
</Button>
)}
</div>
{pages.length === 0 ? (
<Card>
<div className="p-12 text-center">
<DocumentTextIcon className="w-12 h-12 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium mb-2">
{locale === 'es' ? 'No hay páginas legales' : 'No legal pages found'}
</h3>
<p className="text-gray-500 mb-4">
{locale === 'es'
? 'Haz clic en "Importar desde archivos" para cargar las páginas legales existentes.'
: 'Click "Import from files" to load existing legal pages.'
}
</p>
<Button onClick={handleSeed} isLoading={seeding}>
<ArrowPathIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Importar Páginas' : 'Import Pages'}
</Button>
</div>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Página' : 'Page'}
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Slug
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Idiomas' : 'Languages'}
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Última actualización' : 'Last Updated'}
</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Acciones' : 'Actions'}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{pages.map((page) => (
<tr key={page.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div>
<p className="font-medium">{page.title}</p>
{page.titleEs && (
<p className="text-sm text-gray-500">{page.titleEs}</p>
)}
</div>
</td>
<td className="px-6 py-4">
<code className="bg-gray-100 px-2 py-1 rounded text-sm">
{page.slug}
</code>
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<span className={clsx(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
page.hasEnglish
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-500'
)}>
EN {page.hasEnglish ? '✓' : '—'}
</span>
<span className={clsx(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
page.hasSpanish
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-500'
)}>
ES {page.hasSpanish ? '✓' : '—'}
</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDate(page.updatedAt)}
</td>
<td className="px-6 py-4 text-right">
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(page)}
>
<PencilSquareIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Editar' : 'Edit'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}

View File

@@ -35,6 +35,7 @@ export default function AdminDashboardPage() {
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: 'America/Asuncion',
}); });
}; };
@@ -113,12 +114,12 @@ export default function AdminDashboardPage() {
{/* Low capacity warnings */} {/* Low capacity warnings */}
{data?.upcomingEvents {data?.upcomingEvents
.filter(event => { .filter(event => {
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0)); const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100; const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
return percentFull >= 80 && availableSeats > 0; return percentFull >= 80 && spotsLeft > 0;
}) })
.map(event => { .map(event => {
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0)); const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100); const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
return ( return (
<Link <Link
@@ -130,7 +131,7 @@ export default function AdminDashboardPage() {
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" /> <ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
<div> <div>
<span className="text-sm font-medium">{event.title}</span> <span className="text-sm font-medium">{event.title}</span>
<p className="text-xs text-gray-500">Only {availableSeats} spots left ({percentFull}% full)</p> <p className="text-xs text-gray-500">Only {spotsLeft} spots left ({percentFull}% full)</p>
</div> </div>
</div> </div>
<span className="badge badge-warning">Low capacity</span> <span className="badge badge-warning">Low capacity</span>
@@ -140,7 +141,7 @@ export default function AdminDashboardPage() {
{/* Sold out events */} {/* Sold out events */}
{data?.upcomingEvents {data?.upcomingEvents
.filter(event => (event.availableSeats ?? (event.capacity - (event.bookedCount || 0))) === 0) .filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
.map(event => ( .map(event => (
<Link <Link
key={event.id} key={event.id}

View File

@@ -19,6 +19,7 @@ import {
BanknotesIcon, BanknotesIcon,
BuildingLibraryIcon, BuildingLibraryIcon,
CreditCardIcon, CreditCardIcon,
EnvelopeIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -38,6 +39,8 @@ export default function AdminPaymentsPage() {
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null); const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
const [noteText, setNoteText] = useState(''); const [noteText, setNoteText] = useState('');
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [sendEmail, setSendEmail] = useState(true);
const [sendingReminder, setSendingReminder] = useState(false);
// Export state // Export state
const [showExportModal, setShowExportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false);
@@ -77,10 +80,11 @@ export default function AdminPaymentsPage() {
const handleApprove = async (payment: PaymentWithDetails) => { const handleApprove = async (payment: PaymentWithDetails) => {
setProcessing(true); setProcessing(true);
try { try {
await paymentsApi.approve(payment.id, noteText); await paymentsApi.approve(payment.id, noteText, sendEmail);
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved'); toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
setSelectedPayment(null); setSelectedPayment(null);
setNoteText(''); setNoteText('');
setSendEmail(true);
loadData(); loadData();
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Failed to approve payment'); toast.error(error.message || 'Failed to approve payment');
@@ -92,10 +96,11 @@ export default function AdminPaymentsPage() {
const handleReject = async (payment: PaymentWithDetails) => { const handleReject = async (payment: PaymentWithDetails) => {
setProcessing(true); setProcessing(true);
try { try {
await paymentsApi.reject(payment.id, noteText); await paymentsApi.reject(payment.id, noteText, sendEmail);
toast.success(locale === 'es' ? 'Pago rechazado' : 'Payment rejected'); toast.success(locale === 'es' ? 'Pago rechazado' : 'Payment rejected');
setSelectedPayment(null); setSelectedPayment(null);
setNoteText(''); setNoteText('');
setSendEmail(true);
loadData(); loadData();
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Failed to reject payment'); toast.error(error.message || 'Failed to reject payment');
@@ -104,6 +109,24 @@ export default function AdminPaymentsPage() {
} }
}; };
const handleSendReminder = async (payment: PaymentWithDetails) => {
setSendingReminder(true);
try {
const result = await paymentsApi.sendReminder(payment.id);
toast.success(locale === 'es' ? 'Recordatorio enviado' : 'Reminder sent');
// Update the selected payment with the new reminderSentAt timestamp
if (result.reminderSentAt) {
setSelectedPayment({ ...payment, reminderSentAt: result.reminderSentAt });
}
// Also refresh the data to update the lists
loadData();
} catch (error: any) {
toast.error(error.message || 'Failed to send reminder');
} finally {
setSendingReminder(false);
}
};
const handleConfirmPayment = async (id: string) => { const handleConfirmPayment = async (id: string) => {
try { try {
await paymentsApi.approve(id); await paymentsApi.approve(id);
@@ -176,6 +199,7 @@ export default function AdminPaymentsPage() {
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: 'America/Asuncion',
}); });
}; };
@@ -230,13 +254,69 @@ export default function AdminPaymentsPage() {
return labels[provider] || provider; return labels[provider] || provider;
}; };
// Calculate totals // Helper to get booking info for a payment (ticket count and total)
const getBookingInfo = (payment: PaymentWithDetails) => {
if (!payment.ticket?.bookingId) {
return { ticketCount: 1, bookingTotal: payment.amount };
}
// Count all payments with the same bookingId
const bookingPayments = payments.filter(
p => p.ticket?.bookingId === payment.ticket?.bookingId
);
return {
ticketCount: bookingPayments.length,
bookingTotal: bookingPayments.reduce((sum, p) => sum + Number(p.amount), 0),
};
};
// Get booking info for pending approval payments
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
if (!payment.ticket?.bookingId) {
return { ticketCount: 1, bookingTotal: payment.amount };
}
// Count all pending payments with the same bookingId
const bookingPayments = pendingApprovalPayments.filter(
p => p.ticket?.bookingId === payment.ticket?.bookingId
);
return {
ticketCount: bookingPayments.length,
bookingTotal: bookingPayments.reduce((sum, p) => sum + Number(p.amount), 0),
};
};
// Calculate totals (sum all individual payment amounts)
const totalPending = payments const totalPending = payments
.filter(p => p.status === 'pending' || p.status === 'pending_approval') .filter(p => p.status === 'pending' || p.status === 'pending_approval')
.reduce((sum, p) => sum + p.amount, 0); .reduce((sum, p) => sum + Number(p.amount), 0);
const totalPaid = payments const totalPaid = payments
.filter(p => p.status === 'paid') .filter(p => p.status === 'paid')
.reduce((sum, p) => sum + p.amount, 0); .reduce((sum, p) => sum + Number(p.amount), 0);
// Get unique booking count (for summary display)
const getUniqueBookingsCount = (paymentsList: PaymentWithDetails[]) => {
const seen = new Set<string>();
let count = 0;
paymentsList.forEach(p => {
const bookingKey = p.ticket?.bookingId || p.id;
if (!seen.has(bookingKey)) {
seen.add(bookingKey);
count++;
}
});
return count;
};
const pendingBookingsCount = getUniqueBookingsCount(
payments.filter(p => p.status === 'pending' || p.status === 'pending_approval')
);
const paidBookingsCount = getUniqueBookingsCount(
payments.filter(p => p.status === 'paid')
);
const pendingApprovalBookingsCount = getUniqueBookingsCount(pendingApprovalPayments);
if (loading) { if (loading) {
return ( return (
@@ -257,9 +337,11 @@ export default function AdminPaymentsPage() {
</div> </div>
{/* Approval Detail Modal */} {/* Approval Detail Modal */}
{selectedPayment && ( {selectedPayment && (() => {
const modalBookingInfo = getBookingInfo(selectedPayment);
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-center justify-center p-4">
<Card className="w-full max-w-lg p-6"> <Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold mb-4"> <h2 className="text-xl font-bold mb-4">
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'} {locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
</h2> </h2>
@@ -268,8 +350,15 @@ export default function AdminPaymentsPage() {
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p className="text-gray-500">{locale === 'es' ? 'Monto' : 'Amount'}</p> <p className="text-gray-500">{locale === 'es' ? 'Monto Total' : 'Total Amount'}</p>
<p className="font-bold text-lg">{formatCurrency(selectedPayment.amount, selectedPayment.currency)}</p> <p className="font-bold text-lg">{formatCurrency(modalBookingInfo.bookingTotal, selectedPayment.currency)}</p>
{modalBookingInfo.ticketCount > 1 && (
<div className="mt-2 p-2 bg-purple-50 rounded">
<p className="text-xs text-purple-700">
📦 {modalBookingInfo.ticketCount} tickets × {formatCurrency(selectedPayment.amount, selectedPayment.currency)}
</p>
</div>
)}
</div> </div>
<div> <div>
<p className="text-gray-500">{locale === 'es' ? 'Método' : 'Method'}</p> <p className="text-gray-500">{locale === 'es' ? 'Método' : 'Method'}</p>
@@ -309,6 +398,22 @@ export default function AdminPaymentsPage() {
</div> </div>
)} )}
{selectedPayment.reminderSentAt && (
<div className="flex items-center gap-2 text-sm text-amber-600">
<EnvelopeIcon className="w-4 h-4" />
{locale === 'es' ? 'Recordatorio enviado:' : 'Reminder sent:'} {formatDate(selectedPayment.reminderSentAt)}
</div>
)}
{selectedPayment.payerName && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800 font-medium">
{locale === 'es' ? '⚠️ Pagado por otra persona:' : '⚠️ Paid by someone else:'}
</p>
<p className="text-amber-900 font-bold">{selectedPayment.payerName}</p>
</div>
)}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Nota interna (opcional)' : 'Internal note (optional)'} {locale === 'es' ? 'Nota interna (opcional)' : 'Internal note (optional)'}
@@ -321,6 +426,19 @@ export default function AdminPaymentsPage() {
placeholder={locale === 'es' ? 'Agregar nota...' : 'Add a note...'} placeholder={locale === 'es' ? 'Agregar nota...' : 'Add a note...'}
/> />
</div> </div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="sendEmail"
checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)}
className="w-4 h-4 text-primary-yellow border-gray-300 rounded focus:ring-primary-yellow"
/>
<label htmlFor="sendEmail" className="text-sm text-gray-700">
{locale === 'es' ? 'Enviar email de notificación' : 'Send notification email'}
</label>
</div>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -343,15 +461,28 @@ export default function AdminPaymentsPage() {
</Button> </Button>
</div> </div>
<div className="pt-2 border-t">
<Button
variant="outline"
onClick={() => handleSendReminder(selectedPayment)}
isLoading={sendingReminder}
className="w-full"
>
<EnvelopeIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Enviar recordatorio de pago' : 'Send payment reminder'}
</Button>
</div>
<button <button
onClick={() => { setSelectedPayment(null); setNoteText(''); }} onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700" className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
> >
{locale === 'es' ? 'Cancelar' : 'Cancel'} {locale === 'es' ? 'Cancelar' : 'Cancel'}
</button> </button>
</Card> </Card>
</div> </div>
)} );
})()}
{/* Export Modal */} {/* Export Modal */}
{showExportModal && ( {showExportModal && (
@@ -481,7 +612,10 @@ export default function AdminPaymentsPage() {
</div> </div>
<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">{pendingApprovalPayments.length}</p> <p className="text-xl font-bold text-yellow-600">{pendingApprovalBookingsCount}</p>
{pendingApprovalPayments.length !== pendingApprovalBookingsCount && (
<p className="text-xs text-gray-400">({pendingApprovalPayments.length} tickets)</p>
)}
</div> </div>
</div> </div>
</Card> </Card>
@@ -493,6 +627,7 @@ export default function AdminPaymentsPage() {
<div> <div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p> <p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p> <p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
</div> </div>
</div> </div>
</Card> </Card>
@@ -504,6 +639,7 @@ export default function AdminPaymentsPage() {
<div> <div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p> <p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p>
<p className="text-xl font-bold text-green-600">{formatCurrency(totalPaid, 'PYG')}</p> <p className="text-xl font-bold text-green-600">{formatCurrency(totalPaid, 'PYG')}</p>
<p className="text-xs text-gray-400">{paidBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
</div> </div>
</div> </div>
</Card> </Card>
@@ -513,7 +649,7 @@ export default function AdminPaymentsPage() {
<BoltIcon className="w-5 h-5 text-blue-600" /> <BoltIcon className="w-5 h-5 text-blue-600" />
</div> </div>
<div> <div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagos' : 'Total Payments'}</p> <p className="text-sm text-gray-500">{locale === 'es' ? 'Total Tickets' : 'Total Tickets'}</p>
<p className="text-xl font-bold">{payments.length}</p> <p className="text-xl font-bold">{payments.length}</p>
</div> </div>
</div> </div>
@@ -565,7 +701,9 @@ export default function AdminPaymentsPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{pendingApprovalPayments.map((payment) => ( {pendingApprovalPayments.map((payment) => {
const bookingInfo = getPendingBookingInfo(payment);
return (
<Card key={payment.id} className="p-4"> <Card key={payment.id} className="p-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
@@ -574,12 +712,18 @@ export default function AdminPaymentsPage() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<p className="font-bold text-lg">{formatCurrency(payment.amount, payment.currency)}</p> <p className="font-bold text-lg">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
{bookingInfo.ticketCount > 1 && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
📦 {bookingInfo.ticketCount} tickets × {formatCurrency(payment.amount, payment.currency)}
</span>
)}
{getStatusBadge(payment.status)} {getStatusBadge(payment.status)}
</div> </div>
{payment.ticket && ( {payment.ticket && (
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName} {payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
{bookingInfo.ticketCount > 1 && <span className="text-gray-400 font-normal"> +{bookingInfo.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}</span>}
</p> </p>
)} )}
{payment.event && ( {payment.event && (
@@ -597,6 +741,11 @@ export default function AdminPaymentsPage() {
</span> </span>
)} )}
</div> </div>
{payment.payerName && (
<p className="text-xs text-amber-600 mt-1 font-medium">
{locale === 'es' ? 'Pago por:' : 'Paid by:'} {payment.payerName}
</p>
)}
</div> </div>
</div> </div>
<Button onClick={() => setSelectedPayment(payment)}> <Button onClick={() => setSelectedPayment(payment)}>
@@ -604,7 +753,8 @@ export default function AdminPaymentsPage() {
</Button> </Button>
</div> </div>
</Card> </Card>
))} );
})}
</div> </div>
)} )}
</> </>
@@ -671,7 +821,9 @@ export default function AdminPaymentsPage() {
</td> </td>
</tr> </tr>
) : ( ) : (
payments.map((payment) => ( payments.map((payment) => {
const bookingInfo = getBookingInfo(payment);
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-6 py-4">
{payment.ticket ? ( {payment.ticket ? (
@@ -680,6 +832,11 @@ export default function AdminPaymentsPage() {
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName} {payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
</p> </p>
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</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>
@@ -692,8 +849,15 @@ export default function AdminPaymentsPage() {
<span className="text-gray-400 text-sm">-</span> <span className="text-gray-400 text-sm">-</span>
)} )}
</td> </td>
<td className="px-6 py-4 font-medium"> <td className="px-6 py-4">
{formatCurrency(payment.amount, payment.currency)} <div>
<p className="font-medium">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
{bookingInfo.ticketCount > 1 && (
<p className="text-xs text-purple-600 mt-1">
📦 {bookingInfo.ticketCount} × {formatCurrency(payment.amount, payment.currency)}
</p>
)}
</div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-gray-600"> <div className="flex items-center gap-2 text-sm text-gray-600">
@@ -705,7 +869,14 @@ export default function AdminPaymentsPage() {
{formatDate(payment.createdAt)} {formatDate(payment.createdAt)}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="space-y-1">
{getStatusBadge(payment.status)} {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>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
@@ -731,7 +902,8 @@ export default function AdminPaymentsPage() {
</div> </div>
</td> </td>
</tr> </tr>
)) );
})
)} )}
</tbody> </tbody>
</table> </table>

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { siteSettingsApi, SiteSettings, TimezoneOption } from '@/lib/api'; import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } 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';
@@ -13,14 +14,22 @@ import {
EnvelopeIcon, EnvelopeIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
CheckCircleIcon, CheckCircleIcon,
StarIcon,
ScaleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
type SettingsTab = 'general' | 'legal';
export default function AdminSettingsPage() { export default function AdminSettingsPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savingLegal, setSavingLegal] = useState(false);
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
const [timezones, setTimezones] = useState<TimezoneOption[]>([]); const [timezones, setTimezones] = useState<TimezoneOption[]>([]);
const [featuredEvent, setFeaturedEvent] = useState<Event | null>(null);
const [clearingFeatured, setClearingFeatured] = useState(false);
const [settings, setSettings] = useState<SiteSettings>({ const [settings, setSettings] = useState<SiteSettings>({
timezone: 'America/Asuncion', timezone: 'America/Asuncion',
@@ -33,23 +42,52 @@ export default function AdminSettingsPage() {
instagramUrl: null, instagramUrl: null,
twitterUrl: null, twitterUrl: null,
linkedinUrl: null, linkedinUrl: null,
featuredEventId: null,
maintenanceMode: false, maintenanceMode: false,
maintenanceMessage: null, maintenanceMessage: null,
maintenanceMessageEs: null, maintenanceMessageEs: null,
}); });
const [legalSettings, setLegalSettings] = useState<LegalSettingsData>({
companyName: null,
legalEntityName: null,
rucNumber: null,
companyAddress: null,
companyCity: null,
companyCountry: null,
supportEmail: null,
legalEmail: null,
governingLaw: null,
jurisdictionCity: null,
});
const [legalErrors, setLegalErrors] = useState<Record<string, string>>({});
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, []); }, []);
const loadData = async () => { const loadData = async () => {
try { try {
const [settingsRes, timezonesRes] = await Promise.all([ const [settingsRes, timezonesRes, legalRes] = await Promise.all([
siteSettingsApi.get(), siteSettingsApi.get(),
siteSettingsApi.getTimezones(), siteSettingsApi.getTimezones(),
legalSettingsApi.get().catch(() => ({ settings: {} as LegalSettingsData })),
]); ]);
setSettings(settingsRes.settings); setSettings(settingsRes.settings);
setTimezones(timezonesRes.timezones); setTimezones(timezonesRes.timezones);
setLegalSettings(legalRes.settings);
// Load featured event details if one is set
if (settingsRes.settings.featuredEventId) {
try {
const { event } = await eventsApi.getById(settingsRes.settings.featuredEventId);
setFeaturedEvent(event);
} catch {
// Featured event may no longer exist
setFeaturedEvent(null);
}
}
} catch (error) { } catch (error) {
toast.error('Failed to load settings'); toast.error('Failed to load settings');
} finally { } finally {
@@ -57,6 +95,20 @@ export default function AdminSettingsPage() {
} }
}; };
const handleClearFeatured = async () => {
setClearingFeatured(true);
try {
await siteSettingsApi.setFeaturedEvent(null);
setSettings(prev => ({ ...prev, featuredEventId: null }));
setFeaturedEvent(null);
toast.success(locale === 'es' ? 'Evento destacado eliminado' : 'Featured event removed');
} catch (error: any) {
toast.error(error.message || 'Failed to clear featured event');
} finally {
setClearingFeatured(false);
}
};
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
try { try {
@@ -70,10 +122,53 @@ export default function AdminSettingsPage() {
} }
}; };
const validateLegalSettings = (): boolean => {
const errors: Record<string, string> = {};
// Validate email formats if provided
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (legalSettings.supportEmail && !emailRegex.test(legalSettings.supportEmail)) {
errors.supportEmail = locale === 'es' ? 'Email no válido' : 'Invalid email address';
}
if (legalSettings.legalEmail && !emailRegex.test(legalSettings.legalEmail)) {
errors.legalEmail = locale === 'es' ? 'Email no válido' : 'Invalid email address';
}
setLegalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSaveLegal = async () => {
if (!validateLegalSettings()) return;
setSavingLegal(true);
try {
const response = await legalSettingsApi.update(legalSettings);
setLegalSettings(response.settings);
toast.success(locale === 'es' ? 'Configuración legal guardada' : 'Legal settings saved');
} catch (error: any) {
toast.error(error.message || 'Failed to save legal settings');
} finally {
setSavingLegal(false);
}
};
const updateSetting = <K extends keyof SiteSettings>(key: K, value: SiteSettings[K]) => { const updateSetting = <K extends keyof SiteSettings>(key: K, value: SiteSettings[K]) => {
setSettings((prev) => ({ ...prev, [key]: value })); setSettings((prev) => ({ ...prev, [key]: value }));
}; };
const updateLegalSetting = <K extends keyof LegalSettingsData>(key: K, value: LegalSettingsData[K]) => {
setLegalSettings((prev) => ({ ...prev, [key]: value }));
// Clear error for this field when user types
if (legalErrors[key]) {
setLegalErrors((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -82,6 +177,21 @@ export default function AdminSettingsPage() {
); );
} }
const tabs: { id: SettingsTab; label: string; labelEs: string; icon: React.ReactNode }[] = [
{
id: 'general',
label: 'General Settings',
labelEs: 'Configuración General',
icon: <Cog6ToothIcon className="w-4 h-4" />,
},
{
id: 'legal',
label: 'Legal Settings',
labelEs: 'Configuración Legal',
icon: <ScaleIcon className="w-4 h-4" />,
},
];
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -96,12 +206,42 @@ export default function AdminSettingsPage() {
: 'Configure general website settings'} : 'Configure general website settings'}
</p> </p>
</div> </div>
{activeTab === 'general' && (
<Button onClick={handleSave} isLoading={saving}> <Button onClick={handleSave} isLoading={saving}>
<CheckCircleIcon className="w-5 h-5 mr-2" /> <CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'} {locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button> </Button>
)}
{activeTab === 'legal' && (
<Button onClick={handleSaveLegal} isLoading={savingLegal}>
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
)}
</div> </div>
{/* Tabs */}
<div className="border-b border-secondary-light-gray mb-6">
<nav className="flex space-x-0" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.icon}
{locale === 'es' ? tab.labelEs : tab.label}
</button>
))}
</nav>
</div>
{/* General Settings Tab */}
{activeTab === 'general' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Timezone Settings */} {/* Timezone Settings */}
<Card> <Card>
@@ -146,6 +286,94 @@ export default function AdminSettingsPage() {
</div> </div>
</Card> </Card>
{/* Featured Event */}
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<StarIcon className="w-5 h-5 text-amber-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Evento Destacado' : 'Featured Event'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'El evento destacado aparece en la página de inicio y linktree'
: 'The featured event is displayed on the homepage and linktree'}
</p>
</div>
</div>
{featuredEvent ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
{featuredEvent.bannerUrl && (
<img
src={featuredEvent.bannerUrl}
alt={featuredEvent.title}
className="w-16 h-16 rounded-lg object-cover"
/>
)}
<div>
<p className="font-medium text-amber-900">{featuredEvent.title}</p>
<p className="text-sm text-amber-700">
{new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'America/Asuncion',
})}
</p>
<p className="text-xs text-amber-600 mt-1">
{locale === 'es' ? 'Estado:' : 'Status:'} {featuredEvent.status}
</p>
</div>
</div>
<div className="flex gap-2">
<Link
href="/admin/events"
className="text-sm text-amber-700 hover:text-amber-900 underline"
>
{locale === 'es' ? 'Cambiar' : 'Change'}
</Link>
<button
onClick={handleClearFeatured}
disabled={clearingFeatured}
className="text-sm text-red-600 hover:text-red-800 underline disabled:opacity-50"
>
{clearingFeatured
? (locale === 'es' ? 'Eliminando...' : 'Removing...')
: (locale === 'es' ? 'Eliminar' : 'Remove')}
</button>
</div>
</div>
</div>
) : (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-gray-600 mb-3">
{locale === 'es'
? 'No hay evento destacado. El próximo evento publicado se mostrará automáticamente.'
: 'No featured event set. The next upcoming published event will be shown automatically.'}
</p>
<Link
href="/admin/events"
className="text-sm text-primary-yellow hover:underline font-medium"
>
{locale === 'es' ? 'Ir a Eventos para destacar uno' : 'Go to Events to feature one'}
</Link>
</div>
)}
<p className="text-xs text-gray-400 mt-3">
{locale === 'es'
? 'Cuando el evento destacado termine o se despublique, el sistema mostrará automáticamente el próximo evento.'
: 'When the featured event ends or is unpublished, the system will automatically show the next upcoming event.'}
</p>
</div>
</Card>
{/* Site Information */} {/* Site Information */}
<Card> <Card>
<div className="p-6"> <div className="p-6">
@@ -371,6 +599,196 @@ export default function AdminSettingsPage() {
</Button> </Button>
</div> </div>
</div> </div>
)}
{/* Legal Settings Tab */}
{activeTab === 'legal' && (
<div className="space-y-6">
{/* Info Banner */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-blue-800 text-sm">
<span className="font-medium">
{locale === 'es' ? 'Nota:' : 'Note:'}
</span>{' '}
{locale === 'es'
? 'Estos valores se usan como marcadores de posición en las páginas legales. Los marcadores como {{COMPANY_NAME}} se reemplazan automáticamente con los valores configurados aquí.'
: 'These values are used as placeholders in legal pages. Placeholders like {{COMPANY_NAME}} are automatically replaced with the values configured here.'}
</p>
</div>
{/* Company Information */}
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
<ScaleIcon className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Información de la Empresa' : 'Company Information'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Datos legales de la empresa que aparecerán en las páginas legales'
: 'Legal company details that will appear in legal pages'}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={locale === 'es' ? 'Nombre de la Empresa' : 'Company Name'}
value={legalSettings.companyName || ''}
onChange={(e) => updateLegalSetting('companyName', e.target.value || null)}
placeholder={locale === 'es' ? 'Ej: Spanglish S.A.' : 'e.g. Spanglish S.A.'}
/>
<Input
label={locale === 'es' ? 'Nombre de la Entidad Legal' : 'Legal Entity Name'}
value={legalSettings.legalEntityName || ''}
onChange={(e) => updateLegalSetting('legalEntityName', e.target.value || null)}
placeholder={locale === 'es' ? 'Ej: Spanglish S.A.' : 'e.g. Spanglish S.A.'}
/>
<Input
label={locale === 'es' ? 'Número de RUC' : 'RUC Number'}
value={legalSettings.rucNumber || ''}
onChange={(e) => updateLegalSetting('rucNumber', e.target.value || null)}
placeholder={locale === 'es' ? 'Ej: 80012345-6' : 'e.g. 80012345-6'}
/>
</div>
</div>
</Card>
{/* Address & Location */}
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-teal-100 rounded-full flex items-center justify-center">
<GlobeAltIcon className="w-5 h-5 text-teal-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Dirección y Ubicación' : 'Address & Location'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Dirección física y jurisdicción legal'
: 'Physical address and legal jurisdiction'}
</p>
</div>
</div>
<div className="space-y-4">
<div className="max-w-lg">
<Input
label={locale === 'es' ? 'Dirección de la Empresa' : 'Company Address'}
value={legalSettings.companyAddress || ''}
onChange={(e) => updateLegalSetting('companyAddress', e.target.value || null)}
placeholder={locale === 'es' ? 'Ej: Av. Mariscal López 1234' : 'e.g. 1234 Main Street'}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={locale === 'es' ? 'Ciudad' : 'City'}
value={legalSettings.companyCity || ''}
onChange={(e) => updateLegalSetting('companyCity', e.target.value || null)}
placeholder={locale === 'es' ? 'Ej: Asunción' : 'e.g. Asunción'}
/>
<Input
label={locale === 'es' ? 'País' : 'Country'}
value={legalSettings.companyCountry || ''}
onChange={(e) => updateLegalSetting('companyCountry', e.target.value || null)}
placeholder={locale === 'es' ? 'Ej: Paraguay' : 'e.g. Paraguay'}
/>
</div>
</div>
</div>
</Card>
{/* Contact Emails */}
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-rose-100 rounded-full flex items-center justify-center">
<EnvelopeIcon className="w-5 h-5 text-rose-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Emails Legales' : 'Legal Emails'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Direcciones de email para asuntos legales y soporte'
: 'Email addresses for legal matters and support'}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={locale === 'es' ? 'Email de Soporte' : 'Support Email'}
type="email"
value={legalSettings.supportEmail || ''}
onChange={(e) => updateLegalSetting('supportEmail', e.target.value || null)}
placeholder="support@example.com"
error={legalErrors.supportEmail}
/>
<Input
label={locale === 'es' ? 'Email Legal' : 'Legal Email'}
type="email"
value={legalSettings.legalEmail || ''}
onChange={(e) => updateLegalSetting('legalEmail', e.target.value || null)}
placeholder="legal@example.com"
error={legalErrors.legalEmail}
/>
</div>
</div>
</Card>
{/* Legal Jurisdiction */}
<Card>
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<ScaleIcon className="w-5 h-5 text-amber-600" />
</div>
<div>
<h3 className="font-semibold text-lg">
{locale === 'es' ? 'Jurisdicción Legal' : 'Legal Jurisdiction'}
</h3>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Ley aplicable y jurisdicción para las páginas legales'
: 'Governing law and jurisdiction for legal pages'}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={locale === 'es' ? 'Ley Aplicable' : 'Governing Law'}
value={legalSettings.governingLaw || ''}
onChange={(e) => updateLegalSetting('governingLaw', e.target.value || null)}
placeholder={locale === 'es' ? 'Ej: las leyes de la República del Paraguay' : 'e.g. the laws of the Republic of Paraguay'}
/>
<Input
label={locale === 'es' ? 'Ciudad de Jurisdicción' : 'Jurisdiction City'}
value={legalSettings.jurisdictionCity || ''}
onChange={(e) => updateLegalSetting('jurisdictionCity', e.target.value || null)}
placeholder={locale === 'es' ? 'Ej: Asunción' : 'e.g. Asunción'}
/>
</div>
</div>
</Card>
{/* Save Button at Bottom */}
<div className="flex justify-end">
<Button onClick={handleSaveLegal} isLoading={savingLegal} size="lg">
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Guardar Configuración Legal' : 'Save Legal Settings'}
</Button>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -140,6 +140,7 @@ export default function AdminTicketsPage() {
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
timeZone: 'America/Asuncion',
}); });
}; };

View File

@@ -5,7 +5,8 @@ import { useLanguage } from '@/context/LanguageContext';
import { usersApi, User } from '@/lib/api'; import { usersApi, User } 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 { TrashIcon } from '@heroicons/react/24/outline'; import Input from '@/components/ui/Input';
import { TrashIcon, PencilSquareIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
export default function AdminUsersPage() { export default function AdminUsersPage() {
@@ -13,6 +14,16 @@ export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [roleFilter, setRoleFilter] = useState<string>(''); const [roleFilter, setRoleFilter] = useState<string>('');
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editForm, setEditForm] = useState({
name: '',
email: '',
phone: '',
role: '' as User['role'],
languagePreference: '' as string,
accountStatus: '' as string,
});
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
loadUsers(); loadUsers();
@@ -51,11 +62,57 @@ export default function AdminUsersPage() {
} }
}; };
const openEditModal = (user: User) => {
setEditingUser(user);
setEditForm({
name: user.name,
email: user.email,
phone: user.phone || '',
role: user.role,
languagePreference: user.languagePreference || '',
accountStatus: user.accountStatus || 'active',
});
};
const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingUser) return;
if (!editForm.name.trim() || editForm.name.trim().length < 2) {
toast.error('Name must be at least 2 characters');
return;
}
if (!editForm.email.trim()) {
toast.error('Email is required');
return;
}
setSaving(true);
try {
await usersApi.update(editingUser.id, {
name: editForm.name.trim(),
email: editForm.email.trim(),
phone: editForm.phone.trim() || undefined,
role: editForm.role,
languagePreference: editForm.languagePreference || undefined,
accountStatus: editForm.accountStatus || undefined,
} as Partial<User>);
toast.success('User updated successfully');
setEditingUser(null);
loadUsers();
} catch (error: any) {
toast.error(error.message || 'Failed to update user');
} finally {
setSaving(false);
}
};
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
timeZone: 'America/Asuncion',
}); });
}; };
@@ -162,6 +219,13 @@ export default function AdminUsersPage() {
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditModal(user)}
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
title="Edit"
>
<PencilSquareIcon className="w-4 h-4" />
</button>
<button <button
onClick={() => handleDelete(user.id)} onClick={() => handleDelete(user.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"
@@ -178,6 +242,94 @@ export default function AdminUsersPage() {
</table> </table>
</div> </div>
</Card> </Card>
{/* Edit User Modal */}
{editingUser && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
<h2 className="text-xl font-bold text-primary-dark mb-6">Edit User</h2>
<form onSubmit={handleEditSubmit} className="space-y-4">
<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}
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>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label>
<select
value={editForm.role}
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="staff">{t('admin.users.roles.staff')}</option>
<option value="marketing">{t('admin.users.roles.marketing')}</option>
<option value="organizer">{t('admin.users.roles.organizer')}</option>
<option value="admin">{t('admin.users.roles.admin')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label>
<select
value={editForm.languagePreference}
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"
>
<option value="">Not set</option>
<option value="en">English</option>
<option value="es">Espa&#241;ol</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label>
<select
value={editForm.accountStatus}
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"
>
<option value="active">Active</option>
<option value="unclaimed">Unclaimed</option>
<option value="suspended">Suspended</option>
</select>
</div>
<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)}
>
Cancel
</Button>
<Button type="submit" isLoading={saving}>
Save Changes
</Button>
</div>
</form>
</Card>
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,31 @@
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { secret, tag } = body;
// Validate the revalidation secret
const revalidateSecret = process.env.REVALIDATE_SECRET;
if (!revalidateSecret || secret !== revalidateSecret) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
// Validate tag(s) - supports single tag or array of tags
const allowedTags = ['events-sitemap', 'next-event'];
const tags: string[] = Array.isArray(tag) ? tag : [tag];
const invalidTags = tags.filter((t: string) => !allowedTags.includes(t));
if (tags.length === 0 || invalidTags.length > 0) {
return NextResponse.json({ error: 'Invalid tag' }, { status: 400 });
}
for (const t of tags) {
revalidateTag(t);
}
return NextResponse.json({ revalidated: true, tags, now: Date.now() });
} catch {
return NextResponse.json({ error: 'Failed to revalidate' }, { status: 500 });
}
}

View File

@@ -99,3 +99,57 @@
text-wrap: balance; text-wrap: balance;
} }
} }
/* TipTap Rich Text Editor Styles */
.ProseMirror {
outline: none;
}
.ProseMirror p.is-editor-empty:first-child::before {
@apply text-gray-400 pointer-events-none float-left h-0;
content: attr(data-placeholder);
}
.ProseMirror > * + * {
margin-top: 0.75em;
}
.ProseMirror h1 {
@apply text-2xl font-bold mt-6 mb-3;
}
.ProseMirror h2 {
@apply text-xl font-bold mt-5 mb-2;
}
.ProseMirror h3 {
@apply text-lg font-semibold mt-4 mb-2;
}
.ProseMirror ul {
@apply list-disc list-inside my-3;
}
.ProseMirror ol {
@apply list-decimal list-inside my-3;
}
.ProseMirror li {
@apply my-1;
}
.ProseMirror blockquote {
@apply border-l-4 border-gray-300 pl-4 my-4 italic text-gray-600;
}
.ProseMirror hr {
@apply border-t border-gray-300 my-6;
}
.ProseMirror strong {
@apply font-bold;
}
.ProseMirror em {
@apply italic;
}

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 { eventsApi, Event } from '@/lib/api'; import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import { import {
CalendarIcon, CalendarIcon,
MapPinIcon, MapPinIcon,
@@ -28,20 +29,8 @@ export default function LinktreePage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
// Handle both full URLs and handles // Handle both full URLs and handles
const instagramUrl = instagramHandle const instagramUrl = instagramHandle
@@ -79,7 +68,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={`/book/${nextEvent.id}`} className="block group"> <Link href={`/events/${nextEvent.id}`} 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}
@@ -88,7 +77,7 @@ export default function LinktreePage() {
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-gray-300 text-sm"> <div className="flex items-center gap-2 text-gray-300 text-sm">
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" /> <CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
<span>{formatDate(nextEvent.startDatetime)} {formatTime(nextEvent.startDatetime)}</span> <span>{formatDate(nextEvent.startDatetime)} {fmtTime(nextEvent.startDatetime)}</span>
</div> </div>
<div className="flex items-center gap-2 text-gray-300 text-sm"> <div className="flex items-center gap-2 text-gray-300 text-sm">
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" /> <MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
@@ -100,7 +89,7 @@ export default function LinktreePage() {
<span className="font-bold text-primary-yellow"> <span className="font-bold text-primary-yellow">
{nextEvent.price === 0 {nextEvent.price === 0
? t('events.details.free') ? t('events.details.free')
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`} : formatPrice(nextEvent.price, nextEvent.currency)}
</span> </span>
{!nextEvent.externalBookingEnabled && ( {!nextEvent.externalBookingEnabled && (
<span className="text-sm text-gray-400"> <span className="text-sm text-gray-400">
@@ -110,7 +99,7 @@ export default function LinktreePage() {
</div> </div>
<div className="mt-4 bg-primary-yellow text-primary-dark font-semibold py-3 px-4 rounded-xl text-center transition-all duration-200 group-hover:bg-yellow-400"> <div className="mt-4 bg-primary-yellow text-primary-dark font-semibold py-3 px-4 rounded-xl text-center transition-all duration-200 group-hover:bg-yellow-400">
{t('linktree.bookNow')} {t('linktree.moreInfo')}
</div> </div>
</div> </div>
</Link> </Link>

View File

@@ -0,0 +1,270 @@
import { NextResponse } from 'next/server';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface LlmsFaq {
question: string;
answer: string;
}
interface LlmsEvent {
id: string;
title: string;
titleEs?: string;
shortDescription?: string;
shortDescriptionEs?: string;
description: string;
descriptionEs?: string;
startDatetime: string;
endDatetime?: string;
location: string;
price: number;
currency: string;
availableSeats?: number;
status: string;
}
async function getNextUpcomingEvent(): Promise<LlmsEvent | null> {
try {
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
next: { tags: ['next-event'] },
});
if (!response.ok) return null;
const data = await response.json();
return data.event || null;
} catch {
return null;
}
}
async function getUpcomingEvents(): Promise<LlmsEvent[]> {
try {
const response = await fetch(`${apiUrl}/api/events?status=published&upcoming=true`, {
next: { tags: ['next-event'] },
});
if (!response.ok) return [];
const data = await response.json();
return data.events || [];
} catch {
return [];
}
}
// Event times are always shown in Paraguay time (America/Asuncion) so llms.txt
// matches what users see on the website, regardless of server timezone.
const EVENT_TIMEZONE = 'America/Asuncion';
function formatEventDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: EVENT_TIMEZONE,
});
}
function formatEventTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: true,
timeZone: EVENT_TIMEZONE,
});
}
function formatPrice(price: number, currency: string): string {
if (price === 0) return 'Free';
return `${price.toLocaleString()} ${currency}`;
}
function formatISODate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
function formatISOTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: EVENT_TIMEZONE,
});
}
function getTodayISO(): string {
return new Date().toLocaleDateString('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
function getEventStatus(event: LlmsEvent): string {
if (event.availableSeats !== undefined && event.availableSeats === 0) return 'Sold Out';
if (event.status === 'published') return 'Available';
return event.status;
}
async function getHomepageFaqs(): Promise<LlmsFaq[]> {
try {
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
next: { revalidate: 3600 },
});
if (!response.ok) return [];
const data = await response.json();
return (data.faqs || []).map((f: any) => ({
question: f.question,
answer: f.answer,
}));
} catch {
return [];
}
}
export async function GET() {
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
getNextUpcomingEvent(),
getUpcomingEvents(),
getHomepageFaqs(),
]);
const lines: string[] = [];
// Header
lines.push('# Spanglish Community');
lines.push('');
lines.push('## Metadata');
lines.push('');
lines.push('- Type: Event Community');
lines.push('- Primary Language: English, Spanish');
lines.push('- Location: Asunción, Paraguay');
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
lines.push(`- Last Updated: ${getTodayISO()}`);
lines.push(`- Canonical URL: ${siteUrl}`);
lines.push('');
lines.push('> English-Spanish language exchange community organizing social events and meetups in Asunción, Paraguay.');
lines.push('');
lines.push(`- Website: ${siteUrl}`);
lines.push(`- Events page: ${siteUrl}/events`);
// Social links
const instagram = process.env.NEXT_PUBLIC_INSTAGRAM;
const whatsapp = process.env.NEXT_PUBLIC_WHATSAPP;
const telegram = process.env.NEXT_PUBLIC_TELEGRAM;
const email = process.env.NEXT_PUBLIC_EMAIL;
if (instagram) lines.push(`- Instagram: ${instagram}`);
if (telegram) lines.push(`- Telegram: ${telegram}`);
if (email) lines.push(`- Email: ${email}`);
if (whatsapp) lines.push(`- WhatsApp: ${whatsapp}`);
lines.push('');
// Next Event (most important section for AI)
lines.push('## Next Event');
lines.push('');
if (nextEvent) {
const status = getEventStatus(nextEvent);
lines.push(`- Event Name: ${nextEvent.title}`);
lines.push(`- Event ID: ${nextEvent.id}`);
lines.push(`- Status: ${status}`);
lines.push(`- Date: ${formatISODate(nextEvent.startDatetime)}`);
lines.push(`- Start Time: ${formatISOTime(nextEvent.startDatetime)}`);
if (nextEvent.endDatetime) {
lines.push(`- End Time: ${formatISOTime(nextEvent.endDatetime)}`);
}
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
lines.push(`- Venue: ${nextEvent.location}`);
lines.push('- City: Asunción');
lines.push('- Country: Paraguay');
lines.push(`- Price: ${nextEvent.price === 0 ? 'Free' : nextEvent.price}`);
lines.push(`- Currency: ${nextEvent.currency}`);
if (nextEvent.availableSeats !== undefined) {
lines.push(`- Capacity Remaining: ${nextEvent.availableSeats}`);
}
lines.push(`- Tickets URL: ${siteUrl}/events/${nextEvent.id}`);
if (nextEvent.shortDescription) {
lines.push(`- Description: ${nextEvent.shortDescription}`);
}
} else {
lines.push('No upcoming events currently scheduled. Check back soon or follow us on social media for announcements.');
}
lines.push('');
// All upcoming events
if (upcomingEvents.length > 1) {
lines.push('## All Upcoming Events');
lines.push('');
for (const event of upcomingEvents) {
const status = getEventStatus(event);
lines.push(`### ${event.title}`);
lines.push(`- Event ID: ${event.id}`);
lines.push(`- Status: ${status}`);
lines.push(`- Date: ${formatISODate(event.startDatetime)}`);
lines.push(`- Start Time: ${formatISOTime(event.startDatetime)}`);
if (event.endDatetime) {
lines.push(`- End Time: ${formatISOTime(event.endDatetime)}`);
}
lines.push(`- Time Zone: ${EVENT_TIMEZONE}`);
lines.push(`- Venue: ${event.location}`);
lines.push('- City: Asunción');
lines.push('- Country: Paraguay');
lines.push(`- Price: ${event.price === 0 ? 'Free' : event.price}`);
lines.push(`- Currency: ${event.currency}`);
if (event.availableSeats !== undefined) {
lines.push(`- Capacity Remaining: ${event.availableSeats}`);
}
lines.push(`- Tickets URL: ${siteUrl}/events/${event.id}`);
lines.push('');
}
}
// About section
lines.push('## About Spanglish');
lines.push('');
lines.push('Spanglish is a language exchange community based in Asunción, Paraguay. We organize regular social events where people can practice English and Spanish in a relaxed, friendly environment. Our events bring together locals and internationals for conversation, cultural exchange, and fun.');
lines.push('');
lines.push('## Frequently Asked Questions');
lines.push('');
if (faqs.length > 0) {
for (const faq of faqs) {
lines.push(`- **${faq.question}** ${faq.answer}`);
}
} else {
lines.push('- **What is Spanglish?** A language exchange community that hosts social events to practice English and Spanish.');
lines.push('- **Where are events held?** In Asunción, Paraguay. Specific venues are listed on each event page.');
lines.push('- **How do I attend an event?** Visit the events page to see upcoming events and book tickets.');
}
lines.push(`- More FAQ: ${siteUrl}/faq`);
lines.push('');
// Update Policy
lines.push('## Update Policy');
lines.push('');
lines.push('Event information is updated whenever new events are published or ticket availability changes.');
lines.push('');
// AI Summary
lines.push('## AI Summary');
lines.push('');
lines.push('Spanglish Community organizes English-Spanish language exchange events in Asunción, Paraguay. Events require registration via the website.');
lines.push('');
const content = lines.join('\n');
return new NextResponse(content, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
},
});
}

View File

@@ -7,29 +7,16 @@ export default function robots(): MetadataRoute.Robots {
rules: [ rules: [
{ {
userAgent: '*', userAgent: '*',
allow: [ allow: '/',
'/',
'/events',
'/events/*',
'/community',
'/contact',
'/faq',
'/legal/*',
],
disallow: [ disallow: [
'/admin', '/admin/',
'/admin/*', '/dashboard/',
'/dashboard', '/api/',
'/dashboard/*', '/book/',
'/api', '/booking/',
'/api/*',
'/book',
'/book/*',
'/booking',
'/booking/*',
'/login', '/login',
'/register', '/register',
'/auth/*', '/auth/',
], ],
}, },
], ],

View File

@@ -3,89 +3,109 @@ import { MetadataRoute } from 'next';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface Event { interface SitemapEvent {
id: string; id: string;
status: string; status: string;
startDatetime: string;
updatedAt: string; updatedAt: string;
} }
async function getPublishedEvents(): Promise<Event[]> { /**
* Fetch all indexable events: published, completed, and cancelled.
* Sold-out / past events stay in the index (marked as expired, not removed).
* Only draft and archived events are excluded.
*/
async function getIndexableEvents(): Promise<SitemapEvent[]> {
try { try {
const response = await fetch(`${apiUrl}/api/events?status=published`, { const [publishedRes, completedRes] = await Promise.all([
next: { revalidate: 3600 }, // Cache for 1 hour fetch(`${apiUrl}/api/events?status=published`, {
}); next: { tags: ['events-sitemap'] },
if (!response.ok) return []; }),
const data = await response.json(); fetch(`${apiUrl}/api/events?status=completed`, {
return data.events || []; next: { tags: ['events-sitemap'] },
}),
]);
const published = publishedRes.ok
? ((await publishedRes.json()).events as SitemapEvent[]) || []
: [];
const completed = completedRes.ok
? ((await completedRes.json()).events as SitemapEvent[]) || []
: [];
return [...published, ...completed];
} catch { } catch {
return []; return [];
} }
} }
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Fetch published events for dynamic event pages const events = await getIndexableEvents();
const events = await getPublishedEvents(); const now = new Date();
// Static pages // Static pages
const staticPages: MetadataRoute.Sitemap = [ const staticPages: MetadataRoute.Sitemap = [
{ {
url: siteUrl, url: siteUrl,
lastModified: new Date(), lastModified: now,
changeFrequency: 'weekly', changeFrequency: 'weekly',
priority: 1, priority: 1,
}, },
{ {
url: `${siteUrl}/events`, url: `${siteUrl}/events`,
lastModified: new Date(), lastModified: now,
changeFrequency: 'daily', changeFrequency: 'daily',
priority: 0.9, priority: 0.9,
}, },
{ {
url: `${siteUrl}/community`, url: `${siteUrl}/community`,
lastModified: new Date(), lastModified: now,
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.7, priority: 0.7,
}, },
{ {
url: `${siteUrl}/contact`, url: `${siteUrl}/contact`,
lastModified: new Date(), lastModified: now,
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.6, priority: 0.6,
}, },
{ {
url: `${siteUrl}/faq`, url: `${siteUrl}/faq`,
lastModified: new Date(), lastModified: now,
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.6, priority: 0.6,
}, },
// Legal pages // Legal pages
{ {
url: `${siteUrl}/legal/terms-policy`, url: `${siteUrl}/legal/terms-policy`,
lastModified: new Date(), lastModified: now,
changeFrequency: 'yearly', changeFrequency: 'yearly',
priority: 0.3, priority: 0.3,
}, },
{ {
url: `${siteUrl}/legal/privacy-policy`, url: `${siteUrl}/legal/privacy-policy`,
lastModified: new Date(), lastModified: now,
changeFrequency: 'yearly', changeFrequency: 'yearly',
priority: 0.3, priority: 0.3,
}, },
{ {
url: `${siteUrl}/legal/refund-cancelation-policy`, url: `${siteUrl}/legal/refund-cancelation-policy`,
lastModified: new Date(), lastModified: now,
changeFrequency: 'yearly', changeFrequency: 'yearly',
priority: 0.3, priority: 0.3,
}, },
]; ];
// Dynamic event pages // Dynamic event pages — upcoming events get higher priority
const eventPages: MetadataRoute.Sitemap = events.map((event) => ({ const eventPages: MetadataRoute.Sitemap = events.map((event) => {
const isUpcoming = new Date(event.startDatetime) > now;
return {
url: `${siteUrl}/events/${event.id}`, url: `${siteUrl}/events/${event.id}`,
lastModified: new Date(event.updatedAt), lastModified: new Date(event.updatedAt),
changeFrequency: 'weekly' as const, changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const),
priority: 0.8, priority: isUpcoming ? 0.8 : 0.5,
})); };
});
return [...staticPages, ...eventPages]; return [...staticPages, ...eventPages];
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { import {
ShareIcon, ShareIcon,
@@ -18,6 +18,12 @@ interface ShareButtonsProps {
export default function ShareButtons({ title, url, description }: ShareButtonsProps) { export default function ShareButtons({ title, url, description }: ShareButtonsProps) {
const { locale } = useLanguage(); const { locale } = useLanguage();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [supportsNativeShare, setSupportsNativeShare] = useState(false);
// Check for native share support only after mount to avoid hydration mismatch
useEffect(() => {
setSupportsNativeShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function');
}, []);
// Use provided URL or current page URL // Use provided URL or current page URL
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : ''); const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
@@ -133,7 +139,7 @@ export default function ShareButtons({ title, url, description }: ShareButtonsPr
</button> </button>
{/* Native Share (mobile) */} {/* Native Share (mobile) */}
{typeof navigator !== 'undefined' && typeof navigator.share === 'function' && ( {supportsNativeShare && (
<button <button
onClick={handleNativeShare} onClick={handleNativeShare}
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-yellow text-primary-dark hover:bg-primary-yellow/90 transition-colors" className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-yellow text-primary-dark hover:bg-primary-yellow/90 transition-colors"

View File

@@ -2,14 +2,13 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { useState } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import LanguageToggle from '@/components/LanguageToggle'; import LanguageToggle from '@/components/LanguageToggle';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
function NavLink({ href, children }: { href: string; children: React.ReactNode }) { function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
@@ -33,7 +32,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
return ( return (
<Link <Link
href={href} href={href}
className="px-4 py-2 hover:bg-gray-50 rounded-lg font-medium" className="block px-6 py-3 text-lg font-medium transition-colors hover:bg-gray-50"
style={{ color: isActive ? '#FBB82B' : '#002F44' }} style={{ color: isActive ? '#FBB82B' : '#002F44' }}
onClick={onClick} onClick={onClick}
> >
@@ -44,8 +43,67 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
export default function Header() { export default function Header() {
const { t } = useLanguage(); const { t } = useLanguage();
const { user, isAdmin, logout } = useAuth(); const { user, hasAdminAccess, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const touchStartX = useRef<number>(0);
const touchCurrentX = useRef<number>(0);
const isDragging = useRef<boolean>(false);
// Close menu on route change
const pathname = usePathname();
useEffect(() => {
setMobileMenuOpen(false);
}, [pathname]);
// Prevent body scroll when menu is open
useEffect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [mobileMenuOpen]);
// Handle swipe to close
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
touchCurrentX.current = e.touches[0].clientX;
isDragging.current = true;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!isDragging.current) return;
touchCurrentX.current = e.touches[0].clientX;
const deltaX = touchCurrentX.current - touchStartX.current;
// Only allow dragging to the right (to close)
if (deltaX > 0 && menuRef.current) {
menuRef.current.style.transform = `translateX(${deltaX}px)`;
}
}, []);
const handleTouchEnd = useCallback(() => {
if (!isDragging.current) return;
isDragging.current = false;
const deltaX = touchCurrentX.current - touchStartX.current;
const threshold = 100; // Minimum swipe distance to close
if (menuRef.current) {
menuRef.current.style.transform = '';
if (deltaX > threshold) {
setMobileMenuOpen(false);
}
}
}, []);
const closeMenu = useCallback(() => {
setMobileMenuOpen(false);
}, []);
const navLinks = [ const navLinks = [
{ href: '/', label: t('nav.home') }, { href: '/', label: t('nav.home') },
@@ -90,7 +148,7 @@ export default function Header() {
{t('nav.dashboard')} {t('nav.dashboard')}
</Button> </Button>
</Link> </Link>
{isAdmin && ( {hasAdminAccess && (
<Link href="/admin"> <Link href="/admin">
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
{t('nav.admin')} {t('nav.admin')}
@@ -118,81 +176,142 @@ export default function Header() {
)} )}
</div> </div>
{/* Mobile menu button */} {/* Mobile menu button (hamburger) */}
<button <button
className="md:hidden p-2 rounded-lg hover:bg-gray-100" className="md:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(true)}
aria-label="Open menu"
> >
{mobileMenuOpen ? ( <Bars3Icon className="w-6 h-6" style={{ color: '#002F44' }} />
<XMarkIcon className="w-6 h-6" /> </button>
) : ( </div>
<Bars3Icon className="w-6 h-6" /> </nav>
)}
{/* Mobile Slide-in Menu */}
{/* Overlay */}
<div
className={`
fixed inset-0 bg-black/50 z-40 md:hidden
transition-opacity duration-300 ease-in-out
${mobileMenuOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
`}
onClick={closeMenu}
aria-hidden="true"
/>
{/* Slide-in Panel */}
<div
ref={menuRef}
className={`
fixed top-0 right-0 h-full w-[280px] max-w-[85vw] bg-white z-50 md:hidden
shadow-xl transform transition-transform duration-300 ease-in-out
${mobileMenuOpen ? 'translate-x-0' : 'translate-x-full'}
`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Menu Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<Image
src="/images/logo-spanglish.png"
alt="Spanglish"
width={100}
height={28}
className="h-7 w-auto"
/>
<button
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
onClick={closeMenu}
aria-label="Close menu"
>
<XMarkIcon className="w-6 h-6" style={{ color: '#002F44' }} />
</button> </button>
</div> </div>
{/* Mobile Navigation */} {/* Menu Content */}
<div <div className="flex flex-col h-[calc(100%-65px)] overflow-y-auto">
className={clsx( {/* Navigation Links */}
'md:hidden overflow-hidden transition-all duration-300', <nav className="py-4">
{
'max-h-0': !mobileMenuOpen,
'max-h-96 pb-4': mobileMenuOpen,
}
)}
>
<div className="flex flex-col gap-2 pt-4">
{navLinks.map((link) => ( {navLinks.map((link) => (
<MobileNavLink <MobileNavLink
key={link.href} key={link.href}
href={link.href} href={link.href}
onClick={() => setMobileMenuOpen(false)} onClick={closeMenu}
> >
{link.label} {link.label}
</MobileNavLink> </MobileNavLink>
))} ))}
</nav>
<div className="border-t border-gray-100 mt-2 pt-4 px-4"> {/* Divider */}
<div className="border-t border-gray-100 mx-6" />
{/* Language Toggle */}
<div className="px-6 py-4">
<LanguageToggle variant="buttons" /> <LanguageToggle variant="buttons" />
</div> </div>
<div className="px-4 pt-2 flex flex-col gap-2"> {/* Divider */}
<div className="border-t border-gray-100 mx-6" />
{/* Auth Actions */}
<div className="px-6 py-4 flex flex-col gap-3 mt-auto">
{user ? ( {user ? (
<> <>
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}> <div className="text-sm text-gray-500 mb-2 flex items-center gap-2">
<Button variant="outline" className="w-full"> <div className="w-8 h-8 rounded-full bg-[#002F44] flex items-center justify-center text-white text-sm font-medium">
{user.name?.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-[#002F44]">{user.name}</span>
</div>
<Link href="/dashboard" onClick={closeMenu}>
<Button variant="outline" className="w-full justify-center">
{t('nav.dashboard')} {t('nav.dashboard')}
</Button> </Button>
</Link> </Link>
{isAdmin && ( {hasAdminAccess && (
<Link href="/admin" onClick={() => setMobileMenuOpen(false)}> <Link href="/admin" onClick={closeMenu}>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full justify-center">
{t('nav.admin')} {t('nav.admin')}
</Button> </Button>
</Link> </Link>
)} )}
<Button variant="secondary" onClick={logout} className="w-full"> <Button
variant="secondary"
onClick={() => {
logout();
closeMenu();
}}
className="w-full justify-center"
>
{t('nav.logout')} {t('nav.logout')}
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<Link href="/login" onClick={() => setMobileMenuOpen(false)}> <Link href="/login" onClick={closeMenu}>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full justify-center">
{t('nav.login')} {t('nav.login')}
</Button> </Button>
</Link> </Link>
<Link href="/events" onClick={() => setMobileMenuOpen(false)}> <Link href="/events" onClick={closeMenu}>
<Button className="w-full"> <Button className="w-full justify-center">
{t('nav.joinEvent')} {t('nav.joinEvent')}
</Button> </Button>
</Link> </Link>
</> </>
)} )}
</div> </div>
{/* Swipe hint */}
<div className="px-6 pb-6 pt-2">
<p className="text-xs text-gray-400 text-center">
Swipe right to close
</p>
</div>
</div> </div>
</div> </div>
</nav>
</header> </header>
); );
} }

View File

@@ -0,0 +1,419 @@
'use client';
import { useEditor, EditorContent, Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect, useRef } from 'react';
import clsx from 'clsx';
interface RichTextEditorProps {
content: string; // Markdown content
onChange: (content: string) => void; // Returns markdown
placeholder?: string;
className?: string;
editable?: boolean;
}
// Convert markdown to HTML for TipTap
function markdownToHtml(markdown: string): string {
if (!markdown) return '<p></p>';
let html = markdown;
// Convert horizontal rules first (before other processing)
html = html.replace(/^---+$/gm, '<hr>');
// Convert headings (must be done before other inline formatting)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Convert bold and italic
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
// Convert unordered lists
const lines = html.split('\n');
let inList = false;
let listType = '';
const processedLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const bulletMatch = line.match(/^[\*\-\+]\s+(.+)$/);
const numberedMatch = line.match(/^\d+\.\s+(.+)$/);
if (bulletMatch) {
if (!inList || listType !== 'ul') {
if (inList) processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
processedLines.push('<ul>');
inList = true;
listType = 'ul';
}
processedLines.push(`<li>${bulletMatch[1]}</li>`);
} else if (numberedMatch) {
if (!inList || listType !== 'ol') {
if (inList) processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
processedLines.push('<ol>');
inList = true;
listType = 'ol';
}
processedLines.push(`<li>${numberedMatch[1]}</li>`);
} else {
if (inList) {
processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
listType = '';
}
processedLines.push(line);
}
}
if (inList) {
processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
}
html = processedLines.join('\n');
// Convert blockquotes
html = html.replace(/^>\s*(.+)$/gm, '<blockquote><p>$1</p></blockquote>');
// Convert paragraphs (lines that aren't already HTML tags)
const finalLines = html.split('\n');
const result: string[] = [];
let paragraph: string[] = [];
for (const line of finalLines) {
const trimmed = line.trim();
if (!trimmed) {
// Empty line - close paragraph if open
if (paragraph.length > 0) {
result.push(`<p>${paragraph.join('<br>')}</p>`);
paragraph = [];
}
} else if (trimmed.startsWith('<h') || trimmed.startsWith('<ul') || trimmed.startsWith('<ol') ||
trimmed.startsWith('<li') || trimmed.startsWith('</ul') || trimmed.startsWith('</ol') ||
trimmed.startsWith('<hr') || trimmed.startsWith('<blockquote')) {
// HTML tag - close paragraph and add tag
if (paragraph.length > 0) {
result.push(`<p>${paragraph.join('<br>')}</p>`);
paragraph = [];
}
result.push(trimmed);
} else {
// Regular text - add to paragraph
paragraph.push(trimmed);
}
}
if (paragraph.length > 0) {
result.push(`<p>${paragraph.join('<br>')}</p>`);
}
return result.join('') || '<p></p>';
}
// Convert HTML from TipTap back to markdown
function htmlToMarkdown(html: string): string {
if (!html) return '';
let md = html;
// Convert headings
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
// Convert bold and italic
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
// Convert lists
md = md.replace(/<ul[^>]*>/gi, '\n');
md = md.replace(/<\/ul>/gi, '\n');
md = md.replace(/<ol[^>]*>/gi, '\n');
md = md.replace(/<\/ol>/gi, '\n');
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '* $1\n');
// Convert blockquotes
md = md.replace(/<blockquote[^>]*><p[^>]*>(.*?)<\/p><\/blockquote>/gi, '> $1\n\n');
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n');
// Convert horizontal rules
md = md.replace(/<hr[^>]*\/?>/gi, '\n---\n\n');
// Convert paragraphs
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
// Convert line breaks
md = md.replace(/<br[^>]*\/?>/gi, '\n');
// Remove any remaining HTML tags
md = md.replace(/<[^>]+>/g, '');
// Decode HTML entities
md = md.replace(/&amp;/g, '&');
md = md.replace(/&lt;/g, '<');
md = md.replace(/&gt;/g, '>');
md = md.replace(/&quot;/g, '"');
md = md.replace(/&#39;/g, "'");
md = md.replace(/&nbsp;/g, ' ');
// Clean up extra newlines
md = md.replace(/\n{3,}/g, '\n\n');
return md.trim();
}
// Toolbar button component
function ToolbarButton({
onClick,
isActive = false,
disabled = false,
children,
title,
}: {
onClick: () => void;
isActive?: boolean;
disabled?: boolean;
children: React.ReactNode;
title?: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={clsx(
'p-2 rounded transition-colors',
isActive
? 'bg-primary-yellow text-primary-dark'
: 'text-gray-600 hover:bg-gray-100',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{children}
</button>
);
}
// Toolbar component
function Toolbar({ editor }: { editor: Editor | null }) {
if (!editor) return null;
return (
<div className="flex flex-wrap gap-1 p-2 border-b border-gray-200 bg-gray-50">
{/* Text formatting */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 12h9a4 4 0 014 4 4 4 0 01-4 4H6z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 4h4m-2 0v16m-4 0h8" transform="skewX(-10)" />
</svg>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Headings */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Heading 1"
>
<span className="text-sm font-bold">H1</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<span className="text-sm font-bold">H2</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<span className="text-sm font-bold">H3</span>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Lists */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
<circle cx="2" cy="6" r="1" fill="currentColor" />
<circle cx="2" cy="12" r="1" fill="currentColor" />
<circle cx="2" cy="18" r="1" fill="currentColor" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Numbered List"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 6h13M7 12h13M7 18h13" />
<text x="1" y="8" fontSize="6" fill="currentColor">1</text>
<text x="1" y="14" fontSize="6" fill="currentColor">2</text>
<text x="1" y="20" fontSize="6" fill="currentColor">3</text>
</svg>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Block elements */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Quote"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
</svg>
</ToolbarButton>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
{/* Undo/Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Shift+Z)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
</svg>
</ToolbarButton>
</div>
);
}
export default function RichTextEditor({
content,
onChange,
placeholder = 'Start writing...',
className = '',
editable = true,
}: RichTextEditorProps) {
const lastContentRef = useRef(content);
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Placeholder.configure({
placeholder,
}),
],
content: markdownToHtml(content),
editable,
onUpdate: ({ editor }) => {
// Convert HTML back to markdown
const markdown = htmlToMarkdown(editor.getHTML());
lastContentRef.current = markdown;
onChange(markdown);
},
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[400px] p-4',
},
},
});
// Update content when prop changes (e.g., switching languages)
useEffect(() => {
if (editor && content !== lastContentRef.current) {
lastContentRef.current = content;
const html = markdownToHtml(content);
editor.commands.setContent(html);
}
}, [content, editor]);
return (
<div className={clsx('border border-secondary-light-gray rounded-btn overflow-hidden bg-white', className)}>
{editable && <Toolbar editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}
// Read-only preview component
export function RichTextPreview({
content,
className = '',
}: {
content: string; // Markdown content
className?: string;
}) {
const editor = useEditor({
immediatelyRender: false,
extensions: [StarterKit],
content: markdownToHtml(content),
editable: false,
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none p-4',
},
},
});
useEffect(() => {
if (editor) {
editor.commands.setContent(markdownToHtml(content));
}
}, [content, editor]);
return (
<div className={clsx('border border-secondary-light-gray rounded-btn bg-gray-50', className)}>
<EditorContent editor={editor} />
</div>
);
}

View File

@@ -21,6 +21,7 @@ interface AuthContextType {
token: string | null; token: string | null;
isLoading: boolean; isLoading: boolean;
isAdmin: boolean; isAdmin: boolean;
hasAdminAccess: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
loginWithGoogle: (credential: string) => Promise<void>; loginWithGoogle: (credential: string) => Promise<void>;
loginWithMagicLink: (token: string) => Promise<void>; loginWithMagicLink: (token: string) => Promise<void>;
@@ -177,6 +178,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const isAdmin = user?.role === 'admin' || user?.role === 'organizer'; const isAdmin = user?.role === 'admin' || user?.role === 'organizer';
const hasAdminAccess = user?.role === 'admin' || user?.role === 'organizer' || user?.role === 'staff' || user?.role === 'marketing';
return ( return (
<AuthContext.Provider <AuthContext.Provider
@@ -185,6 +187,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
token, token,
isLoading, isLoading,
isAdmin, isAdmin,
hasAdminAccess,
login, login,
loginWithGoogle, loginWithGoogle,
loginWithMagicLink, loginWithMagicLink,

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