Compare commits
40 Commits
backup
...
b33c68feb0
| Author | SHA1 | Date | |
|---|---|---|---|
| b33c68feb0 | |||
|
|
bbfaa1172a | ||
|
|
958181e049 | ||
| 15655e3987 | |||
|
|
5263fa6834 | ||
|
|
923c86a3b3 | ||
| d8b3864411 | |||
|
|
4aaffe99c7 | ||
| 194cbd6ca8 | |||
|
|
a11da5a977 | ||
| d5445c2282 | |||
|
|
6bc7e13e78 | ||
| dcfefc8371 | |||
|
|
c3897efd02 | ||
| b5f14335c4 | |||
|
|
62bf048680 | ||
| d44ac949b5 | |||
|
|
b9f46b02cc | ||
| a5e939221d | |||
|
|
18254c566e | ||
|
|
95ee5a5dec | ||
| 833e3e5a9c | |||
|
|
77e92e5d96 | ||
| ba1975dd6d | |||
|
|
07ba357194 | ||
|
|
5885044369 | ||
|
|
af94c99fd2 | ||
|
|
74464b0a7a | ||
| 3025ef3d21 | |||
|
|
6a807a7cc6 | ||
|
|
fe75912f23 | ||
| 8564f8af83 | |||
|
|
8315029091 | ||
|
|
2b2f2cc4ed | ||
|
|
23d0325d8d | ||
|
|
0c142884c7 | ||
|
|
0fd8172e04 | ||
|
|
9090d7bad2 | ||
|
|
4a84ad22c7 | ||
|
|
bafd1425c4 |
1
.gitignore
vendored
@@ -37,6 +37,7 @@ backend/uploads/
|
||||
# Tooling
|
||||
.turbo/
|
||||
.cursor/
|
||||
.npm-cache/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -19,7 +19,11 @@ GOOGLE_CLIENT_ID=
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
API_URL=http://localhost:3001
|
||||
FRONTEND_URL=http://localhost:3002
|
||||
FRONTEND_URL=http://localhost:3019
|
||||
|
||||
# Revalidation secret (shared with frontend for on-demand cache revalidation)
|
||||
# Must match the REVALIDATE_SECRET in frontend/.env
|
||||
REVALIDATE_SECRET=change-me-to-a-random-secret
|
||||
|
||||
# Payment Providers (optional)
|
||||
STRIPE_SECRET_KEY=
|
||||
@@ -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
|
||||
# Mailgun: SMTP_HOST=smtp.mailgun.org, 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
|
||||
|
||||
|
||||
270
backend/drizzle/0000_steady_wendell_vaughn.sql
Normal 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`);
|
||||
1836
backend/drizzle/meta/0000_snapshot.json
Normal file
13
backend/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1769975033554,
|
||||
"tag": "0000_steady_wendell_vaughn",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3';
|
||||
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -29,5 +30,51 @@ if (dbType === 'postgres') {
|
||||
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';
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dotenv/config';
|
||||
import { db } from './index.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
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() {
|
||||
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)`);
|
||||
} 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)
|
||||
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||
|
||||
@@ -198,6 +206,12 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN admin_note TEXT`);
|
||||
} 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
|
||||
await (db as any).run(sql`
|
||||
@@ -377,6 +391,7 @@ async function migrate() {
|
||||
instagram_url TEXT,
|
||||
twitter_url TEXT,
|
||||
linkedin_url TEXT,
|
||||
featured_event_id TEXT REFERENCES events(id),
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||
maintenance_message TEXT,
|
||||
maintenance_message_es TEXT,
|
||||
@@ -384,6 +399,63 @@ async function migrate() {
|
||||
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 {
|
||||
// PostgreSQL migrations
|
||||
await (db as any).execute(sql`
|
||||
@@ -514,6 +586,11 @@ async function migrate() {
|
||||
try {
|
||||
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 */ }
|
||||
|
||||
// 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`
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
@@ -525,6 +602,7 @@ async function migrate() {
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
reference VARCHAR(255),
|
||||
user_marked_paid_at TIMESTAMP,
|
||||
payer_name VARCHAR(255),
|
||||
paid_at TIMESTAMP,
|
||||
paid_by_admin_id UUID,
|
||||
admin_note TEXT,
|
||||
@@ -532,6 +610,14 @@ async function migrate() {
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// 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
|
||||
await (db as any).execute(sql`
|
||||
@@ -709,6 +795,7 @@ async function migrate() {
|
||||
instagram_url VARCHAR(500),
|
||||
twitter_url VARCHAR(500),
|
||||
linkedin_url VARCHAR(500),
|
||||
featured_event_id UUID REFERENCES events(id),
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||
maintenance_message TEXT,
|
||||
maintenance_message_es TEXT,
|
||||
@@ -716,6 +803,63 @@ async function migrate() {
|
||||
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!');
|
||||
|
||||
@@ -75,7 +75,7 @@ export const sqliteEvents = sqliteTable('events', {
|
||||
price: real('price').notNull().default(0),
|
||||
currency: text('currency').notNull().default('PYG'),
|
||||
capacity: integer('capacity').notNull().default(50),
|
||||
status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
|
||||
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
|
||||
bannerUrl: text('banner_url'),
|
||||
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
externalBookingUrl: text('external_booking_url'),
|
||||
@@ -85,6 +85,7 @@ export const sqliteEvents = sqliteTable('events', {
|
||||
|
||||
export const sqliteTickets = sqliteTable('tickets', {
|
||||
id: text('id').primaryKey(),
|
||||
bookingId: text('booking_id'), // Groups multiple tickets from same booking
|
||||
userId: text('user_id').notNull().references(() => sqliteUsers.id),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
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'),
|
||||
reference: text('reference'),
|
||||
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'),
|
||||
paidByAdminId: text('paid_by_admin_id'),
|
||||
adminNote: text('admin_note'), // Internal admin notes
|
||||
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
@@ -249,6 +252,52 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
|
||||
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
|
||||
export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -266,6 +315,8 @@ export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||
instagramUrl: text('instagram_url'),
|
||||
twitterUrl: text('twitter_url'),
|
||||
linkedinUrl: text('linkedin_url'),
|
||||
// Featured event - manually promoted event shown on homepage/linktree
|
||||
featuredEventId: text('featured_event_id').references(() => sqliteEvents.id),
|
||||
// Other settings
|
||||
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
|
||||
maintenanceMessage: text('maintenance_message'),
|
||||
@@ -356,6 +407,7 @@ export const pgEvents = pgTable('events', {
|
||||
|
||||
export const pgTickets = pgTable('tickets', {
|
||||
id: uuid('id').primaryKey(),
|
||||
bookingId: uuid('booking_id'), // Groups multiple tickets from same booking
|
||||
userId: uuid('user_id').notNull().references(() => pgUsers.id),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
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'),
|
||||
reference: varchar('reference', { length: 255 }),
|
||||
userMarkedPaidAt: timestamp('user_marked_paid_at'),
|
||||
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
|
||||
paidAt: timestamp('paid_at'),
|
||||
paidByAdminId: uuid('paid_by_admin_id'),
|
||||
adminNote: pgText('admin_note'),
|
||||
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
@@ -512,6 +566,52 @@ export const pgEmailSettings = pgTable('email_settings', {
|
||||
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
|
||||
export const pgSiteSettings = pgTable('site_settings', {
|
||||
id: uuid('id').primaryKey(),
|
||||
@@ -529,6 +629,8 @@ export const pgSiteSettings = pgTable('site_settings', {
|
||||
instagramUrl: varchar('instagram_url', { length: 500 }),
|
||||
twitterUrl: varchar('twitter_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
|
||||
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
|
||||
maintenanceMessage: pgText('maintenance_message'),
|
||||
@@ -555,7 +657,10 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
|
||||
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
||||
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
|
||||
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
|
||||
export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLegalSettings;
|
||||
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
||||
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
|
||||
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
|
||||
|
||||
// Type exports
|
||||
export type User = typeof sqliteUsers.$inferSelect;
|
||||
@@ -584,3 +689,9 @@ export type Invoice = typeof sqliteInvoices.$inferSelect;
|
||||
export type NewInvoice = typeof sqliteInvoices.$inferInsert;
|
||||
export type SiteSettings = typeof sqliteSiteSettings.$inferSelect;
|
||||
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;
|
||||
@@ -20,7 +20,11 @@ import emailsRoutes from './routes/emails.js';
|
||||
import paymentOptionsRoutes from './routes/payment-options.js';
|
||||
import dashboardRoutes from './routes/dashboard.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 { initEmailQueue } from './lib/emailQueue.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -83,6 +87,7 @@ const openApiSpec = {
|
||||
{ name: 'Media', description: 'File uploads and media management' },
|
||||
{ name: 'Lightning', description: 'Lightning/Bitcoin payments via LNBits' },
|
||||
{ name: 'Admin', description: 'Admin dashboard and analytics' },
|
||||
{ name: 'FAQ', description: 'FAQ questions (public and admin)' },
|
||||
],
|
||||
paths: {
|
||||
// ==================== 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: {
|
||||
securitySchemes: {
|
||||
@@ -1714,6 +1857,9 @@ app.route('/api/emails', emailsRoutes);
|
||||
app.route('/api/payment-options', paymentOptionsRoutes);
|
||||
app.route('/api/dashboard', dashboardRoutes);
|
||||
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
|
||||
app.notFound((c) => {
|
||||
@@ -1728,6 +1874,9 @@ app.onError((err, c) => {
|
||||
|
||||
const port = parseInt(process.env.PORT || '3001');
|
||||
|
||||
// Initialize email queue with the email service reference
|
||||
initEmailQueue(emailService);
|
||||
|
||||
// Initialize email templates on startup
|
||||
emailService.seedDefaultTemplates().catch(err => {
|
||||
console.error('[Email] Failed to seed templates:', err);
|
||||
|
||||
@@ -3,9 +3,9 @@ import * as argon2 from 'argon2';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
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 { 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_ISSUER = 'spanglish';
|
||||
@@ -51,7 +51,7 @@ export async function createMagicLinkToken(
|
||||
): Promise<string> {
|
||||
const token = generateSecureToken();
|
||||
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({
|
||||
id: generateId(),
|
||||
@@ -72,16 +72,17 @@ export async function verifyMagicLinkToken(
|
||||
): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const now = getNow();
|
||||
|
||||
const tokenRecord = await (db as any)
|
||||
.select()
|
||||
.from(magicLinkTokens)
|
||||
.where(
|
||||
and(
|
||||
eq((magicLinkTokens as any).token, token),
|
||||
eq((magicLinkTokens as any).type, type)
|
||||
const tokenRecord = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(magicLinkTokens)
|
||||
.where(
|
||||
and(
|
||||
eq((magicLinkTokens as any).token, token),
|
||||
eq((magicLinkTokens as any).type, type)
|
||||
)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!tokenRecord) {
|
||||
return { valid: false, error: 'Invalid token' };
|
||||
@@ -112,7 +113,7 @@ export async function createUserSession(
|
||||
): Promise<string> {
|
||||
const sessionToken = generateSecureToken();
|
||||
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({
|
||||
id: generateId(),
|
||||
@@ -132,16 +133,17 @@ export async function createUserSession(
|
||||
export async function getUserSessions(userId: string) {
|
||||
const now = getNow();
|
||||
|
||||
return (db as any)
|
||||
.select()
|
||||
.from(userSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((userSessions as any).userId, userId),
|
||||
gt((userSessions as any).expiresAt, now)
|
||||
return dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(userSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((userSessions as any).userId, userId),
|
||||
gt((userSessions as any).expiresAt, now)
|
||||
)
|
||||
)
|
||||
)
|
||||
.all();
|
||||
);
|
||||
}
|
||||
|
||||
// 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');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return null;
|
||||
@@ -221,7 +223,9 @@ export async function getAuthUser(c: Context) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -243,6 +247,8 @@ export function requireAuth(roles?: string[]) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Email service for Spanglish platform
|
||||
// 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 { nanoid } from 'nanoid';
|
||||
import { getNow } from './utils.js';
|
||||
import { getNow, generateId } from './utils.js';
|
||||
import {
|
||||
replaceTemplateVariables,
|
||||
wrapInBaseTemplate,
|
||||
defaultTemplates,
|
||||
type DefaultTemplate
|
||||
} from './emailTemplates.js';
|
||||
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
|
||||
import nodemailer 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);
|
||||
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
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);
|
||||
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: timezone,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -362,11 +374,12 @@ export const emailService = {
|
||||
* Get a template by slug
|
||||
*/
|
||||
async getTemplate(slug: string): Promise<any | null> {
|
||||
const template = await (db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).slug, slug))
|
||||
.get();
|
||||
const template = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).slug, slug))
|
||||
);
|
||||
|
||||
return template || null;
|
||||
},
|
||||
@@ -385,7 +398,7 @@ export const emailService = {
|
||||
console.log(`[Email] Creating template: ${template.name}`);
|
||||
|
||||
await (db as any).insert(emailTemplates).values({
|
||||
id: nanoid(),
|
||||
id: generateId(),
|
||||
name: template.name,
|
||||
slug: template.slug,
|
||||
subject: template.subject,
|
||||
@@ -470,7 +483,7 @@ export const emailService = {
|
||||
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
|
||||
|
||||
// Create log entry
|
||||
const logId = nanoid();
|
||||
const logId = generateId();
|
||||
const now = getNow();
|
||||
|
||||
await (db as any).insert(emailLogs).values({
|
||||
@@ -522,37 +535,66 @@ export const emailService = {
|
||||
|
||||
/**
|
||||
* Send booking confirmation email
|
||||
* Supports multi-ticket bookings - includes all tickets in the booking
|
||||
*/
|
||||
async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get ticket with event info
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
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' };
|
||||
}
|
||||
|
||||
// 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 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 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();
|
||||
|
||||
// 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({
|
||||
templateSlug: 'booking-confirmation',
|
||||
to: ticket.attendeeEmail,
|
||||
@@ -563,14 +605,20 @@ export const emailService = {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
bookingId: ticket.bookingId || ticket.id,
|
||||
qrCode: ticket.qrCode || '',
|
||||
ticketPdfUrl,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
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 }> {
|
||||
// Get payment with ticket and event info
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
.get();
|
||||
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' };
|
||||
}
|
||||
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
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' };
|
||||
}
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
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' };
|
||||
}
|
||||
|
||||
// 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 eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
const paymentMethodNames: Record<string, Record<string, string>> = {
|
||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash' },
|
||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo' },
|
||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
|
||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
|
||||
};
|
||||
|
||||
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({
|
||||
templateSlug: 'payment-receipt',
|
||||
to: ticket.attendeeEmail,
|
||||
@@ -627,13 +714,13 @@ export const emailService = {
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: receiptFullName,
|
||||
ticketId: ticket.id,
|
||||
ticketId: ticket.bookingId || ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
paymentAmount: this.formatCurrency(payment.amount, payment.currency),
|
||||
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
||||
paymentAmount: amountDisplay,
|
||||
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
|
||||
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>> {
|
||||
// Get global options
|
||||
const globalOptions = await (db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
const globalOptions = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
);
|
||||
|
||||
// Get event overrides
|
||||
const overrides = await (db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
.get();
|
||||
const overrides = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
);
|
||||
|
||||
// Defaults
|
||||
const defaults = {
|
||||
@@ -696,33 +785,36 @@ export const emailService = {
|
||||
*/
|
||||
async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get ticket
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
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' };
|
||||
}
|
||||
|
||||
// Get payment
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
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 attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Generate a payment reference using ticket ID
|
||||
const paymentReference = `SPG-${ticket.id.substring(0, 8).toUpperCase()}`;
|
||||
// Calculate total price for multi-ticket bookings
|
||||
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
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
@@ -752,17 +860,25 @@ export const emailService = {
|
||||
? 'payment-instructions-tpago'
|
||||
: '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
|
||||
const variables: Record<string, any> = {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
ticketId: ticket.bookingId || ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
paymentAmount: this.formatCurrency(event.price, event.currency),
|
||||
paymentAmount: amountDisplay,
|
||||
paymentReference,
|
||||
bookingUrl,
|
||||
};
|
||||
@@ -797,33 +913,36 @@ export const emailService = {
|
||||
*/
|
||||
async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get payment
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
.get();
|
||||
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' };
|
||||
}
|
||||
|
||||
// Get ticket
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
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 (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
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' };
|
||||
@@ -837,6 +956,9 @@ export const emailService = {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
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}`);
|
||||
|
||||
return this.sendTemplateEmail({
|
||||
@@ -850,8 +972,8 @@ export const emailService = {
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
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
|
||||
*/
|
||||
@@ -872,11 +1094,12 @@ export const emailService = {
|
||||
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
||||
|
||||
// Get event
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
.get();
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
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) {
|
||||
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 failedCount = 0;
|
||||
const errors: string[] = [];
|
||||
@@ -925,8 +1151,8 @@ export const emailService = {
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
...customVariables,
|
||||
@@ -948,6 +1174,100 @@ export const emailService = {
|
||||
errors,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -958,10 +1278,11 @@ export const emailService = {
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyText?: string;
|
||||
replyTo?: string;
|
||||
eventId?: string;
|
||||
sentBy: string;
|
||||
sentBy?: string | null;
|
||||
}): 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 = {
|
||||
...this.getCommonVariables(),
|
||||
@@ -971,7 +1292,7 @@ export const emailService = {
|
||||
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
|
||||
|
||||
// Create log entry
|
||||
const logId = nanoid();
|
||||
const logId = generateId();
|
||||
const now = getNow();
|
||||
|
||||
await (db as any).insert(emailLogs).values({
|
||||
@@ -983,7 +1304,7 @@ export const emailService = {
|
||||
subject,
|
||||
bodyHtml: finalBodyHtml,
|
||||
status: 'pending',
|
||||
sentBy,
|
||||
sentBy: sentBy || null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
@@ -993,6 +1314,7 @@ export const emailService = {
|
||||
subject,
|
||||
html: finalBodyHtml,
|
||||
text: bodyText,
|
||||
replyTo,
|
||||
});
|
||||
|
||||
// Update log
|
||||
|
||||
194
backend/src/lib/emailQueue.ts
Normal 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.`);
|
||||
}
|
||||
@@ -991,6 +991,118 @@ Spanglish`,
|
||||
],
|
||||
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',
|
||||
slug: 'payment-rejected',
|
||||
|
||||
80
backend/src/lib/legal-placeholders.ts
Normal 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] ?? '';
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ interface TicketData {
|
||||
location: 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);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
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);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
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.moveDown(0.5);
|
||||
|
||||
// Date and time
|
||||
// Date and time (using site timezone)
|
||||
const tz = ticket.timezone || 'America/Asuncion';
|
||||
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 endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null;
|
||||
const startTime = formatTime(ticket.event.startDatetime, tz);
|
||||
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
|
||||
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
|
||||
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.moveDown(0.5);
|
||||
|
||||
// Date and time (using site timezone)
|
||||
const tz = ticket.timezone || 'America/Asuncion';
|
||||
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 endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null;
|
||||
const startTime = formatTime(ticket.event.startDatetime, tz);
|
||||
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
|
||||
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
|
||||
doc.text(timeRange, { align: 'center' });
|
||||
|
||||
|
||||
22
backend/src/lib/revalidate.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Trigger frontend cache revalidation (fire-and-forget)
|
||||
// Revalidates both the sitemap and the next-event data (homepage, llms.txt)
|
||||
export function revalidateFrontendCache() {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||
const secret = process.env.REVALIDATE_SECRET;
|
||||
if (!secret) {
|
||||
console.warn('REVALIDATE_SECRET not set, skipping frontend revalidation');
|
||||
return;
|
||||
}
|
||||
fetch(`${frontendUrl}/api/revalidate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret, tag: ['events-sitemap', 'next-event'] }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) console.error('Frontend revalidation failed:', res.status);
|
||||
else console.log('Frontend revalidation triggered (sitemap + next-event)');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Frontend revalidation error:', err.message);
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,71 @@
|
||||
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 {
|
||||
return nanoid(21);
|
||||
return getDbType() === 'postgres' ? randomUUID() : nanoid(21);
|
||||
}
|
||||
|
||||
export function generateTicketCode(): string {
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { db, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { eq, and, gte, sql, desc } from 'drizzle-orm';
|
||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { eq, and, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
|
||||
@@ -11,74 +11,84 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
const now = getNow();
|
||||
|
||||
// Get upcoming events
|
||||
const upcomingEvents = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq((events as any).status, 'published'),
|
||||
gte((events as any).startDatetime, now)
|
||||
const upcomingEvents = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq((events as any).status, 'published'),
|
||||
gte((events as any).startDatetime, now)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy((events as any).startDatetime)
|
||||
.limit(5)
|
||||
.all();
|
||||
.orderBy((events as any).startDatetime)
|
||||
.limit(5)
|
||||
);
|
||||
|
||||
// Get recent tickets
|
||||
const recentTickets = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
.limit(10)
|
||||
.all();
|
||||
const recentTickets = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
.limit(10)
|
||||
);
|
||||
|
||||
// Get total stats
|
||||
const totalUsers = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.get();
|
||||
const totalUsers = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
);
|
||||
|
||||
const totalEvents = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(events)
|
||||
.get();
|
||||
const totalEvents = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(events)
|
||||
);
|
||||
|
||||
const totalTickets = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.get();
|
||||
const totalTickets = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
);
|
||||
|
||||
const confirmedTickets = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).status, 'confirmed'))
|
||||
.get();
|
||||
const confirmedTickets = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).status, 'confirmed'))
|
||||
);
|
||||
|
||||
const pendingPayments = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending'))
|
||||
.get();
|
||||
const pendingPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending'))
|
||||
);
|
||||
|
||||
const paidPayments = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'paid'))
|
||||
.all();
|
||||
const paidPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'paid'))
|
||||
);
|
||||
|
||||
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)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(contacts)
|
||||
.where(eq((contacts as any).status, 'new'))
|
||||
.get();
|
||||
const newContacts = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(contacts)
|
||||
.where(eq((contacts as any).status, 'new'))
|
||||
);
|
||||
|
||||
const totalSubscribers = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emailSubscribers)
|
||||
.where(eq((emailSubscribers as any).status, 'active'))
|
||||
.get();
|
||||
const totalSubscribers = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emailSubscribers)
|
||||
.where(eq((emailSubscribers as any).status, 'active'))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
dashboard: {
|
||||
@@ -101,37 +111,40 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
// Get analytics data (admin)
|
||||
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
||||
// 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(
|
||||
allEvents.map(async (event: any) => {
|
||||
const ticketCount = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, event.id))
|
||||
.get();
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, event.id))
|
||||
);
|
||||
|
||||
const confirmedCount = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
const confirmedCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
const checkedInCount = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'checked_in')
|
||||
const checkedInCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'checked_in')
|
||||
)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
@@ -163,28 +176,31 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
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
|
||||
const enrichedTickets = await Promise.all(
|
||||
ticketList.map(async (ticket: any) => {
|
||||
const user = await (db as any)
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq((users as any).id, ticket.userId))
|
||||
.get();
|
||||
const user = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq((users as any).id, ticket.userId))
|
||||
);
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
.get();
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
|
||||
return {
|
||||
ticketId: ticket.id,
|
||||
@@ -206,6 +222,211 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
return c.json({ tickets: enrichedTickets });
|
||||
});
|
||||
|
||||
// Export attendees for a specific event (admin) — CSV download
|
||||
adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
|
||||
const q = c.req.query('q') || '';
|
||||
|
||||
// Verify event exists
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Build query for tickets belonging to this event
|
||||
let conditions: any[] = [eq((tickets as any).eventId, eventId)];
|
||||
|
||||
if (status === 'confirmed') {
|
||||
conditions.push(eq((tickets as any).status, 'confirmed'));
|
||||
} else if (status === 'checked_in') {
|
||||
conditions.push(eq((tickets as any).status, 'checked_in'));
|
||||
} else if (status === 'confirmed_pending') {
|
||||
conditions.push(inArray((tickets as any).status, ['confirmed', 'pending']));
|
||||
} else {
|
||||
// "all" — include everything
|
||||
}
|
||||
|
||||
let ticketList = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(conditions.length === 1 ? conditions[0] : and(...conditions))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Apply text search filter in-memory
|
||||
if (q) {
|
||||
const query = q.toLowerCase();
|
||||
ticketList = ticketList.filter((t: any) => {
|
||||
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
|
||||
return (
|
||||
fullName.includes(query) ||
|
||||
(t.attendeeEmail || '').toLowerCase().includes(query) ||
|
||||
(t.attendeePhone || '').toLowerCase().includes(query) ||
|
||||
t.id.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich each ticket with payment data
|
||||
const rows = await Promise.all(
|
||||
ticketList.map(async (ticket: any) => {
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
|
||||
const fullName = [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' ');
|
||||
const isCheckedIn = ticket.status === 'checked_in';
|
||||
|
||||
return {
|
||||
'Ticket ID': ticket.id,
|
||||
'Full Name': fullName,
|
||||
'Email': ticket.attendeeEmail || '',
|
||||
'Phone': ticket.attendeePhone || '',
|
||||
'Status': ticket.status,
|
||||
'Checked In': isCheckedIn ? 'true' : 'false',
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Payment Status': payment?.status || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
'Notes': ticket.adminNote || '',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Generate CSV
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
||||
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
||||
'Booked At', 'Notes',
|
||||
];
|
||||
|
||||
const headerLine = columns.map(csvEscape).join(',');
|
||||
const dataLines = rows.map((row: any) =>
|
||||
columns.map((col) => csvEscape(row[col])).join(',')
|
||||
);
|
||||
|
||||
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n'); // BOM for UTF-8
|
||||
|
||||
// Build filename: event-slug-attendees-YYYY-MM-DD.csv
|
||||
const slug = (event.title || 'event')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `${slug}-attendees-${dateStr}.csv`;
|
||||
|
||||
c.header('Content-Type', 'text/csv; charset=utf-8');
|
||||
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return c.body(csvContent);
|
||||
});
|
||||
|
||||
// Legacy alias — keep old path working
|
||||
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => {
|
||||
const newUrl = new URL(c.req.url);
|
||||
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
|
||||
return c.redirect(newUrl.toString(), 301);
|
||||
});
|
||||
|
||||
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
|
||||
adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
|
||||
const q = c.req.query('q') || '';
|
||||
|
||||
// Verify event exists
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Only confirmed/checked_in for tickets export
|
||||
let conditions: any[] = [
|
||||
eq((tickets as any).eventId, eventId),
|
||||
inArray((tickets as any).status, ['confirmed', 'checked_in']),
|
||||
];
|
||||
|
||||
if (status === 'confirmed') {
|
||||
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'confirmed')];
|
||||
} else if (status === 'checked_in') {
|
||||
conditions = [eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'checked_in')];
|
||||
}
|
||||
|
||||
let ticketList = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Apply text search filter
|
||||
if (q) {
|
||||
const query = q.toLowerCase();
|
||||
ticketList = ticketList.filter((t: any) => {
|
||||
const fullName = `${t.attendeeFirstName || ''} ${t.attendeeLastName || ''}`.toLowerCase();
|
||||
return (
|
||||
fullName.includes(query) ||
|
||||
t.id.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
|
||||
|
||||
const rows = ticketList.map((ticket: any) => ({
|
||||
'Ticket ID': ticket.id,
|
||||
'Booking ID': ticket.bookingId || '',
|
||||
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
|
||||
'Status': ticket.status,
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
}));
|
||||
|
||||
const headerLine = columns.map(csvEscape).join(',');
|
||||
const dataLines = rows.map((row: any) =>
|
||||
columns.map((col: string) => csvEscape(row[col])).join(',')
|
||||
);
|
||||
|
||||
const csvContent = '\uFEFF' + [headerLine, ...dataLines].join('\r\n');
|
||||
|
||||
const slug = (event.title || 'event')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `${slug}-tickets-${dateStr}.csv`;
|
||||
|
||||
c.header('Content-Type', 'text/csv; charset=utf-8');
|
||||
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return c.body(csvContent);
|
||||
});
|
||||
|
||||
// Export financial data (admin)
|
||||
adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
const startDate = c.req.query('startDate');
|
||||
@@ -215,24 +436,26 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
// Get all 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
|
||||
const enrichedPayments = await Promise.all(
|
||||
allPayments.map(async (payment: any) => {
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) return null;
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
// Apply filters
|
||||
if (eventId && ticket.eventId !== eventId) return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 {
|
||||
hashPassword,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
invalidateAllUserSessions,
|
||||
requireAuth,
|
||||
} 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';
|
||||
|
||||
// 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
|
||||
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 user exists but is unclaimed, allow claiming
|
||||
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
|
||||
@@ -149,7 +151,7 @@ auth.post('/register', zValidator('json', registerSchema), async (c) => {
|
||||
phone: data.phone || null,
|
||||
role: firstUser ? 'admin' : 'user',
|
||||
languagePreference: data.languagePreference || null,
|
||||
isClaimed: true,
|
||||
isClaimed: toDbBool(true),
|
||||
googleId: null,
|
||||
rucNumber: null,
|
||||
accountStatus: 'active',
|
||||
@@ -189,7 +191,9 @@ auth.post('/login', zValidator('json', loginSchema), async (c) => {
|
||||
}, 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) {
|
||||
recordFailedAttempt(data.email);
|
||||
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) => {
|
||||
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) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
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') {
|
||||
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) => {
|
||||
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) {
|
||||
// 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) => {
|
||||
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) {
|
||||
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 updates: Record<string, any> = {
|
||||
isClaimed: true,
|
||||
isClaimed: toDbBool(true),
|
||||
accountStatus: 'active',
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -461,7 +473,9 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn
|
||||
.set(updates)
|
||||
.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 refreshToken = await createRefreshToken(user.id);
|
||||
@@ -510,11 +524,15 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
|
||||
const { sub: googleId, email, name } = googleData;
|
||||
|
||||
// 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) {
|
||||
// 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();
|
||||
@@ -530,7 +548,7 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
|
||||
.update(users)
|
||||
.set({
|
||||
googleId,
|
||||
isClaimed: true,
|
||||
isClaimed: toDbBool(true),
|
||||
accountStatus: 'active',
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -538,7 +556,9 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Create new user
|
||||
const firstUser = await isFirstUser();
|
||||
@@ -552,7 +572,7 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
|
||||
phone: null,
|
||||
role: firstUser ? 'admin' : 'user',
|
||||
languagePreference: null,
|
||||
isClaimed: true,
|
||||
isClaimed: toDbBool(true),
|
||||
googleId,
|
||||
rucNumber: null,
|
||||
accountStatus: 'active',
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
import { emailService } from '../lib/email.js';
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
name: z.string().min(2),
|
||||
email: z.string().email(),
|
||||
@@ -29,17 +53,74 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
|
||||
// Sanitize header-sensitive values to prevent email header injection
|
||||
const sanitizedEmail = sanitizeHeaderValue(data.email);
|
||||
const sanitizedName = sanitizeHeaderValue(data.name);
|
||||
|
||||
const newContact = {
|
||||
id,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
name: sanitizedName,
|
||||
email: sanitizedEmail,
|
||||
message: data.message,
|
||||
status: 'new' as const,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Always store the message in admin, regardless of email outcome
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -48,11 +129,12 @@ contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c)
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Check if already subscribed
|
||||
const existing = await (db as any)
|
||||
.select()
|
||||
.from(emailSubscribers)
|
||||
.where(eq((emailSubscribers as any).email, data.email))
|
||||
.get();
|
||||
const existing = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailSubscribers)
|
||||
.where(eq((emailSubscribers as any).email, data.email))
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
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) => {
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const existing = await (db as any)
|
||||
.select()
|
||||
.from(emailSubscribers)
|
||||
.where(eq((emailSubscribers as any).email, email))
|
||||
.get();
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
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));
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
@@ -124,11 +204,12 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
contactsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const contact = await (db as any)
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq((contacts as any).id, id))
|
||||
.get();
|
||||
const contact = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq((contacts as any).id, id))
|
||||
);
|
||||
|
||||
if (!contact) {
|
||||
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 data = c.req.valid('json');
|
||||
|
||||
const existing = await (db as any)
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq((contacts as any).id, id))
|
||||
.get();
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(contacts).where(eq((contacts as any).id, id))
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Contact not found' }, 404);
|
||||
@@ -157,11 +236,9 @@ contactsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
.set({ status: data.status })
|
||||
.where(eq((contacts as any).id, id));
|
||||
|
||||
const updated = await (db as any)
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq((contacts as any).id, id))
|
||||
.get();
|
||||
const updated = await dbGet<any>(
|
||||
(db as any).select().from(contacts).where(eq((contacts as any).id, id))
|
||||
);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.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));
|
||||
|
||||
const updatedUser = await (db as any)
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq((users as any).id, user.id))
|
||||
.get();
|
||||
const updatedUser = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq((users as any).id, user.id))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
profile: {
|
||||
@@ -95,36 +96,40 @@ dashboard.put('/profile', zValidator('json', updateProfileSchema), async (c) =>
|
||||
dashboard.get('/tickets', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
const userTickets = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.id))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
.all();
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.id))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Get event details for each ticket
|
||||
const ticketsWithEvents = await Promise.all(
|
||||
userTickets.map(async (ticket: any) => {
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
.get();
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
|
||||
// Check for invoice
|
||||
let invoice = null;
|
||||
let invoice: any = null;
|
||||
if (payment && payment.status === 'paid') {
|
||||
invoice = await (db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).paymentId, payment.id))
|
||||
.get();
|
||||
invoice = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).paymentId, payment.id))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -168,40 +173,44 @@ dashboard.get('/tickets/:id', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const ticketId = c.req.param('id');
|
||||
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).id, ticketId),
|
||||
eq((tickets as any).userId, user.id)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).id, ticketId),
|
||||
eq((tickets as any).userId, user.id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
const event = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
.get();
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
|
||||
let invoice = null;
|
||||
if (payment && payment.status === 'paid') {
|
||||
invoice = await (db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).paymentId, payment.id))
|
||||
.get();
|
||||
invoice = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).paymentId, payment.id))
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -222,11 +231,12 @@ dashboard.get('/next-event', async (c) => {
|
||||
const now = getNow();
|
||||
|
||||
// Get user's tickets for upcoming events
|
||||
const userTickets = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.id))
|
||||
.all();
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.id))
|
||||
);
|
||||
|
||||
if (userTickets.length === 0) {
|
||||
return c.json({ nextEvent: null });
|
||||
@@ -240,11 +250,12 @@ dashboard.get('/next-event', async (c) => {
|
||||
for (const ticket of userTickets) {
|
||||
if (ticket.status === 'cancelled') continue;
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (!event) continue;
|
||||
|
||||
@@ -253,11 +264,12 @@ dashboard.get('/next-event', async (c) => {
|
||||
if (!nextEvent || new Date(event.startDatetime) < new Date(nextEvent.startDatetime)) {
|
||||
nextEvent = event;
|
||||
nextTicket = ticket;
|
||||
nextPayment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
.get();
|
||||
nextPayment = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,11 +294,12 @@ dashboard.get('/payments', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
// Get all user's tickets first
|
||||
const userTickets = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.id))
|
||||
.all();
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.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
|
||||
const allPayments = [];
|
||||
for (const ticketId of ticketIds) {
|
||||
const ticketPayments = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.all();
|
||||
const ticketPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
for (const payment of ticketPayments) {
|
||||
const ticket = userTickets.find((t: any) => t.id === payment.ticketId);
|
||||
const event = ticket
|
||||
? await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get()
|
||||
? await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
)
|
||||
: null;
|
||||
|
||||
let invoice = null;
|
||||
let invoice: any = null;
|
||||
if (payment.status === 'paid') {
|
||||
invoice = await (db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).paymentId, payment.id))
|
||||
.get();
|
||||
invoice = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).paymentId, payment.id))
|
||||
);
|
||||
}
|
||||
|
||||
allPayments.push({
|
||||
@@ -355,36 +371,40 @@ dashboard.get('/payments', async (c) => {
|
||||
dashboard.get('/invoices', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
const userInvoices = await (db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).userId, user.id))
|
||||
.orderBy(desc((invoices as any).createdAt))
|
||||
.all();
|
||||
const userInvoices = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).userId, user.id))
|
||||
.orderBy(desc((invoices as any).createdAt))
|
||||
);
|
||||
|
||||
// Get payment and event details for each invoice
|
||||
const invoicesWithDetails = await Promise.all(
|
||||
userInvoices.map(async (invoice: any) => {
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, invoice.paymentId))
|
||||
.get();
|
||||
|
||||
let event = null;
|
||||
if (payment) {
|
||||
const ticket = await (db as any)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, invoice.paymentId))
|
||||
);
|
||||
|
||||
let event: any = null;
|
||||
if (payment) {
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
if (ticket) {
|
||||
event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,11 +531,12 @@ dashboard.get('/summary', async (c) => {
|
||||
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Get ticket count
|
||||
const userTickets = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.id))
|
||||
.all();
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.id))
|
||||
);
|
||||
|
||||
const totalTickets = userTickets.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) {
|
||||
if (ticket.status === 'cancelled') continue;
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (event && new Date(event.startDatetime) > now) {
|
||||
upcomingTickets.push({ ticket, event });
|
||||
@@ -540,16 +562,17 @@ dashboard.get('/summary', async (c) => {
|
||||
let pendingPayments = 0;
|
||||
|
||||
for (const ticketId of ticketIds) {
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(
|
||||
and(
|
||||
eq((payments as any).ticketId, ticketId),
|
||||
eq((payments as any).status, 'pending_approval')
|
||||
const payment = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(
|
||||
and(
|
||||
eq((payments as any).ticketId, ticketId),
|
||||
eq((payments as any).status, 'pending_approval')
|
||||
)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
if (payment) pendingPayments++;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getNow, generateId } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
||||
import { getQueueStatus } from '../lib/emailQueue.js';
|
||||
|
||||
const emailsRouter = new Hono();
|
||||
|
||||
@@ -13,11 +13,9 @@ const emailsRouter = new Hono();
|
||||
|
||||
// Get all email templates
|
||||
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const templates = await (db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.orderBy(desc((emailTemplates as any).createdAt))
|
||||
.all();
|
||||
const templates = await dbAll<any>(
|
||||
(db as any).select().from(emailTemplates).orderBy(desc((emailTemplates as any).createdAt))
|
||||
);
|
||||
|
||||
// Parse variables JSON for each template
|
||||
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) => {
|
||||
const { id } = c.req.param();
|
||||
|
||||
const template = await (db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).id, id))
|
||||
.get();
|
||||
const template = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).id, id))
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
return c.json({ error: 'Template not found' }, 404);
|
||||
@@ -64,11 +63,9 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
|
||||
}
|
||||
|
||||
// Check if slug already exists
|
||||
const existing = await (db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).slug, slug))
|
||||
.get();
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
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 template = {
|
||||
id: nanoid(),
|
||||
id: generateId(),
|
||||
name,
|
||||
slug,
|
||||
subject,
|
||||
@@ -111,11 +108,12 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await (db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).id, id))
|
||||
.get();
|
||||
const existing = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).id, id))
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Template not found' }, 404);
|
||||
@@ -148,11 +146,12 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||
.set(updateData)
|
||||
.where(eq((emailTemplates as any).id, id));
|
||||
|
||||
const updated = await (db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).id, id))
|
||||
.get();
|
||||
const updated = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
template: {
|
||||
@@ -169,11 +168,9 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||
emailsRouter.delete('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
|
||||
const template = await (db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).id, id))
|
||||
.get();
|
||||
const template = await dbGet<any>(
|
||||
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).id, id))
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
return c.json({ error: 'Template not found' }, 404);
|
||||
@@ -199,7 +196,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
|
||||
|
||||
// ==================== 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) => {
|
||||
const { eventId } = c.req.param();
|
||||
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);
|
||||
}
|
||||
|
||||
const result = await emailService.sendToEventAttendees({
|
||||
// Queue emails for background processing instead of sending synchronously
|
||||
const result = await emailService.queueEventEmails({
|
||||
eventId,
|
||||
templateSlug,
|
||||
customVariables,
|
||||
@@ -306,11 +304,12 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
|
||||
const logs = await query
|
||||
.orderBy(desc((emailLogs as any).createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
const logs = await dbAll(
|
||||
query
|
||||
.orderBy(desc((emailLogs as any).createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
);
|
||||
|
||||
// Get total count
|
||||
let countQuery = (db as any)
|
||||
@@ -321,7 +320,7 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
countQuery = countQuery.where(and(...conditions));
|
||||
}
|
||||
|
||||
const totalResult = await countQuery.get();
|
||||
const totalResult = await dbGet<any>(countQuery);
|
||||
const total = totalResult?.count || 0;
|
||||
|
||||
return c.json({
|
||||
@@ -339,11 +338,9 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
|
||||
const log = await (db as any)
|
||||
.select()
|
||||
.from(emailLogs)
|
||||
.where(eq((emailLogs as any).id, id))
|
||||
.get();
|
||||
const log = await dbGet<any>(
|
||||
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, id))
|
||||
);
|
||||
|
||||
if (!log) {
|
||||
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);
|
||||
|
||||
const total = (await totalQuery.get())?.count || 0;
|
||||
const total = (await dbGet<any>(totalQuery))?.count || 0;
|
||||
|
||||
const sentCondition = baseCondition
|
||||
? and(baseCondition, 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
|
||||
? and(baseCondition, 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
|
||||
? and(baseCondition, 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({
|
||||
stats: {
|
||||
@@ -416,4 +413,10 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
|
||||
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;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -15,6 +16,21 @@ interface UserContext {
|
||||
|
||||
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
|
||||
// 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
|
||||
const validationHook = (result: any, c: any) => {
|
||||
if (!result.success) {
|
||||
@@ -23,6 +39,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({
|
||||
title: z.string().min(1),
|
||||
titleEs: z.string().optional().nullable(),
|
||||
@@ -34,14 +71,15 @@ const baseEventSchema = z.object({
|
||||
endDatetime: z.string().optional().nullable(),
|
||||
location: z.string().min(1),
|
||||
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'),
|
||||
capacity: z.number().min(1).default(50),
|
||||
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||
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', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||
// Accept relative paths (/uploads/...) or full URLs
|
||||
bannerUrl: z.string().optional().nullable().or(z.literal('')),
|
||||
// External booking support
|
||||
externalBookingEnabled: z.boolean().default(false),
|
||||
// External booking support - accept boolean or number (0/1 from DB)
|
||||
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
|
||||
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||
});
|
||||
|
||||
@@ -94,26 +132,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
|
||||
const eventsWithCounts = await Promise.all(
|
||||
result.map(async (event: any) => {
|
||||
const ticketCount = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||
// This ensures check-in doesn't affect capacity/spots_left
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
const normalized = normalizeEvent(event);
|
||||
const bookedCount = ticketCount?.count || 0;
|
||||
return {
|
||||
...event,
|
||||
bookedCount: ticketCount?.count || 0,
|
||||
availableSeats: event.capacity - (ticketCount?.count || 0),
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -125,70 +168,144 @@ eventsRouter.get('/', async (c) => {
|
||||
eventsRouter.get('/:id', async (c) => {
|
||||
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) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Get ticket count
|
||||
const ticketCount = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, id),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||
// This ensures check-in doesn't affect capacity/spots_left
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, id),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
const normalized = normalizeEvent(event);
|
||||
const bookedCount = ticketCount?.count || 0;
|
||||
return c.json({
|
||||
event: {
|
||||
...event,
|
||||
bookedCount: ticketCount?.count || 0,
|
||||
availableSeats: event.capacity - (ticketCount?.count || 0),
|
||||
...normalized,
|
||||
bookedCount,
|
||||
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) => {
|
||||
const now = getNow();
|
||||
const nowMs = Date.now();
|
||||
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq((events as any).status, 'published'),
|
||||
gte((events as any).startDatetime, now)
|
||||
// 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) {
|
||||
featuredEvent = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, settings.featuredEventId))
|
||||
);
|
||||
|
||||
if (featuredEvent) {
|
||||
const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime;
|
||||
const isPublished = featuredEvent.status === 'published';
|
||||
const hasNotEnded = new Date(eventEndTime).getTime() > nowMs;
|
||||
|
||||
if (!isPublished || !hasNotEnded) {
|
||||
shouldUnsetFeatured = true;
|
||||
featuredEvent = null;
|
||||
}
|
||||
} else {
|
||||
shouldUnsetFeatured = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUnsetFeatured && settings) {
|
||||
try {
|
||||
await (db as any)
|
||||
.update(siteSettings)
|
||||
.set({ featuredEventId: null, updatedAt: now })
|
||||
.where(eq((siteSettings as any).id, settings.id));
|
||||
console.log('Featured event auto-cleared (event ended or unpublished)');
|
||||
revalidateFrontendCache();
|
||||
} 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()
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq((events as any).status, 'published'),
|
||||
gte((events as any).startDatetime, now)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy((events as any).startDatetime)
|
||||
.limit(1)
|
||||
.get();
|
||||
.orderBy((events as any).startDatetime)
|
||||
.limit(1)
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ event: null });
|
||||
}
|
||||
|
||||
const ticketCount = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
const bookedCount = await getEventTicketCount(event.id);
|
||||
const normalized = normalizeEvent(event);
|
||||
return c.json({
|
||||
event: {
|
||||
...event,
|
||||
bookedCount: ticketCount?.count || 0,
|
||||
availableSeats: event.capacity - (ticketCount?.count || 0),
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
isFeatured: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -200,16 +317,25 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
|
||||
// Convert data for database compatibility
|
||||
const dbData = convertBooleansForDb(data);
|
||||
|
||||
const newEvent = {
|
||||
id,
|
||||
...data,
|
||||
...dbData,
|
||||
startDatetime: toDbDate(data.startDatetime),
|
||||
endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
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)
|
||||
@@ -217,46 +343,67 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
const id = c.req.param('id');
|
||||
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) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
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)
|
||||
.update(events)
|
||||
.set({ ...data, updatedAt: now })
|
||||
.set(updateData)
|
||||
.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)
|
||||
eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
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) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Get all tickets for this event
|
||||
const eventTickets = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, id))
|
||||
.all();
|
||||
const eventTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, id))
|
||||
);
|
||||
|
||||
// Delete invoices and payments for all tickets of this event
|
||||
for (const ticket of eventTickets) {
|
||||
// Get payments for this ticket
|
||||
const ticketPayments = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
.all();
|
||||
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) {
|
||||
@@ -282,6 +429,9 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
// Finally delete the event
|
||||
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' });
|
||||
});
|
||||
|
||||
@@ -289,11 +439,12 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const attendees = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, id))
|
||||
.all();
|
||||
const attendees = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, id))
|
||||
);
|
||||
|
||||
return c.json({ attendees });
|
||||
});
|
||||
@@ -302,7 +453,9 @@ eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']),
|
||||
eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
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) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
@@ -319,7 +472,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
descriptionEs: existing.descriptionEs,
|
||||
shortDescription: existing.shortDescription,
|
||||
shortDescriptionEs: existing.shortDescriptionEs,
|
||||
startDatetime: existing.startDatetime,
|
||||
startDatetime: existing.startDatetime, // Already in DB format from existing record
|
||||
endDatetime: existing.endDatetime,
|
||||
location: existing.location,
|
||||
locationUrl: existing.locationUrl,
|
||||
@@ -328,7 +481,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
capacity: existing.capacity,
|
||||
status: 'draft',
|
||||
bannerUrl: existing.bannerUrl,
|
||||
externalBookingEnabled: existing.externalBookingEnabled || false,
|
||||
externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1)
|
||||
externalBookingUrl: existing.externalBookingUrl,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -336,7 +489,7 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
|
||||
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;
|
||||
|
||||
242
backend/src/routes/faq.ts
Normal 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;
|
||||
400
backend/src/routes/legal-pages.ts
Normal 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;
|
||||
146
backend/src/routes/legal-settings.ts
Normal 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
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 { getNow } from '../lib/utils.js';
|
||||
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
|
||||
@@ -152,49 +152,72 @@ lnbitsRouter.post('/webhook', async (c) => {
|
||||
|
||||
/**
|
||||
* Handle successful payment
|
||||
* Supports multi-ticket bookings - confirms all tickets in the booking
|
||||
*/
|
||||
async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
const now = getNow();
|
||||
|
||||
// Check if already confirmed to avoid duplicate updates
|
||||
const existingTicket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
// Get the ticket to check for booking ID
|
||||
const existingTicket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
|
||||
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`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, ticketId));
|
||||
// Get all tickets in this booking (if multi-ticket)
|
||||
let ticketsToConfirm: any[] = [existingTicket];
|
||||
|
||||
// Update payment status to paid
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
reference: paymentHash,
|
||||
paidAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, ticketId));
|
||||
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`);
|
||||
}
|
||||
|
||||
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||
// Confirm all tickets in the booking
|
||||
for (const ticket of ticketsToConfirm) {
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, ticket.id));
|
||||
|
||||
// Update payment status to paid
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
reference: paymentHash,
|
||||
paidAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, ticket.id));
|
||||
|
||||
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||
}
|
||||
|
||||
// Get payment for sending receipt
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
// Get primary payment for sending receipt
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
// Send confirmation emails asynchronously
|
||||
// For multi-ticket bookings, send email with all ticket info
|
||||
Promise.all([
|
||||
emailService.sendBookingConfirmation(ticketId),
|
||||
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
|
||||
@@ -211,11 +234,9 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
const ticketId = c.req.param('ticketId');
|
||||
|
||||
// Verify ticket exists
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
@@ -227,11 +248,9 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
}
|
||||
|
||||
// Get payment to start background checker
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
// Start background checker if not already running
|
||||
if (payment?.reference && !activeCheckers.has(ticketId)) {
|
||||
@@ -290,21 +309,23 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
lnbitsRouter.get('/status/:ticketId', async (c) => {
|
||||
const ticketId = c.req.param('ticketId');
|
||||
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
ticketStatus: ticket.status,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { requireAuth } from '../lib/auth.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) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const mediaRecord = await (db as any)
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq((media as any).id, id))
|
||||
.get();
|
||||
const mediaRecord = await dbGet<any>(
|
||||
(db as any).select().from(media).where(eq((media as any).id, id))
|
||||
);
|
||||
|
||||
if (!mediaRecord) {
|
||||
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) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const mediaRecord = await (db as any)
|
||||
.select()
|
||||
.from(media)
|
||||
.where(eq((media as any).id, id))
|
||||
.get();
|
||||
const mediaRecord = await dbGet<any>(
|
||||
(db as any).select().from(media).where(eq((media as any).id, id))
|
||||
);
|
||||
|
||||
if (!mediaRecord) {
|
||||
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));
|
||||
}
|
||||
|
||||
const result = await query.all();
|
||||
const result = await dbAll(query);
|
||||
|
||||
return c.json({ media: result });
|
||||
});
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
|
||||
|
||||
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
|
||||
const updatePaymentOptionsSchema = z.object({
|
||||
tpagoEnabled: z.boolean().optional(),
|
||||
tpagoEnabled: booleanOrNumber.optional(),
|
||||
tpagoLink: z.string().optional().nullable(),
|
||||
tpagoInstructions: z.string().optional().nullable(),
|
||||
tpagoInstructionsEs: z.string().optional().nullable(),
|
||||
bankTransferEnabled: z.boolean().optional(),
|
||||
bankTransferEnabled: booleanOrNumber.optional(),
|
||||
bankName: z.string().optional().nullable(),
|
||||
bankAccountHolder: z.string().optional().nullable(),
|
||||
bankAccountNumber: z.string().optional().nullable(),
|
||||
@@ -22,21 +28,21 @@ const updatePaymentOptionsSchema = z.object({
|
||||
bankPhone: z.string().optional().nullable(),
|
||||
bankNotes: z.string().optional().nullable(),
|
||||
bankNotesEs: z.string().optional().nullable(),
|
||||
lightningEnabled: z.boolean().optional(),
|
||||
cashEnabled: z.boolean().optional(),
|
||||
lightningEnabled: booleanOrNumber.optional(),
|
||||
cashEnabled: booleanOrNumber.optional(),
|
||||
cashInstructions: z.string().optional().nullable(),
|
||||
cashInstructionsEs: z.string().optional().nullable(),
|
||||
// Booking settings
|
||||
allowDuplicateBookings: z.boolean().optional(),
|
||||
allowDuplicateBookings: booleanOrNumber.optional(),
|
||||
});
|
||||
|
||||
// Schema for event-level overrides
|
||||
const updateEventOverridesSchema = z.object({
|
||||
tpagoEnabled: z.boolean().optional().nullable(),
|
||||
tpagoEnabled: booleanOrNumber.optional().nullable(),
|
||||
tpagoLink: z.string().optional().nullable(),
|
||||
tpagoInstructions: z.string().optional().nullable(),
|
||||
tpagoInstructionsEs: z.string().optional().nullable(),
|
||||
bankTransferEnabled: z.boolean().optional().nullable(),
|
||||
bankTransferEnabled: booleanOrNumber.optional().nullable(),
|
||||
bankName: z.string().optional().nullable(),
|
||||
bankAccountHolder: z.string().optional().nullable(),
|
||||
bankAccountNumber: z.string().optional().nullable(),
|
||||
@@ -44,18 +50,17 @@ const updateEventOverridesSchema = z.object({
|
||||
bankPhone: z.string().optional().nullable(),
|
||||
bankNotes: z.string().optional().nullable(),
|
||||
bankNotesEs: z.string().optional().nullable(),
|
||||
lightningEnabled: z.boolean().optional().nullable(),
|
||||
cashEnabled: z.boolean().optional().nullable(),
|
||||
lightningEnabled: booleanOrNumber.optional().nullable(),
|
||||
cashEnabled: booleanOrNumber.optional().nullable(),
|
||||
cashInstructions: z.string().optional().nullable(),
|
||||
cashInstructionsEs: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// Get global payment options
|
||||
paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const options = await (db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
const options = await dbGet<any>(
|
||||
(db as any).select().from(paymentOptions)
|
||||
);
|
||||
|
||||
// If no options exist yet, return defaults
|
||||
if (!options) {
|
||||
@@ -92,17 +97,21 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
|
||||
const now = getNow();
|
||||
|
||||
// Check if options exist
|
||||
const existing = await (db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
const existing = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
);
|
||||
|
||||
// Convert boolean fields for database compatibility
|
||||
const dbData = convertBooleansForDb(data);
|
||||
|
||||
if (existing) {
|
||||
// Update existing
|
||||
await (db as any)
|
||||
.update(paymentOptions)
|
||||
.set({
|
||||
...data,
|
||||
...dbData,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
})
|
||||
@@ -112,16 +121,17 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
|
||||
const id = generateId();
|
||||
await (db as any).insert(paymentOptions).values({
|
||||
id,
|
||||
...data,
|
||||
...dbData,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await (db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
);
|
||||
|
||||
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');
|
||||
|
||||
// Get the event first to verify it exists
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
.get();
|
||||
const event = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Get global options
|
||||
const globalOptions = await (db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
const globalOptions = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
);
|
||||
|
||||
// Get event overrides
|
||||
const overrides = await (db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
.get();
|
||||
const overrides = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
);
|
||||
|
||||
// Merge global with overrides (override takes precedence if not null)
|
||||
const defaults = {
|
||||
@@ -206,11 +219,9 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
||||
paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
|
||||
const overrides = await (db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
.get();
|
||||
const overrides = await dbGet<any>(
|
||||
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
);
|
||||
|
||||
return c.json({ overrides: overrides || null });
|
||||
});
|
||||
@@ -222,28 +233,27 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org
|
||||
const now = getNow();
|
||||
|
||||
// Verify event exists
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
.get();
|
||||
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);
|
||||
}
|
||||
|
||||
// Check if overrides exist
|
||||
const existing = await (db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
.get();
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
);
|
||||
|
||||
// Convert boolean fields for database compatibility
|
||||
const dbData = convertBooleansForDb(data);
|
||||
|
||||
if (existing) {
|
||||
await (db as any)
|
||||
.update(eventPaymentOverrides)
|
||||
.set({
|
||||
...data,
|
||||
...dbData,
|
||||
updatedAt: now,
|
||||
})
|
||||
.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({
|
||||
id,
|
||||
eventId,
|
||||
...data,
|
||||
...dbData,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await (db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
.get();
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
);
|
||||
|
||||
return c.json({ overrides: updated, message: 'Event payment overrides updated successfully' });
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
@@ -17,10 +17,12 @@ const updatePaymentSchema = z.object({
|
||||
|
||||
const approvePaymentSchema = z.object({
|
||||
adminNote: z.string().optional(),
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
const rejectPaymentSchema = z.object({
|
||||
adminNote: z.string().optional(),
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
// 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');
|
||||
|
||||
// Get all payments with their associated tickets
|
||||
let allPayments = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.orderBy(desc((payments as any).createdAt))
|
||||
.all();
|
||||
let allPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.orderBy(desc((payments as any).createdAt))
|
||||
);
|
||||
|
||||
// Filter by status
|
||||
if (status) {
|
||||
@@ -54,25 +57,28 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
// Enrich with ticket and event data
|
||||
const enrichedPayments = await Promise.all(
|
||||
allPayments.map(async (payment: any) => {
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
|
||||
let event = null;
|
||||
if (ticket) {
|
||||
event = await (db as any)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
let event: any = null;
|
||||
if (ticket) {
|
||||
event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...payment,
|
||||
ticket: ticket ? {
|
||||
id: ticket.id,
|
||||
bookingId: ticket.bookingId,
|
||||
attendeeFirstName: ticket.attendeeFirstName,
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
@@ -93,35 +99,39 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
|
||||
// Get payments pending approval (admin dashboard view)
|
||||
paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const pendingPayments = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending_approval'))
|
||||
.orderBy(desc((payments as any).userMarkedPaidAt))
|
||||
.all();
|
||||
const pendingPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending_approval'))
|
||||
.orderBy(desc((payments as any).userMarkedPaidAt))
|
||||
);
|
||||
|
||||
// Enrich with ticket and event data
|
||||
const enrichedPayments = await Promise.all(
|
||||
pendingPayments.map(async (payment: any) => {
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
|
||||
let event = null;
|
||||
if (ticket) {
|
||||
event = await (db as any)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
let event: any = null;
|
||||
if (ticket) {
|
||||
event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...payment,
|
||||
ticket: ticket ? {
|
||||
id: ticket.id,
|
||||
bookingId: ticket.bookingId,
|
||||
attendeeFirstName: ticket.attendeeFirstName,
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
@@ -144,22 +154,24 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
|
||||
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
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);
|
||||
}
|
||||
|
||||
// Get associated ticket
|
||||
const ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
const ticket = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
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 user = (c as any).get('user');
|
||||
|
||||
const existing = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
const existing = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
@@ -190,17 +203,42 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
updateData.paidByAdminId = user.id;
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set(updateData)
|
||||
.where(eq((payments as any).id, id));
|
||||
|
||||
// If payment confirmed, update ticket status and send emails
|
||||
// If payment confirmed, handle multi-ticket booking
|
||||
if (data.status === 'paid') {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, existing.ticketId));
|
||||
// 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)
|
||||
.update(payments)
|
||||
.set(updateData)
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
|
||||
// Send confirmation emails asynchronously (don't block the response)
|
||||
Promise.all([
|
||||
@@ -209,13 +247,20 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
]).catch(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)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
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
|
||||
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
||||
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 payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
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);
|
||||
@@ -243,37 +289,66 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Update payment status to paid
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).id, id));
|
||||
// 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))
|
||||
);
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, payment.ticketId));
|
||||
// Check if this is part of a multi-ticket booking
|
||||
let ticketsToConfirm: any[] = [ticket];
|
||||
|
||||
// Send confirmation emails asynchronously
|
||||
Promise.all([
|
||||
emailService.sendBookingConfirmation(payment.ticketId),
|
||||
emailService.sendPaymentReceipt(id),
|
||||
]).catch(err => {
|
||||
console.error('[Email] Failed to send confirmation emails:', err);
|
||||
});
|
||||
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`);
|
||||
}
|
||||
|
||||
const updated = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
|
||||
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||
if (sendEmail !== false) {
|
||||
Promise.all([
|
||||
emailService.sendBookingConfirmation(payment.ticketId),
|
||||
emailService.sendPaymentReceipt(id),
|
||||
]).catch(err => {
|
||||
console.error('[Email] Failed to send confirmation emails:', err);
|
||||
});
|
||||
} else {
|
||||
console.log('[Payment] Skipping confirmation emails per admin request');
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ payment: updated, message: 'Payment approved successfully' });
|
||||
});
|
||||
@@ -281,14 +356,15 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
// Reject payment (admin)
|
||||
paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidator('json', rejectPaymentSchema), async (c) => {
|
||||
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 payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
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);
|
||||
@@ -320,33 +396,82 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
})
|
||||
.where(eq((tickets as any).id, payment.ticketId));
|
||||
|
||||
// Send rejection email asynchronously (for manual payment methods only)
|
||||
if (['bank_transfer', 'tpago'].includes(payment.provider)) {
|
||||
// Send rejection email asynchronously (for manual payment methods only, if sendEmail is true)
|
||||
if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
|
||||
emailService.sendPaymentRejectionEmail(id).catch(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)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
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
|
||||
paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
const { adminNote } = body;
|
||||
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
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);
|
||||
@@ -362,11 +487,12 @@ paymentsRouter.post('/:id/note', requireAuth(['admin', 'organizer']), async (c)
|
||||
})
|
||||
.where(eq((payments as any).id, id));
|
||||
|
||||
const updated = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
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) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const payment = await (db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
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);
|
||||
@@ -426,7 +553,7 @@ paymentsRouter.post('/webhook', async (c) => {
|
||||
|
||||
// Get payment statistics (admin)
|
||||
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 = {
|
||||
total: allPayments.length,
|
||||
@@ -436,7 +563,7 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
failed: allPayments.filter((p: any) => p.status === 'failed').length,
|
||||
totalRevenue: allPayments
|
||||
.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 });
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, siteSettings } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, dbGet, siteSettings, events } from '../db/index.js';
|
||||
import { eq, and, gte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -27,6 +28,7 @@ const updateSiteSettingsSchema = z.object({
|
||||
instagramUrl: 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('')),
|
||||
featuredEventId: z.string().optional().nullable(),
|
||||
maintenanceMode: z.boolean().optional(),
|
||||
maintenanceMessage: z.string().optional().nullable(),
|
||||
maintenanceMessageEs: z.string().optional().nullable(),
|
||||
@@ -34,7 +36,9 @@ const updateSiteSettingsSchema = z.object({
|
||||
|
||||
// Get site settings (public - needed for frontend timezone)
|
||||
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) {
|
||||
// Return default settings if none exist
|
||||
@@ -50,6 +54,7 @@ siteSettingsRouter.get('/', async (c) => {
|
||||
instagramUrl: null,
|
||||
twitterUrl: null,
|
||||
linkedinUrl: null,
|
||||
featuredEventId: null,
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: null,
|
||||
maintenanceMessageEs: null,
|
||||
@@ -95,11 +100,24 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
const now = getNow();
|
||||
|
||||
// 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) {
|
||||
// Create new settings record
|
||||
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 = {
|
||||
id,
|
||||
timezone: data.timezone || 'America/Asuncion',
|
||||
@@ -112,7 +130,8 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
instagramUrl: data.instagramUrl || null,
|
||||
twitterUrl: data.twitterUrl || null,
|
||||
linkedinUrl: data.linkedinUrl || null,
|
||||
maintenanceMode: data.maintenanceMode || false,
|
||||
featuredEventId: data.featuredEventId || null,
|
||||
maintenanceMode: toDbBool(data.maintenanceMode || false),
|
||||
maintenanceMessage: data.maintenanceMessage || null,
|
||||
maintenanceMessageEs: data.maintenanceMessageEs || null,
|
||||
updatedAt: now,
|
||||
@@ -124,21 +143,105 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
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
|
||||
const updateData = {
|
||||
const updateData: Record<string, any> = {
|
||||
...data,
|
||||
updatedAt: now,
|
||||
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)
|
||||
.update(siteSettings)
|
||||
.set(updateData)
|
||||
.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))
|
||||
);
|
||||
|
||||
// Revalidate frontend cache if featured event changed
|
||||
if (data.featuredEventId !== undefined) {
|
||||
revalidateFrontendCache();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Revalidate frontend cache so homepage shows the updated featured event
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
// Revalidate frontend cache so homepage shows the updated featured event
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
});
|
||||
|
||||
export default siteSettingsRouter;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
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 { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
@@ -17,9 +17,11 @@ const usersRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
name: z.string().min(2).optional(),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
role: z.enum(['admin', 'organizer', 'staff', 'marketing', 'user']).optional(),
|
||||
languagePreference: z.enum(['en', 'es']).optional(),
|
||||
accountStatus: z.enum(['active', 'unclaimed', 'suspended']).optional(),
|
||||
});
|
||||
|
||||
// Get all users (admin only)
|
||||
@@ -33,6 +35,9 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
phone: (users as any).phone,
|
||||
role: (users as any).role,
|
||||
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);
|
||||
|
||||
@@ -40,7 +45,7 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
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 });
|
||||
});
|
||||
@@ -55,19 +60,23 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const user = await (db as any)
|
||||
.select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
name: (users as any).name,
|
||||
phone: (users as any).phone,
|
||||
role: (users as any).role,
|
||||
languagePreference: (users as any).languagePreference,
|
||||
createdAt: (users as any).createdAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq((users as any).id, id))
|
||||
.get();
|
||||
const user = await dbGet(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
name: (users as any).name,
|
||||
phone: (users as any).phone,
|
||||
role: (users as any).role,
|
||||
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)
|
||||
.where(eq((users as any).id, id))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Only admin can change roles
|
||||
// Only admin can change roles, email, and account status
|
||||
if (data.role && currentUser.role !== 'admin') {
|
||||
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) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
@@ -102,18 +119,23 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
||||
.set({ ...data, updatedAt: getNow() })
|
||||
.where(eq((users as any).id, id));
|
||||
|
||||
const updated = await (db as any)
|
||||
.select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
name: (users as any).name,
|
||||
phone: (users as any).phone,
|
||||
role: (users as any).role,
|
||||
languagePreference: (users as any).languagePreference,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq((users as any).id, id))
|
||||
.get();
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
name: (users as any).name,
|
||||
phone: (users as any).phone,
|
||||
role: (users as any).role,
|
||||
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)
|
||||
.where(eq((users as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ user: updated });
|
||||
});
|
||||
@@ -128,21 +150,23 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const userTickets = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, id))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
.all();
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, id))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Get event details for each ticket
|
||||
const history = await Promise.all(
|
||||
userTickets.map(async (ticket: any) => {
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
const event = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
return {
|
||||
...ticket,
|
||||
@@ -164,7 +188,9 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
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) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
@@ -176,20 +202,80 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
|
||||
try {
|
||||
// Get all tickets for this user
|
||||
const userTickets = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, id))
|
||||
.all();
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, id))
|
||||
);
|
||||
|
||||
// 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) {
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
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)
|
||||
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
const totalUsers = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.get();
|
||||
const totalUsers = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
);
|
||||
|
||||
const adminCount = await (db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(eq((users as any).role, 'admin'))
|
||||
.get();
|
||||
const adminCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(eq((users as any).role, 'admin'))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
stats: {
|
||||
|
||||
@@ -8,9 +8,9 @@ Type=simple
|
||||
User=spanglish
|
||||
Group=spanglish
|
||||
WorkingDirectory=/home/spanglish/Spanglish/backend
|
||||
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3018
|
||||
EnvironmentFile=/home/spanglish/Spanglish/backend/.env
|
||||
ExecStart=/usr/bin/node dist/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
@@ -21,6 +21,10 @@ NEXT_PUBLIC_EMAIL=hola@spanglish.com.py
|
||||
NEXT_PUBLIC_TELEGRAM=spanglish_py
|
||||
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)
|
||||
NEXT_PUBLIC_PLAUSIBLE_URL=https://analytics.azzamo.net
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=spanglishcommunity.com
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"next": "^14.2.4",
|
||||
|
||||
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 116 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.34.55.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.14.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.17.jpg
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.19.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.22.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.24.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.27.jpg
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.29.jpg
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.32.jpg
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.35.35.jpg
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.37.37.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 11.37.39.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -25,9 +26,17 @@ import {
|
||||
BuildingLibraryIcon,
|
||||
ClockIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
UserIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
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';
|
||||
|
||||
interface BookingFormData {
|
||||
@@ -51,14 +60,19 @@ interface LightningInvoice {
|
||||
|
||||
interface BookingResult {
|
||||
ticketId: string;
|
||||
ticketIds?: string[]; // For multi-ticket bookings
|
||||
bookingId?: string;
|
||||
qrCode: string;
|
||||
qrCodes?: string[]; // For multi-ticket bookings
|
||||
paymentMethod: PaymentMethod;
|
||||
lightningInvoice?: LightningInvoice;
|
||||
ticketCount?: number;
|
||||
}
|
||||
|
||||
export default function BookingPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t, locale } = useLanguage();
|
||||
const { user } = useAuth();
|
||||
const [event, setEvent] = useState<Event | null>(null);
|
||||
@@ -70,6 +84,20 @@ export default function BookingPage() {
|
||||
const [paymentPending, setPaymentPending] = 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>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
@@ -82,43 +110,12 @@ export default function BookingPage() {
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof BookingFormData, string>>>({});
|
||||
|
||||
// RUC validation using modulo 11 algorithm
|
||||
const validateRucCheckDigit = (ruc: string): boolean => {
|
||||
const match = ruc.match(/^(\d{6,8})-(\d)$/);
|
||||
if (!match) return false;
|
||||
|
||||
const baseNumber = match[1];
|
||||
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;
|
||||
};
|
||||
const rucPattern = /^\d{6,10}$/;
|
||||
|
||||
// Format RUC input: auto-insert hyphen before last digit
|
||||
// Format RUC input: digits only, max 10
|
||||
const formatRuc = (value: string): string => {
|
||||
// Remove non-numeric characters
|
||||
const digits = value.replace(/\D/g, '');
|
||||
|
||||
// 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;
|
||||
const digits = value.replace(/\D/g, '').slice(0, 10);
|
||||
return digits;
|
||||
};
|
||||
|
||||
// Handle RUC input change
|
||||
@@ -132,19 +129,12 @@ export default function BookingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Validate RUC on blur
|
||||
// Validate RUC on blur (optional field: 6–10 digits)
|
||||
const handleRucBlur = () => {
|
||||
if (!formData.ruc) return; // Optional field, no validation if empty
|
||||
|
||||
const rucPattern = /^[0-9]{6,8}-[0-9]{1}$/;
|
||||
|
||||
if (!rucPattern.test(formData.ruc)) {
|
||||
if (!formData.ruc) return;
|
||||
const digits = formData.ruc.replace(/\D/g, '');
|
||||
if (digits.length > 0 && !rucPattern.test(digits)) {
|
||||
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateRucCheckDigit(formData.ruc)) {
|
||||
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidCheckDigit') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -155,19 +145,37 @@ export default function BookingPage() {
|
||||
paymentOptionsApi.getForEvent(params.eventId as string),
|
||||
])
|
||||
.then(([eventRes, paymentRes]) => {
|
||||
if (!eventRes.event || eventRes.event.status !== 'published') {
|
||||
if (!eventRes.event || !['published', 'unlisted'].includes(eventRes.event.status)) {
|
||||
toast.error('Event is not available for booking');
|
||||
router.push('/events');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Redirect to external booking if enabled
|
||||
if (eventRes.event.externalBookingEnabled && eventRes.event.externalBookingUrl) {
|
||||
window.location.href = eventRes.event.externalBookingUrl;
|
||||
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);
|
||||
// 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);
|
||||
|
||||
// Set default payment method based on what's enabled
|
||||
@@ -209,24 +217,12 @@ export default function BookingPage() {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<Record<keyof BookingFormData, string>> = {};
|
||||
const newAttendeeErrors: { [key: number]: string } = {};
|
||||
|
||||
if (!formData.firstName.trim() || formData.firstName.length < 2) {
|
||||
newErrors.firstName = t('booking.form.errors.firstNameRequired');
|
||||
@@ -246,18 +242,26 @@ export default function BookingPage() {
|
||||
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
||||
}
|
||||
|
||||
// RUC validation (optional field - only validate if filled)
|
||||
// RUC validation (optional field - 6–10 digits if filled)
|
||||
if (formData.ruc.trim()) {
|
||||
const rucPattern = /^[0-9]{6,8}-[0-9]{1}$/;
|
||||
if (!rucPattern.test(formData.ruc)) {
|
||||
const digits = formData.ruc.replace(/\D/g, '');
|
||||
if (!/^\d{6,10}$/.test(digits)) {
|
||||
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);
|
||||
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
|
||||
@@ -345,9 +349,20 @@ export default function BookingPage() {
|
||||
const handleMarkPaymentSent = async () => {
|
||||
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);
|
||||
try {
|
||||
await ticketsApi.markPaymentSent(bookingResult.ticketId);
|
||||
await ticketsApi.markPaymentSent(
|
||||
bookingResult.ticketId,
|
||||
paidUnderDifferentName ? payerName.trim() : undefined
|
||||
);
|
||||
setStep('pending_approval');
|
||||
toast.success(locale === 'es'
|
||||
? 'Pago marcado como enviado. Esperando aprobación.'
|
||||
@@ -365,6 +380,12 @@ export default function BookingPage() {
|
||||
|
||||
setSubmitting(true);
|
||||
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({
|
||||
eventId: event.id,
|
||||
firstName: formData.firstName,
|
||||
@@ -373,17 +394,25 @@ export default function BookingPage() {
|
||||
phone: formData.phone,
|
||||
preferredLanguage: formData.preferredLanguage,
|
||||
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 (formData.paymentMethod === 'lightning' && lightningInvoice?.paymentRequest) {
|
||||
const result: BookingResult = {
|
||||
ticketId: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
ticketId: primaryTicket.id,
|
||||
ticketIds: ticketsList?.map((t: any) => t.id),
|
||||
bookingId,
|
||||
qrCode: primaryTicket.qrCode,
|
||||
qrCodes: ticketsList?.map((t: any) => t.qrCode),
|
||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||
ticketCount,
|
||||
lightningInvoice: {
|
||||
paymentHash: lightningInvoice.paymentHash,
|
||||
paymentRequest: lightningInvoice.paymentRequest,
|
||||
@@ -398,21 +427,29 @@ export default function BookingPage() {
|
||||
setPaymentPending(true);
|
||||
|
||||
// Connect to SSE for real-time payment updates
|
||||
connectPaymentStream(ticket.id);
|
||||
connectPaymentStream(primaryTicket.id);
|
||||
} else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') {
|
||||
// Manual payment methods - show payment details
|
||||
setBookingResult({
|
||||
ticketId: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
ticketId: primaryTicket.id,
|
||||
ticketIds: ticketsList?.map((t: any) => t.id),
|
||||
bookingId,
|
||||
qrCode: primaryTicket.qrCode,
|
||||
qrCodes: ticketsList?.map((t: any) => t.qrCode),
|
||||
paymentMethod: formData.paymentMethod,
|
||||
ticketCount,
|
||||
});
|
||||
setStep('manual_payment');
|
||||
} else {
|
||||
// Cash payment - go straight to success
|
||||
setBookingResult({
|
||||
ticketId: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
ticketId: primaryTicket.id,
|
||||
ticketIds: ticketsList?.map((t: any) => t.id),
|
||||
bookingId,
|
||||
qrCode: primaryTicket.qrCode,
|
||||
qrCodes: ticketsList?.map((t: any) => t.qrCode),
|
||||
paymentMethod: formData.paymentMethod,
|
||||
ticketCount,
|
||||
});
|
||||
setStep('success');
|
||||
toast.success(t('booking.success.message'));
|
||||
@@ -441,8 +478,8 @@ export default function BookingPage() {
|
||||
paymentMethods.push({
|
||||
id: 'tpago',
|
||||
icon: CreditCardIcon,
|
||||
label: locale === 'es' ? 'TPago / Tarjeta Internacional' : 'TPago / International Card',
|
||||
description: locale === 'es' ? 'Paga con tarjeta de crédito o débito' : 'Pay with credit or debit card',
|
||||
label: locale === 'es' ? 'TPago / Tarjetas de Crédito' : 'TPago / Credit Cards',
|
||||
description: locale === 'es' ? 'Pagá con tarjetas de crédito locales o internacionales' : 'Pay with local or international credit cards',
|
||||
badge: locale === 'es' ? 'Manual' : 'Manual',
|
||||
});
|
||||
}
|
||||
@@ -451,8 +488,8 @@ export default function BookingPage() {
|
||||
paymentMethods.push({
|
||||
id: 'bank_transfer',
|
||||
icon: BuildingLibraryIcon,
|
||||
label: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
|
||||
description: locale === 'es' ? 'Transferencia bancaria local' : 'Local bank transfer',
|
||||
label: 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',
|
||||
});
|
||||
}
|
||||
@@ -481,7 +518,8 @@ export default function BookingPage() {
|
||||
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
|
||||
const getSuccessContent = () => {
|
||||
@@ -591,6 +629,8 @@ export default function BookingPage() {
|
||||
if (step === 'manual_payment' && bookingResult && paymentConfig) {
|
||||
const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer';
|
||||
const isTpago = bookingResult.paymentMethod === 'tpago';
|
||||
const ticketCount = bookingResult.ticketCount || 1;
|
||||
const totalAmount = (event?.price || 0) * ticketCount;
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
@@ -620,8 +660,13 @@ export default function BookingPage() {
|
||||
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-primary-dark">
|
||||
{event?.price?.toLocaleString()} {event?.currency}
|
||||
{event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''}
|
||||
</p>
|
||||
{ticketCount > 1 && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bank Transfer Details */}
|
||||
@@ -724,6 +769,45 @@ export default function BookingPage() {
|
||||
</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 */}
|
||||
<p className="text-sm text-center text-amber-700 font-medium mb-3">
|
||||
{locale === 'es'
|
||||
@@ -737,6 +821,7 @@ export default function BookingPage() {
|
||||
isLoading={markingPaid}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={paidUnderDifferentName && !payerName.trim()}
|
||||
>
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{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">
|
||||
<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.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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -828,15 +913,36 @@ export default function BookingPage() {
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<TicketIcon className="w-6 h-6 text-primary-yellow" />
|
||||
<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 className="text-sm text-gray-600 space-y-2">
|
||||
<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.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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -872,6 +978,25 @@ export default function BookingPage() {
|
||||
{t('booking.success.emailSent')}
|
||||
</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">
|
||||
<Link href="/events">
|
||||
<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="flex items-center gap-3">
|
||||
<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 className="flex items-center gap-3">
|
||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||
@@ -916,7 +1041,7 @@ export default function BookingPage() {
|
||||
{!event.externalBookingEnabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<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 className="flex items-center gap-3">
|
||||
@@ -924,9 +1049,27 @@ export default function BookingPage() {
|
||||
<span className="font-bold text-lg">
|
||||
{event.price === 0
|
||||
? 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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -940,8 +1083,18 @@ export default function BookingPage() {
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* User Information Section */}
|
||||
<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')}
|
||||
{attendees.length > 0 && (
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
({locale === 'es' ? 'Asistente principal' : 'Primary attendee'})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -1039,6 +1192,74 @@ export default function BookingPage() {
|
||||
</div>
|
||||
</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 */}
|
||||
<Card className="mb-6 p-6">
|
||||
<h3 className="font-bold text-lg mb-4 text-primary-dark">
|
||||
@@ -1097,45 +1318,6 @@ export default function BookingPage() {
|
||||
</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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
@@ -151,21 +152,8 @@ export default function BookingPaymentPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
// Loading state
|
||||
if (step === 'loading') {
|
||||
@@ -236,7 +224,7 @@ export default function BookingPaymentPage() {
|
||||
<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' ? '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>
|
||||
</div>
|
||||
)}
|
||||
@@ -285,7 +273,7 @@ export default function BookingPaymentPage() {
|
||||
<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' ? '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>
|
||||
</div>
|
||||
)}
|
||||
@@ -332,7 +320,7 @@ export default function BookingPaymentPage() {
|
||||
<div className="p-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<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 className="flex items-center gap-3">
|
||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||
@@ -341,7 +329,7 @@ export default function BookingPaymentPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,7 +362,7 @@ export default function BookingPaymentPage() {
|
||||
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, Ticket } from '@/lib/api';
|
||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
@@ -69,21 +70,8 @@ export default function BookingSuccessPage() {
|
||||
};
|
||||
}, [ticketId]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -191,7 +179,7 @@ export default function BookingSuccessPage() {
|
||||
<>
|
||||
<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' ? '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>
|
||||
</>
|
||||
)}
|
||||
@@ -229,12 +217,15 @@ export default function BookingSuccessPage() {
|
||||
{isPaid && (
|
||||
<div className="mb-6">
|
||||
<a
|
||||
href={`/api/tickets/${ticketId}/pdf`}
|
||||
href={ticket.bookingId
|
||||
? `/api/tickets/booking/${ticket.bookingId}/pdf`
|
||||
: `/api/tickets/${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' ? 'Descargar Ticket' : 'Download Ticket'}
|
||||
{locale === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
82
frontend/src/app/(public)/components/HomepageFaqSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,37 +4,51 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
|
||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||
import { CalendarIcon, MapPinIcon, ClockIcon } 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 [nextEvent, setNextEvent] = useState<Event | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
|
||||
const [loading, setLoading] = useState(initialEvent === undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialEvent !== undefined) {
|
||||
if (initialEvent) {
|
||||
const endTime = initialEvent.endDatetime || initialEvent.startDatetime;
|
||||
if (new Date(endTime).getTime() <= Date.now()) {
|
||||
setNextEvent(null);
|
||||
setLoading(true);
|
||||
eventsApi.getNextUpcoming()
|
||||
.then(({ event }) => setNextEvent(event))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
eventsApi.getNextUpcoming()
|
||||
.then(({ event }) => setNextEvent(event))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [initialEvent]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
const title = nextEvent
|
||||
? (locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title)
|
||||
: '';
|
||||
const description = nextEvent
|
||||
? (locale === 'es'
|
||||
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
|
||||
: (nextEvent.shortDescription || nextEvent.description))
|
||||
: '';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -55,56 +69,72 @@ export default function NextEventSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/events/${nextEvent.id}`} className="block">
|
||||
<Card variant="elevated" className="p-8 cursor-pointer hover:shadow-lg transition-shadow">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold text-primary-dark">
|
||||
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-gray-600 whitespace-pre-line">
|
||||
{locale === 'es'
|
||||
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
|
||||
: (nextEvent.shortDescription || nextEvent.description)}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="flex items-center gap-3 text-gray-700">
|
||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{formatDate(nextEvent.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-gray-700">
|
||||
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
|
||||
⏰
|
||||
</span>
|
||||
<span>{formatTime(nextEvent.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-gray-700">
|
||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{nextEvent.location}</span>
|
||||
</div>
|
||||
<Link href={`/events/${nextEvent.id}`} className="block group">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg transition-all duration-300 hover:shadow-2xl hover:scale-[1.01]">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Banner */}
|
||||
{nextEvent.bannerUrl ? (
|
||||
<div className="relative w-full md:w-2/5 flex-shrink-0">
|
||||
<img
|
||||
src={nextEvent.bannerUrl}
|
||||
alt={title}
|
||||
className="w-full h-48 md:h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between items-start md:items-end">
|
||||
<div className="text-right">
|
||||
<span className="text-3xl font-bold text-primary-dark">
|
||||
{nextEvent.price === 0
|
||||
? t('events.details.free')
|
||||
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
||||
</span>
|
||||
{!nextEvent.externalBookingEnabled && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
||||
) : (
|
||||
<div className="w-full md:w-2/5 flex-shrink-0 h-48 md:h-auto bg-gradient-to-br from-primary-yellow/20 to-secondary-gray flex items-center justify-center">
|
||||
<CalendarIcon className="w-16 h-16 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 p-5 md:p-8 flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl md:text-2xl font-bold text-primary-dark group-hover:text-brand-navy transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm md:text-base text-gray-600 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 md:mt-5 space-y-2">
|
||||
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||
<span>{formatDate(nextEvent.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||
<ClockIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||
<span>{fmtTime(nextEvent.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||
<span>{nextEvent.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 md:mt-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-2xl md:text-3xl font-bold text-primary-dark">
|
||||
{nextEvent.price === 0
|
||||
? t('events.details.free')
|
||||
: formatPrice(nextEvent.price, nextEvent.currency)}
|
||||
</span>
|
||||
{!nextEvent.externalBookingEnabled && nextEvent.availableSeats != null && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="inline-flex items-center bg-primary-yellow text-primary-dark font-semibold py-2.5 px-5 rounded-xl text-sm transition-all duration-200 group-hover:bg-yellow-400 flex-shrink-0">
|
||||
{t('common.moreInfo')}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="lg" className="mt-6">
|
||||
{t('common.moreInfo')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
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();
|
||||
|
||||
return (
|
||||
@@ -12,8 +17,8 @@ export default function NextEventSectionWrapper() {
|
||||
<h2 className="section-title text-center">
|
||||
{t('home.nextEvent.title')}
|
||||
</h2>
|
||||
<div className="mt-12 max-w-3xl mx-auto">
|
||||
<NextEventSection />
|
||||
<div className="mt-12 max-w-4xl mx-auto">
|
||||
<NextEventSection initialEvent={initialEvent} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) {
|
||||
{profile?.memberSince
|
||||
? new Date(profile.memberSince).toLocaleDateString(
|
||||
language === 'es' ? 'es-ES' : 'en-US',
|
||||
{ year: 'numeric', month: 'long', day: 'numeric' }
|
||||
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
|
||||
@@ -153,6 +153,7 @@ export default function SecurityTab() {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -170,6 +171,20 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
||||
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
|
||||
</Button>
|
||||
</Link>
|
||||
{(ticket.status === 'confirmed' || ticket.status === 'checked_in') && (
|
||||
<a
|
||||
href={ticket.bookingId
|
||||
? `/api/tickets/booking/${ticket.bookingId}/pdf`
|
||||
: `/api/tickets/${ticket.id}/pdf`
|
||||
}
|
||||
download
|
||||
className="text-center"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
{language === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{ticket.invoice && (
|
||||
<a
|
||||
href={ticket.invoice.pdfUrl || '#'}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
|
||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
@@ -85,21 +86,8 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||
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',
|
||||
});
|
||||
};
|
||||
const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es');
|
||||
|
||||
return (
|
||||
<div className="section-padding min-h-[70vh]">
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
MapPinIcon,
|
||||
UserGroupIcon,
|
||||
ArrowLeftIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface EventDetailClientProps {
|
||||
@@ -24,6 +27,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
const { t, locale } = useLanguage();
|
||||
const [event, setEvent] = useState<Event>(initialEvent);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [ticketQuantity, setTicketQuantity] = useState(1);
|
||||
|
||||
// Ensure consistent hydration by only rendering dynamic content after mount
|
||||
useEffect(() => {
|
||||
@@ -37,27 +41,112 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
.catch(console.error);
|
||||
}, [eventId]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
// Spots left: never negative; sold out when confirmed >= capacity
|
||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
||||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
||||
const maxTickets = isSoldOut ? 0 : Math.max(1, spotsLeft);
|
||||
|
||||
const decreaseQuantity = () => {
|
||||
setTicketQuantity(prev => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const increaseQuantity = () => {
|
||||
setTicketQuantity(prev => Math.min(maxTickets, prev + 1));
|
||||
};
|
||||
|
||||
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';
|
||||
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
||||
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
|
||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
|
||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted');
|
||||
|
||||
// Booking card content - reused for mobile and desktop positions
|
||||
const BookingCardContent = () => (
|
||||
<>
|
||||
<div className="text-center mb-4">
|
||||
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
||||
<p className="text-4xl font-bold text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: formatPrice(event.price, event.currency)}
|
||||
</p>
|
||||
{event.price > 0 && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{locale === 'es' ? 'por persona' : 'per person'}
|
||||
</p>
|
||||
)}
|
||||
</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 ? (
|
||||
event.externalBookingEnabled && event.externalBookingUrl ? (
|
||||
<a
|
||||
href={event.externalBookingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button className="w-full" size="lg">
|
||||
{t('events.booking.join')}
|
||||
</Button>
|
||||
</a>
|
||||
) : (
|
||||
<Link href={`/book/${event.id}?qty=${ticketQuantity}`}>
|
||||
<Button className="w-full" size="lg">
|
||||
{t('events.booking.join')}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
) : (
|
||||
<Button className="w-full" size="lg" disabled>
|
||||
{isPastEvent
|
||||
? t('events.details.eventEnded')
|
||||
: isSoldOut
|
||||
? t('events.details.soldOut')
|
||||
: t('events.details.cancelled')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!event.externalBookingEnabled && (
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
@@ -72,157 +161,128 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Event Details */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Top section: Image + Event Info side by side on desktop */}
|
||||
<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="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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
{!event.externalBookingEnabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<CalendarIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.capacity')}</p>
|
||||
<p className="text-gray-600">
|
||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
|
||||
{/* Booking Card */}
|
||||
<div className="lg:col-span-1">
|
||||
{/* 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">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
||||
<p className="text-4xl font-bold text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canBook ? (
|
||||
event.externalBookingEnabled && event.externalBookingUrl ? (
|
||||
<a
|
||||
href={event.externalBookingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button className="w-full" size="lg">
|
||||
{t('events.booking.join')}
|
||||
</Button>
|
||||
</a>
|
||||
) : (
|
||||
<Link href={`/book/${event.id}`}>
|
||||
<Button className="w-full" size="lg">
|
||||
{t('events.booking.join')}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
) : (
|
||||
<Button className="w-full" size="lg" disabled>
|
||||
{isPastEvent
|
||||
? t('events.details.eventEnded')
|
||||
: isSoldOut
|
||||
? t('events.details.soldOut')
|
||||
: t('events.details.cancelled')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!event.externalBookingEnabled && (
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
)}
|
||||
<BookingCardContent />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ interface Event {
|
||||
price: number;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||
bannerUrl?: string;
|
||||
availableSeats?: number;
|
||||
bookedCount?: number;
|
||||
@@ -95,11 +95,9 @@ function generateEventJsonLd(event: Event) {
|
||||
startDate: event.startDatetime,
|
||||
endDate: event.endDatetime || event.startDatetime,
|
||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
eventStatus: isCancelled
|
||||
? 'https://schema.org/EventCancelled'
|
||||
: isPastEvent
|
||||
? 'https://schema.org/EventPostponed'
|
||||
: 'https://schema.org/EventScheduled',
|
||||
eventStatus: isCancelled
|
||||
? 'https://schema.org/EventCancelled'
|
||||
: 'https://schema.org/EventScheduled',
|
||||
location: {
|
||||
'@type': 'Place',
|
||||
name: event.location,
|
||||
@@ -118,7 +116,7 @@ function generateEventJsonLd(event: Event) {
|
||||
'@type': 'Offer',
|
||||
price: event.price,
|
||||
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/SoldOut',
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||
@@ -32,20 +33,8 @@ export default function EventsPage() {
|
||||
|
||||
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const getStatusBadge = (event: Event) => {
|
||||
if (event.status === 'cancelled') {
|
||||
@@ -129,7 +118,7 @@ export default function EventsPage() {
|
||||
<div className="mt-4 space-y-2 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<span>{formatDate(event.startDatetime)} - {formatTime(event.startDatetime)}</span>
|
||||
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinIcon className="w-4 h-4" />
|
||||
@@ -139,7 +128,7 @@ export default function EventsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="w-4 h-4" />
|
||||
<span>
|
||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -149,7 +138,7 @@ export default function EventsPage() {
|
||||
<span className="font-bold text-xl text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||
: formatPrice(event.price, event.currency)}
|
||||
</span>
|
||||
<Button size="sm">
|
||||
{t('common.moreInfo')}
|
||||
|
||||
@@ -1,44 +1,21 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
// FAQ Page structured data
|
||||
const faqSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What is Spanglish?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
async function getFaqForSchema(): Promise<{ question: string; answer: string }[]> {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const faqs = data.faqs || [];
|
||||
return faqs.map((f: { question: string; questionEs?: string | null; answer: string; answerEs?: string | null }) => ({
|
||||
question: f.question,
|
||||
answer: f.answer || '',
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Frequently Asked Questions',
|
||||
@@ -49,11 +26,25 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function FAQLayout({
|
||||
export default async function FAQLayout({
|
||||
children,
|
||||
}: {
|
||||
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 (
|
||||
<>
|
||||
<script
|
||||
|
||||
@@ -1,89 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { faqApi, FaqItem } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
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() {
|
||||
const { t, locale } = useLanguage();
|
||||
const { locale } = useLanguage();
|
||||
const [faqs, setFaqs] = useState<FaqItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
faqApi.getList().then((res) => {
|
||||
if (!cancelled) {
|
||||
setFaqs(res.faqs);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const toggleFAQ = (index: number) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-3xl flex justify-center py-20">
|
||||
<div className="animate-spin w-10 h-10 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-3xl">
|
||||
@@ -92,53 +47,63 @@ export default function FAQPage() {
|
||||
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
|
||||
{locale === 'es'
|
||||
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
|
||||
: 'Find answers to the most common questions about Spanglish'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<Card key={index} className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleFAQ(index)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-primary-dark pr-4">
|
||||
{locale === 'es' ? faq.questionEs : faq.question}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
{faqs.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'No hay preguntas frecuentes publicadas en este momento.'
|
||||
: 'No FAQ questions are published at the moment.'}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<Card key={faq.id} className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleFAQ(index)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-primary-dark pr-4">
|
||||
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
|
||||
openIndex === index && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
|
||||
openIndex === index && 'transform rotate-180'
|
||||
'overflow-hidden transition-all duration-200',
|
||||
openIndex === index ? 'max-h-96' : 'max-h-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
openIndex === index ? 'max-h-96' : 'max-h-0'
|
||||
)}
|
||||
>
|
||||
<div className="px-6 pb-4 text-gray-600">
|
||||
{locale === 'es' ? faq.answerEs : faq.answer}
|
||||
>
|
||||
<div className="px-6 pb-4 text-gray-600">
|
||||
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
|
||||
<h2 className="text-xl font-semibold text-primary-dark mb-2">
|
||||
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{locale === 'es'
|
||||
{locale === 'es'
|
||||
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
|
||||
: "Don't hesitate to reach out. We're here to help!"}
|
||||
</p>
|
||||
<a
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const metadata: Metadata = {
|
||||
const organizationSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Spanglish',
|
||||
name: 'Spanglish Community',
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/images/logo.png`,
|
||||
description: 'Language exchange community organizing English and Spanish meetups in Asunción, Paraguay.',
|
||||
@@ -30,7 +30,7 @@ const organizationSchema = {
|
||||
addressCountry: 'PY',
|
||||
},
|
||||
sameAs: [
|
||||
process.env.NEXT_PUBLIC_INSTAGRAM_URL,
|
||||
'https://instagram.com/spanglishsocialpy',
|
||||
process.env.NEXT_PUBLIC_WHATSAPP_URL,
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_URL,
|
||||
].filter(Boolean),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Metadata } from 'next';
|
||||
import { getLegalPage, getAllLegalSlugs } from '@/lib/legal';
|
||||
import { getLegalPageAsync, getAllLegalSlugs } from '@/lib/legal';
|
||||
import LegalPageLayout from '@/components/layout/LegalPageLayout';
|
||||
|
||||
interface PageProps {
|
||||
params: { slug: string };
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ locale?: string }>;
|
||||
}
|
||||
|
||||
// Generate static params for all legal pages
|
||||
@@ -13,11 +14,24 @@ export async function generateStaticParams() {
|
||||
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';
|
||||
|
||||
// Validate and normalize locale
|
||||
function getValidLocale(locale?: string): 'en' | 'es' {
|
||||
if (locale === 'es') return 'es';
|
||||
return 'en'; // Default to English
|
||||
}
|
||||
|
||||
// Generate metadata for SEO
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const legalPage = getLegalPage(params.slug);
|
||||
export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const locale = getValidLocale(resolvedSearchParams.locale);
|
||||
const legalPage = await getLegalPageAsync(resolvedParams.slug, locale);
|
||||
|
||||
if (!legalPage) {
|
||||
return {
|
||||
@@ -33,13 +47,20 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
follow: true,
|
||||
},
|
||||
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) {
|
||||
const legalPage = getLegalPage(params.slug);
|
||||
export default async function LegalPage({ params, searchParams }: PageProps) {
|
||||
const resolvedParams = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const locale = getValidLocale(resolvedSearchParams.locale);
|
||||
const legalPage = await getLegalPageAsync(resolvedParams.slug, locale);
|
||||
|
||||
if (!legalPage) {
|
||||
notFound();
|
||||
|
||||
@@ -1,20 +1,168 @@
|
||||
import type { Metadata } from 'next';
|
||||
import HeroSection from './components/HeroSection';
|
||||
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
||||
import AboutSection from './components/AboutSection';
|
||||
import MediaCarouselSection from './components/MediaCarouselSection';
|
||||
import NewsletterSection from './components/NewsletterSection';
|
||||
import HomepageFaqSection from './components/HomepageFaqSection';
|
||||
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 nextEvent = await getNextUpcomingEvent();
|
||||
|
||||
return (
|
||||
<>
|
||||
{nextEvent && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(generateNextEventJsonLd(nextEvent)),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<HeroSection />
|
||||
<NextEventSectionWrapper />
|
||||
<NextEventSectionWrapper initialEvent={nextEvent} />
|
||||
<AboutSection />
|
||||
<MediaCarouselSection images={carouselImages} />
|
||||
<NewsletterSection />
|
||||
<HomepageFaqSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
TicketIcon,
|
||||
CheckCircleIcon,
|
||||
@@ -14,10 +15,13 @@ import {
|
||||
EnvelopeIcon,
|
||||
PhoneIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface TicketWithDetails extends Omit<Ticket, 'payment'> {
|
||||
bookingId?: string;
|
||||
event?: Event;
|
||||
payment?: {
|
||||
id: string;
|
||||
@@ -39,10 +43,11 @@ export default function AdminBookingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processing, setProcessing] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>('');
|
||||
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -55,7 +60,6 @@ export default function AdminBookingsPage() {
|
||||
eventsApi.getAll(),
|
||||
]);
|
||||
|
||||
// Fetch full ticket details with payment info
|
||||
const ticketsWithDetails = await Promise.all(
|
||||
ticketsRes.tickets.map(async (ticket) => {
|
||||
try {
|
||||
@@ -124,67 +128,56 @@ export default function AdminBookingsPage() {
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'checked_in':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'confirmed': return 'bg-green-100 text-green-800';
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||
case 'checked_in': return 'bg-blue-100 text-blue-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'paid': return 'bg-green-100 text-green-800';
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'refunded':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||
case 'refunded': return 'bg-purple-100 text-purple-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentMethodLabel = (provider: string) => {
|
||||
switch (provider) {
|
||||
case 'bancard':
|
||||
return 'TPago / Card';
|
||||
case 'lightning':
|
||||
return 'Bitcoin Lightning';
|
||||
case 'cash':
|
||||
return 'Cash at Event';
|
||||
default:
|
||||
return provider;
|
||||
case 'bancard': return 'TPago / Card';
|
||||
case 'lightning': return 'Bitcoin Lightning';
|
||||
case 'cash': return 'Cash at Event';
|
||||
default: return provider;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter tickets
|
||||
const filteredTickets = tickets.filter((ticket) => {
|
||||
if (selectedEvent && ticket.eventId !== selectedEvent) return false;
|
||||
if (selectedStatus && ticket.status !== selectedStatus) return false;
|
||||
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const name = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.toLowerCase();
|
||||
return name.includes(q) || (ticket.attendeeEmail?.toLowerCase().includes(q) || false);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort by created date (newest first)
|
||||
const sortedTickets = [...filteredTickets].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: tickets.length,
|
||||
pending: tickets.filter(t => t.status === 'pending').length,
|
||||
@@ -194,6 +187,36 @@ export default function AdminBookingsPage() {
|
||||
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
|
||||
};
|
||||
|
||||
const getBookingInfo = (ticket: TicketWithDetails) => {
|
||||
if (!ticket.bookingId) {
|
||||
return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) };
|
||||
}
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
const hasActiveFilters = selectedEvent || selectedStatus || selectedPaymentStatus || searchQuery;
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedEvent('');
|
||||
setSelectedStatus('');
|
||||
setSelectedPaymentStatus('');
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const getPrimaryAction = (ticket: TicketWithDetails) => {
|
||||
if (ticket.status === 'pending' && ticket.payment?.status === 'pending') {
|
||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
|
||||
}
|
||||
if (ticket.status === 'confirmed') {
|
||||
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -205,51 +228,61 @@ export default function AdminBookingsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">Manage Bookings</h1>
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">Manage Bookings</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary-dark">{stats.total}</p>
|
||||
<p className="text-sm text-gray-500">Total</p>
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-6 gap-2 md:gap-4 mb-6">
|
||||
<Card className="p-3 md:p-4 text-center">
|
||||
<p className="text-xl md:text-2xl font-bold text-primary-dark">{stats.total}</p>
|
||||
<p className="text-xs md:text-sm text-gray-500">Total</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center border-l-4 border-yellow-400">
|
||||
<p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
|
||||
<p className="text-sm text-gray-500">Pending</p>
|
||||
<Card className="p-3 md:p-4 text-center border-l-4 border-yellow-400">
|
||||
<p className="text-xl md:text-2xl font-bold text-yellow-600">{stats.pending}</p>
|
||||
<p className="text-xs md:text-sm text-gray-500">Pending</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center border-l-4 border-green-400">
|
||||
<p className="text-2xl font-bold text-green-600">{stats.confirmed}</p>
|
||||
<p className="text-sm text-gray-500">Confirmed</p>
|
||||
<Card className="p-3 md:p-4 text-center border-l-4 border-green-400">
|
||||
<p className="text-xl md:text-2xl font-bold text-green-600">{stats.confirmed}</p>
|
||||
<p className="text-xs md:text-sm text-gray-500">Confirmed</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center border-l-4 border-blue-400">
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
|
||||
<p className="text-sm text-gray-500">Checked In</p>
|
||||
<Card className="p-3 md:p-4 text-center border-l-4 border-blue-400">
|
||||
<p className="text-xl md:text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
|
||||
<p className="text-xs md:text-sm text-gray-500">Checked In</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center border-l-4 border-red-400">
|
||||
<p className="text-2xl font-bold text-red-600">{stats.cancelled}</p>
|
||||
<p className="text-sm text-gray-500">Cancelled</p>
|
||||
<Card className="p-3 md:p-4 text-center border-l-4 border-red-400">
|
||||
<p className="text-xl md:text-2xl font-bold text-red-600">{stats.cancelled}</p>
|
||||
<p className="text-xs md:text-sm text-gray-500">Cancelled</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center border-l-4 border-orange-400">
|
||||
<p className="text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
|
||||
<p className="text-sm text-gray-500">Pending Payment</p>
|
||||
<Card className="p-3 md:p-4 text-center border-l-4 border-orange-400">
|
||||
<p className="text-xl md:text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
|
||||
<p className="text-xs md:text-sm text-gray-500">Pending Pay</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4 mb-6">
|
||||
{/* Desktop Filters */}
|
||||
<Card className="p-4 mb-6 hidden md:block">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FunnelIcon className="w-5 h-5 text-gray-500" />
|
||||
<span className="font-medium">Filters</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<select
|
||||
value={selectedEvent}
|
||||
onChange={(e) => setSelectedEvent(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||
<option value="">All Events</option>
|
||||
{events.map((event) => (
|
||||
<option key={event.id} value={event.id}>{event.title}</option>
|
||||
@@ -258,11 +291,8 @@ export default function AdminBookingsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<select value={selectedStatus} onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
@@ -272,12 +302,9 @@ export default function AdminBookingsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
|
||||
<select
|
||||
value={selectedPaymentStatus}
|
||||
onChange={(e) => setSelectedPaymentStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">All Payment Statuses</option>
|
||||
<select value={selectedPaymentStatus} onChange={(e) => setSelectedPaymentStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||
<option value="">All Payments</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="refunded">Refunded</option>
|
||||
@@ -285,142 +312,295 @@ export default function AdminBookingsPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span>Showing {sortedTickets.length} of {tickets.length}</span>
|
||||
<button onClick={clearFilters} className="text-primary-yellow hover:underline">Clear</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Bookings List */}
|
||||
<Card className="overflow-hidden">
|
||||
{/* Mobile Toolbar */}
|
||||
<div className="md:hidden space-y-2 mb-4">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setMobileFilterOpen(true)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||||
hasActiveFilters
|
||||
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
|
||||
: 'border-secondary-light-gray text-gray-600'
|
||||
)}
|
||||
>
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
Filters
|
||||
{hasActiveFilters && <span className="text-xs">({sortedTickets.length})</span>}
|
||||
</button>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<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</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Payment</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">Booked</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Payment</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{sortedTickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">
|
||||
No bookings found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedTickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<EnvelopeIcon className="w-4 h-4" />
|
||||
<span>{ticket.attendeeEmail || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<PhoneIcon className="w-4 h-4" />
|
||||
<span>{ticket.attendeePhone || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm">
|
||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||
sortedTickets.map((ticket) => {
|
||||
const bookingInfo = getBookingInfo(ticket);
|
||||
return (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
|
||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm truncate max-w-[150px] block">
|
||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||
{ticket.payment?.status || 'pending'}
|
||||
</span>
|
||||
<p className="text-sm text-gray-500">
|
||||
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}</p>
|
||||
{ticket.payment && (
|
||||
<p className="text-sm font-medium">
|
||||
{ticket.payment.amount?.toLocaleString()} {ticket.payment.currency}
|
||||
</p>
|
||||
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
{ticket.qrCode && (
|
||||
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(ticket.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Mark as Paid (for pending payments) */}
|
||||
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleMarkPaid(ticket.id)}
|
||||
isLoading={processing === ticket.id}
|
||||
className="text-green-600 hover:bg-green-50"
|
||||
>
|
||||
<CurrencyDollarIcon className="w-4 h-4 mr-1" />
|
||||
Mark Paid
|
||||
</Button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
||||
{ticket.status.replace('_', ' ')}
|
||||
</span>
|
||||
{ticket.bookingId && (
|
||||
<p className="text-[10px] text-purple-600 mt-0.5">Group Booking</p>
|
||||
)}
|
||||
|
||||
{/* Check-in (for confirmed tickets) */}
|
||||
{ticket.status === 'confirmed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleCheckin(ticket.id)}
|
||||
isLoading={processing === ticket.id}
|
||||
className="text-blue-600 hover:bg-blue-50"
|
||||
>
|
||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Cancel (for pending/confirmed) */}
|
||||
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleCancel(ticket.id)}
|
||||
isLoading={processing === ticket.id}
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<XCircleIcon className="w-4 h-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{ticket.status === 'checked_in' && (
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-4 h-4" />
|
||||
Attended
|
||||
</span>
|
||||
)}
|
||||
|
||||
{ticket.status === 'cancelled' && (
|
||||
<span className="text-sm text-gray-400">Cancelled</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{formatDate(ticket.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleMarkPaid(ticket.id)}
|
||||
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
|
||||
Mark Paid
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status === 'confirmed' && (
|
||||
<Button size="sm" onClick={() => handleCheckin(ticket.id)}
|
||||
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
||||
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
)}
|
||||
{ticket.status === 'checked_in' && (
|
||||
<span className="text-xs text-green-600 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-4 h-4" /> Attended
|
||||
</span>
|
||||
)}
|
||||
{ticket.status === 'cancelled' && (
|
||||
<span className="text-xs text-gray-400">Cancelled</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{sortedTickets.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">
|
||||
No bookings found.
|
||||
</div>
|
||||
) : (
|
||||
sortedTickets.map((ticket) => {
|
||||
const bookingInfo = getBookingInfo(ticket);
|
||||
const primary = getPrimaryAction(ticket);
|
||||
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
|
||||
return (
|
||||
<Card key={ticket.id} className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail || 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span className={clsx('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', getStatusColor(ticket.status))}>
|
||||
{ticket.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="truncate">{eventTitle}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className={clsx('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', getPaymentStatusColor(ticket.payment?.status || 'pending'))}>
|
||||
{ticket.payment?.status || 'pending'}
|
||||
</span>
|
||||
{ticket.payment && (
|
||||
<>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="font-medium text-gray-700">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{ticket.bookingId && (
|
||||
<p className="text-[10px] text-purple-600 mt-1">{bookingInfo.ticketCount} tickets - Group Booking</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">{formatDate(ticket.createdAt)}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{primary && (
|
||||
<Button size="sm" variant={ticket.status === 'confirmed' ? 'primary' : 'outline'}
|
||||
onClick={primary.onClick} isLoading={processing === ticket.id}
|
||||
className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||
{primary.label}
|
||||
</Button>
|
||||
)}
|
||||
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
||||
<MoreMenu>
|
||||
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && !primary && (
|
||||
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
|
||||
<CurrencyDollarIcon className="w-4 h-4 mr-2" /> Mark Paid
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
||||
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel Booking
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
)}
|
||||
{ticket.status === 'checked_in' && (
|
||||
<span className="text-[10px] text-green-600 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
|
||||
</span>
|
||||
)}
|
||||
{ticket.status === 'cancelled' && (
|
||||
<span className="text-[10px] text-gray-400">Cancelled</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Filter BottomSheet */}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||
<option value="">All Events</option>
|
||||
{events.map((event) => (
|
||||
<option key={event.id} value={event.id}>{event.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ value: '', label: 'All Statuses' },
|
||||
{ value: 'pending', label: `Pending (${stats.pending})` },
|
||||
{ value: 'confirmed', label: `Confirmed (${stats.confirmed})` },
|
||||
{ value: 'checked_in', label: `Checked In (${stats.checkedIn})` },
|
||||
{ value: 'cancelled', label: `Cancelled (${stats.cancelled})` },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setSelectedStatus(opt.value)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||
selectedStatus === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
{selectedStatus === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ value: '', label: 'All Payments' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'paid', label: 'Paid' },
|
||||
{ value: 'refunded', label: 'Refunded' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setSelectedPaymentStatus(opt.value)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||
selectedPaymentStatus === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
{selectedPaymentStatus === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="outline" onClick={() => { clearFilters(); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">
|
||||
Clear All
|
||||
</Button>
|
||||
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function AdminContactsPage() {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
EnvelopeIcon,
|
||||
PencilIcon,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
ExclamationTriangleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -189,7 +191,6 @@ export default function AdminEmailsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await emailsApi.sendToEvent(composeForm.eventId, {
|
||||
templateSlug: composeForm.templateSlug,
|
||||
@@ -197,20 +198,15 @@ export default function AdminEmailsPage() {
|
||||
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
|
||||
});
|
||||
|
||||
if (res.success || res.sentCount > 0) {
|
||||
toast.success(`Sent ${res.sentCount} emails successfully`);
|
||||
if (res.failedCount > 0) {
|
||||
toast.error(`${res.failedCount} emails failed`);
|
||||
}
|
||||
if (res.success) {
|
||||
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
|
||||
clearDraft();
|
||||
setShowRecipientPreview(false);
|
||||
} else {
|
||||
toast.error('Failed to send emails');
|
||||
toast.error(res.error || 'Failed to queue emails');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to send emails');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -373,6 +369,7 @@ export default function AdminEmailsPage() {
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -387,7 +384,7 @@ export default function AdminEmailsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">Email Center</h1>
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">Email Center</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
@@ -441,18 +438,15 @@ export default function AdminEmailsPage() {
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-secondary-light-gray mb-6">
|
||||
<nav className="flex gap-6">
|
||||
<div className="border-b border-secondary-light-gray mb-6 overflow-x-auto scrollbar-hide">
|
||||
<nav className="flex gap-4 md:gap-6 min-w-max">
|
||||
{(['templates', 'compose', 'logs'] as TabType[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={clsx(
|
||||
'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative',
|
||||
{
|
||||
'border-primary-yellow text-primary-dark': activeTab === tab,
|
||||
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab,
|
||||
}
|
||||
'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative whitespace-nowrap min-h-[44px]',
|
||||
activeTab === tab ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
{tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'}
|
||||
@@ -504,30 +498,35 @@ export default function AdminEmailsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePreviewTemplate(template)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="Preview"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => handlePreviewTemplate(template)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" title="Preview">
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditTemplate(template)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="Edit"
|
||||
>
|
||||
<button onClick={() => handleEditTemplate(template)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center hidden md:flex" title="Edit">
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{!template.isSystem && (
|
||||
<button
|
||||
onClick={() => handleDeleteTemplate(template.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Delete"
|
||||
>
|
||||
<XCircleIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="hidden md:block">
|
||||
{!template.isSystem && (
|
||||
<button onClick={() => handleDeleteTemplate(template.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" title="Delete">
|
||||
<XCircleIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleEditTemplate(template)}>
|
||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit
|
||||
</DropdownItem>
|
||||
{!template.isSystem && (
|
||||
<DropdownItem onClick={() => handleDeleteTemplate(template.id)} className="text-red-600">
|
||||
<XCircleIcon className="w-4 h-4 mr-2" /> Delete
|
||||
</DropdownItem>
|
||||
)}
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -545,7 +544,7 @@ export default function AdminEmailsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
{hasDraft && (
|
||||
<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>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={saveDraft}>
|
||||
@@ -569,9 +568,9 @@ export default function AdminEmailsPage() {
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">Choose an event</option>
|
||||
{events.filter(e => e.status === 'published').map((event) => (
|
||||
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
|
||||
<option key={event.id} value={event.id}>
|
||||
{event.title} - {new Date(event.startDatetime).toLocaleDateString()}
|
||||
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -640,13 +639,17 @@ export default function AdminEmailsPage() {
|
||||
|
||||
{/* Recipient Preview Modal */}
|
||||
{showRecipientPreview && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-secondary-light-gray">
|
||||
<h2 className="text-lg font-bold">Recipient Preview</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{previewRecipients.length} recipient(s) will receive this email
|
||||
</p>
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Recipient Preview</h2>
|
||||
<p className="text-xs text-gray-500">{previewRecipients.length} recipient(s)</p>
|
||||
</div>
|
||||
<button onClick={() => setShowRecipientPreview(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
@@ -680,14 +683,10 @@ export default function AdminEmailsPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-secondary-light-gray flex gap-3">
|
||||
<Button
|
||||
onClick={handleSendEmail}
|
||||
isLoading={sending}
|
||||
disabled={previewRecipients.length === 0}
|
||||
>
|
||||
Send to {previewRecipients.length} Recipients
|
||||
<Button onClick={handleSendEmail} isLoading={sending} disabled={previewRecipients.length === 0} className="flex-1 min-h-[44px]">
|
||||
Send to {previewRecipients.length}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowRecipientPreview(false)}>
|
||||
<Button variant="outline" onClick={() => setShowRecipientPreview(false)} className="flex-1 min-h-[44px]">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -700,51 +699,37 @@ export default function AdminEmailsPage() {
|
||||
{/* Logs Tab */}
|
||||
{activeTab === 'logs' && (
|
||||
<div>
|
||||
<Card className="overflow-hidden">
|
||||
{/* Desktop: Table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<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">Status</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Recipient</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Subject</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Sent</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Recipient</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Subject</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Sent</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
No emails sent yet
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No emails sent yet</td></tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(log.status)}
|
||||
<span className="capitalize text-sm">{log.status}</span>
|
||||
</div>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
|
||||
<p className="text-sm text-gray-500">{log.recipientEmail}</p>
|
||||
<p className="text-xs text-gray-500">{log.recipientEmail}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 max-w-xs">
|
||||
<p className="text-sm truncate">{log.subject}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(log.sentAt || log.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="View Email"
|
||||
>
|
||||
<td className="px-4 py-3 max-w-xs"><p className="text-sm truncate">{log.subject}</p></td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<button onClick={() => setSelectedLog(log)} className="p-2 hover:bg-gray-100 rounded-btn" title="View">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -755,46 +740,69 @@ export default function AdminEmailsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{logsTotal > 20 && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary-light-gray">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}
|
||||
</p>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-light-gray">
|
||||
<p className="text-sm text-gray-600">Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={logsOffset === 0}
|
||||
onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={logsOffset === 0} onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={logsOffset + 20 >= logsTotal}
|
||||
onClick={() => setLogsOffset(logsOffset + 20)}
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={logsOffset + 20 >= logsTotal} onClick={() => setLogsOffset(logsOffset + 20)}>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">No emails sent yet</div>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(log.status)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{log.subject}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} <{log.recipientEmail}></p>
|
||||
<p className="text-[10px] text-gray-400 mt-1">{formatDate(log.sentAt || log.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
{logsTotal > 20 && (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<p className="text-xs text-gray-500">{logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={logsOffset === 0} onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))} className="min-h-[44px]">
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={logsOffset + 20 >= logsTotal} onClick={() => setLogsOffset(logsOffset + 20)} className="min-h-[44px]">
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template Form Modal */}
|
||||
{showTemplateForm && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6">
|
||||
<h2 className="text-xl font-bold mb-6">
|
||||
{editingTemplate ? 'Edit Template' : 'Create Template'}
|
||||
</h2>
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-4xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base font-bold">{editingTemplate ? 'Edit Template' : 'Create Template'}</h2>
|
||||
<button onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveTemplate} className="space-y-4">
|
||||
<form onSubmit={handleSaveTemplate} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Template Name"
|
||||
@@ -878,14 +886,10 @@ export default function AdminEmailsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
{editingTemplate ? 'Update Template' : 'Create Template'}
|
||||
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">
|
||||
{editingTemplate ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }} className="flex-1 min-h-[44px]">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -896,16 +900,17 @@ export default function AdminEmailsPage() {
|
||||
|
||||
{/* Preview Modal */}
|
||||
{previewHtml && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Email Preview</h2>
|
||||
<p className="text-sm text-gray-500">Subject: {previewSubject}</p>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-bold">Email Preview</h2>
|
||||
<p className="text-xs text-gray-500 truncate">Subject: {previewSubject}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)}>
|
||||
Close
|
||||
</Button>
|
||||
<button onClick={() => setPreviewHtml(null)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center flex-shrink-0">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<iframe
|
||||
@@ -919,23 +924,26 @@ export default function AdminEmailsPage() {
|
||||
)}
|
||||
|
||||
{/* Log Detail Modal */}
|
||||
<AdminMobileStyles />
|
||||
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Email Details</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-bold">Email Details</h2>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{getStatusIcon(selectedLog.status)}
|
||||
<span className="capitalize text-sm">{selectedLog.status}</span>
|
||||
{selectedLog.errorMessage && (
|
||||
<span className="text-sm text-red-500">- {selectedLog.errorMessage}</span>
|
||||
<span className="text-xs text-red-500">- {selectedLog.errorMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectedLog(null)}>
|
||||
Close
|
||||
</Button>
|
||||
<button onClick={() => setSelectedLog(null)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center flex-shrink-0">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
|
||||
<p><strong>To:</strong> {selectedLog.recipientName} <{selectedLog.recipientEmail}></p>
|
||||
|
||||
@@ -2,23 +2,29 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
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 Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import MediaPicker from '@/components/MediaPicker';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, XMarkIcon, LinkIcon } from '@heroicons/react/24/outline';
|
||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function AdminEventsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const searchParams = useSearchParams();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||||
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
@@ -34,7 +40,7 @@ export default function AdminEventsPage() {
|
||||
price: number;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||
bannerUrl: string;
|
||||
externalBookingEnabled: boolean;
|
||||
externalBookingUrl: string;
|
||||
@@ -60,8 +66,17 @@ export default function AdminEventsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
loadFeaturedEvent();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const editId = searchParams.get('edit');
|
||||
if (editId && events.length > 0) {
|
||||
const event = events.find(e => e.id === editId);
|
||||
if (event) handleEdit(event);
|
||||
}
|
||||
}, [searchParams, events]);
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
const { events } = await eventsApi.getAll();
|
||||
@@ -73,30 +88,39 @@ export default function AdminEventsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadFeaturedEvent = async () => {
|
||||
try {
|
||||
const { settings } = await siteSettingsApi.get();
|
||||
setFeaturedEventId(settings.featuredEventId || null);
|
||||
} catch (error) {
|
||||
// Ignore - 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 = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
titleEs: '',
|
||||
description: '',
|
||||
descriptionEs: '',
|
||||
shortDescription: '',
|
||||
shortDescriptionEs: '',
|
||||
startDatetime: '',
|
||||
endDatetime: '',
|
||||
location: '',
|
||||
locationUrl: '',
|
||||
price: 0,
|
||||
currency: 'PYG',
|
||||
capacity: 50,
|
||||
status: 'draft' as const,
|
||||
bannerUrl: '',
|
||||
externalBookingEnabled: false,
|
||||
externalBookingUrl: '',
|
||||
title: '', titleEs: '', description: '', descriptionEs: '',
|
||||
shortDescription: '', shortDescriptionEs: '',
|
||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
|
||||
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||
});
|
||||
setEditingEvent(null);
|
||||
};
|
||||
|
||||
// Convert ISO UTC string to local datetime-local format (YYYY-MM-DDTHH:MM)
|
||||
const isoToLocalDatetime = (isoString: string): string => {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
@@ -109,21 +133,14 @@ export default function AdminEventsPage() {
|
||||
|
||||
const handleEdit = (event: Event) => {
|
||||
setFormData({
|
||||
title: event.title,
|
||||
titleEs: event.titleEs || '',
|
||||
description: event.description,
|
||||
descriptionEs: event.descriptionEs || '',
|
||||
shortDescription: event.shortDescription || '',
|
||||
shortDescriptionEs: event.shortDescriptionEs || '',
|
||||
title: event.title, titleEs: event.titleEs || '',
|
||||
description: event.description, descriptionEs: event.descriptionEs || '',
|
||||
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
|
||||
startDatetime: isoToLocalDatetime(event.startDatetime),
|
||||
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||
location: event.location,
|
||||
locationUrl: event.locationUrl || '',
|
||||
price: event.price,
|
||||
currency: event.currency,
|
||||
capacity: event.capacity,
|
||||
status: event.status,
|
||||
bannerUrl: event.bannerUrl || '',
|
||||
location: event.location, locationUrl: event.locationUrl || '',
|
||||
price: event.price, currency: event.currency, capacity: event.capacity,
|
||||
status: event.status, bannerUrl: event.bannerUrl || '',
|
||||
externalBookingEnabled: event.externalBookingEnabled || false,
|
||||
externalBookingUrl: event.externalBookingUrl || '',
|
||||
});
|
||||
@@ -134,9 +151,7 @@ export default function AdminEventsPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Validate external booking URL if enabled
|
||||
if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
|
||||
toast.error('External booking URL is required when external booking is enabled');
|
||||
setSaving(false);
|
||||
@@ -147,27 +162,18 @@ export default function AdminEventsPage() {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
title: formData.title,
|
||||
titleEs: formData.titleEs || undefined,
|
||||
description: formData.description,
|
||||
descriptionEs: formData.descriptionEs || undefined,
|
||||
shortDescription: formData.shortDescription || undefined,
|
||||
shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||
startDatetime: new Date(formData.startDatetime).toISOString(),
|
||||
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
||||
location: formData.location,
|
||||
locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price,
|
||||
currency: formData.currency,
|
||||
capacity: formData.capacity,
|
||||
status: formData.status,
|
||||
bannerUrl: formData.bannerUrl || undefined,
|
||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||
externalBookingEnabled: formData.externalBookingEnabled,
|
||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||
};
|
||||
|
||||
if (editingEvent) {
|
||||
await eventsApi.update(editingEvent.id, eventData);
|
||||
toast.success('Event updated');
|
||||
@@ -175,7 +181,6 @@ export default function AdminEventsPage() {
|
||||
await eventsApi.create(eventData);
|
||||
toast.success('Event created');
|
||||
}
|
||||
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
loadEvents();
|
||||
@@ -188,7 +193,6 @@ export default function AdminEventsPage() {
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this event?')) return;
|
||||
|
||||
try {
|
||||
await eventsApi.delete(id);
|
||||
toast.success('Event deleted');
|
||||
@@ -208,22 +212,21 @@ export default function AdminEventsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
const isEventOver = (event: Event) => {
|
||||
const refDate = event.endDatetime || event.startDatetime;
|
||||
return new Date(refDate) < new Date();
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
draft: 'badge-gray',
|
||||
published: 'badge-success',
|
||||
cancelled: 'badge-danger',
|
||||
completed: 'badge-info',
|
||||
archived: 'badge-gray',
|
||||
draft: 'badge-gray', published: 'badge-success', unlisted: 'badge-warning',
|
||||
cancelled: 'badge-danger', completed: 'badge-info', archived: 'badge-gray',
|
||||
};
|
||||
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
|
||||
};
|
||||
@@ -259,8 +262,8 @@ export default function AdminEventsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
|
||||
<Button onClick={() => { resetForm(); setShowForm(true); }}>
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
|
||||
<Button onClick={() => { resetForm(); setShowForm(true); }} className="hidden md:flex">
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
{t('admin.events.create')}
|
||||
</Button>
|
||||
@@ -268,202 +271,163 @@ export default function AdminEventsPage() {
|
||||
|
||||
{/* Event Form Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
||||
<h2 className="text-xl font-bold mb-6">
|
||||
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
||||
</h2>
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-lg md:text-xl font-bold">
|
||||
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
||||
</h2>
|
||||
<button onClick={() => { setShowForm(false); resetForm(); }}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="p-4 md:p-6 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Title (English)"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Title (Spanish)"
|
||||
value={formData.titleEs}
|
||||
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })}
|
||||
/>
|
||||
<Input label="Title (English)" value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })} required />
|
||||
<Input label="Title (Spanish)" value={formData.titleEs}
|
||||
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (English)</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
<textarea value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
rows={3} required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
|
||||
<textarea
|
||||
value={formData.descriptionEs}
|
||||
<textarea value={formData.descriptionEs}
|
||||
onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={3}
|
||||
/>
|
||||
rows={3} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Short Description (English)</label>
|
||||
<textarea
|
||||
value={formData.shortDescription}
|
||||
<textarea value={formData.shortDescription}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2}
|
||||
maxLength={300}
|
||||
placeholder="Brief summary for SEO and cards (max 300 chars)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300 characters</p>
|
||||
rows={2} maxLength={300} placeholder="Brief summary for SEO and cards (max 300 chars)" />
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
|
||||
<textarea
|
||||
value={formData.shortDescriptionEs}
|
||||
<textarea value={formData.shortDescriptionEs}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2}
|
||||
maxLength={300}
|
||||
placeholder="Resumen breve para SEO y tarjetas (máx 300 caracteres)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300 characters</p>
|
||||
rows={2} maxLength={300} placeholder="Resumen breve (máx 300 caracteres)" />
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Start Date & Time"
|
||||
type="datetime-local"
|
||||
value={formData.startDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="End Date & Time"
|
||||
type="datetime-local"
|
||||
value={formData.endDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })}
|
||||
/>
|
||||
<Input label="Start Date & Time" type="datetime-local" value={formData.startDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })} required />
|
||||
<Input label="End Date & Time" type="datetime-local" value={formData.endDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Location"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input label="Location" value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
|
||||
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
|
||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
||||
|
||||
<Input
|
||||
label="Location URL (Google Maps)"
|
||||
type="url"
|
||||
value={formData.locationUrl}
|
||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
label="Price"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input label="Price" type="number" min="0" value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||
<select
|
||||
value={formData.currency}
|
||||
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="PYG">PYG</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
label="Capacity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.capacity}
|
||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })}
|
||||
/>
|
||||
<Input label="Capacity" type="number" min="1" value={formData.capacity}
|
||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<select value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* External Booking Section */}
|
||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">External Booking</label>
|
||||
<p className="text-xs text-gray-500">Redirect users to an external booking platform</p>
|
||||
<p className="text-xs text-gray-500">Redirect users to an external platform</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<button type="button"
|
||||
onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 ${
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||
formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<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 ${
|
||||
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
}`}>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.externalBookingEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
label="External Booking URL"
|
||||
type="url"
|
||||
value={formData.externalBookingUrl}
|
||||
<Input label="External Booking URL" type="url" value={formData.externalBookingUrl}
|
||||
onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })}
|
||||
placeholder="https://example.com/book"
|
||||
required
|
||||
/>
|
||||
placeholder="https://example.com/book" required />
|
||||
<p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Upload / Media Picker */}
|
||||
<MediaPicker
|
||||
value={formData.bannerUrl}
|
||||
<MediaPicker value={formData.bannerUrl}
|
||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||
relatedId={editingEvent?.id}
|
||||
relatedType="event"
|
||||
/>
|
||||
relatedId={editingEvent?.id} relatedType="event" />
|
||||
|
||||
{editingEvent && editingEvent.status === 'published' && (
|
||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<StarIcon className="w-5 h-5 text-amber-500" /> Featured Event
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Prominently displayed on homepage</p>
|
||||
</div>
|
||||
<button type="button" disabled={settingFeatured !== null}
|
||||
onClick={() => handleSetFeatured(featuredEventId === editingEvent.id ? null : editingEvent.id)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
|
||||
featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200'
|
||||
}`}>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{featuredEventId && featuredEventId !== editingEvent.id && (
|
||||
<p className="text-xs text-amber-700 bg-amber-100 p-2 rounded">
|
||||
Note: Another event is currently featured. Setting this event as featured will replace it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">
|
||||
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowForm(false); resetForm(); }}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => { setShowForm(false); resetForm(); }} className="flex-1 min-h-[44px]">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -472,17 +436,17 @@ export default function AdminEventsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events Table */}
|
||||
<Card className="overflow-hidden">
|
||||
{/* Desktop: Table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<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">Event</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Date</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Capacity</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Capacity</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
@@ -494,83 +458,91 @@ export default function AdminEventsPage() {
|
||||
</tr>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<tr key={event.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={event.title}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
<img src={event.bannerUrl} alt={event.title}
|
||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
||||
<PhotoIcon className="w-6 h-6 text-gray-400" />
|
||||
<div className="w-10 h-10 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
||||
<PhotoIcon className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{event.title}</p>
|
||||
<p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm">{event.title}</p>
|
||||
{featuredEventId === event.id && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-amber-100 text-amber-800">
|
||||
<StarIconSolid className="w-2.5 h-2.5" /> Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate max-w-xs">{event.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(event.startDatetime)}
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
|
||||
<td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getStatusBadge(event.status)}
|
||||
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
{event.bookedCount || 0} / {event.capacity}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{getStatusBadge(event.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{event.status === 'draft' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleStatusChange(event, 'published')}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/events/${event.id}`}
|
||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"
|
||||
title="Manage Event"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleEdit(event)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="Edit"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDuplicate(event)}
|
||||
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
|
||||
title="Duplicate"
|
||||
>
|
||||
<DocumentDuplicateIcon className="w-4 h-4" />
|
||||
</button>
|
||||
{event.status !== 'archived' && (
|
||||
<button
|
||||
onClick={() => handleArchive(event)}
|
||||
className="p-2 hover:bg-gray-100 text-gray-600 rounded-btn"
|
||||
title="Archive"
|
||||
>
|
||||
<ArchiveBoxIcon className="w-4 h-4" />
|
||||
{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>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(event.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
<Link href={`/admin/events/${event.id}`}
|
||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn" title="Manage">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
<button onClick={() => handleEdit(event)} className="p-2 hover:bg-gray-100 rounded-btn" title="Edit">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<MoreMenu>
|
||||
{(event.status === 'draft' || event.status === 'published') && (
|
||||
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
|
||||
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
|
||||
</DropdownItem>
|
||||
)}
|
||||
{event.status === 'unlisted' && (
|
||||
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
||||
Make Public
|
||||
</DropdownItem>
|
||||
)}
|
||||
{(event.status === 'published' || event.status === 'unlisted') && (
|
||||
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
|
||||
Unpublish
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => handleDuplicate(event)}>
|
||||
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
|
||||
</DropdownItem>
|
||||
{event.status !== 'archived' && (
|
||||
<DropdownItem onClick={() => handleArchive(event)}>
|
||||
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
|
||||
<TrashIcon className="w-4 h-4 mr-2" /> Delete
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -580,6 +552,109 @@ export default function AdminEventsPage() {
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{events.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">
|
||||
No events found. Create your first event!
|
||||
</div>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<Card key={event.id} className={clsx("p-3", featuredEventId === event.id && "ring-2 ring-amber-300")}>
|
||||
<div className="flex items-start gap-3">
|
||||
{event.bannerUrl ? (
|
||||
<img src={event.bannerUrl} alt={event.title}
|
||||
className="w-14 h-14 rounded-lg object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
||||
<PhotoIcon className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate">{event.title}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{event.location}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
|
||||
{getStatusBadge(event.status)}
|
||||
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
|
||||
)}
|
||||
{featuredEventId === event.id && (
|
||||
<StarIconSolid className="w-4 h-4 text-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/admin/events/${event.id}`}
|
||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleEdit(event)}>
|
||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit
|
||||
</DropdownItem>
|
||||
{event.status === 'draft' && (
|
||||
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
||||
Publish
|
||||
</DropdownItem>
|
||||
)}
|
||||
{(event.status === 'draft' || event.status === 'published') && (
|
||||
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
|
||||
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
|
||||
</DropdownItem>
|
||||
)}
|
||||
{event.status === 'unlisted' && (
|
||||
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
||||
Make Public
|
||||
</DropdownItem>
|
||||
)}
|
||||
{(event.status === 'published' || event.status === 'unlisted') && (
|
||||
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
|
||||
Unpublish
|
||||
</DropdownItem>
|
||||
)}
|
||||
{event.status === 'published' && (
|
||||
<DropdownItem onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}>
|
||||
<StarIcon className="w-4 h-4 mr-2" />
|
||||
{featuredEventId === event.id ? 'Unfeature' : 'Set Featured'}
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => handleDuplicate(event)}>
|
||||
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
|
||||
</DropdownItem>
|
||||
{event.status !== 'archived' && (
|
||||
<DropdownItem onClick={() => handleArchive(event)}>
|
||||
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
|
||||
<TrashIcon className="w-4 h-4 mr-2" /> Delete
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||
<button onClick={() => { resetForm(); setShowForm(true); }}
|
||||
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
|
||||
<PlusIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
393
frontend/src/app/admin/faq/page.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
'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 { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
PlusIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
CheckIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
} 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 handleMoveUp = async (index: number) => {
|
||||
if (index === 0) return;
|
||||
const newOrder = [...faqs];
|
||||
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
|
||||
const ids = newOrder.map(f => f.id);
|
||||
try {
|
||||
const res = await faqApi.reorder(ids);
|
||||
setFaqs(res.faqs);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDown = async (index: number) => {
|
||||
if (index >= faqs.length - 1) return;
|
||||
const newOrder = [...faqs];
|
||||
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
|
||||
const ids = newOrder.map(f => f.id);
|
||||
try {
|
||||
const res = await faqApi.reorder(ids);
|
||||
setFaqs(res.faqs);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
|
||||
}
|
||||
};
|
||||
|
||||
// Desktop drag handlers
|
||||
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||
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-xl md:text-2xl font-bold font-heading">FAQ</h1>
|
||||
<p className="text-gray-500 text-xs md:text-sm mt-1 hidden md:block">
|
||||
{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} className="hidden md:flex">
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
{locale === 'es' ? 'Nueva pregunta' : 'Add question'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form Modal - bottom sheet on mobile */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base 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 min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<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 min-h-[44px]">
|
||||
<input type="checkbox" checked={form.enabled} onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} className="rounded border-gray-300 w-4 h-4" />
|
||||
<span className="text-sm">{locale === 'es' ? 'Mostrar en el sitio' : 'Show on site'}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
|
||||
<input type="checkbox" checked={form.showOnHomepage} onChange={e => setForm(f => ({ ...f, showOnHomepage: e.target.checked }))} className="rounded border-gray-300 w-4 h-4" />
|
||||
<span className="text-sm">{locale === 'es' ? 'Mostrar en inicio' : 'Show on homepage'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button onClick={handleSave} isLoading={saving} className="flex-1 min-h-[44px]">
|
||||
<CheckIcon className="w-4 h-4 mr-1" /> {locale === 'es' ? 'Guardar' : 'Save'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => { setForm(emptyForm); setShowForm(false); }} disabled={saving} className="flex-1 min-h-[44px]">
|
||||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<Card className="hidden md:block">
|
||||
<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-2 text-left text-xs font-semibold text-gray-500 uppercase">{locale === 'es' ? 'Pregunta' : 'Question'}</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase w-24">{locale === 'es' ? 'En sitio' : 'On site'}</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase w-28">{locale === 'es' ? 'En inicio' : 'Homepage'}</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-500 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 text-sm">{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 text-sm 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 min-h-[32px]',
|
||||
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||
{faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : '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 min-h-[32px]',
|
||||
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||
{faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : '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>
|
||||
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{faqs.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}</div>
|
||||
) : (
|
||||
faqs.map((faq, index) => (
|
||||
<Card key={faq.id} className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex flex-col gap-0.5 flex-shrink-0 pt-0.5">
|
||||
<button onClick={() => handleMoveUp(index)} disabled={index === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 min-h-[28px] min-w-[28px] flex items-center justify-center">
|
||||
<ChevronUpIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleMoveDown(index)} disabled={index >= faqs.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 min-h-[28px] min-w-[28px] flex items-center justify-center">
|
||||
<ChevronDownIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-primary-dark line-clamp-2">
|
||||
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<button onClick={() => handleToggleEnabled(faq)}
|
||||
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium min-h-[28px]',
|
||||
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||
{faq.enabled ? (locale === 'es' ? 'Sitio: Sí' : 'Site: Yes') : (locale === 'es' ? 'Sitio: No' : 'Site: No')}
|
||||
</button>
|
||||
<button onClick={() => handleToggleShowOnHomepage(faq)}
|
||||
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium min-h-[28px]',
|
||||
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||
{faq.showOnHomepage ? (locale === 'es' ? 'Inicio: Sí' : 'Home: Yes') : (locale === 'es' ? 'Inicio: No' : 'Home: No')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleEdit(faq)}>
|
||||
<PencilSquareIcon className="w-4 h-4 mr-2" /> {locale === 'es' ? 'Editar' : 'Edit'}
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => handleDelete(faq.id)} className="text-red-600">
|
||||
<TrashIcon className="w-4 h-4 mr-2" /> {locale === 'es' ? 'Eliminar' : 'Delete'}
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||
<button onClick={handleCreate}
|
||||
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
|
||||
<PlusIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export default function AdminGalleryPage() {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
XMarkIcon,
|
||||
BanknotesIcon,
|
||||
QrCodeIcon,
|
||||
DocumentTextIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
@@ -35,14 +37,56 @@ export default function AdminLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t, locale } = useLanguage();
|
||||
const { user, isAdmin, isLoading, logout } = useAuth();
|
||||
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
||||
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(() => {
|
||||
if (!isLoading && (!user || !isAdmin)) {
|
||||
if (!isLoading && (!user || !hasAdminAccess)) {
|
||||
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) {
|
||||
return (
|
||||
@@ -52,29 +96,29 @@ export default function AdminLayout({
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !isAdmin) {
|
||||
if (!user || !hasAdminAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
||||
{ 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 visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole));
|
||||
const navigation = visibleNav;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
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 (
|
||||
<div className="min-h-screen bg-secondary-gray">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
|
||||
597
frontend/src/app/admin/legal-pages/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export default function AdminDashboardPage() {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -113,12 +114,12 @@ export default function AdminDashboardPage() {
|
||||
{/* Low capacity warnings */}
|
||||
{data?.upcomingEvents
|
||||
.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;
|
||||
return percentFull >= 80 && availableSeats > 0;
|
||||
return percentFull >= 80 && spotsLeft > 0;
|
||||
})
|
||||
.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);
|
||||
return (
|
||||
<Link
|
||||
@@ -130,7 +131,7 @@ export default function AdminDashboardPage() {
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
||||
<div>
|
||||
<span className="text-sm font-medium">{event.title}</span>
|
||||
<p className="text-xs text-gray-500">Only {availableSeats} spots left ({percentFull}% full)</p>
|
||||
<p className="text-xs text-gray-500">Only {spotsLeft} spots left ({percentFull}% full)</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge badge-warning">Low capacity</span>
|
||||
@@ -140,7 +141,7 @@ export default function AdminDashboardPage() {
|
||||
|
||||
{/* Sold out events */}
|
||||
{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 => (
|
||||
<Link
|
||||
key={event.id}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPa
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ArrowPathIcon,
|
||||
@@ -19,8 +20,12 @@ import {
|
||||
BanknotesIcon,
|
||||
BuildingLibraryIcon,
|
||||
CreditCardIcon,
|
||||
EnvelopeIcon,
|
||||
FunnelIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type Tab = 'pending_approval' | 'all';
|
||||
|
||||
@@ -33,11 +38,14 @@ export default function AdminPaymentsPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [providerFilter, setProviderFilter] = useState<string>('');
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
// Modal state
|
||||
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [sendEmail, setSendEmail] = useState(true);
|
||||
const [sendingReminder, setSendingReminder] = useState(false);
|
||||
|
||||
// Export state
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
@@ -77,10 +85,11 @@ export default function AdminPaymentsPage() {
|
||||
const handleApprove = async (payment: PaymentWithDetails) => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
await paymentsApi.approve(payment.id, noteText);
|
||||
await paymentsApi.approve(payment.id, noteText, sendEmail);
|
||||
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
||||
setSelectedPayment(null);
|
||||
setNoteText('');
|
||||
setSendEmail(true);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to approve payment');
|
||||
@@ -92,10 +101,11 @@ export default function AdminPaymentsPage() {
|
||||
const handleReject = async (payment: PaymentWithDetails) => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
await paymentsApi.reject(payment.id, noteText);
|
||||
await paymentsApi.reject(payment.id, noteText, sendEmail);
|
||||
toast.success(locale === 'es' ? 'Pago rechazado' : 'Payment rejected');
|
||||
setSelectedPayment(null);
|
||||
setNoteText('');
|
||||
setSendEmail(true);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to reject payment');
|
||||
@@ -104,6 +114,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) => {
|
||||
try {
|
||||
await paymentsApi.approve(id);
|
||||
@@ -176,6 +204,7 @@ export default function AdminPaymentsPage() {
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -230,13 +259,69 @@ export default function AdminPaymentsPage() {
|
||||
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
|
||||
.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
|
||||
.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) {
|
||||
return (
|
||||
@@ -249,27 +334,44 @@ export default function AdminPaymentsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
|
||||
<Button onClick={() => setShowExportModal(true)}>
|
||||
<DocumentArrowDownIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Exportar Datos' : 'Export Data'}
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
|
||||
<Button onClick={() => setShowExportModal(true)} size="sm" className="min-h-[44px] md:min-h-0">
|
||||
<DocumentArrowDownIcon className="w-4 h-4 mr-1.5" />
|
||||
<span className="hidden md:inline">{locale === 'es' ? 'Exportar Datos' : 'Export Data'}</span>
|
||||
<span className="md:hidden">{locale === 'es' ? 'Exportar' : 'Export'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Approval Detail Modal */}
|
||||
{selectedPayment && (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
||||
</h2>
|
||||
{selectedPayment && (() => {
|
||||
const modalBookingInfo = getBookingInfo(selectedPayment);
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base font-bold">
|
||||
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
||||
</h2>
|
||||
<button onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto flex-1 min-h-0">
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">{locale === 'es' ? 'Monto' : 'Amount'}</p>
|
||||
<p className="font-bold text-lg">{formatCurrency(selectedPayment.amount, selectedPayment.currency)}</p>
|
||||
<p className="text-gray-500">{locale === 'es' ? 'Monto Total' : 'Total Amount'}</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>
|
||||
<p className="text-gray-500">{locale === 'es' ? 'Método' : 'Method'}</p>
|
||||
@@ -309,6 +411,22 @@ export default function AdminPaymentsPage() {
|
||||
</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>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{locale === 'es' ? 'Nota interna (opcional)' : 'Internal note (optional)'}
|
||||
@@ -321,43 +439,57 @@ export default function AdminPaymentsPage() {
|
||||
placeholder={locale === 'es' ? 'Agregar nota...' : 'Add a note...'}
|
||||
/>
|
||||
</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 className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => handleApprove(selectedPayment)}
|
||||
isLoading={processing}
|
||||
className="flex-1"
|
||||
>
|
||||
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Aprobar' : 'Approve'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleReject(selectedPayment)}
|
||||
isLoading={processing}
|
||||
className="flex-1 border-red-300 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
|
||||
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
|
||||
<XCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Rechazar' : 'Reject'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setSelectedPayment(null); setNoteText(''); }}
|
||||
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||
</button>
|
||||
<div className="pt-2 border-t">
|
||||
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
|
||||
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Export Modal */}
|
||||
{showExportModal && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
||||
<h2 className="text-xl font-bold mb-6">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base font-bold">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
|
||||
<button onClick={() => { setShowExportModal(false); setExportData(null); }}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto flex-1 min-h-0">
|
||||
|
||||
{!exportData ? (
|
||||
<div className="space-y-4">
|
||||
@@ -391,10 +523,10 @@ export default function AdminPaymentsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button onClick={handleExport} isLoading={exporting}>
|
||||
<Button onClick={handleExport} isLoading={exporting} className="flex-1 min-h-[44px]">
|
||||
{locale === 'es' ? 'Generar Reporte' : 'Generate Report'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowExportModal(false)}>
|
||||
<Button variant="outline" onClick={() => setShowExportModal(false)} className="flex-1 min-h-[44px]">
|
||||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -454,20 +586,21 @@ export default function AdminPaymentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={downloadCSV}>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button onClick={downloadCSV} className="min-h-[44px]">
|
||||
<ArrowDownTrayIcon className="w-4 h-4 mr-2" />
|
||||
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setExportData(null)}>
|
||||
<Button variant="outline" onClick={() => setExportData(null)} className="min-h-[44px]">
|
||||
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }}>
|
||||
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }} className="min-h-[44px]">
|
||||
{locale === 'es' ? 'Cerrar' : 'Close'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
@@ -481,7 +614,10 @@ export default function AdminPaymentsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p>
|
||||
<p className="text-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>
|
||||
</Card>
|
||||
@@ -493,6 +629,7 @@ export default function AdminPaymentsPage() {
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
|
||||
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
|
||||
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -504,6 +641,7 @@ export default function AdminPaymentsPage() {
|
||||
<div>
|
||||
<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-xs text-gray-400">{paidBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -513,7 +651,7 @@ export default function AdminPaymentsPage() {
|
||||
<BoltIcon className="w-5 h-5 text-blue-600" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -521,31 +659,19 @@ export default function AdminPaymentsPage() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b mb-6">
|
||||
<nav className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('pending_approval')}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'pending_approval'
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}
|
||||
<div className="border-b mb-6 overflow-x-auto scrollbar-hide">
|
||||
<nav className="flex gap-4 min-w-max">
|
||||
<button onClick={() => setActiveTab('pending_approval')}
|
||||
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
|
||||
activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
|
||||
{locale === 'es' ? 'Pendientes' : 'Pending Approval'}
|
||||
{pendingApprovalPayments.length > 0 && (
|
||||
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">
|
||||
{pendingApprovalPayments.length}
|
||||
</span>
|
||||
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{pendingApprovalPayments.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'all'
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => setActiveTab('all')}
|
||||
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
|
||||
activeTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
|
||||
{locale === 'es' ? 'Todos los Pagos' : 'All Payments'}
|
||||
</button>
|
||||
</nav>
|
||||
@@ -565,46 +691,60 @@ export default function AdminPaymentsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{pendingApprovalPayments.map((payment) => (
|
||||
<Card key={payment.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
{getProviderIcon(payment.provider)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-bold text-lg">{formatCurrency(payment.amount, payment.currency)}</p>
|
||||
{getStatusBadge(payment.status)}
|
||||
{pendingApprovalPayments.map((payment) => {
|
||||
const bookingInfo = getPendingBookingInfo(payment);
|
||||
return (
|
||||
<Card key={payment.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
{getProviderIcon(payment.provider)}
|
||||
</div>
|
||||
{payment.ticket && (
|
||||
<p className="text-sm font-medium">
|
||||
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
||||
</p>
|
||||
)}
|
||||
{payment.event && (
|
||||
<p className="text-sm text-gray-500">{payment.event.title}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
{getProviderIcon(payment.provider)}
|
||||
{getProviderLabel(payment.provider)}
|
||||
</span>
|
||||
{payment.userMarkedPaidAt && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<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)}
|
||||
</div>
|
||||
{payment.ticket && (
|
||||
<p className="text-sm font-medium">
|
||||
{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>
|
||||
)}
|
||||
{payment.event && (
|
||||
<p className="text-sm text-gray-500">{payment.event.title}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="w-3 h-3" />
|
||||
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
||||
{getProviderIcon(payment.provider)}
|
||||
{getProviderLabel(payment.provider)}
|
||||
</span>
|
||||
{payment.userMarkedPaidAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="w-3 h-3" />
|
||||
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
<Button onClick={() => setSelectedPayment(payment)} size="sm" className="min-h-[44px] md:min-h-0 flex-shrink-0">
|
||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setSelectedPayment(payment)}>
|
||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -613,16 +753,13 @@ export default function AdminPaymentsPage() {
|
||||
{/* All Payments Tab */}
|
||||
{activeTab === 'all' && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<Card className="p-4 mb-6">
|
||||
{/* Desktop Filters */}
|
||||
<Card className="p-4 mb-6 hidden md:block">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||
>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||||
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
|
||||
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
|
||||
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
|
||||
@@ -633,112 +770,180 @@ export default function AdminPaymentsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
|
||||
<select
|
||||
value={providerFilter}
|
||||
onChange={(e) => setProviderFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||
>
|
||||
<select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||||
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
|
||||
<option value="lightning">Lightning</option>
|
||||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}</option>
|
||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||
<option value="tpago">TPago</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Payments Table */}
|
||||
<Card className="overflow-hidden">
|
||||
{/* Mobile Filter Toolbar */}
|
||||
<div className="md:hidden mb-4 flex items-center gap-2">
|
||||
<button onClick={() => setMobileFilterOpen(true)}
|
||||
className={clsx('flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||||
(statusFilter || providerFilter) ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600')}>
|
||||
<FunnelIcon className="w-4 h-4" /> Filters
|
||||
</button>
|
||||
{(statusFilter || providerFilter) && (
|
||||
<button onClick={() => { setStatusFilter(''); setProviderFilter(''); }}
|
||||
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<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">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Evento' : 'Event'}</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Monto' : 'Amount'}</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Método' : 'Method'}</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Fecha' : 'Date'}</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Evento' : 'Event'}</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Monto' : 'Amount'}</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Método' : 'Method'}</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{payments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
||||
{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr>
|
||||
) : (
|
||||
payments.map((payment) => (
|
||||
<tr key={payment.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
{payment.ticket ? (
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
|
||||
payments.map((payment) => {
|
||||
const bookingInfo = getBookingInfo(payment);
|
||||
return (
|
||||
<tr key={payment.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
{payment.ticket ? (
|
||||
<div>
|
||||
<p className="font-medium text-sm">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p>
|
||||
<p className="text-xs text-gray-500 truncate max-w-[180px]">{payment.ticket.attendeeEmail}</p>
|
||||
</div>
|
||||
) : <span className="text-gray-400 text-sm">-</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm truncate max-w-[150px]">{payment.event?.title || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-sm">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
|
||||
{bookingInfo.ticketCount > 1 && <p className="text-[10px] text-purple-600">{bookingInfo.ticketCount} tickets</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{payment.event ? (
|
||||
<p className="text-sm">{payment.event.title}</p>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 font-medium">
|
||||
{formatCurrency(payment.amount, payment.currency)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
{getProviderIcon(payment.provider)}
|
||||
{getProviderLabel(payment.provider)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(payment.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{getStatusBadge(payment.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setSelectedPayment(payment)}
|
||||
>
|
||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||
</Button>
|
||||
)}
|
||||
{payment.status === 'paid' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRefund(payment.id)}
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4 mr-1" />
|
||||
{t('admin.payments.refund')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
||||
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1">
|
||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||
</Button>
|
||||
)}
|
||||
{payment.status === 'paid' && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2 py-1">
|
||||
{t('admin.payments.refund')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{payments.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</div>
|
||||
) : (
|
||||
payments.map((payment) => {
|
||||
const bookingInfo = getBookingInfo(payment);
|
||||
return (
|
||||
<Card key={payment.id} className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{payment.ticket ? (
|
||||
<p className="font-medium text-sm truncate">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p>
|
||||
) : <p className="text-sm text-gray-400">-</p>}
|
||||
<p className="text-xs text-gray-500 truncate">{payment.event?.title || '-'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{getStatusBadge(payment.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}</span>
|
||||
{bookingInfo.ticketCount > 1 && (
|
||||
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
||||
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||
</Button>
|
||||
)}
|
||||
{payment.status === 'paid' && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||
{t('admin.payments.refund')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Filter BottomSheet */}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title={locale === 'es' ? 'Filtros' : 'Filters'}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
|
||||
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
|
||||
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
|
||||
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
|
||||
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
|
||||
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
|
||||
<select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
|
||||
<option value="lightning">Lightning</option>
|
||||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||
<option value="tpago">TPago</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="outline" onClick={() => { setStatusFilter(''); setProviderFilter(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
|
||||
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { CheckCircleIcon, XCircleIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { CheckCircleIcon, XCircleIcon, PlusIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@@ -17,26 +18,17 @@ export default function AdminTicketsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
// Manual ticket creation state
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
eventId: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
preferredLanguage: 'en' as 'en' | 'es',
|
||||
autoCheckin: false,
|
||||
adminNote: '',
|
||||
eventId: '', firstName: '', lastName: '', email: '', phone: '',
|
||||
preferredLanguage: 'en' as 'en' | 'es', autoCheckin: false, adminNote: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
ticketsApi.getAll(),
|
||||
eventsApi.getAll(),
|
||||
])
|
||||
Promise.all([ticketsApi.getAll(), eventsApi.getAll()])
|
||||
.then(([ticketsRes, eventsRes]) => {
|
||||
setTickets(ticketsRes.tickets);
|
||||
setEvents(eventsRes.events);
|
||||
@@ -58,9 +50,7 @@ export default function AdminTicketsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
loadTickets();
|
||||
}
|
||||
if (!loading) loadTickets();
|
||||
}, [selectedEvent, statusFilter]);
|
||||
|
||||
const handleCheckin = async (id: string) => {
|
||||
@@ -75,7 +65,6 @@ export default function AdminTicketsPage() {
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to cancel this ticket?')) return;
|
||||
|
||||
try {
|
||||
await ticketsApi.cancel(id);
|
||||
toast.success('Ticket cancelled');
|
||||
@@ -97,35 +86,18 @@ export default function AdminTicketsPage() {
|
||||
|
||||
const handleCreateTicket = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!createForm.eventId) {
|
||||
toast.error('Please select an event');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createForm.eventId) { toast.error('Please select an event'); return; }
|
||||
setCreating(true);
|
||||
try {
|
||||
await ticketsApi.adminCreate({
|
||||
eventId: createForm.eventId,
|
||||
firstName: createForm.firstName,
|
||||
lastName: createForm.lastName || undefined,
|
||||
email: createForm.email,
|
||||
phone: createForm.phone,
|
||||
preferredLanguage: createForm.preferredLanguage,
|
||||
autoCheckin: createForm.autoCheckin,
|
||||
adminNote: createForm.adminNote || undefined,
|
||||
eventId: createForm.eventId, firstName: createForm.firstName,
|
||||
lastName: createForm.lastName || undefined, email: createForm.email,
|
||||
phone: createForm.phone, preferredLanguage: createForm.preferredLanguage,
|
||||
autoCheckin: createForm.autoCheckin, adminNote: createForm.adminNote || undefined,
|
||||
});
|
||||
toast.success('Ticket created successfully');
|
||||
setShowCreateForm(false);
|
||||
setCreateForm({
|
||||
eventId: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
preferredLanguage: 'en',
|
||||
autoCheckin: false,
|
||||
adminNote: '',
|
||||
});
|
||||
setCreateForm({ eventId: '', firstName: '', lastName: '', email: '', phone: '', preferredLanguage: 'en', autoCheckin: false, adminNote: '' });
|
||||
loadTickets();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to create ticket');
|
||||
@@ -136,32 +108,29 @@ export default function AdminTicketsPage() {
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
pending: 'badge-warning',
|
||||
confirmed: 'badge-success',
|
||||
cancelled: 'badge-danger',
|
||||
checked_in: 'badge-info',
|
||||
pending: 'badge-warning', confirmed: 'badge-success', cancelled: 'badge-danger', checked_in: 'badge-info',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
pending: t('admin.tickets.status.pending'),
|
||||
confirmed: t('admin.tickets.status.confirmed'),
|
||||
cancelled: t('admin.tickets.status.cancelled'),
|
||||
checked_in: t('admin.tickets.status.checkedIn'),
|
||||
pending: t('admin.tickets.status.pending'), confirmed: t('admin.tickets.status.confirmed'),
|
||||
cancelled: t('admin.tickets.status.cancelled'), checked_in: t('admin.tickets.status.checkedIn'),
|
||||
};
|
||||
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>;
|
||||
};
|
||||
|
||||
const getEventName = (eventId: string) => {
|
||||
const event = events.find(e => e.id === eventId);
|
||||
return event?.title || 'Unknown Event';
|
||||
const getEventName = (eventId: string) => events.find(e => e.id === eventId)?.title || 'Unknown Event';
|
||||
|
||||
const hasActiveFilters = selectedEvent || statusFilter;
|
||||
|
||||
const getPrimaryAction = (ticket: Ticket) => {
|
||||
if (ticket.status === 'pending') return { label: 'Confirm', onClick: () => handleConfirm(ticket.id) };
|
||||
if (ticket.status === 'confirmed') return { label: t('admin.tickets.checkin'), onClick: () => handleCheckin(ticket.id) };
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -175,134 +144,86 @@ export default function AdminTicketsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1>
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
Create Ticket
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1>
|
||||
<Button onClick={() => setShowCreateForm(true)} className="hidden md:flex">
|
||||
<PlusIcon className="w-5 h-5 mr-2" /> Create Ticket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Manual Ticket Creation Modal */}
|
||||
{/* Create Ticket Modal */}
|
||||
{showCreateForm && (
|
||||
<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">
|
||||
<h2 className="text-xl font-bold mb-6">Create Ticket Manually</h2>
|
||||
<form onSubmit={handleCreateTicket} className="space-y-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base font-bold">Create Ticket Manually</h2>
|
||||
<button onClick={() => setShowCreateForm(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreateTicket} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event *</label>
|
||||
<select
|
||||
value={createForm.eventId}
|
||||
<select value={createForm.eventId}
|
||||
onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
required
|
||||
>
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray min-h-[44px]" required>
|
||||
<option value="">Select an event</option>
|
||||
{events.filter(e => e.status === 'published').map((event) => (
|
||||
<option key={event.id} value={event.id}>
|
||||
{event.title} ({event.availableSeats} spots left)
|
||||
</option>
|
||||
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
|
||||
<option key={event.id} value={event.id}>{event.title} ({event.availableSeats} spots left)</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="First Name *"
|
||||
value={createForm.firstName}
|
||||
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })}
|
||||
required
|
||||
placeholder="First name"
|
||||
/>
|
||||
<Input
|
||||
label="Last Name (optional)"
|
||||
value={createForm.lastName}
|
||||
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })}
|
||||
placeholder="Last name"
|
||||
/>
|
||||
<Input label="First Name *" value={createForm.firstName}
|
||||
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })} required placeholder="First name" />
|
||||
<Input label="Last Name" value={createForm.lastName}
|
||||
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })} placeholder="Last name" />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Email (optional)"
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
|
||||
placeholder="attendee@email.com"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Phone (optional)"
|
||||
value={createForm.phone}
|
||||
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })}
|
||||
placeholder="+595 XXX XXX XXX"
|
||||
/>
|
||||
|
||||
<Input label="Email (optional)" type="email" value={createForm.email}
|
||||
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })} placeholder="attendee@email.com" />
|
||||
<Input label="Phone (optional)" value={createForm.phone}
|
||||
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })} placeholder="+595 XXX XXX XXX" />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Preferred Language</label>
|
||||
<select
|
||||
value={createForm.preferredLanguage}
|
||||
<select value={createForm.preferredLanguage}
|
||||
onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray min-h-[44px]">
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Admin Note</label>
|
||||
<textarea
|
||||
value={createForm.adminNote}
|
||||
<textarea value={createForm.adminNote}
|
||||
onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
rows={2}
|
||||
placeholder="Internal note about this booking (optional)"
|
||||
/>
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" rows={2}
|
||||
placeholder="Internal note (optional)" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoCheckin"
|
||||
checked={createForm.autoCheckin}
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="autoCheckin" checked={createForm.autoCheckin}
|
||||
onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="autoCheckin" className="text-sm">
|
||||
Automatically check in (mark as present)
|
||||
</label>
|
||||
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
|
||||
<label htmlFor="autoCheckin" className="text-sm">Auto check-in immediately</label>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800">
|
||||
Note: This creates a ticket with cash payment marked as paid. Use this for walk-ins at the door. Email and phone are optional for door entries.
|
||||
Creates a ticket with cash payment marked as paid. Use for walk-ins at the door.
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={creating}>
|
||||
Create Ticket
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCreateForm(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={creating} className="flex-1 min-h-[44px]">Create Ticket</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4 mb-6">
|
||||
{/* Desktop Filters */}
|
||||
<Card className="p-4 mb-6 hidden md:block">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event</label>
|
||||
<select
|
||||
value={selectedEvent}
|
||||
onChange={(e) => setSelectedEvent(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px]"
|
||||
>
|
||||
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px] text-sm">
|
||||
<option value="">All Events</option>
|
||||
{events.map((event) => (
|
||||
<option key={event.id} value={event.id}>{event.title}</option>
|
||||
@@ -311,11 +232,8 @@ export default function AdminTicketsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||
>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
@@ -326,70 +244,61 @@ export default function AdminTicketsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tickets Table */}
|
||||
<Card className="overflow-hidden">
|
||||
{/* Mobile Toolbar */}
|
||||
<div className="md:hidden mb-4 flex items-center gap-2">
|
||||
<button onClick={() => setMobileFilterOpen(true)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||||
hasActiveFilters ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
|
||||
)}>
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
Filters {hasActiveFilters && `(${tickets.length})`}
|
||||
</button>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={() => { setSelectedEvent(''); setStatusFilter(''); }}
|
||||
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<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">Ticket</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Ticket</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{tickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
No tickets found
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No tickets found</td></tr>
|
||||
) : (
|
||||
tickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
|
||||
<p className="text-xs text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
|
||||
</div>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
|
||||
<p className="text-[10px] text-gray-400">ID: {ticket.id.slice(0, 8)}...</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
{getEventName(ticket.eventId)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(ticket.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{getStatusBadge(ticket.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<td className="px-4 py-3 text-sm">{getEventName(ticket.eventId)}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(ticket.createdAt)}</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(ticket.status)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{ticket.status === 'pending' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleConfirm(ticket.id)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleConfirm(ticket.id)}>Confirm</Button>
|
||||
)}
|
||||
{ticket.status === 'confirmed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleCheckin(ticket.id)}
|
||||
>
|
||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||
{t('admin.tickets.checkin')}
|
||||
<Button size="sm" onClick={() => handleCheckin(ticket.id)}>
|
||||
<CheckCircleIcon className="w-4 h-4 mr-1" /> {t('admin.tickets.checkin')}
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
|
||||
<button
|
||||
onClick={() => handleCancel(ticket.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Cancel"
|
||||
>
|
||||
<button onClick={() => handleCancel(ticket.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" title="Cancel">
|
||||
<XCircleIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
@@ -402,6 +311,102 @@ export default function AdminTicketsPage() {
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{tickets.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">No tickets found</div>
|
||||
) : (
|
||||
tickets.map((ticket) => {
|
||||
const primary = getPrimaryAction(ticket);
|
||||
return (
|
||||
<Card key={ticket.id} className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{getEventName(ticket.eventId)}</p>
|
||||
</div>
|
||||
{getStatusBadge(ticket.status)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">{formatDate(ticket.createdAt)}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{primary && (
|
||||
<Button size="sm" variant={ticket.status === 'confirmed' ? 'primary' : 'outline'}
|
||||
onClick={primary.onClick} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||
{primary.label}
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
||||
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel Ticket
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
)}
|
||||
{ticket.status === 'checked_in' && (
|
||||
<span className="text-[10px] text-green-600 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||
<button onClick={() => setShowCreateForm(true)}
|
||||
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
|
||||
<PlusIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Filter BottomSheet */}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||
<option value="">All Events</option>
|
||||
{events.map((event) => (
|
||||
<option key={event.id} value={event.id}>{event.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ value: '', label: 'All Statuses' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'confirmed', label: 'Confirmed' },
|
||||
{ value: 'checked_in', label: 'Checked In' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
].map((opt) => (
|
||||
<button key={opt.value} onClick={() => setStatusFilter(opt.value)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||
statusFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||
)}>
|
||||
{opt.label}
|
||||
{statusFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="outline" onClick={() => { setSelectedEvent(''); setStatusFilter(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
|
||||
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,29 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
import { usersApi, User } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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);
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
@@ -41,7 +56,6 @@ export default function AdminUsersPage() {
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
|
||||
try {
|
||||
await usersApi.delete(userId);
|
||||
toast.success('User deleted');
|
||||
@@ -51,21 +65,54 @@ 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) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
const getRoleBadge = (role: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
admin: 'badge-danger',
|
||||
organizer: 'badge-info',
|
||||
staff: 'badge-warning',
|
||||
marketing: 'badge-success',
|
||||
user: 'badge-gray',
|
||||
admin: 'badge-danger', organizer: 'badge-info', staff: 'badge-warning',
|
||||
marketing: 'badge-success', user: 'badge-gray',
|
||||
};
|
||||
return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>;
|
||||
};
|
||||
@@ -81,19 +128,16 @@ export default function AdminUsersPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1>
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4 mb-6">
|
||||
{/* Desktop Filters */}
|
||||
<Card className="p-4 mb-6 hidden md:block">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||
>
|
||||
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||
@@ -105,51 +149,58 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card className="overflow-hidden">
|
||||
{/* Mobile Toolbar */}
|
||||
<div className="md:hidden mb-4 flex items-center gap-2">
|
||||
<button onClick={() => setMobileFilterOpen(true)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||||
roleFilter ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
|
||||
)}>
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
{roleFilter ? t(`admin.users.roles.${roleFilter}`) : 'Filter by Role'}
|
||||
</button>
|
||||
{roleFilter && (
|
||||
<button onClick={() => setRoleFilter('')} className="text-xs text-primary-yellow min-h-[44px] flex items-center">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 ml-auto">{users.length} users</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<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">User</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Contact</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Role</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Joined</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
||||
<span className="font-semibold text-primary-dark">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="w-8 h-8 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="font-semibold text-sm text-primary-dark">{user.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<p className="font-medium text-sm">{user.name}</p>
|
||||
<p className="text-xs text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{user.phone || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||
className="px-2 py-1 rounded border border-secondary-light-gray text-sm"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
|
||||
<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>
|
||||
@@ -157,16 +208,15 @@ export default function AdminUsersPage() {
|
||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(user.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Delete"
|
||||
>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<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 onClick={() => handleDelete(user.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" title="Delete">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -178,6 +228,128 @@ export default function AdminUsersPage() {
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{users.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">No users found</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<Card key={user.id} className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="font-semibold text-primary-dark">{user.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate">{user.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
|
||||
</div>
|
||||
{getRoleBadge(user.role)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">Joined {formatDate(user.createdAt)}</p>
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => openEditModal(user)}>
|
||||
<PencilSquareIcon className="w-4 h-4 mr-2" /> Edit User
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => handleDelete(user.id)} className="text-red-600">
|
||||
<TrashIcon className="w-4 h-4 mr-2" /> Delete
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Filter BottomSheet */}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filter by Role">
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ value: '', label: 'All Roles' },
|
||||
{ value: 'admin', label: t('admin.users.roles.admin') },
|
||||
{ value: 'organizer', label: t('admin.users.roles.organizer') },
|
||||
{ value: 'staff', label: t('admin.users.roles.staff') },
|
||||
{ value: 'marketing', label: t('admin.users.roles.marketing') },
|
||||
{ value: 'user', label: t('admin.users.roles.user') },
|
||||
].map((opt) => (
|
||||
<button key={opt.value}
|
||||
onClick={() => { setRoleFilter(opt.value); setMobileFilterOpen(false); }}
|
||||
className={clsx(
|
||||
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||
roleFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||
)}>
|
||||
{opt.label}
|
||||
{roleFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base font-bold">Edit User</h2>
|
||||
<button onClick={() => setEditingUser(null)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleEditSubmit} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<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 min-h-[44px]">
|
||||
<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 min-h-[44px]">
|
||||
<option value="">Not set</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Españ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 min-h-[44px]">
|
||||
<option value="active">Active</option>
|
||||
<option value="unclaimed">Unclaimed</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setEditingUser(null)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">Save Changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
31
frontend/src/app/api/revalidate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -99,3 +99,57 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
@@ -23,25 +24,22 @@ export default function LinktreePage() {
|
||||
|
||||
useEffect(() => {
|
||||
eventsApi.getNextUpcoming()
|
||||
.then(({ event }) => setNextEvent(event))
|
||||
.then(({ event }) => {
|
||||
if (event) {
|
||||
const endTime = event.endDatetime || event.startDatetime;
|
||||
if (new Date(endTime).getTime() <= Date.now()) {
|
||||
setNextEvent(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setNextEvent(event);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
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 formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
// Handle both full URLs and handles
|
||||
const instagramUrl = instagramHandle
|
||||
@@ -79,7 +77,7 @@ export default function LinktreePage() {
|
||||
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
</div>
|
||||
) : 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">
|
||||
<h3 className="font-bold text-lg text-white group-hover:text-primary-yellow transition-colors">
|
||||
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
||||
@@ -88,7 +86,7 @@ export default function LinktreePage() {
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||
<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 className="flex items-center gap-2 text-gray-300 text-sm">
|
||||
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||
@@ -100,7 +98,7 @@ export default function LinktreePage() {
|
||||
<span className="font-bold text-primary-yellow">
|
||||
{nextEvent.price === 0
|
||||
? t('events.details.free')
|
||||
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
||||
: formatPrice(nextEvent.price, nextEvent.currency)}
|
||||
</span>
|
||||
{!nextEvent.externalBookingEnabled && (
|
||||
<span className="text-sm text-gray-400">
|
||||
@@ -110,7 +108,7 @@ export default function LinktreePage() {
|
||||
</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">
|
||||
{t('linktree.bookNow')}
|
||||
{t('linktree.moreInfo')}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
272
frontend/src/app/llms.txt/route.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
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`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
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`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
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`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return (data.faqs || []).map((f: any) => ({
|
||||
question: f.question,
|
||||
answer: f.answer,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,29 +7,16 @@ export default function robots(): MetadataRoute.Robots {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: [
|
||||
'/',
|
||||
'/events',
|
||||
'/events/*',
|
||||
'/community',
|
||||
'/contact',
|
||||
'/faq',
|
||||
'/legal/*',
|
||||
],
|
||||
allow: '/',
|
||||
disallow: [
|
||||
'/admin',
|
||||
'/admin/*',
|
||||
'/dashboard',
|
||||
'/dashboard/*',
|
||||
'/api',
|
||||
'/api/*',
|
||||
'/book',
|
||||
'/book/*',
|
||||
'/booking',
|
||||
'/booking/*',
|
||||
'/admin/',
|
||||
'/dashboard/',
|
||||
'/api/',
|
||||
'/book/',
|
||||
'/booking/',
|
||||
'/login',
|
||||
'/register',
|
||||
'/auth/*',
|
||||
'/auth/',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,89 +3,109 @@ import { MetadataRoute } from 'next';
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface Event {
|
||||
interface SitemapEvent {
|
||||
id: string;
|
||||
status: string;
|
||||
startDatetime: 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 {
|
||||
const response = await fetch(`${apiUrl}/api/events?status=published`, {
|
||||
next: { revalidate: 3600 }, // Cache for 1 hour
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.events || [];
|
||||
const [publishedRes, completedRes] = await Promise.all([
|
||||
fetch(`${apiUrl}/api/events?status=published`, {
|
||||
next: { tags: ['events-sitemap'] },
|
||||
}),
|
||||
fetch(`${apiUrl}/api/events?status=completed`, {
|
||||
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 {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// Fetch published events for dynamic event pages
|
||||
const events = await getPublishedEvents();
|
||||
const events = await getIndexableEvents();
|
||||
const now = new Date();
|
||||
|
||||
// Static pages
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: siteUrl,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/events`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/community`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/contact`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/faq`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.6,
|
||||
},
|
||||
// Legal pages
|
||||
{
|
||||
url: `${siteUrl}/legal/terms-policy`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/legal/privacy-policy`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/legal/refund-cancelation-policy`,
|
||||
lastModified: new Date(),
|
||||
lastModified: now,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.3,
|
||||
},
|
||||
];
|
||||
|
||||
// Dynamic event pages
|
||||
const eventPages: MetadataRoute.Sitemap = events.map((event) => ({
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
lastModified: new Date(event.updatedAt),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
// Dynamic event pages — upcoming events get higher priority
|
||||
const eventPages: MetadataRoute.Sitemap = events.map((event) => {
|
||||
const isUpcoming = new Date(event.startDatetime) > now;
|
||||
return {
|
||||
url: `${siteUrl}/events/${event.id}`,
|
||||
lastModified: new Date(event.updatedAt),
|
||||
changeFrequency: isUpcoming ? ('weekly' as const) : ('monthly' as const),
|
||||
priority: isUpcoming ? 0.8 : 0.5,
|
||||
};
|
||||
});
|
||||
|
||||
return [...staticPages, ...eventPages];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import {
|
||||
ShareIcon,
|
||||
@@ -18,6 +18,12 @@ interface ShareButtonsProps {
|
||||
export default function ShareButtons({ title, url, description }: ShareButtonsProps) {
|
||||
const { locale } = useLanguage();
|
||||
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
|
||||
const shareUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||
@@ -133,7 +139,7 @@ export default function ShareButtons({ title, url, description }: ShareButtonsPr
|
||||
</button>
|
||||
|
||||
{/* Native Share (mobile) */}
|
||||
{typeof navigator !== 'undefined' && typeof navigator.share === 'function' && (
|
||||
{supportsNativeShare && (
|
||||
<button
|
||||
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"
|
||||
|
||||
183
frontend/src/components/admin/MobileComponents.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { XMarkIcon, EllipsisVerticalIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ----- Skeleton loaders -----
|
||||
|
||||
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 px-4 py-3 border-b border-gray-100">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4" />
|
||||
<div className="h-4 bg-gray-200 rounded w-1/5" />
|
||||
<div className="h-4 bg-gray-200 rounded w-16" />
|
||||
<div className="h-4 bg-gray-200 rounded w-20" />
|
||||
<div className="h-4 bg-gray-200 rounded w-16 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardSkeleton({ count = 3 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-card shadow-card p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3" />
|
||||
<div className="h-5 bg-gray-200 rounded-full w-16" />
|
||||
</div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2 mb-2" />
|
||||
<div className="h-3 bg-gray-200 rounded w-1/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Dropdown component (portal-based to escape overflow:hidden) -----
|
||||
|
||||
export function Dropdown({ trigger, children, open, onOpenChange, align = 'right' }: {
|
||||
trigger: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
align?: 'left' | 'right';
|
||||
}) {
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const menuWidth = 192;
|
||||
let left = align === 'right' ? rect.right - menuWidth : rect.left;
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - menuWidth - 8));
|
||||
setPos({ top: rect.bottom + 4, left });
|
||||
}
|
||||
}, [open, align]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
triggerRef.current && !triggerRef.current.contains(target) &&
|
||||
menuRef.current && !menuRef.current.contains(target)
|
||||
) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = () => onOpenChange(false);
|
||||
window.addEventListener('scroll', handler, true);
|
||||
return () => window.removeEventListener('scroll', handler, true);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={triggerRef} className="inline-block">
|
||||
<div onClick={() => onOpenChange(!open)}>{trigger}</div>
|
||||
</div>
|
||||
{open && pos && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[9999] w-48 bg-white border border-secondary-light-gray rounded-btn shadow-lg py-1"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownItem({ onClick, children, className }: { onClick: () => void; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<button onClick={onClick} className={clsx('w-full text-left px-4 py-2 text-sm hover:bg-gray-50 min-h-[44px] flex items-center', className)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Bottom Sheet (mobile) -----
|
||||
|
||||
export function BottomSheet({ open, onClose, title, children }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center md:hidden" onClick={onClose}>
|
||||
<div className="fixed inset-0 bg-black/50" />
|
||||
<div
|
||||
className="relative w-full bg-white rounded-t-2xl max-h-[80vh] overflow-auto animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 sticky top-0 bg-white z-10">
|
||||
<h3 className="font-semibold text-base">{title}</h3>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- More Menu (per-row) -----
|
||||
|
||||
export function MoreMenu({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<EllipsisVerticalIcon className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Global CSS for animations -----
|
||||
|
||||
export function AdminMobileStyles() {
|
||||
return (
|
||||
<style jsx global>{`
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.25s ease-out;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
);
|
||||
}
|
||||