Compare commits
6 Commits
backup
...
23d0325d8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23d0325d8d | ||
|
|
0c142884c7 | ||
|
|
0fd8172e04 | ||
|
|
9090d7bad2 | ||
|
|
4a84ad22c7 | ||
|
|
bafd1425c4 |
1
.gitignore
vendored
@@ -37,6 +37,7 @@ backend/uploads/
|
||||
# Tooling
|
||||
.turbo/
|
||||
.cursor/
|
||||
.npm-cache/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
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,28 @@ 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
|
||||
)
|
||||
`);
|
||||
} else {
|
||||
// PostgreSQL migrations
|
||||
await (db as any).execute(sql`
|
||||
@@ -515,6 +552,11 @@ async function migrate() {
|
||||
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 (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -525,6 +567,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,
|
||||
@@ -533,6 +576,14 @@ async function migrate() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Add payer_name column if it doesn't exist
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN payer_name VARCHAR(255)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
@@ -709,6 +760,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 +768,28 @@ 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
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
console.log('Migrations completed successfully!');
|
||||
|
||||
@@ -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,21 @@ 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(),
|
||||
});
|
||||
|
||||
// Site Settings table for global website configuration
|
||||
export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -266,6 +284,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 +376,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 +402,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 +535,21 @@ 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(),
|
||||
});
|
||||
|
||||
// Site Settings table for global website configuration
|
||||
export const pgSiteSettings = pgTable('site_settings', {
|
||||
id: uuid('id').primaryKey(),
|
||||
@@ -529,6 +567,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'),
|
||||
@@ -556,6 +596,7 @@ export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqlit
|
||||
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
|
||||
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
|
||||
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
||||
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
|
||||
|
||||
// Type exports
|
||||
export type User = typeof sqliteUsers.$inferSelect;
|
||||
@@ -584,3 +625,5 @@ 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;
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 emailService from './lib/email.js';
|
||||
|
||||
const app = new Hono();
|
||||
@@ -1714,6 +1715,7 @@ 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);
|
||||
|
||||
// 404 handler
|
||||
app.notFound((c) => {
|
||||
|
||||
@@ -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,7 +72,8 @@ export async function verifyMagicLinkToken(
|
||||
): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const now = getNow();
|
||||
|
||||
const tokenRecord = await (db as any)
|
||||
const tokenRecord = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(magicLinkTokens)
|
||||
.where(
|
||||
@@ -81,7 +82,7 @@ export async function verifyMagicLinkToken(
|
||||
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,7 +133,8 @@ export async function createUserSession(
|
||||
export async function getUserSessions(userId: string) {
|
||||
const now = getNow();
|
||||
|
||||
return (db as any)
|
||||
return dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(userSessions)
|
||||
.where(
|
||||
@@ -141,7 +143,7 @@ export async function getUserSessions(userId: string) {
|
||||
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,10 +1,9 @@
|
||||
// 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,
|
||||
@@ -325,26 +324,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 +373,12 @@ export const emailService = {
|
||||
* Get a template by slug
|
||||
*/
|
||||
async getTemplate(slug: string): Promise<any | null> {
|
||||
const template = await (db as any)
|
||||
const template = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).slug, slug))
|
||||
.get();
|
||||
);
|
||||
|
||||
return template || null;
|
||||
},
|
||||
@@ -385,7 +397,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 +482,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 +534,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)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
const event = await (db as any)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
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 +604,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 +627,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)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
const ticket = await (db as any)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
const event = await (db as any)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
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 +713,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 +729,19 @@ export const emailService = {
|
||||
*/
|
||||
async getPaymentConfig(eventId: string): Promise<Record<string, any>> {
|
||||
// Get global options
|
||||
const globalOptions = await (db as any)
|
||||
const globalOptions = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
);
|
||||
|
||||
// Get event overrides
|
||||
const overrides = await (db as any)
|
||||
const overrides = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
// Defaults
|
||||
const defaults = {
|
||||
@@ -696,33 +784,36 @@ export const emailService = {
|
||||
*/
|
||||
async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get ticket
|
||||
const ticket = await (db as any)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await (db as any)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Get payment
|
||||
const payment = await (db as any)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
@@ -740,8 +831,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 +859,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 +912,36 @@ export const emailService = {
|
||||
*/
|
||||
async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get payment
|
||||
const payment = await (db as any)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
// Get ticket
|
||||
const ticket = await (db as any)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await (db as any)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
@@ -837,6 +955,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 +971,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 +980,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 +1093,12 @@ export const emailService = {
|
||||
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
||||
|
||||
// Get event
|
||||
const event = await (db as any)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, sentCount: 0, failedCount: 0, errors: ['Event not found'] };
|
||||
@@ -897,12 +1119,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 +1150,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,
|
||||
@@ -971,7 +1196,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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import { db, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { eq, and, gte, sql, desc } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
@@ -11,7 +11,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
const now = getNow();
|
||||
|
||||
// Get upcoming events
|
||||
const upcomingEvents = await (db as any)
|
||||
const upcomingEvents = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
@@ -22,63 +23,72 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
)
|
||||
.orderBy((events as any).startDatetime)
|
||||
.limit(5)
|
||||
.all();
|
||||
);
|
||||
|
||||
// Get recent tickets
|
||||
const recentTickets = await (db as any)
|
||||
const recentTickets = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
.limit(10)
|
||||
.all();
|
||||
);
|
||||
|
||||
// Get total stats
|
||||
const totalUsers = await (db as any)
|
||||
const totalUsers = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.get();
|
||||
);
|
||||
|
||||
const totalEvents = await (db as any)
|
||||
const totalEvents = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(events)
|
||||
.get();
|
||||
);
|
||||
|
||||
const totalTickets = await (db as any)
|
||||
const totalTickets = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.get();
|
||||
);
|
||||
|
||||
const confirmedTickets = await (db as any)
|
||||
const confirmedTickets = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).status, 'confirmed'))
|
||||
.get();
|
||||
);
|
||||
|
||||
const pendingPayments = await (db as any)
|
||||
const pendingPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending'))
|
||||
.get();
|
||||
);
|
||||
|
||||
const paidPayments = await (db as any)
|
||||
const paidPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'paid'))
|
||||
.all();
|
||||
);
|
||||
|
||||
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
|
||||
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
||||
|
||||
const newContacts = await (db as any)
|
||||
const newContacts = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(contacts)
|
||||
.where(eq((contacts as any).status, 'new'))
|
||||
.get();
|
||||
);
|
||||
|
||||
const totalSubscribers = await (db as any)
|
||||
const totalSubscribers = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emailSubscribers)
|
||||
.where(eq((emailSubscribers as any).status, 'active'))
|
||||
.get();
|
||||
);
|
||||
|
||||
return c.json({
|
||||
dashboard: {
|
||||
@@ -101,17 +111,19 @@ 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)
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, event.id))
|
||||
.get();
|
||||
);
|
||||
|
||||
const confirmedCount = await (db as any)
|
||||
const confirmedCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
@@ -120,9 +132,10 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
const checkedInCount = await (db as any)
|
||||
const checkedInCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
@@ -131,7 +144,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
||||
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)
|
||||
const user = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq((users as any).id, ticket.userId))
|
||||
.get();
|
||||
);
|
||||
|
||||
const event = await (db as any)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
const payment = await (db as any)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
.get();
|
||||
);
|
||||
|
||||
return {
|
||||
ticketId: ticket.id,
|
||||
@@ -215,24 +231,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)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!ticket) return null;
|
||||
|
||||
const event = await (db as any)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
// 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,7 +1,7 @@
|
||||
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 } from '../db/index.js';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
@@ -48,11 +48,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)
|
||||
const existing = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailSubscribers)
|
||||
.where(eq((emailSubscribers as any).email, data.email))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'unsubscribed') {
|
||||
@@ -87,11 +88,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 +114,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 +123,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)
|
||||
const contact = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq((contacts as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!contact) {
|
||||
return c.json({ error: 'Contact not found' }, 404);
|
||||
@@ -142,11 +142,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 +155,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 +181,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)
|
||||
const updatedUser = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq((users as any).id, user.id))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
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))
|
||||
.all();
|
||||
);
|
||||
|
||||
// Get event details for each ticket
|
||||
const ticketsWithEvents = await Promise.all(
|
||||
userTickets.map(async (ticket: any) => {
|
||||
const event = await (db as any)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
const payment = await (db as any)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
.get();
|
||||
);
|
||||
|
||||
// Check for invoice
|
||||
let invoice = null;
|
||||
let invoice: any = null;
|
||||
if (payment && payment.status === 'paid') {
|
||||
invoice = await (db as any)
|
||||
invoice = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).paymentId, payment.id))
|
||||
.get();
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -168,7 +173,8 @@ 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)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
@@ -177,31 +183,34 @@ dashboard.get('/tickets/:id', async (c) => {
|
||||
eq((tickets as any).userId, user.id)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
const event = await (db as any)
|
||||
const event = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
const payment = await (db as any)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
.get();
|
||||
);
|
||||
|
||||
let invoice = null;
|
||||
if (payment && payment.status === 'paid') {
|
||||
invoice = await (db as any)
|
||||
invoice = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).paymentId, payment.id))
|
||||
.get();
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.id))
|
||||
.all();
|
||||
);
|
||||
|
||||
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)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
nextPayment = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
.get();
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,11 +294,12 @@ dashboard.get('/payments', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
// Get all user's tickets first
|
||||
const userTickets = await (db as any)
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.id))
|
||||
.all();
|
||||
);
|
||||
|
||||
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)
|
||||
const ticketPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.all();
|
||||
);
|
||||
|
||||
for (const payment of ticketPayments) {
|
||||
const ticket = userTickets.find((t: any) => t.id === payment.ticketId);
|
||||
const event = ticket
|
||||
? await (db as any)
|
||||
? await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get()
|
||||
)
|
||||
: null;
|
||||
|
||||
let invoice = null;
|
||||
let invoice: any = null;
|
||||
if (payment.status === 'paid') {
|
||||
invoice = await (db as any)
|
||||
invoice = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).paymentId, payment.id))
|
||||
.get();
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
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))
|
||||
.all();
|
||||
);
|
||||
|
||||
// Get payment and event details for each invoice
|
||||
const invoicesWithDetails = await Promise.all(
|
||||
userInvoices.map(async (invoice: any) => {
|
||||
const payment = await (db as any)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, invoice.paymentId))
|
||||
.get();
|
||||
);
|
||||
|
||||
let event = null;
|
||||
let event: any = null;
|
||||
if (payment) {
|
||||
const ticket = await (db as any)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (ticket) {
|
||||
event = await (db as any)
|
||||
event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,11 +531,12 @@ dashboard.get('/summary', async (c) => {
|
||||
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Get ticket count
|
||||
const userTickets = await (db as any)
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, user.id))
|
||||
.all();
|
||||
);
|
||||
|
||||
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)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (event && new Date(event.startDatetime) > now) {
|
||||
upcomingTickets.push({ ticket, event });
|
||||
@@ -540,7 +562,8 @@ dashboard.get('/summary', async (c) => {
|
||||
let pendingPayments = 0;
|
||||
|
||||
for (const ticketId of ticketIds) {
|
||||
const payment = await (db as any)
|
||||
const payment = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(
|
||||
@@ -549,7 +572,7 @@ dashboard.get('/summary', async (c) => {
|
||||
eq((payments as any).status, 'pending_approval')
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
if (payment) pendingPayments++;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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';
|
||||
|
||||
@@ -13,11 +12,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 +31,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)
|
||||
const template = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
return c.json({ error: 'Template not found' }, 404);
|
||||
@@ -64,11 +62,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 +72,7 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
|
||||
|
||||
const now = getNow();
|
||||
const template = {
|
||||
id: nanoid(),
|
||||
id: generateId(),
|
||||
name,
|
||||
slug,
|
||||
subject,
|
||||
@@ -111,11 +107,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)
|
||||
const existing = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Template not found' }, 404);
|
||||
@@ -148,11 +145,12 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||
.set(updateData)
|
||||
.where(eq((emailTemplates as any).id, id));
|
||||
|
||||
const updated = await (db as any)
|
||||
const updated = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
return c.json({
|
||||
template: {
|
||||
@@ -169,11 +167,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);
|
||||
@@ -306,11 +302,12 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
|
||||
const logs = await query
|
||||
const logs = await dbAll(
|
||||
query
|
||||
.orderBy(desc((emailLogs as any).createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
);
|
||||
|
||||
// Get total count
|
||||
let countQuery = (db as any)
|
||||
@@ -321,7 +318,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 +336,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 +357,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: {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 } from '../lib/utils.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -15,6 +15,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 +38,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 +70,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),
|
||||
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
||||
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||
// 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 +131,30 @@ 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)
|
||||
// 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),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
const normalized = normalizeEvent(event);
|
||||
return {
|
||||
...event,
|
||||
...normalized,
|
||||
bookedCount: ticketCount?.count || 0,
|
||||
availableSeats: event.capacity - (ticketCount?.count || 0),
|
||||
availableSeats: normalized.capacity - (ticketCount?.count || 0),
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -125,38 +166,126 @@ 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)
|
||||
// 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),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
const normalized = normalizeEvent(event);
|
||||
return c.json({
|
||||
event: {
|
||||
...event,
|
||||
...normalized,
|
||||
bookedCount: ticketCount?.count || 0,
|
||||
availableSeats: event.capacity - (ticketCount?.count || 0),
|
||||
availableSeats: normalized.capacity - (ticketCount?.count || 0),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 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 event = await (db as any)
|
||||
// First, check if there's a featured event in site settings
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
|
||||
let featuredEvent = null;
|
||||
let shouldUnsetFeatured = false;
|
||||
|
||||
if (settings?.featuredEventId) {
|
||||
// Get the featured event
|
||||
featuredEvent = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, settings.featuredEventId))
|
||||
);
|
||||
|
||||
if (featuredEvent) {
|
||||
// Check if featured event is still valid:
|
||||
// 1. Must be published
|
||||
// 2. Must not have ended (endDatetime >= now, or startDatetime >= now if no endDatetime)
|
||||
const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime;
|
||||
const isPublished = featuredEvent.status === 'published';
|
||||
const hasNotEnded = eventEndTime >= now;
|
||||
|
||||
if (!isPublished || !hasNotEnded) {
|
||||
// Featured event is no longer valid - mark for unsetting
|
||||
shouldUnsetFeatured = true;
|
||||
featuredEvent = null;
|
||||
}
|
||||
} else {
|
||||
// Featured event no longer exists
|
||||
shouldUnsetFeatured = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we need to unset the featured event, do it asynchronously
|
||||
if (shouldUnsetFeatured && settings) {
|
||||
// Unset featured event in background (don't await to avoid blocking response)
|
||||
(db as any)
|
||||
.update(siteSettings)
|
||||
.set({ featuredEventId: null, updatedAt: now })
|
||||
.where(eq((siteSettings as any).id, settings.id))
|
||||
.then(() => {
|
||||
console.log('Featured event auto-cleared (event ended or unpublished)');
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error('Failed to clear featured event:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// If we have a valid featured event, return it
|
||||
if (featuredEvent) {
|
||||
const bookedCount = await getEventTicketCount(featuredEvent.id);
|
||||
const normalized = normalizeEvent(featuredEvent);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: normalized.capacity - bookedCount,
|
||||
isFeatured: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: get the next upcoming published event
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
@@ -167,28 +296,20 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
||||
)
|
||||
.orderBy((events as any).startDatetime)
|
||||
.limit(1)
|
||||
.get();
|
||||
);
|
||||
|
||||
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: normalized.capacity - bookedCount,
|
||||
isFeatured: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -200,16 +321,22 @@ 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);
|
||||
// Return normalized event data
|
||||
return c.json({ event: normalizeEvent(newEvent) }, 201);
|
||||
});
|
||||
|
||||
// Update event (admin/organizer only)
|
||||
@@ -217,46 +344,64 @@ 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 });
|
||||
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)
|
||||
const eventTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, id))
|
||||
.all();
|
||||
);
|
||||
|
||||
// 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)
|
||||
const ticketPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
.all();
|
||||
);
|
||||
|
||||
// Delete invoices for each payment
|
||||
for (const payment of ticketPayments) {
|
||||
@@ -289,11 +434,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)
|
||||
const attendees = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, id))
|
||||
.all();
|
||||
);
|
||||
|
||||
return c.json({ attendees });
|
||||
});
|
||||
@@ -302,7 +448,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 +467,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 +476,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 +484,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;
|
||||
|
||||
393
backend/src/routes/legal-pages.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
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 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);
|
||||
|
||||
return c.json({
|
||||
page: {
|
||||
id: page.id,
|
||||
slug: page.slug,
|
||||
title,
|
||||
contentMarkdown,
|
||||
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);
|
||||
|
||||
return c.json({
|
||||
page: {
|
||||
slug,
|
||||
title,
|
||||
contentMarkdown: content,
|
||||
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;
|
||||
@@ -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,27 +152,47 @@ 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;
|
||||
}
|
||||
|
||||
// Get all tickets in this booking (if multi-ticket)
|
||||
let ticketsToConfirm: any[] = [existingTicket];
|
||||
|
||||
if (existingTicket.bookingId) {
|
||||
// This is a multi-ticket booking - get all tickets with same bookingId
|
||||
ticketsToConfirm = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, existingTicket.bookingId))
|
||||
);
|
||||
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
|
||||
}
|
||||
|
||||
// Confirm all tickets in the booking
|
||||
for (const ticket of ticketsToConfirm) {
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, ticketId));
|
||||
.where(eq((tickets as any).id, ticket.id));
|
||||
|
||||
// Update payment status to paid
|
||||
await (db as any)
|
||||
@@ -183,18 +203,21 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
paidAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, ticketId));
|
||||
.where(eq((payments as any).ticketId, ticket.id));
|
||||
|
||||
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||
}
|
||||
|
||||
// Get payment for sending receipt
|
||||
const payment = await (db as any)
|
||||
// Get primary payment for sending receipt
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
// 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)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
const payment = await (db as any)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
const existing = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
);
|
||||
|
||||
// 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)
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
const event = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Get global options
|
||||
const globalOptions = await (db as any)
|
||||
const globalOptions = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
);
|
||||
|
||||
// Get event overrides
|
||||
const overrides = await (db as any)
|
||||
const overrides = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
// 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)
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
let allPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.orderBy(desc((payments as any).createdAt))
|
||||
.all();
|
||||
);
|
||||
|
||||
// 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)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
let event = null;
|
||||
let event: any = null;
|
||||
if (ticket) {
|
||||
event = await (db as any)
|
||||
event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
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))
|
||||
.all();
|
||||
);
|
||||
|
||||
// Enrich with ticket and event data
|
||||
const enrichedPayments = await Promise.all(
|
||||
pendingPayments.map(async (payment: any) => {
|
||||
const ticket = await (db as any)
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
let event = null;
|
||||
let event: any = null;
|
||||
if (ticket) {
|
||||
event = await (db as any)
|
||||
event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Get associated ticket
|
||||
const ticket = await (db as any)
|
||||
const ticket = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
const existing = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// If payment confirmed, handle multi-ticket booking
|
||||
if (data.status === 'paid') {
|
||||
// Get the ticket associated with this payment
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, existing.ticketId))
|
||||
);
|
||||
|
||||
// Check if this is part of a multi-ticket booking
|
||||
let ticketsToConfirm: any[] = [ticket];
|
||||
|
||||
if (ticket?.bookingId) {
|
||||
// Get all tickets in this booking
|
||||
ticketsToConfirm = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
console.log(`[Payment] Confirming multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||
}
|
||||
|
||||
// Update all payments and tickets in the booking
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set(updateData)
|
||||
.where(eq((payments as any).id, id));
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// If payment confirmed, update ticket status and send emails
|
||||
if (data.status === 'paid') {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, existing.ticketId));
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
|
||||
// Send confirmation emails asynchronously (don't block the response)
|
||||
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)
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
@@ -243,7 +289,30 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Update payment status to paid
|
||||
// Get the ticket associated with this payment
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
// Check if this is part of a multi-ticket booking
|
||||
let ticketsToConfirm: any[] = [ticket];
|
||||
|
||||
if (ticket?.bookingId) {
|
||||
// Get all tickets in this booking
|
||||
ticketsToConfirm = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||
}
|
||||
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
@@ -253,27 +322,33 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).id, id));
|
||||
.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, payment.ticketId));
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
|
||||
// Send confirmation emails asynchronously
|
||||
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||
if (sendEmail !== false) {
|
||||
Promise.all([
|
||||
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 (db as any)
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
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)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
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,10 @@
|
||||
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';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -27,6 +27,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 +35,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 +53,7 @@ siteSettingsRouter.get('/', async (c) => {
|
||||
instagramUrl: null,
|
||||
twitterUrl: null,
|
||||
linkedinUrl: null,
|
||||
featuredEventId: null,
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: null,
|
||||
maintenanceMessageEs: null,
|
||||
@@ -95,11 +99,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 +129,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 +142,94 @@ 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))
|
||||
);
|
||||
|
||||
return c.json({ settings: updated, message: 'Settings updated successfully' });
|
||||
});
|
||||
|
||||
// Set featured event (admin only) - convenience endpoint for event editor
|
||||
siteSettingsRouter.put('/featured-event', requireAuth(['admin']), zValidator('json', z.object({
|
||||
eventId: z.string().nullable(),
|
||||
})), async (c) => {
|
||||
const { eventId } = c.req.valid('json');
|
||||
const user = c.get('user');
|
||||
const now = getNow();
|
||||
|
||||
// Validate event if provided
|
||||
if (eventId) {
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
if (event.status !== 'published') {
|
||||
return c.json({ error: 'Event must be published to be featured' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create settings
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
// Create new settings record with featured event
|
||||
const id = generateId();
|
||||
const newSettings = {
|
||||
id,
|
||||
timezone: 'America/Asuncion',
|
||||
siteName: 'Spanglish',
|
||||
featuredEventId: eventId,
|
||||
maintenanceMode: 0,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
};
|
||||
|
||||
await (db as any).insert(siteSettings).values(newSettings);
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
}
|
||||
|
||||
// Update existing settings
|
||||
await (db as any)
|
||||
.update(siteSettings)
|
||||
.set({
|
||||
featuredEventId: eventId,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
})
|
||||
.where(eq((siteSettings as any).id, existing.id));
|
||||
|
||||
return c.json({ featuredEventId: eventId, message: eventId ? 'Event set as featured' : 'Featured event removed' });
|
||||
});
|
||||
|
||||
export default siteSettingsRouter;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, tickets, events, users, payments, paymentOptions } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
|
||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { generateTicketPDF } from '../lib/pdf.js';
|
||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||
|
||||
const ticketsRouter = new Hono();
|
||||
|
||||
// Attendee info schema for multi-ticket bookings
|
||||
const attendeeSchema = z.object({
|
||||
firstName: z.string().min(2),
|
||||
lastName: z.string().min(2).optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
const createTicketSchema = z.object({
|
||||
eventId: z.string(),
|
||||
firstName: z.string().min(2),
|
||||
@@ -19,7 +25,9 @@ const createTicketSchema = z.object({
|
||||
phone: z.string().min(6).optional().or(z.literal('')),
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(),
|
||||
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
|
||||
// Optional: array of attendees for multi-ticket booking
|
||||
attendees: z.array(attendeeSchema).optional(),
|
||||
});
|
||||
|
||||
const updateTicketSchema = z.object({
|
||||
@@ -42,12 +50,21 @@ const adminCreateTicketSchema = z.object({
|
||||
adminNote: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
// Book a ticket (public)
|
||||
// Book a ticket (public) - supports single or multi-ticket bookings
|
||||
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Determine attendees list (use attendees array if provided, otherwise single attendee from main fields)
|
||||
const attendeesList = data.attendees && data.attendees.length > 0
|
||||
? data.attendees
|
||||
: [{ firstName: data.firstName, lastName: data.lastName }];
|
||||
|
||||
const ticketCount = attendeesList.length;
|
||||
|
||||
// Get event
|
||||
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get();
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
@@ -56,24 +73,36 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
return c.json({ error: 'Event is not available for booking' }, 400);
|
||||
}
|
||||
|
||||
// Check capacity
|
||||
const ticketCount = await (db as any)
|
||||
// Check capacity - count confirmed AND checked_in tickets
|
||||
// (checked_in were previously confirmed, check-in doesn't affect capacity)
|
||||
const existingTicketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, data.eventId),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
if ((ticketCount?.count || 0) >= event.capacity) {
|
||||
const availableSeats = event.capacity - (existingTicketCount?.count || 0);
|
||||
|
||||
if (availableSeats <= 0) {
|
||||
return c.json({ error: 'Event is sold out' }, 400);
|
||||
}
|
||||
|
||||
if (ticketCount > availableSeats) {
|
||||
return c.json({
|
||||
error: `Not enough seats available. Only ${availableSeats} spot(s) remaining.`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
let user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
|
||||
let user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
||||
);
|
||||
|
||||
const now = getNow();
|
||||
|
||||
@@ -98,15 +127,17 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
}
|
||||
|
||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||
const globalOptions = await (db as any)
|
||||
const globalOptions = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
.get();
|
||||
);
|
||||
|
||||
const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false;
|
||||
|
||||
if (!allowDuplicateBookings) {
|
||||
const existingTicket = await (db as any)
|
||||
const existingTicket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
@@ -115,39 +146,46 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
eq((tickets as any).eventId, data.eventId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||
return c.json({ error: 'You have already booked this event' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Create ticket
|
||||
// Generate booking ID to group multiple tickets
|
||||
const bookingId = generateId();
|
||||
|
||||
// Create tickets for each attendee
|
||||
const createdTickets: any[] = [];
|
||||
const createdPayments: any[] = [];
|
||||
|
||||
for (let i = 0; i < attendeesList.length; i++) {
|
||||
const attendee = attendeesList[i];
|
||||
const ticketId = generateId();
|
||||
const qrCode = generateTicketCode();
|
||||
|
||||
// Cash payments start as pending, card/lightning start as pending until payment confirmed
|
||||
const ticketStatus = 'pending';
|
||||
|
||||
const newTicket = {
|
||||
id: ticketId,
|
||||
bookingId: ticketCount > 1 ? bookingId : null, // Only set bookingId for multi-ticket bookings
|
||||
userId: user.id,
|
||||
eventId: data.eventId,
|
||||
attendeeFirstName: data.firstName,
|
||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||
attendeeEmail: data.email,
|
||||
attendeeFirstName: attendee.firstName,
|
||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||
attendeeEmail: data.email, // Buyer's email for all tickets
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: ticketStatus,
|
||||
status: 'pending',
|
||||
qrCode,
|
||||
checkinAt: null,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(tickets).values(newTicket);
|
||||
createdTickets.push(newTicket);
|
||||
|
||||
// Create payment record
|
||||
// Create payment record for each ticket
|
||||
const paymentId = generateId();
|
||||
const newPayment = {
|
||||
id: paymentId,
|
||||
@@ -162,15 +200,20 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
};
|
||||
|
||||
await (db as any).insert(payments).values(newPayment);
|
||||
createdPayments.push(newPayment);
|
||||
}
|
||||
|
||||
const primaryTicket = createdTickets[0];
|
||||
const primaryPayment = createdPayments[0];
|
||||
|
||||
// Send payment instructions email for manual payment methods (TPago, Bank Transfer)
|
||||
if (['bank_transfer', 'tpago'].includes(data.paymentMethod)) {
|
||||
// Send asynchronously - don't block the response
|
||||
emailService.sendPaymentInstructions(ticketId).then(result => {
|
||||
emailService.sendPaymentInstructions(primaryTicket.id).then(result => {
|
||||
if (result.success) {
|
||||
console.log(`[Email] Payment instructions email sent successfully for ticket ${ticketId}`);
|
||||
console.log(`[Email] Payment instructions email sent successfully for ticket ${primaryTicket.id}`);
|
||||
} else {
|
||||
console.error(`[Email] Failed to send payment instructions email for ticket ${ticketId}:`, result.error);
|
||||
console.error(`[Email] Failed to send payment instructions email for ticket ${primaryTicket.id}:`, result.error);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[Email] Exception sending payment instructions email:', err);
|
||||
@@ -179,11 +222,17 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
|
||||
// If Lightning payment, create LNbits invoice
|
||||
let lnbitsInvoice = null;
|
||||
if (data.paymentMethod === 'lightning' && event.price > 0) {
|
||||
const totalPrice = event.price * ticketCount;
|
||||
|
||||
if (data.paymentMethod === 'lightning' && totalPrice > 0) {
|
||||
if (!isLNbitsConfigured()) {
|
||||
// Delete the ticket and payment we just created
|
||||
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
|
||||
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
|
||||
// Delete the tickets and payments we just created
|
||||
for (const payment of createdPayments) {
|
||||
await (db as any).delete(payments).where(eq((payments as any).id, payment.id));
|
||||
}
|
||||
for (const ticket of createdTickets) {
|
||||
await (db as any).delete(tickets).where(eq((tickets as any).id, ticket.id));
|
||||
}
|
||||
return c.json({
|
||||
error: 'Bitcoin Lightning payments are not available at this time'
|
||||
}, 400);
|
||||
@@ -193,49 +242,68 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
|
||||
// Pass the fiat currency directly to LNbits - it handles conversion automatically
|
||||
// For multi-ticket, use total price
|
||||
lnbitsInvoice = await createInvoice({
|
||||
amount: event.price,
|
||||
amount: totalPrice,
|
||||
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
|
||||
memo: `Spanglish: ${event.title} - ${fullName}`,
|
||||
memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`,
|
||||
webhookUrl: `${apiUrl}/api/lnbits/webhook`,
|
||||
expiry: 900, // 15 minutes expiry for faster UX
|
||||
extra: {
|
||||
ticketId,
|
||||
ticketId: primaryTicket.id,
|
||||
bookingId: ticketCount > 1 ? bookingId : null,
|
||||
ticketIds: createdTickets.map(t => t.id),
|
||||
eventId: event.id,
|
||||
eventTitle: event.title,
|
||||
attendeeName: fullName,
|
||||
attendeeEmail: data.email,
|
||||
ticketCount,
|
||||
},
|
||||
});
|
||||
|
||||
// Update payment with LNbits payment hash reference
|
||||
// Update primary payment with LNbits payment hash reference
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ reference: lnbitsInvoice.paymentHash })
|
||||
.where(eq((payments as any).id, paymentId));
|
||||
.where(eq((payments as any).id, primaryPayment.id));
|
||||
|
||||
(newPayment as any).reference = lnbitsInvoice.paymentHash;
|
||||
(primaryPayment as any).reference = lnbitsInvoice.paymentHash;
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create Lightning invoice:', error);
|
||||
// Delete the ticket and payment we just created since Lightning payment failed
|
||||
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
|
||||
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
|
||||
// Delete the tickets and payments we just created since Lightning payment failed
|
||||
for (const payment of createdPayments) {
|
||||
await (db as any).delete(payments).where(eq((payments as any).id, payment.id));
|
||||
}
|
||||
for (const ticket of createdTickets) {
|
||||
await (db as any).delete(tickets).where(eq((tickets as any).id, ticket.id));
|
||||
}
|
||||
return c.json({
|
||||
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ticket: {
|
||||
...newTicket,
|
||||
event: {
|
||||
// Response format depends on single vs multi-ticket
|
||||
const eventInfo = {
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
};
|
||||
|
||||
return c.json({
|
||||
// For backward compatibility, include primary ticket as 'ticket'
|
||||
ticket: {
|
||||
...primaryTicket,
|
||||
event: eventInfo,
|
||||
},
|
||||
},
|
||||
payment: newPayment,
|
||||
// For multi-ticket bookings, include all tickets
|
||||
tickets: createdTickets.map(t => ({
|
||||
...t,
|
||||
event: eventInfo,
|
||||
})),
|
||||
bookingId: ticketCount > 1 ? bookingId : null,
|
||||
payment: primaryPayment,
|
||||
payments: createdPayments,
|
||||
lightningInvoice: lnbitsInvoice ? {
|
||||
paymentHash: lnbitsInvoice.paymentHash,
|
||||
paymentRequest: lnbitsInvoice.paymentRequest,
|
||||
@@ -244,16 +312,116 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
fiatCurrency: lnbitsInvoice.fiatCurrency,
|
||||
expiry: lnbitsInvoice.expiry,
|
||||
} : null,
|
||||
message: 'Booking created successfully',
|
||||
message: ticketCount > 1
|
||||
? `${ticketCount} tickets booked successfully`
|
||||
: 'Booking created successfully',
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// Download ticket as PDF
|
||||
// Download combined PDF for multi-ticket booking
|
||||
// NOTE: This route MUST be defined before /:id/pdf to prevent the wildcard from matching "booking"
|
||||
ticketsRouter.get('/booking/:bookingId/pdf', async (c) => {
|
||||
const bookingId = c.req.param('bookingId');
|
||||
const user: any = await getAuthUser(c);
|
||||
|
||||
console.log(`[PDF] Generating combined PDF for booking: ${bookingId}`);
|
||||
|
||||
// Get all tickets in this booking
|
||||
const bookingTickets = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, bookingId))
|
||||
);
|
||||
|
||||
console.log(`[PDF] Found ${bookingTickets?.length || 0} tickets for booking ${bookingId}`);
|
||||
|
||||
if (!bookingTickets || bookingTickets.length === 0) {
|
||||
return c.json({ error: 'Booking not found' }, 404);
|
||||
}
|
||||
|
||||
const primaryTicket = bookingTickets[0] as any;
|
||||
|
||||
// Check authorization - must be ticket owner or admin
|
||||
if (user) {
|
||||
const isAdmin = ['admin', 'organizer', 'staff'].includes(user.role);
|
||||
const isOwner = user.id === primaryTicket.userId;
|
||||
|
||||
if (!isAdmin && !isOwner) {
|
||||
return c.json({ error: 'Unauthorized' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that at least one ticket is confirmed
|
||||
const hasConfirmedTicket = bookingTickets.some((t: any) =>
|
||||
['confirmed', 'checked_in'].includes(t.status)
|
||||
);
|
||||
|
||||
if (!hasConfirmedTicket) {
|
||||
return c.json({ error: 'No confirmed tickets in this booking' }, 400);
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, primaryTicket.eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
// Filter to only confirmed/checked_in tickets
|
||||
const confirmedTickets = bookingTickets.filter((t: any) =>
|
||||
['confirmed', 'checked_in'].includes(t.status)
|
||||
);
|
||||
|
||||
console.log(`[PDF] Generating PDF with ${confirmedTickets.length} confirmed tickets`);
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
const timezone = settings?.timezone || 'America/Asuncion';
|
||||
|
||||
const ticketsData = confirmedTickets.map((ticket: any) => ({
|
||||
id: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
event: {
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
endDatetime: event.endDatetime,
|
||||
location: event.location,
|
||||
locationUrl: event.locationUrl,
|
||||
},
|
||||
timezone,
|
||||
}));
|
||||
|
||||
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
|
||||
|
||||
// Set response headers for PDF download
|
||||
return new Response(new Uint8Array(pdfBuffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="spanglish-booking-${bookingId}.pdf"`,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Combined PDF generation error:', error);
|
||||
return c.json({ error: 'Failed to generate PDF' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Download ticket as PDF (single ticket)
|
||||
ticketsRouter.get('/:id/pdf', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const user = await getAuthUser(c);
|
||||
const user: any = await getAuthUser(c);
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
@@ -278,13 +446,21 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
||||
}
|
||||
|
||||
// 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 c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get site timezone for proper date/time formatting
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
const timezone = settings?.timezone || 'America/Asuncion';
|
||||
|
||||
const pdfBuffer = await generateTicketPDF({
|
||||
id: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
@@ -297,6 +473,7 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
||||
location: event.location,
|
||||
locationUrl: event.locationUrl,
|
||||
},
|
||||
timezone,
|
||||
});
|
||||
|
||||
// Set response headers for PDF download
|
||||
@@ -316,17 +493,23 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
||||
ticketsRouter.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
// Get associated event
|
||||
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))
|
||||
);
|
||||
|
||||
// Get payment
|
||||
const payment = await (db as any).select().from(payments).where(eq((payments as any).ticketId, id)).get();
|
||||
const payment = await dbGet(
|
||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, id))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
ticket: {
|
||||
@@ -342,7 +525,9 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
||||
const id = c.req.param('id');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
@@ -361,7 +546,9 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
||||
await (db as any).update(tickets).set(updates).where(eq((tickets as any).id, id));
|
||||
}
|
||||
|
||||
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ ticket: updated });
|
||||
});
|
||||
@@ -376,19 +563,21 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
|
||||
}
|
||||
|
||||
// Try to find ticket by QR code or ID
|
||||
let ticket = await (db as any)
|
||||
let ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).qrCode, code))
|
||||
.get();
|
||||
);
|
||||
|
||||
// If not found by QR, try by ID
|
||||
if (!ticket) {
|
||||
ticket = await (db as any)
|
||||
ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, code))
|
||||
.get();
|
||||
);
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
@@ -409,11 +598,12 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
|
||||
}
|
||||
|
||||
// Get event details
|
||||
const event = await (db as any)
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
// Determine validity status
|
||||
let validityStatus = 'invalid';
|
||||
@@ -433,11 +623,12 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
|
||||
// Get admin who checked in (if applicable)
|
||||
let checkedInBy = null;
|
||||
if (ticket.checkedInByAdminId) {
|
||||
const admin = await (db as any)
|
||||
const admin = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq((users as any).id, ticket.checkedInByAdminId))
|
||||
.get();
|
||||
);
|
||||
checkedInBy = admin ? admin.name : null;
|
||||
}
|
||||
|
||||
@@ -469,7 +660,9 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
|
||||
const id = c.req.param('id');
|
||||
const adminUser = (c as any).get('user');
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
@@ -494,10 +687,14 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
|
||||
})
|
||||
.where(eq((tickets as any).id, id));
|
||||
|
||||
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const updated = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
// Get event for response
|
||||
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))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
ticket: {
|
||||
@@ -513,11 +710,14 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
|
||||
});
|
||||
|
||||
// Mark payment as received (for cash payments - admin only)
|
||||
// Supports multi-ticket bookings - confirms all tickets in the booking
|
||||
ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const user = (c as any).get('user');
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
@@ -533,11 +733,26 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Get all tickets in this booking (if multi-ticket)
|
||||
let ticketsToConfirm: any[] = [ticket];
|
||||
|
||||
if (ticket.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, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
// Confirm all tickets in the booking
|
||||
for (const t of ticketsToConfirm) {
|
||||
// Update ticket status
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, id));
|
||||
.where(eq((tickets as any).id, t.id));
|
||||
|
||||
// Update payment status
|
||||
await (db as any)
|
||||
@@ -548,14 +763,16 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
||||
paidByAdminId: user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, id));
|
||||
.where(eq((payments as any).ticketId, t.id));
|
||||
}
|
||||
|
||||
// Get payment for sending receipt
|
||||
const payment = await (db as any)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
// Send confirmation emails asynchronously (don't block the response)
|
||||
Promise.all([
|
||||
@@ -565,28 +782,40 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
||||
console.error('[Email] Failed to send confirmation emails:', err);
|
||||
});
|
||||
|
||||
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ ticket: updated, message: 'Payment marked as received' });
|
||||
return c.json({
|
||||
ticket: updated,
|
||||
message: ticketsToConfirm.length > 1
|
||||
? `${ticketsToConfirm.length} tickets marked as paid`
|
||||
: 'Payment marked as received'
|
||||
});
|
||||
});
|
||||
|
||||
// User marks payment as sent (for manual payment methods: bank_transfer, tpago)
|
||||
// This sets status to "pending_approval" and notifies admin
|
||||
ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const { payerName } = body;
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
// Get the payment
|
||||
const payment = await (db as any)
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
@@ -627,16 +856,18 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
|
||||
.set({
|
||||
status: 'pending_approval',
|
||||
userMarkedPaidAt: now,
|
||||
payerName: payerName?.trim() || null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).id, payment.id));
|
||||
|
||||
// Get updated payment
|
||||
const updatedPayment = await (db as any)
|
||||
const updatedPayment = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, payment.id))
|
||||
.get();
|
||||
);
|
||||
|
||||
// TODO: Send notification to admin about pending payment approval
|
||||
|
||||
@@ -649,9 +880,11 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
|
||||
// Cancel ticket
|
||||
ticketsRouter.post('/:id/cancel', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const user = await getAuthUser(c);
|
||||
const user: any = await getAuthUser(c);
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
@@ -675,7 +908,9 @@ ticketsRouter.post('/:id/cancel', async (c) => {
|
||||
ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
@@ -690,7 +925,9 @@ ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'st
|
||||
.set({ status: 'confirmed', checkinAt: null })
|
||||
.where(eq((tickets as any).id, id));
|
||||
|
||||
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ ticket: updated, message: 'Check-in removed successfully' });
|
||||
});
|
||||
@@ -700,7 +937,9 @@ ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zV
|
||||
const id = c.req.param('id');
|
||||
const { note } = c.req.valid('json');
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
@@ -711,7 +950,9 @@ ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zV
|
||||
.set({ adminNote: note || null })
|
||||
.where(eq((tickets as any).id, id));
|
||||
|
||||
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ ticket: updated, message: 'Note updated successfully' });
|
||||
});
|
||||
@@ -721,13 +962,16 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Get event
|
||||
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get();
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Check capacity
|
||||
const ticketCount = await (db as any)
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
@@ -736,7 +980,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
if ((ticketCount?.count || 0) >= event.capacity) {
|
||||
return c.json({ error: 'Event is at capacity' }, 400);
|
||||
@@ -750,7 +994,9 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
: `door-${generateId()}@doorentry.local`;
|
||||
|
||||
// Find or create user
|
||||
let user = await (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)).get();
|
||||
let user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
||||
);
|
||||
|
||||
const adminFullName = data.lastName && data.lastName.trim()
|
||||
? `${data.firstName} ${data.lastName}`.trim()
|
||||
@@ -774,7 +1020,8 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
|
||||
// Check for existing active ticket for this user and event (only if real email provided)
|
||||
if (data.email && data.email.trim() && !data.email.includes('@doorentry.local')) {
|
||||
const existingTicket = await (db as any)
|
||||
const existingTicket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
@@ -783,7 +1030,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
eq((tickets as any).eventId, data.eventId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
);
|
||||
|
||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||
return c.json({ error: 'This person already has a ticket for this event' }, 400);
|
||||
@@ -869,7 +1116,7 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
|
||||
const result = await query.all();
|
||||
const result = await dbAll(query);
|
||||
|
||||
return c.json({ tickets: result });
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -40,7 +40,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,7 +55,8 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const user = await (db as any)
|
||||
const user = await dbGet(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
@@ -67,7 +68,7 @@ usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
||||
})
|
||||
.from(users)
|
||||
.where(eq((users as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
@@ -92,7 +93,9 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
||||
delete data.role;
|
||||
}
|
||||
|
||||
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,7 +105,8 @@ 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)
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
@@ -113,7 +117,7 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
||||
})
|
||||
.from(users)
|
||||
.where(eq((users as any).id, id))
|
||||
.get();
|
||||
);
|
||||
|
||||
return c.json({ user: updated });
|
||||
});
|
||||
@@ -128,21 +132,23 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const userTickets = await (db as any)
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, id))
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
.all();
|
||||
);
|
||||
|
||||
// Get event details for each ticket
|
||||
const history = await Promise.all(
|
||||
userTickets.map(async (ticket: any) => {
|
||||
const event = await (db as any)
|
||||
const event = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
);
|
||||
|
||||
return {
|
||||
...ticket,
|
||||
@@ -164,7 +170,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 +184,80 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
|
||||
try {
|
||||
// Get all tickets for this user
|
||||
const userTickets = await (db as any)
|
||||
const userTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).userId, id))
|
||||
.all();
|
||||
);
|
||||
|
||||
// Delete payments associated with user's tickets
|
||||
// Delete invoices associated with user's tickets (invoices reference payments which reference tickets)
|
||||
for (const ticket of userTickets) {
|
||||
// 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 +270,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)
|
||||
const totalUsers = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.get();
|
||||
);
|
||||
|
||||
const adminCount = await (db as any)
|
||||
const adminCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(eq((users as any).role, 'admin'))
|
||||
.get();
|
||||
);
|
||||
|
||||
return c.json({
|
||||
stats: {
|
||||
|
||||
@@ -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 } 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 rucPattern = /^\d{6,10}$/;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 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') });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,6 +217,7 @@ export default function BookingPage() {
|
||||
|
||||
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 +237,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 +344,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 +375,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 +389,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 +422,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 +473,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 +483,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',
|
||||
});
|
||||
}
|
||||
@@ -591,6 +623,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 +654,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 +763,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 +815,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'}
|
||||
@@ -828,9 +907,30 @@ 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">
|
||||
@@ -872,6 +972,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>
|
||||
@@ -924,10 +1043,28 @@ 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>
|
||||
|
||||
{isSoldOut ? (
|
||||
@@ -940,8 +1077,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 +1186,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 +1312,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 } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
@@ -341,7 +342,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 +375,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>
|
||||
|
||||
|
||||
@@ -229,12 +229,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>
|
||||
)}
|
||||
|
||||
@@ -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 } from '@/lib/utils';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
|
||||
@@ -91,7 +92,7 @@ export default function NextEventSection() {
|
||||
<span className="text-3xl font-bold text-primary-dark">
|
||||
{nextEvent.price === 0
|
||||
? t('events.details.free')
|
||||
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
||||
: formatPrice(nextEvent.price, nextEvent.currency)}
|
||||
</span>
|
||||
{!nextEvent.externalBookingEnabled && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
|
||||
@@ -170,6 +170,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 || '#'}
|
||||
|
||||
@@ -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 } 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,6 +41,17 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
.catch(console.error);
|
||||
}, [eventId]);
|
||||
|
||||
// Max tickets is remaining capacity
|
||||
const maxTickets = Math.max(1, event.availableSeats || 1);
|
||||
|
||||
const decreaseQuantity = () => {
|
||||
setTicketQuantity(prev => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const increaseQuantity = () => {
|
||||
setTicketQuantity(prev => Math.min(maxTickets, prev + 1));
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
@@ -59,137 +74,56 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
|
||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<Link
|
||||
href="/events"
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Event Details */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="overflow-hidden">
|
||||
{/* Banner - LCP element, loaded with high priority */}
|
||||
{/* Using unoptimized for backend-served images via /uploads/ rewrite */}
|
||||
{event.bannerUrl ? (
|
||||
<div className="relative h-64 w-full">
|
||||
<Image
|
||||
src={event.bannerUrl}
|
||||
alt={`${event.title} - Spanglish language exchange event in Asunción`}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 66vw"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
|
||||
<CalendarIcon className="w-24 h-24 text-primary-dark/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h1 className="text-3xl font-bold text-primary-dark" suppressHydrationWarning>
|
||||
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
</h1>
|
||||
{isCancelled && (
|
||||
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
|
||||
)}
|
||||
{isSoldOut && !isCancelled && (
|
||||
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.date')}</p>
|
||||
<p className="text-gray-600" suppressHydrationWarning>{formatDate(event.startDatetime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-6 h-6 flex items-center justify-center text-primary-yellow text-xl">⏰</span>
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.time')}</p>
|
||||
<p className="text-gray-600" suppressHydrationWarning>
|
||||
{formatTime(event.startDatetime)}
|
||||
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPinIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.location')}</p>
|
||||
<p className="text-gray-600">{event.location}</p>
|
||||
{event.locationUrl && (
|
||||
<a
|
||||
href={event.locationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-secondary-blue hover:underline text-sm"
|
||||
>
|
||||
View on map
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!event.externalBookingEnabled && (
|
||||
<div className="flex items-start gap-3">
|
||||
<UserGroupIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('events.details.capacity')}</p>
|
||||
<p className="text-gray-600">
|
||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray">
|
||||
<h2 className="font-semibold text-lg mb-4">About this event</h2>
|
||||
<p className="text-gray-700 whitespace-pre-line" suppressHydrationWarning>
|
||||
{locale === 'es' && event.descriptionEs
|
||||
? event.descriptionEs
|
||||
: event.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Sharing */}
|
||||
<div className="mt-8 pt-8 border-t border-secondary-light-gray" suppressHydrationWarning>
|
||||
<ShareButtons
|
||||
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Booking Card */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="p-6 sticky top-24">
|
||||
<div className="text-center mb-6">
|
||||
// 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')
|
||||
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||
: 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
|
||||
@@ -202,7 +136,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
</Button>
|
||||
</a>
|
||||
) : (
|
||||
<Link href={`/book/${event.id}`}>
|
||||
<Link href={`/book/${event.id}?qty=${ticketQuantity}`}>
|
||||
<Button className="w-full" size="lg">
|
||||
{t('events.booking.join')}
|
||||
</Button>
|
||||
@@ -223,6 +157,144 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
{event.availableSeats} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<Link
|
||||
href="/events"
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-8"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Event Details */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Top section: Image + Event Info side by side on desktop */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Image - smaller on desktop, side by side */}
|
||||
{event.bannerUrl ? (
|
||||
<div className="relative md:w-2/5 flex-shrink-0 bg-gray-100">
|
||||
<Image
|
||||
src={event.bannerUrl}
|
||||
alt={`${event.title} - Spanglish language exchange event in Asunción`}
|
||||
width={400}
|
||||
height={400}
|
||||
className="w-full h-auto md:h-full object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 300px"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="md:w-2/5 flex-shrink-0 h-48 md:h-auto bg-gradient-to-br from-primary-yellow/40 to-secondary-blue/30 flex items-center justify-center">
|
||||
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event title and key info */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-primary-dark" suppressHydrationWarning>
|
||||
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
</h1>
|
||||
<div className="flex-shrink-0">
|
||||
{isCancelled && (
|
||||
<span className="badge badge-danger text-sm">{t('events.details.cancelled')}</span>
|
||||
)}
|
||||
{isSoldOut && !isCancelled && (
|
||||
<span className="badge badge-warning text-sm">{t('events.details.soldOut')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{t('events.details.date')}</p>
|
||||
<p className="text-gray-600" suppressHydrationWarning>{formatDate(event.startDatetime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow text-lg">⏰</span>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{t('events.details.time')}</p>
|
||||
<p className="text-gray-600" suppressHydrationWarning>
|
||||
{formatTime(event.startDatetime)}
|
||||
{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPinIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{t('events.details.location')}</p>
|
||||
<p className="text-gray-600">{event.location}</p>
|
||||
{event.locationUrl && (
|
||||
<a
|
||||
href={event.locationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-secondary-blue hover:underline text-sm"
|
||||
>
|
||||
View on map
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!event.externalBookingEnabled && (
|
||||
<div className="flex items-start gap-3">
|
||||
<UserGroupIcon className="w-5 h-5 text-primary-yellow flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
|
||||
<p className="text-gray-600">
|
||||
{event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile Booking Card - shown between event details and description on mobile */}
|
||||
<Card className="p-6 lg:hidden">
|
||||
<BookingCardContent />
|
||||
</Card>
|
||||
|
||||
{/* Description section - separate card below */}
|
||||
<Card className="p-6">
|
||||
<h2 className="font-semibold text-lg mb-4">About this event</h2>
|
||||
<p className="text-gray-700 whitespace-pre-line" suppressHydrationWarning>
|
||||
{locale === 'es' && event.descriptionEs
|
||||
? event.descriptionEs
|
||||
: event.description}
|
||||
</p>
|
||||
|
||||
{/* Social Sharing */}
|
||||
<div className="mt-8 pt-6 border-t border-secondary-light-gray" suppressHydrationWarning>
|
||||
<ShareButtons
|
||||
title={locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
description={`${locale === 'es' ? 'Únete a' : 'Join'} ${locale === 'es' && event.titleEs ? event.titleEs : event.title} - ${formatDate(event.startDatetime)}`}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Desktop Booking Card - hidden on mobile, shown in sidebar on desktop */}
|
||||
<div className="hidden lg:block lg:col-span-1">
|
||||
<Card className="p-6 sticky top-24">
|
||||
<BookingCardContent />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||
@@ -149,7 +150,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,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();
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface TicketWithDetails extends Omit<Ticket, 'payment'> {
|
||||
bookingId?: string;
|
||||
event?: Event;
|
||||
payment?: {
|
||||
id: string;
|
||||
@@ -194,6 +195,23 @@ export default function AdminBookingsPage() {
|
||||
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
|
||||
};
|
||||
|
||||
// Helper to get booking info for a ticket (ticket count and total)
|
||||
const getBookingInfo = (ticket: TicketWithDetails) => {
|
||||
if (!ticket.bookingId) {
|
||||
return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) };
|
||||
}
|
||||
|
||||
// Count all tickets with the same bookingId
|
||||
const bookingTickets = tickets.filter(
|
||||
t => t.bookingId === ticket.bookingId
|
||||
);
|
||||
|
||||
return {
|
||||
ticketCount: bookingTickets.length,
|
||||
bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 0),
|
||||
};
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -309,7 +327,9 @@ export default function AdminBookingsPage() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedTickets.map((ticket) => (
|
||||
sortedTickets.map((ticket) => {
|
||||
const bookingInfo = getBookingInfo(ticket);
|
||||
return (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
@@ -341,9 +361,16 @@ export default function AdminBookingsPage() {
|
||||
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
|
||||
</p>
|
||||
{ticket.payment && (
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{ticket.payment.amount?.toLocaleString()} {ticket.payment.currency}
|
||||
{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}
|
||||
</p>
|
||||
{bookingInfo.ticketCount > 1 && (
|
||||
<p className="text-xs text-purple-600 mt-1">
|
||||
📦 {bookingInfo.ticketCount} × {Number(ticket.payment.amount).toLocaleString()} {ticket.payment.currency}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -354,6 +381,11 @@ export default function AdminBookingsPage() {
|
||||
{ticket.qrCode && (
|
||||
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
|
||||
)}
|
||||
{ticket.bookingId && (
|
||||
<p className="text-xs text-purple-600 mt-1" title="Part of multi-ticket booking">
|
||||
📦 Group Booking
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(ticket.createdAt)}
|
||||
@@ -415,7 +447,8 @@ export default function AdminBookingsPage() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -678,6 +678,11 @@ export default function AdminEventDetailPage() {
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||
<p className="text-sm text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
|
||||
{ticket.bookingId && (
|
||||
<p className="text-xs text-purple-600 mt-1" title={`Booking: ${ticket.bookingId}`}>
|
||||
📦 {locale === 'es' ? 'Reserva grupal' : 'Group booking'}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm">{ticket.attendeeEmail}</p>
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
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 { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon } from '@heroicons/react/24/outline';
|
||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@@ -19,6 +20,8 @@ export default function AdminEventsPage() {
|
||||
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;
|
||||
@@ -60,6 +63,7 @@ export default function AdminEventsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
loadFeaturedEvent();
|
||||
}, []);
|
||||
|
||||
const loadEvents = async () => {
|
||||
@@ -73,6 +77,28 @@ export default function AdminEventsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadFeaturedEvent = async () => {
|
||||
try {
|
||||
const { settings } = await siteSettingsApi.get();
|
||||
setFeaturedEventId(settings.featuredEventId || null);
|
||||
} catch (error) {
|
||||
// Ignore error - settings may not exist yet
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetFeatured = async (eventId: string | null) => {
|
||||
setSettingFeatured(eventId || 'clearing');
|
||||
try {
|
||||
await siteSettingsApi.setFeaturedEvent(eventId);
|
||||
setFeaturedEventId(eventId);
|
||||
toast.success(eventId ? 'Event set as featured' : 'Featured event removed');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update featured event');
|
||||
} finally {
|
||||
setSettingFeatured(null);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
@@ -455,6 +481,44 @@ export default function AdminEventsPage() {
|
||||
relatedType="event"
|
||||
/>
|
||||
|
||||
{/* Featured Event Section - Only show for published events when editing */}
|
||||
{editingEvent && editingEvent.status === 'published' && (
|
||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<StarIcon className="w-5 h-5 text-amber-500" />
|
||||
Featured Event
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Featured events are prominently displayed on the homepage and linktree
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={settingFeatured !== null}
|
||||
onClick={() => handleSetFeatured(
|
||||
featuredEventId === editingEvent.id ? null : editingEvent.id
|
||||
)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 disabled:opacity-50 ${
|
||||
featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{featuredEventId && featuredEventId !== editingEvent.id && (
|
||||
<p className="text-xs text-amber-700 bg-amber-100 p-2 rounded">
|
||||
Note: Another event is currently featured. Setting this event as featured will replace it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||
@@ -494,7 +558,7 @@ export default function AdminEventsPage() {
|
||||
</tr>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<tr key={event.id} className="hover:bg-gray-50">
|
||||
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{event.bannerUrl ? (
|
||||
@@ -509,7 +573,15 @@ export default function AdminEventsPage() {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{event.title}</p>
|
||||
{featuredEventId === event.id && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
|
||||
<StarIconSolid className="w-3 h-3" />
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -534,6 +606,25 @@ export default function AdminEventsPage() {
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
{event.status === 'published' && (
|
||||
<button
|
||||
onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
|
||||
disabled={settingFeatured !== null}
|
||||
className={clsx(
|
||||
"p-2 rounded-btn disabled:opacity-50",
|
||||
featuredEventId === event.id
|
||||
? "bg-amber-100 text-amber-600 hover:bg-amber-200"
|
||||
: "hover:bg-amber-100 text-gray-400 hover:text-amber-600"
|
||||
)}
|
||||
title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}
|
||||
>
|
||||
{featuredEventId === event.id ? (
|
||||
<StarIconSolid className="w-4 h-4" />
|
||||
) : (
|
||||
<StarIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/events/${event.id}`}
|
||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
XMarkIcon,
|
||||
BanknotesIcon,
|
||||
QrCodeIcon,
|
||||
DocumentTextIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
@@ -67,6 +68,7 @@ export default function AdminLayout({
|
||||
{ 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' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon },
|
||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
|
||||
];
|
||||
|
||||
|
||||
556
frontend/src/app/admin/legal-pages/page.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
'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',
|
||||
});
|
||||
} 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
BanknotesIcon,
|
||||
BuildingLibraryIcon,
|
||||
CreditCardIcon,
|
||||
EnvelopeIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -38,6 +39,8 @@ export default function AdminPaymentsPage() {
|
||||
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 +80,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 +96,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 +109,24 @@ export default function AdminPaymentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendReminder = async (payment: PaymentWithDetails) => {
|
||||
setSendingReminder(true);
|
||||
try {
|
||||
const result = await paymentsApi.sendReminder(payment.id);
|
||||
toast.success(locale === 'es' ? 'Recordatorio enviado' : 'Reminder sent');
|
||||
// Update the selected payment with the new reminderSentAt timestamp
|
||||
if (result.reminderSentAt) {
|
||||
setSelectedPayment({ ...payment, reminderSentAt: result.reminderSentAt });
|
||||
}
|
||||
// Also refresh the data to update the lists
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to send reminder');
|
||||
} finally {
|
||||
setSendingReminder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPayment = async (id: string) => {
|
||||
try {
|
||||
await paymentsApi.approve(id);
|
||||
@@ -230,13 +253,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 (
|
||||
@@ -257,9 +336,11 @@ export default function AdminPaymentsPage() {
|
||||
</div>
|
||||
|
||||
{/* Approval Detail Modal */}
|
||||
{selectedPayment && (
|
||||
{selectedPayment && (() => {
|
||||
const modalBookingInfo = getBookingInfo(selectedPayment);
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-lg p-6">
|
||||
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
||||
</h2>
|
||||
@@ -268,8 +349,15 @@ export default function AdminPaymentsPage() {
|
||||
<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 +397,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,6 +425,19 @@ 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">
|
||||
@@ -343,15 +460,28 @@ export default function AdminPaymentsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleSendReminder(selectedPayment)}
|
||||
isLoading={sendingReminder}
|
||||
className="w-full"
|
||||
>
|
||||
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Enviar recordatorio de pago' : 'Send payment reminder'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setSelectedPayment(null); setNoteText(''); }}
|
||||
onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
|
||||
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Export Modal */}
|
||||
{showExportModal && (
|
||||
@@ -481,7 +611,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 +626,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 +638,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 +648,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>
|
||||
@@ -565,7 +700,9 @@ export default function AdminPaymentsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{pendingApprovalPayments.map((payment) => (
|
||||
{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">
|
||||
@@ -574,12 +711,18 @@ export default function AdminPaymentsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-bold text-lg">{formatCurrency(payment.amount, payment.currency)}</p>
|
||||
<p className="font-bold text-lg">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
|
||||
{bookingInfo.ticketCount > 1 && (
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
|
||||
📦 {bookingInfo.ticketCount} tickets × {formatCurrency(payment.amount, payment.currency)}
|
||||
</span>
|
||||
)}
|
||||
{getStatusBadge(payment.status)}
|
||||
</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 && (
|
||||
@@ -597,6 +740,11 @@ export default function AdminPaymentsPage() {
|
||||
</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)}>
|
||||
@@ -604,7 +752,8 @@ export default function AdminPaymentsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -671,7 +820,9 @@ export default function AdminPaymentsPage() {
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
payments.map((payment) => (
|
||||
payments.map((payment) => {
|
||||
const bookingInfo = getBookingInfo(payment);
|
||||
return (
|
||||
<tr key={payment.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
{payment.ticket ? (
|
||||
@@ -680,6 +831,11 @@ export default function AdminPaymentsPage() {
|
||||
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
|
||||
{payment.payerName && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
⚠️ {locale === 'es' ? 'Pagado por:' : 'Paid by:'} {payment.payerName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">-</span>
|
||||
@@ -692,8 +848,15 @@ export default function AdminPaymentsPage() {
|
||||
<span className="text-gray-400 text-sm">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 font-medium">
|
||||
{formatCurrency(payment.amount, payment.currency)}
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="font-medium">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
|
||||
{bookingInfo.ticketCount > 1 && (
|
||||
<p className="text-xs text-purple-600 mt-1">
|
||||
📦 {bookingInfo.ticketCount} × {formatCurrency(payment.amount, payment.currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
@@ -705,7 +868,14 @@ export default function AdminPaymentsPage() {
|
||||
{formatDate(payment.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
{getStatusBadge(payment.status)}
|
||||
{payment.ticket?.bookingId && (
|
||||
<p className="text-xs text-purple-600" title="Part of multi-ticket booking">
|
||||
📦 {locale === 'es' ? 'Grupo' : 'Group'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
@@ -731,7 +901,8 @@ export default function AdminPaymentsPage() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { siteSettingsApi, SiteSettings, TimezoneOption } from '@/lib/api';
|
||||
import { siteSettingsApi, eventsApi, SiteSettings, TimezoneOption, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
EnvelopeIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
CheckCircleIcon,
|
||||
StarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -21,6 +23,8 @@ export default function AdminSettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [timezones, setTimezones] = useState<TimezoneOption[]>([]);
|
||||
const [featuredEvent, setFeaturedEvent] = useState<Event | null>(null);
|
||||
const [clearingFeatured, setClearingFeatured] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState<SiteSettings>({
|
||||
timezone: 'America/Asuncion',
|
||||
@@ -33,6 +37,7 @@ export default function AdminSettingsPage() {
|
||||
instagramUrl: null,
|
||||
twitterUrl: null,
|
||||
linkedinUrl: null,
|
||||
featuredEventId: null,
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: null,
|
||||
maintenanceMessageEs: null,
|
||||
@@ -50,6 +55,17 @@ export default function AdminSettingsPage() {
|
||||
]);
|
||||
setSettings(settingsRes.settings);
|
||||
setTimezones(timezonesRes.timezones);
|
||||
|
||||
// Load featured event details if one is set
|
||||
if (settingsRes.settings.featuredEventId) {
|
||||
try {
|
||||
const { event } = await eventsApi.getById(settingsRes.settings.featuredEventId);
|
||||
setFeaturedEvent(event);
|
||||
} catch {
|
||||
// Featured event may no longer exist
|
||||
setFeaturedEvent(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load settings');
|
||||
} finally {
|
||||
@@ -57,6 +73,20 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearFeatured = async () => {
|
||||
setClearingFeatured(true);
|
||||
try {
|
||||
await siteSettingsApi.setFeaturedEvent(null);
|
||||
setSettings(prev => ({ ...prev, featuredEventId: null }));
|
||||
setFeaturedEvent(null);
|
||||
toast.success(locale === 'es' ? 'Evento destacado eliminado' : 'Featured event removed');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to clear featured event');
|
||||
} finally {
|
||||
setClearingFeatured(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -146,6 +176,93 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Featured Event */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
|
||||
<StarIcon className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Evento Destacado' : 'Featured Event'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'El evento destacado aparece en la página de inicio y linktree'
|
||||
: 'The featured event is displayed on the homepage and linktree'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{featuredEvent ? (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{featuredEvent.bannerUrl && (
|
||||
<img
|
||||
src={featuredEvent.bannerUrl}
|
||||
alt={featuredEvent.title}
|
||||
className="w-16 h-16 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-amber-900">{featuredEvent.title}</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
{new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
{locale === 'es' ? 'Estado:' : 'Status:'} {featuredEvent.status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href="/admin/events"
|
||||
className="text-sm text-amber-700 hover:text-amber-900 underline"
|
||||
>
|
||||
{locale === 'es' ? 'Cambiar' : 'Change'}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleClearFeatured}
|
||||
disabled={clearingFeatured}
|
||||
className="text-sm text-red-600 hover:text-red-800 underline disabled:opacity-50"
|
||||
>
|
||||
{clearingFeatured
|
||||
? (locale === 'es' ? 'Eliminando...' : 'Removing...')
|
||||
: (locale === 'es' ? 'Eliminar' : 'Remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<p className="text-gray-600 mb-3">
|
||||
{locale === 'es'
|
||||
? 'No hay evento destacado. El próximo evento publicado se mostrará automáticamente.'
|
||||
: 'No featured event set. The next upcoming published event will be shown automatically.'}
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/events"
|
||||
className="text-sm text-primary-yellow hover:underline font-medium"
|
||||
>
|
||||
{locale === 'es' ? 'Ir a Eventos para destacar uno' : 'Go to Events to feature one'}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
{locale === 'es'
|
||||
? 'Cuando el evento destacado termine o se despublique, el sistema mostrará automáticamente el próximo evento.'
|
||||
: 'When the featured event ends or is unpublished, the system will automatically show the next upcoming event.'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Site Information */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -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 } from '@/lib/utils';
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
@@ -100,7 +101,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">
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import LanguageToggle from '@/components/LanguageToggle';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
@@ -33,7 +32,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="px-4 py-2 hover:bg-gray-50 rounded-lg font-medium"
|
||||
className="block px-6 py-3 text-lg font-medium transition-colors hover:bg-gray-50"
|
||||
style={{ color: isActive ? '#FBB82B' : '#002F44' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -46,6 +45,65 @@ export default function Header() {
|
||||
const { t } = useLanguage();
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const touchStartX = useRef<number>(0);
|
||||
const touchCurrentX = useRef<number>(0);
|
||||
const isDragging = useRef<boolean>(false);
|
||||
|
||||
// Close menu on route change
|
||||
const pathname = usePathname();
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Prevent body scroll when menu is open
|
||||
useEffect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
// Handle swipe to close
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchCurrentX.current = e.touches[0].clientX;
|
||||
isDragging.current = true;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
touchCurrentX.current = e.touches[0].clientX;
|
||||
|
||||
const deltaX = touchCurrentX.current - touchStartX.current;
|
||||
// Only allow dragging to the right (to close)
|
||||
if (deltaX > 0 && menuRef.current) {
|
||||
menuRef.current.style.transform = `translateX(${deltaX}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (!isDragging.current) return;
|
||||
isDragging.current = false;
|
||||
|
||||
const deltaX = touchCurrentX.current - touchStartX.current;
|
||||
const threshold = 100; // Minimum swipe distance to close
|
||||
|
||||
if (menuRef.current) {
|
||||
menuRef.current.style.transform = '';
|
||||
if (deltaX > threshold) {
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/', label: t('nav.home') },
|
||||
@@ -118,81 +176,142 @@ export default function Header() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
{/* Mobile menu button (hamburger) */}
|
||||
<button
|
||||
className="md:hidden p-2 rounded-lg hover:bg-gray-100"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<Bars3Icon className="w-6 h-6" />
|
||||
)}
|
||||
<Bars3Icon className="w-6 h-6" style={{ color: '#002F44' }} />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Slide-in Menu */}
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={`
|
||||
fixed inset-0 bg-black/50 z-40 md:hidden
|
||||
transition-opacity duration-300 ease-in-out
|
||||
${mobileMenuOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
|
||||
`}
|
||||
onClick={closeMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Slide-in Panel */}
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`
|
||||
fixed top-0 right-0 h-full w-[280px] max-w-[85vw] bg-white z-50 md:hidden
|
||||
shadow-xl transform transition-transform duration-300 ease-in-out
|
||||
${mobileMenuOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||
`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Menu Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<Image
|
||||
src="/images/logo-spanglish.png"
|
||||
alt="Spanglish"
|
||||
width={100}
|
||||
height={28}
|
||||
className="h-7 w-auto"
|
||||
/>
|
||||
<button
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
onClick={closeMenu}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" style={{ color: '#002F44' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<div
|
||||
className={clsx(
|
||||
'md:hidden overflow-hidden transition-all duration-300',
|
||||
{
|
||||
'max-h-0': !mobileMenuOpen,
|
||||
'max-h-96 pb-4': mobileMenuOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
{/* Menu Content */}
|
||||
<div className="flex flex-col h-[calc(100%-65px)] overflow-y-auto">
|
||||
{/* Navigation Links */}
|
||||
<nav className="py-4">
|
||||
{navLinks.map((link) => (
|
||||
<MobileNavLink
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{link.label}
|
||||
</MobileNavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-100 mt-2 pt-4 px-4">
|
||||
{/* Divider */}
|
||||
<div className="border-t border-gray-100 mx-6" />
|
||||
|
||||
{/* Language Toggle */}
|
||||
<div className="px-6 py-4">
|
||||
<LanguageToggle variant="buttons" />
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-2 flex flex-col gap-2">
|
||||
{/* Divider */}
|
||||
<div className="border-t border-gray-100 mx-6" />
|
||||
|
||||
{/* Auth Actions */}
|
||||
<div className="px-6 py-4 flex flex-col gap-3 mt-auto">
|
||||
{user ? (
|
||||
<>
|
||||
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="outline" className="w-full">
|
||||
<div className="text-sm text-gray-500 mb-2 flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-[#002F44] flex items-center justify-center text-white text-sm font-medium">
|
||||
{user.name?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-[#002F44]">{user.name}</span>
|
||||
</div>
|
||||
<Link href="/dashboard" onClick={closeMenu}>
|
||||
<Button variant="outline" className="w-full justify-center">
|
||||
{t('nav.dashboard')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link href="/admin" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="outline" className="w-full">
|
||||
<Link href="/admin" onClick={closeMenu}>
|
||||
<Button variant="outline" className="w-full justify-center">
|
||||
{t('nav.admin')}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button variant="secondary" onClick={logout} className="w-full">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
logout();
|
||||
closeMenu();
|
||||
}}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('nav.logout')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="outline" className="w-full">
|
||||
<Link href="/login" onClick={closeMenu}>
|
||||
<Button variant="outline" className="w-full justify-center">
|
||||
{t('nav.login')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/events" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button className="w-full">
|
||||
<Link href="/events" onClick={closeMenu}>
|
||||
<Button className="w-full justify-center">
|
||||
{t('nav.joinEvent')}
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swipe hint */}
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Swipe right to close
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
417
frontend/src/components/ui/RichTextEditor.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
'use client';
|
||||
|
||||
import { useEditor, EditorContent, Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string; // Markdown content
|
||||
onChange: (content: string) => void; // Returns markdown
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
// Convert markdown to HTML for TipTap
|
||||
function markdownToHtml(markdown: string): string {
|
||||
if (!markdown) return '<p></p>';
|
||||
|
||||
let html = markdown;
|
||||
|
||||
// Convert horizontal rules first (before other processing)
|
||||
html = html.replace(/^---+$/gm, '<hr>');
|
||||
|
||||
// Convert headings (must be done before other inline formatting)
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||
|
||||
// Convert bold and italic
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
|
||||
// Convert unordered lists
|
||||
const lines = html.split('\n');
|
||||
let inList = false;
|
||||
let listType = '';
|
||||
const processedLines: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const bulletMatch = line.match(/^[\*\-\+]\s+(.+)$/);
|
||||
const numberedMatch = line.match(/^\d+\.\s+(.+)$/);
|
||||
|
||||
if (bulletMatch) {
|
||||
if (!inList || listType !== 'ul') {
|
||||
if (inList) processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||||
processedLines.push('<ul>');
|
||||
inList = true;
|
||||
listType = 'ul';
|
||||
}
|
||||
processedLines.push(`<li>${bulletMatch[1]}</li>`);
|
||||
} else if (numberedMatch) {
|
||||
if (!inList || listType !== 'ol') {
|
||||
if (inList) processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||||
processedLines.push('<ol>');
|
||||
inList = true;
|
||||
listType = 'ol';
|
||||
}
|
||||
processedLines.push(`<li>${numberedMatch[1]}</li>`);
|
||||
} else {
|
||||
if (inList) {
|
||||
processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||||
inList = false;
|
||||
listType = '';
|
||||
}
|
||||
processedLines.push(line);
|
||||
}
|
||||
}
|
||||
if (inList) {
|
||||
processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||||
}
|
||||
|
||||
html = processedLines.join('\n');
|
||||
|
||||
// Convert blockquotes
|
||||
html = html.replace(/^>\s*(.+)$/gm, '<blockquote><p>$1</p></blockquote>');
|
||||
|
||||
// Convert paragraphs (lines that aren't already HTML tags)
|
||||
const finalLines = html.split('\n');
|
||||
const result: string[] = [];
|
||||
let paragraph: string[] = [];
|
||||
|
||||
for (const line of finalLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
// Empty line - close paragraph if open
|
||||
if (paragraph.length > 0) {
|
||||
result.push(`<p>${paragraph.join('<br>')}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
} else if (trimmed.startsWith('<h') || trimmed.startsWith('<ul') || trimmed.startsWith('<ol') ||
|
||||
trimmed.startsWith('<li') || trimmed.startsWith('</ul') || trimmed.startsWith('</ol') ||
|
||||
trimmed.startsWith('<hr') || trimmed.startsWith('<blockquote')) {
|
||||
// HTML tag - close paragraph and add tag
|
||||
if (paragraph.length > 0) {
|
||||
result.push(`<p>${paragraph.join('<br>')}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
result.push(trimmed);
|
||||
} else {
|
||||
// Regular text - add to paragraph
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
}
|
||||
if (paragraph.length > 0) {
|
||||
result.push(`<p>${paragraph.join('<br>')}</p>`);
|
||||
}
|
||||
|
||||
return result.join('') || '<p></p>';
|
||||
}
|
||||
|
||||
// Convert HTML from TipTap back to markdown
|
||||
function htmlToMarkdown(html: string): string {
|
||||
if (!html) return '';
|
||||
|
||||
let md = html;
|
||||
|
||||
// Convert headings
|
||||
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
|
||||
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
|
||||
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
|
||||
|
||||
// Convert bold and italic
|
||||
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
|
||||
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
|
||||
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
|
||||
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
|
||||
|
||||
// Convert lists
|
||||
md = md.replace(/<ul[^>]*>/gi, '\n');
|
||||
md = md.replace(/<\/ul>/gi, '\n');
|
||||
md = md.replace(/<ol[^>]*>/gi, '\n');
|
||||
md = md.replace(/<\/ol>/gi, '\n');
|
||||
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '* $1\n');
|
||||
|
||||
// Convert blockquotes
|
||||
md = md.replace(/<blockquote[^>]*><p[^>]*>(.*?)<\/p><\/blockquote>/gi, '> $1\n\n');
|
||||
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n');
|
||||
|
||||
// Convert horizontal rules
|
||||
md = md.replace(/<hr[^>]*\/?>/gi, '\n---\n\n');
|
||||
|
||||
// Convert paragraphs
|
||||
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
|
||||
|
||||
// Convert line breaks
|
||||
md = md.replace(/<br[^>]*\/?>/gi, '\n');
|
||||
|
||||
// Remove any remaining HTML tags
|
||||
md = md.replace(/<[^>]+>/g, '');
|
||||
|
||||
// Decode HTML entities
|
||||
md = md.replace(/&/g, '&');
|
||||
md = md.replace(/</g, '<');
|
||||
md = md.replace(/>/g, '>');
|
||||
md = md.replace(/"/g, '"');
|
||||
md = md.replace(/'/g, "'");
|
||||
md = md.replace(/ /g, ' ');
|
||||
|
||||
// Clean up extra newlines
|
||||
md = md.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
// Toolbar button component
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
isActive = false,
|
||||
disabled = false,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
isActive?: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={clsx(
|
||||
'p-2 rounded transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-yellow text-primary-dark'
|
||||
: 'text-gray-600 hover:bg-gray-100',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Toolbar component
|
||||
function Toolbar({ editor }: { editor: Editor | null }) {
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 p-2 border-b border-gray-200 bg-gray-50">
|
||||
{/* Text formatting */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
isActive={editor.isActive('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 12h9a4 4 0 014 4 4 4 0 01-4 4H6z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
isActive={editor.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 4h4m-2 0v16m-4 0h8" transform="skewX(-10)" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
isActive={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1"
|
||||
>
|
||||
<span className="text-sm font-bold">H1</span>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
isActive={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<span className="text-sm font-bold">H2</span>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
isActive={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
<span className="text-sm font-bold">H3</span>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
<circle cx="2" cy="6" r="1" fill="currentColor" />
|
||||
<circle cx="2" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="2" cy="18" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
isActive={editor.isActive('orderedList')}
|
||||
title="Numbered List"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 6h13M7 12h13M7 18h13" />
|
||||
<text x="1" y="8" fontSize="6" fill="currentColor">1</text>
|
||||
<text x="1" y="14" fontSize="6" fill="currentColor">2</text>
|
||||
<text x="1" y="20" fontSize="6" fill="currentColor">3</text>
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
{/* Block elements */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
isActive={editor.isActive('blockquote')}
|
||||
title="Quote"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RichTextEditor({
|
||||
content,
|
||||
onChange,
|
||||
placeholder = 'Start writing...',
|
||||
className = '',
|
||||
editable = true,
|
||||
}: RichTextEditorProps) {
|
||||
const lastContentRef = useRef(content);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
content: markdownToHtml(content),
|
||||
editable,
|
||||
onUpdate: ({ editor }) => {
|
||||
// Convert HTML back to markdown
|
||||
const markdown = htmlToMarkdown(editor.getHTML());
|
||||
lastContentRef.current = markdown;
|
||||
onChange(markdown);
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none focus:outline-none min-h-[400px] p-4',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update content when prop changes (e.g., switching languages)
|
||||
useEffect(() => {
|
||||
if (editor && content !== lastContentRef.current) {
|
||||
lastContentRef.current = content;
|
||||
const html = markdownToHtml(content);
|
||||
editor.commands.setContent(html);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
return (
|
||||
<div className={clsx('border border-secondary-light-gray rounded-btn overflow-hidden bg-white', className)}>
|
||||
{editable && <Toolbar editor={editor} />}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only preview component
|
||||
export function RichTextPreview({
|
||||
content,
|
||||
className = '',
|
||||
}: {
|
||||
content: string; // Markdown content
|
||||
className?: string;
|
||||
}) {
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
content: markdownToHtml(content),
|
||||
editable: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none p-4',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.commands.setContent(markdownToHtml(content));
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
return (
|
||||
<div className={clsx('border border-secondary-light-gray rounded-btn bg-gray-50', className)}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +119,8 @@
|
||||
"nameRequired": "Please enter your full name",
|
||||
"firstNameRequired": "Please enter your first name",
|
||||
"lastNameRequired": "Please enter your last name",
|
||||
"lastNameTooShort": "Last name must be at least 2 characters",
|
||||
"phoneTooShort": "Phone number must be at least 6 digits",
|
||||
"emailInvalid": "Please enter a valid email address",
|
||||
"phoneRequired": "Phone number is required",
|
||||
"bookingFailed": "Booking failed. Please try again.",
|
||||
@@ -177,12 +179,13 @@
|
||||
"button": "Follow Us"
|
||||
},
|
||||
"guidelines": {
|
||||
"title": "Community Guidelines",
|
||||
"title": "Community Rules",
|
||||
"items": [
|
||||
"Be respectful to all participants",
|
||||
"Help others practice - we're all learning",
|
||||
"Speak in the language you're practicing",
|
||||
"Have fun and be open to making new friends"
|
||||
"Respect above all. Treat others the way you would like to be treated.",
|
||||
"We are all learning, let's help each other practice.",
|
||||
"Use this space to practice the event languages, mistakes are part of the process.",
|
||||
"Keep an open attitude to meet new people and have fun.",
|
||||
"This is a space to connect, please avoid spam and unsolicited promotions."
|
||||
]
|
||||
},
|
||||
"volunteer": {
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
"nameRequired": "Por favor ingresa tu nombre completo",
|
||||
"firstNameRequired": "Por favor ingresa tu nombre",
|
||||
"lastNameRequired": "Por favor ingresa tu apellido",
|
||||
"lastNameTooShort": "El apellido debe tener al menos 2 caracteres",
|
||||
"phoneTooShort": "El teléfono debe tener al menos 6 dígitos",
|
||||
"emailInvalid": "Por favor ingresa un correo electrónico válido",
|
||||
"phoneRequired": "El número de teléfono es requerido",
|
||||
"bookingFailed": "La reserva falló. Por favor intenta de nuevo.",
|
||||
@@ -158,37 +160,38 @@
|
||||
"subtitle": "Conéctate con nosotros en redes sociales",
|
||||
"whatsapp": {
|
||||
"title": "Grupo de WhatsApp",
|
||||
"description": "Únete a nuestro grupo de WhatsApp para actualizaciones y chat comunitario",
|
||||
"description": "Sumate a nuestro grupo de WhatsApp para recibir novedades y conversar con la comunidad.",
|
||||
"button": "Unirse a WhatsApp"
|
||||
},
|
||||
"instagram": {
|
||||
"title": "Instagram",
|
||||
"description": "Síguenos para fotos, historias y anuncios",
|
||||
"button": "Seguirnos"
|
||||
"description": "Seguinos en Instagram para ver fotos, historias y momentos del Spanglish.",
|
||||
"button": "Seguir en Instagram"
|
||||
},
|
||||
"telegram": {
|
||||
"title": "Canal de Telegram",
|
||||
"description": "Únete a nuestro canal de Telegram para noticias y anuncios",
|
||||
"description": "Seguinos en nuestro canal de Telegram para recibir noticias y anuncios de próximos eventos.",
|
||||
"button": "Unirse a Telegram"
|
||||
},
|
||||
"tiktok": {
|
||||
"title": "TikTok",
|
||||
"description": "Mira nuestros videos y síguenos para contenido divertido",
|
||||
"button": "Seguirnos"
|
||||
"description": "Mirá nuestros videos y viví la experiencia Spanglish.",
|
||||
"button": "Seguir en TikTok"
|
||||
},
|
||||
"guidelines": {
|
||||
"title": "Reglas de la Comunidad",
|
||||
"title": "Normas de la comunidad",
|
||||
"items": [
|
||||
"Sé respetuoso con todos los participantes",
|
||||
"Ayuda a otros a practicar - todos estamos aprendiendo",
|
||||
"Habla en el idioma que estás practicando",
|
||||
"Diviértete y abierto a hacer nuevos amigos"
|
||||
"Respeto ante todo. Tratemos a los demás como nos gustaría que nos traten.",
|
||||
"Todos estamos aprendiendo, ayudemos a otros a practicar.",
|
||||
"Aprovechemos este espacio para usar los idiomas del evento, sin miedo al éxito.",
|
||||
"Mantengamos una actitud abierta para conocer personas y pasarla bien.",
|
||||
"Este es un espacio para conectar, evitemos el spam y las promociones no solicitadas."
|
||||
]
|
||||
},
|
||||
"volunteer": {
|
||||
"title": "Conviértete en Voluntario",
|
||||
"description": "Ayúdanos a organizar eventos y hacer crecer la comunidad",
|
||||
"button": "Contáctanos"
|
||||
"title": "Sumate como voluntario/a",
|
||||
"description": "Ayudanos a organizar los encuentros y a hacer crecer la comunidad Spanglish.",
|
||||
"button": "Contactanos"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
|
||||
@@ -124,9 +124,10 @@ export const ticketsApi = {
|
||||
}),
|
||||
|
||||
// For manual payment methods (bank_transfer, tpago) - user marks payment as sent
|
||||
markPaymentSent: (id: string) =>
|
||||
markPaymentSent: (id: string, payerName?: string) =>
|
||||
fetchApi<{ payment: Payment; message: string }>(`/api/tickets/${id}/mark-payment-sent`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payerName }),
|
||||
}),
|
||||
|
||||
adminCreate: (data: {
|
||||
@@ -217,16 +218,21 @@ export const paymentsApi = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
approve: (id: string, adminNote?: string) =>
|
||||
approve: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminNote }),
|
||||
body: JSON.stringify({ adminNote, sendEmail }),
|
||||
}),
|
||||
|
||||
reject: (id: string, adminNote?: string) =>
|
||||
reject: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminNote }),
|
||||
body: JSON.stringify({ adminNote, sendEmail }),
|
||||
}),
|
||||
|
||||
sendReminder: (id: string) =>
|
||||
fetchApi<{ message: string; reminderSentAt?: string }>(`/api/payments/${id}/send-reminder`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
updateNote: (id: string, adminNote: string) =>
|
||||
@@ -438,18 +444,21 @@ export interface Event {
|
||||
externalBookingUrl?: string;
|
||||
bookedCount?: number;
|
||||
availableSeats?: number;
|
||||
isFeatured?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
bookingId?: string; // Groups multiple tickets from same booking
|
||||
userId: string;
|
||||
eventId: string;
|
||||
attendeeFirstName: string;
|
||||
attendeeLastName?: string;
|
||||
attendeeEmail?: string;
|
||||
attendeePhone?: string;
|
||||
attendeeRuc?: string;
|
||||
preferredLanguage?: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
|
||||
checkinAt?: string;
|
||||
@@ -494,9 +503,11 @@ export interface Payment {
|
||||
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed';
|
||||
reference?: string;
|
||||
userMarkedPaidAt?: string;
|
||||
payerName?: string; // Name of payer if different from attendee
|
||||
paidAt?: string;
|
||||
paidByAdminId?: string;
|
||||
adminNote?: string;
|
||||
reminderSentAt?: string; // When payment reminder email was sent
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -504,6 +515,7 @@ export interface Payment {
|
||||
export interface PaymentWithDetails extends Payment {
|
||||
ticket: {
|
||||
id: string;
|
||||
bookingId?: string;
|
||||
attendeeFirstName: string;
|
||||
attendeeLastName?: string;
|
||||
attendeeEmail?: string;
|
||||
@@ -560,6 +572,11 @@ export interface Contact {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AttendeeData {
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
export interface BookingData {
|
||||
eventId: string;
|
||||
firstName: string;
|
||||
@@ -569,6 +586,8 @@ export interface BookingData {
|
||||
preferredLanguage?: 'en' | 'es';
|
||||
paymentMethod: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
||||
ruc?: string;
|
||||
// For multi-ticket bookings
|
||||
attendees?: AttendeeData[];
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
@@ -943,6 +962,7 @@ export interface SiteSettings {
|
||||
instagramUrl?: string | null;
|
||||
twitterUrl?: string | null;
|
||||
linkedinUrl?: string | null;
|
||||
featuredEventId?: string | null;
|
||||
maintenanceMode: boolean;
|
||||
maintenanceMessage?: string | null;
|
||||
maintenanceMessageEs?: string | null;
|
||||
@@ -966,4 +986,81 @@ export const siteSettingsApi = {
|
||||
|
||||
getTimezones: () =>
|
||||
fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'),
|
||||
|
||||
setFeaturedEvent: (eventId: string | null) =>
|
||||
fetchApi<{ featuredEventId: string | null; message: string }>('/api/site-settings/featured-event', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ eventId }),
|
||||
}),
|
||||
};
|
||||
|
||||
// ==================== Legal Pages Types ====================
|
||||
|
||||
export interface LegalPage {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string | null;
|
||||
contentText: string;
|
||||
contentTextEs?: string | null;
|
||||
contentMarkdown: string;
|
||||
contentMarkdownEs?: string | null;
|
||||
updatedAt: string;
|
||||
updatedBy?: string | null;
|
||||
createdAt: string;
|
||||
source?: 'database' | 'filesystem';
|
||||
hasEnglish?: boolean;
|
||||
hasSpanish?: boolean;
|
||||
}
|
||||
|
||||
export interface LegalPagePublic {
|
||||
id?: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
contentMarkdown: string;
|
||||
updatedAt?: string;
|
||||
source?: 'database' | 'filesystem';
|
||||
}
|
||||
|
||||
export interface LegalPageListItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
hasEnglish?: boolean;
|
||||
hasSpanish?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Legal Pages API ====================
|
||||
|
||||
export const legalPagesApi = {
|
||||
// Public endpoints
|
||||
getAll: (locale?: string) =>
|
||||
fetchApi<{ pages: LegalPageListItem[] }>(`/api/legal-pages${locale ? `?locale=${locale}` : ''}`),
|
||||
|
||||
getBySlug: (slug: string, locale?: string) =>
|
||||
fetchApi<{ page: LegalPagePublic }>(`/api/legal-pages/${slug}${locale ? `?locale=${locale}` : ''}`),
|
||||
|
||||
// Admin endpoints
|
||||
getAdminList: () =>
|
||||
fetchApi<{ pages: LegalPage[] }>('/api/legal-pages/admin/list'),
|
||||
|
||||
getAdminPage: (slug: string) =>
|
||||
fetchApi<{ page: LegalPage }>(`/api/legal-pages/admin/${slug}`),
|
||||
|
||||
update: (slug: string, data: {
|
||||
contentMarkdown?: string;
|
||||
contentMarkdownEs?: string;
|
||||
title?: string;
|
||||
titleEs?: string;
|
||||
}) =>
|
||||
fetchApi<{ page: LegalPage; message: string }>(`/api/legal-pages/admin/${slug}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
seed: () =>
|
||||
fetchApi<{ message: string; seeded: number; pages?: string[] }>('/api/legal-pages/admin/seed', {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -16,8 +16,11 @@ export interface LegalPageMeta {
|
||||
// Map file names to display titles
|
||||
const titleMap: Record<string, { en: string; es: string }> = {
|
||||
'privacy_policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
|
||||
'privacy-policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
|
||||
'terms_policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
|
||||
'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' },
|
||||
'refund-cancelation-policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
|
||||
};
|
||||
|
||||
// Convert file name to URL-friendly slug
|
||||
@@ -70,8 +73,8 @@ export function getAllLegalPagesMeta(locale: string = 'en'): LegalPageMeta[] {
|
||||
});
|
||||
}
|
||||
|
||||
// Get a specific legal page content
|
||||
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null {
|
||||
// Get a specific legal page content from filesystem (fallback)
|
||||
export function getLegalPageFromFilesystem(slug: string, locale: string = 'en'): LegalPage | null {
|
||||
const legalDir = getLegalDir();
|
||||
const fileName = slugToFileName(slug);
|
||||
const filePath = path.join(legalDir, fileName);
|
||||
@@ -82,7 +85,7 @@ export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | n
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const baseFileName = fileName.replace('.md', '');
|
||||
const titles = titleMap[baseFileName];
|
||||
const titles = titleMap[baseFileName] || titleMap[slug];
|
||||
const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' ');
|
||||
|
||||
// Try to extract last updated date from content
|
||||
@@ -96,3 +99,43 @@ export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | n
|
||||
lastUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
// Get a specific legal page content - tries API first, falls back to filesystem
|
||||
export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise<LegalPage | null> {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
// Try to fetch from API with locale parameter
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/legal-pages/${slug}?locale=${locale}`, {
|
||||
next: { revalidate: 60 }, // Cache for 60 seconds
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const page = data.page;
|
||||
|
||||
if (page) {
|
||||
// Extract last updated from content or use updatedAt
|
||||
const lastUpdatedMatch = page.contentMarkdown?.match(/Last updated:\s*(.+)/i);
|
||||
const lastUpdated = lastUpdatedMatch ? lastUpdatedMatch[1].trim() : page.updatedAt;
|
||||
|
||||
return {
|
||||
slug: page.slug,
|
||||
title: page.title, // API already returns localized title with fallback
|
||||
content: page.contentMarkdown, // API already returns localized content with fallback
|
||||
lastUpdated,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch legal page from API, falling back to filesystem:', error);
|
||||
}
|
||||
|
||||
// Fallback to filesystem
|
||||
return getLegalPageFromFilesystem(slug, locale);
|
||||
}
|
||||
|
||||
// Legacy sync function for backwards compatibility
|
||||
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null {
|
||||
return getLegalPageFromFilesystem(slug, locale);
|
||||
}
|
||||
|
||||
36
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Format price - shows decimals only if needed
|
||||
* Uses space as thousands separator (common in Paraguay)
|
||||
* Examples:
|
||||
* 45000 PYG -> "45 000 PYG" (no decimals)
|
||||
* 41.44 PYG -> "41,44 PYG" (with decimals)
|
||||
*/
|
||||
export function formatPrice(price: number, currency: string = 'PYG'): string {
|
||||
const hasDecimals = price % 1 !== 0;
|
||||
|
||||
// Format the integer and decimal parts separately
|
||||
const intPart = Math.floor(Math.abs(price));
|
||||
const decPart = Math.abs(price) - intPart;
|
||||
|
||||
// Format integer part with space as thousands separator
|
||||
const intFormatted = intPart.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
|
||||
// Build final string
|
||||
let result = price < 0 ? '-' : '';
|
||||
result += intFormatted;
|
||||
|
||||
// Add decimals only if present
|
||||
if (hasDecimals) {
|
||||
const decStr = decPart.toFixed(2).substring(2); // Get just the decimal digits
|
||||
result += ',' + decStr;
|
||||
}
|
||||
|
||||
return `${result} ${currency}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency amount (alias for formatPrice for backward compatibility)
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
|
||||
return formatPrice(amount, currency);
|
||||
}
|
||||