diff --git a/backend/package.json b/backend/package.json
index 9bf1856..d71a030 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -23,7 +23,9 @@
"jose": "^5.4.0",
"nanoid": "^5.0.7",
"nodemailer": "^7.0.13",
+ "pdfkit": "^0.17.2",
"pg": "^8.12.0",
+ "qrcode": "^1.5.4",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -31,7 +33,9 @@
"@types/better-sqlite3": "^7.6.10",
"@types/node": "^20.14.9",
"@types/nodemailer": "^7.0.9",
+ "@types/pdfkit": "^0.17.4",
"@types/pg": "^8.11.6",
+ "@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.22.8",
"tsx": "^4.15.7",
"typescript": "^5.5.2"
diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts
index 7c17078..0cf85c1 100644
--- a/backend/src/db/migrate.ts
+++ b/backend/src/db/migrate.ts
@@ -74,6 +74,8 @@ async function migrate() {
title_es TEXT,
description TEXT NOT NULL,
description_es TEXT,
+ short_description TEXT,
+ short_description_es TEXT,
start_datetime TEXT NOT NULL,
end_datetime TEXT,
location TEXT NOT NULL,
@@ -98,6 +100,14 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
} catch (e) { /* column may already exist */ }
+ // Add short description columns to events
+ try {
+ await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
+ } catch (e) { /* column may already exist */ }
+ try {
+ await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`);
+ } catch (e) { /* column may already exist */ }
+
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
@@ -151,6 +161,11 @@ async function migrate() {
`);
} catch (e) { /* migration may have already run */ }
+ // Migration: Add checked_in_by_admin_id column to tickets
+ try {
+ 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 */ }
+
// 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
@@ -347,6 +362,28 @@ async function migrate() {
updated_at TEXT NOT NULL
)
`);
+
+ // Site settings table
+ await (db as any).run(sql`
+ CREATE TABLE IF NOT EXISTS site_settings (
+ id TEXT PRIMARY KEY,
+ timezone TEXT NOT NULL DEFAULT 'America/Asuncion',
+ site_name TEXT NOT NULL DEFAULT 'Spanglish',
+ 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 NOT NULL DEFAULT 0,
+ maintenance_message TEXT,
+ maintenance_message_es TEXT,
+ updated_at TEXT NOT NULL,
+ updated_by TEXT REFERENCES users(id)
+ )
+ `);
} else {
// PostgreSQL migrations
await (db as any).execute(sql`
@@ -415,6 +452,8 @@ async function migrate() {
title_es VARCHAR(255),
description TEXT NOT NULL,
description_es TEXT,
+ short_description VARCHAR(300),
+ short_description_es VARCHAR(300),
start_datetime TIMESTAMP NOT NULL,
end_datetime TIMESTAMP,
location VARCHAR(500) NOT NULL,
@@ -439,6 +478,14 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
} catch (e) { /* column may already exist */ }
+ // Add short description columns to events
+ try {
+ await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
+ } catch (e) { /* column may already exist */ }
+ try {
+ await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`);
+ } catch (e) { /* column may already exist */ }
+
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS tickets (
id UUID PRIMARY KEY,
@@ -462,6 +509,11 @@ async function migrate() {
try {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN attendee_ruc VARCHAR(15)`);
} catch (e) { /* column may already exist */ }
+
+ // Add checked_in_by_admin_id column to tickets
+ try {
+ await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id UUID REFERENCES users(id)`);
+ } catch (e) { /* column may already exist */ }
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS payments (
@@ -642,6 +694,28 @@ async function migrate() {
updated_at TIMESTAMP NOT NULL
)
`);
+
+ // Site settings table
+ await (db as any).execute(sql`
+ CREATE TABLE IF NOT EXISTS site_settings (
+ id UUID PRIMARY KEY,
+ timezone VARCHAR(100) NOT NULL DEFAULT 'America/Asuncion',
+ site_name VARCHAR(255) NOT NULL DEFAULT 'Spanglish',
+ site_description TEXT,
+ site_description_es TEXT,
+ contact_email VARCHAR(255),
+ contact_phone VARCHAR(50),
+ facebook_url VARCHAR(500),
+ instagram_url VARCHAR(500),
+ twitter_url VARCHAR(500),
+ linkedin_url VARCHAR(500),
+ maintenance_mode INTEGER NOT NULL DEFAULT 0,
+ maintenance_message TEXT,
+ maintenance_message_es TEXT,
+ updated_at TIMESTAMP NOT NULL,
+ updated_by UUID REFERENCES users(id)
+ )
+ `);
}
console.log('Migrations completed successfully!');
diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts
index 075c008..1c0851e 100644
--- a/backend/src/db/schema.ts
+++ b/backend/src/db/schema.ts
@@ -66,6 +66,8 @@ export const sqliteEvents = sqliteTable('events', {
titleEs: text('title_es'),
description: text('description').notNull(),
descriptionEs: text('description_es'),
+ shortDescription: text('short_description'),
+ shortDescriptionEs: text('short_description_es'),
startDatetime: text('start_datetime').notNull(),
endDatetime: text('end_datetime'),
location: text('location').notNull(),
@@ -93,6 +95,7 @@ export const sqliteTickets = sqliteTable('tickets', {
preferredLanguage: text('preferred_language'),
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
checkinAt: text('checkin_at'),
+ checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
qrCode: text('qr_code'),
adminNote: text('admin_note'),
createdAt: text('created_at').notNull(),
@@ -246,6 +249,32 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
updatedAt: text('updated_at').notNull(),
});
+// Site Settings table for global website configuration
+export const sqliteSiteSettings = sqliteTable('site_settings', {
+ id: text('id').primaryKey(),
+ // Timezone configuration
+ timezone: text('timezone').notNull().default('America/Asuncion'),
+ // Site info
+ siteName: text('site_name').notNull().default('Spanglish'),
+ siteDescription: text('site_description'),
+ siteDescriptionEs: text('site_description_es'),
+ // Contact info
+ contactEmail: text('contact_email'),
+ contactPhone: text('contact_phone'),
+ // Social links (can also be stored here as fallback)
+ facebookUrl: text('facebook_url'),
+ instagramUrl: text('instagram_url'),
+ twitterUrl: text('twitter_url'),
+ linkedinUrl: text('linkedin_url'),
+ // Other settings
+ maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
+ maintenanceMessage: text('maintenance_message'),
+ maintenanceMessageEs: text('maintenance_message_es'),
+ // Metadata
+ updatedAt: text('updated_at').notNull(),
+ updatedBy: text('updated_by').references(() => sqliteUsers.id),
+});
+
// ==================== PostgreSQL Schema ====================
export const pgUsers = pgTable('users', {
id: uuid('id').primaryKey(),
@@ -308,6 +337,8 @@ export const pgEvents = pgTable('events', {
titleEs: varchar('title_es', { length: 255 }),
description: pgText('description').notNull(),
descriptionEs: pgText('description_es'),
+ shortDescription: varchar('short_description', { length: 300 }),
+ shortDescriptionEs: varchar('short_description_es', { length: 300 }),
startDatetime: timestamp('start_datetime').notNull(),
endDatetime: timestamp('end_datetime'),
location: varchar('location', { length: 500 }).notNull(),
@@ -335,6 +366,7 @@ export const pgTickets = pgTable('tickets', {
preferredLanguage: varchar('preferred_language', { length: 10 }),
status: varchar('status', { length: 20 }).notNull().default('pending'),
checkinAt: timestamp('checkin_at'),
+ checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in
qrCode: varchar('qr_code', { length: 255 }),
adminNote: pgText('admin_note'),
createdAt: timestamp('created_at').notNull(),
@@ -480,6 +512,32 @@ export const pgEmailSettings = pgTable('email_settings', {
updatedAt: timestamp('updated_at').notNull(),
});
+// Site Settings table for global website configuration
+export const pgSiteSettings = pgTable('site_settings', {
+ id: uuid('id').primaryKey(),
+ // Timezone configuration
+ timezone: varchar('timezone', { length: 100 }).notNull().default('America/Asuncion'),
+ // Site info
+ siteName: varchar('site_name', { length: 255 }).notNull().default('Spanglish'),
+ siteDescription: pgText('site_description'),
+ siteDescriptionEs: pgText('site_description_es'),
+ // Contact info
+ contactEmail: varchar('contact_email', { length: 255 }),
+ contactPhone: varchar('contact_phone', { length: 50 }),
+ // Social links
+ facebookUrl: varchar('facebook_url', { length: 500 }),
+ instagramUrl: varchar('instagram_url', { length: 500 }),
+ twitterUrl: varchar('twitter_url', { length: 500 }),
+ linkedinUrl: varchar('linkedin_url', { length: 500 }),
+ // Other settings
+ maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
+ maintenanceMessage: pgText('maintenance_message'),
+ maintenanceMessageEs: pgText('maintenance_message_es'),
+ // Metadata
+ updatedAt: timestamp('updated_at').notNull(),
+ updatedBy: uuid('updated_by').references(() => pgUsers.id),
+});
+
// Export the appropriate schema based on DB_TYPE
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
@@ -497,6 +555,7 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
+export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
// Type exports
export type User = typeof sqliteUsers.$inferSelect;
@@ -523,3 +582,5 @@ export type UserSession = typeof sqliteUserSessions.$inferSelect;
export type NewUserSession = typeof sqliteUserSessions.$inferInsert;
export type Invoice = typeof sqliteInvoices.$inferSelect;
export type NewInvoice = typeof sqliteInvoices.$inferInsert;
+export type SiteSettings = typeof sqliteSiteSettings.$inferSelect;
+export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert;
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 9b2d95a..1acc953 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -19,6 +19,7 @@ import lnbitsRoutes from './routes/lnbits.js';
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 emailService from './lib/email.js';
const app = new Hono();
@@ -1712,6 +1713,7 @@ app.route('/api/lnbits', lnbitsRoutes);
app.route('/api/emails', emailsRoutes);
app.route('/api/payment-options', paymentOptionsRoutes);
app.route('/api/dashboard', dashboardRoutes);
+app.route('/api/site-settings', siteSettingsRoutes);
// 404 handler
app.notFound((c) => {
diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts
index 0ad4d95..047958b 100644
--- a/backend/src/lib/email.ts
+++ b/backend/src/lib/email.ts
@@ -548,6 +548,10 @@ export const emailService = {
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
+ // Generate ticket PDF URL
+ const apiUrl = process.env.API_URL || 'http://localhost:3001';
+ const ticketPdfUrl = `${apiUrl}/api/tickets/${ticket.id}/pdf`;
+
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
return this.sendTemplateEmail({
templateSlug: 'booking-confirmation',
@@ -560,6 +564,7 @@ export const emailService = {
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
qrCode: ticket.qrCode || '',
+ ticketPdfUrl,
eventTitle,
eventDate: this.formatDate(event.startDatetime, locale),
eventTime: this.formatTime(event.startDatetime, locale),
diff --git a/backend/src/lib/emailTemplates.ts b/backend/src/lib/emailTemplates.ts
index f0779a0..dcc3584 100644
--- a/backend/src/lib/emailTemplates.ts
+++ b/backend/src/lib/emailTemplates.ts
@@ -35,6 +35,7 @@ export const bookingVariables: EmailVariable[] = [
{ name: 'attendeeEmail', description: 'Attendee email', example: 'john@example.com' },
{ name: 'ticketId', description: 'Unique ticket ID', example: 'TKT-ABC123' },
{ name: 'qrCode', description: 'QR code for check-in', example: 'data:image/png;base64,...' },
+ { name: 'ticketPdfUrl', description: 'URL to download ticket PDF', example: 'https://api.spanglish.com/api/tickets/abc123/pdf' },
{ name: 'eventTitle', description: 'Event title', example: 'Spanglish Night - January Edition' },
{ name: 'eventDate', description: 'Event date formatted', example: 'January 28, 2026' },
{ name: 'eventTime', description: 'Event time', example: '7:00 PM' },
@@ -228,18 +229,17 @@ export const defaultTemplates: DefaultTemplate[] = [
Your Ticket ID
-
{{ticketId}}
+
{{qrCode}}
- {{#if qrCode}}
-
-
Show this QR code at check-in:
-
-
+ {{#if ticketPdfUrl}}
+
+ 📄 Download Your Ticket (PDF)
+
{{/if}}
- 💡 Important: Please arrive 10-15 minutes early for check-in. Bring your ticket ID or show this email.
+ 💡 Important: Please arrive 10-15 minutes early for check-in. Show the PDF ticket or this email at the entrance.
See you at Spanglish!
@@ -263,18 +263,17 @@ export const defaultTemplates: DefaultTemplate[] = [
Tu ID de Ticket
-
{{ticketId}}
+
{{qrCode}}
- {{#if qrCode}}
-
-
Muestra este código QR en el check-in:
-
-
+ {{#if ticketPdfUrl}}
+
+ 📄 Descargar Tu Ticket (PDF)
+
{{/if}}
- 💡 Importante: Por favor llega 10-15 minutos antes para el check-in. Trae tu ID de ticket o muestra este email.
+ 💡 Importante: Por favor llega 10-15 minutos antes para el check-in. Muestra el PDF del ticket o este email en la entrada.
¡Nos vemos en Spanglish!
diff --git a/backend/src/lib/pdf.ts b/backend/src/lib/pdf.ts
new file mode 100644
index 0000000..c70c499
--- /dev/null
+++ b/backend/src/lib/pdf.ts
@@ -0,0 +1,246 @@
+// PDF Ticket Generation Service
+import PDFDocument from 'pdfkit';
+import QRCode from 'qrcode';
+
+interface TicketData {
+ id: string;
+ qrCode: string;
+ attendeeName: string;
+ attendeeEmail?: string;
+ event: {
+ title: string;
+ startDatetime: string;
+ endDatetime?: string;
+ location: string;
+ locationUrl?: string;
+ };
+}
+
+/**
+ * Generate a QR code as a data URL
+ */
+async function generateQRCode(data: string): Promise {
+ return QRCode.toBuffer(data, {
+ type: 'png',
+ width: 200,
+ margin: 2,
+ errorCorrectionLevel: 'M',
+ });
+}
+
+/**
+ * Format date for display
+ */
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+}
+
+/**
+ * Format time for display
+ */
+function formatTime(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: true,
+ });
+}
+
+/**
+ * Generate a PDF ticket for a single ticket
+ */
+export async function generateTicketPDF(ticket: TicketData): Promise {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const doc = new PDFDocument({
+ size: 'A4',
+ margin: 50,
+ });
+
+ const chunks: Buffer[] = [];
+ doc.on('data', (chunk: Buffer) => chunks.push(chunk));
+ doc.on('end', () => resolve(Buffer.concat(chunks)));
+ doc.on('error', reject);
+
+ const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
+
+ // Generate QR code with ticket URL
+ const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
+ const qrBuffer = await generateQRCode(qrUrl);
+
+ // ==================== Header ====================
+ doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
+ doc.moveDown(0.5);
+ doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
+
+ // Divider line
+ doc.moveDown(1);
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
+ doc.moveDown(1);
+
+ // ==================== Event Info ====================
+ doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
+ doc.moveDown(0.5);
+
+ // Date and time
+ doc.fontSize(14).fillColor('#333');
+ doc.text(formatDate(ticket.event.startDatetime), { align: 'center' });
+
+ const startTime = formatTime(ticket.event.startDatetime);
+ const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null;
+ const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
+ doc.text(timeRange, { align: 'center' });
+
+ doc.moveDown(0.5);
+ doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
+
+ // ==================== QR Code ====================
+ doc.moveDown(2);
+
+ // Center the QR code
+ const qrSize = 180;
+ const pageWidth = 595; // A4 width in points
+ const qrX = (pageWidth - qrSize) / 2;
+
+ doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
+ doc.y += qrSize + 10;
+
+ // ==================== Attendee Info ====================
+ doc.moveDown(1);
+ doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
+
+ if (ticket.attendeeEmail) {
+ doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
+ }
+
+ // ==================== Ticket ID ====================
+ doc.moveDown(1);
+ doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
+ doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
+
+ // ==================== Footer ====================
+ doc.moveDown(2);
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
+ doc.moveDown(0.5);
+
+ doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
+ doc.moveDown(0.3);
+ doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
+
+ doc.end();
+ } catch (error) {
+ reject(error);
+ }
+ });
+}
+
+/**
+ * Generate a combined PDF with multiple tickets
+ */
+export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const doc = new PDFDocument({
+ size: 'A4',
+ margin: 50,
+ });
+
+ const chunks: Buffer[] = [];
+ doc.on('data', (chunk: Buffer) => chunks.push(chunk));
+ doc.on('end', () => resolve(Buffer.concat(chunks)));
+ doc.on('error', reject);
+
+ const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
+
+ for (let i = 0; i < tickets.length; i++) {
+ const ticket = tickets[i];
+
+ if (i > 0) {
+ doc.addPage();
+ }
+
+ // Generate QR code
+ const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
+ const qrBuffer = await generateQRCode(qrUrl);
+
+ // ==================== Header ====================
+ doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
+ doc.moveDown(0.5);
+ doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
+
+ // Divider line
+ doc.moveDown(1);
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
+ doc.moveDown(1);
+
+ // ==================== Event Info ====================
+ doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
+ doc.moveDown(0.5);
+
+ doc.fontSize(14).fillColor('#333');
+ doc.text(formatDate(ticket.event.startDatetime), { align: 'center' });
+
+ const startTime = formatTime(ticket.event.startDatetime);
+ const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null;
+ const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
+ doc.text(timeRange, { align: 'center' });
+
+ doc.moveDown(0.5);
+ doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
+
+ // ==================== QR Code ====================
+ doc.moveDown(2);
+
+ const qrSize = 180;
+ const pageWidth = 595;
+ const qrX = (pageWidth - qrSize) / 2;
+
+ doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
+ doc.y += qrSize + 10;
+
+ // ==================== Attendee Info ====================
+ doc.moveDown(1);
+ doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
+
+ if (ticket.attendeeEmail) {
+ doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
+ }
+
+ // ==================== Ticket ID ====================
+ doc.moveDown(1);
+ doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
+ doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
+
+ // Ticket number for multi-ticket bookings
+ if (tickets.length > 1) {
+ doc.text(`Ticket ${i + 1} of ${tickets.length}`, { align: 'center' });
+ }
+
+ // ==================== Footer ====================
+ doc.moveDown(2);
+ doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
+ doc.moveDown(0.5);
+
+ doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
+ doc.moveDown(0.3);
+ doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
+ }
+
+ doc.end();
+ } catch (error) {
+ reject(error);
+ }
+ });
+}
+
+export default {
+ generateTicketPDF,
+ generateCombinedTicketsPDF,
+};
diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts
index 2b549b2..bba2275 100644
--- a/backend/src/routes/events.ts
+++ b/backend/src/routes/events.ts
@@ -28,6 +28,8 @@ const baseEventSchema = z.object({
titleEs: z.string().optional().nullable(),
description: z.string().min(1),
descriptionEs: z.string().optional().nullable(),
+ shortDescription: z.string().max(300).optional().nullable(),
+ shortDescriptionEs: z.string().max(300).optional().nullable(),
startDatetime: z.string(),
endDatetime: z.string().optional().nullable(),
location: z.string().min(1),
@@ -315,6 +317,8 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
description: existing.description,
descriptionEs: existing.descriptionEs,
+ shortDescription: existing.shortDescription,
+ shortDescriptionEs: existing.shortDescriptionEs,
startDatetime: existing.startDatetime,
endDatetime: existing.endDatetime,
location: existing.location,
diff --git a/backend/src/routes/site-settings.ts b/backend/src/routes/site-settings.ts
new file mode 100644
index 0000000..274dc1c
--- /dev/null
+++ b/backend/src/routes/site-settings.ts
@@ -0,0 +1,144 @@
+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 { requireAuth } from '../lib/auth.js';
+import { generateId, getNow } from '../lib/utils.js';
+
+interface UserContext {
+ id: string;
+ email: string;
+ name: string;
+ role: string;
+}
+
+const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
+
+// Validation schema for updating site settings
+const updateSiteSettingsSchema = z.object({
+ timezone: z.string().optional(),
+ siteName: z.string().optional(),
+ siteDescription: z.string().optional().nullable(),
+ siteDescriptionEs: z.string().optional().nullable(),
+ contactEmail: z.string().email().optional().nullable().or(z.literal('')),
+ contactPhone: z.string().optional().nullable(),
+ facebookUrl: z.string().url().optional().nullable().or(z.literal('')),
+ instagramUrl: z.string().url().optional().nullable().or(z.literal('')),
+ twitterUrl: z.string().url().optional().nullable().or(z.literal('')),
+ linkedinUrl: z.string().url().optional().nullable().or(z.literal('')),
+ maintenanceMode: z.boolean().optional(),
+ maintenanceMessage: z.string().optional().nullable(),
+ maintenanceMessageEs: z.string().optional().nullable(),
+});
+
+// Get site settings (public - needed for frontend timezone)
+siteSettingsRouter.get('/', async (c) => {
+ const settings = await (db as any).select().from(siteSettings).limit(1).get();
+
+ if (!settings) {
+ // Return default settings if none exist
+ return c.json({
+ settings: {
+ timezone: 'America/Asuncion',
+ siteName: 'Spanglish',
+ siteDescription: null,
+ siteDescriptionEs: null,
+ contactEmail: null,
+ contactPhone: null,
+ facebookUrl: null,
+ instagramUrl: null,
+ twitterUrl: null,
+ linkedinUrl: null,
+ maintenanceMode: false,
+ maintenanceMessage: null,
+ maintenanceMessageEs: null,
+ },
+ });
+ }
+
+ return c.json({ settings });
+});
+
+// Get available timezones
+siteSettingsRouter.get('/timezones', async (c) => {
+ // Common timezones for Americas (especially relevant for Paraguay)
+ const timezones = [
+ { value: 'America/Asuncion', label: 'Paraguay (Asunción) - UTC-4/-3' },
+ { value: 'America/Sao_Paulo', label: 'Brazil (São Paulo) - UTC-3' },
+ { value: 'America/Buenos_Aires', label: 'Argentina (Buenos Aires) - UTC-3' },
+ { value: 'America/Santiago', label: 'Chile (Santiago) - UTC-4/-3' },
+ { value: 'America/Lima', label: 'Peru (Lima) - UTC-5' },
+ { value: 'America/Bogota', label: 'Colombia (Bogotá) - UTC-5' },
+ { value: 'America/Caracas', label: 'Venezuela (Caracas) - UTC-4' },
+ { value: 'America/La_Paz', label: 'Bolivia (La Paz) - UTC-4' },
+ { value: 'America/Montevideo', label: 'Uruguay (Montevideo) - UTC-3' },
+ { value: 'America/New_York', label: 'US Eastern - UTC-5/-4' },
+ { value: 'America/Chicago', label: 'US Central - UTC-6/-5' },
+ { value: 'America/Denver', label: 'US Mountain - UTC-7/-6' },
+ { value: 'America/Los_Angeles', label: 'US Pacific - UTC-8/-7' },
+ { value: 'America/Mexico_City', label: 'Mexico (Mexico City) - UTC-6/-5' },
+ { value: 'Europe/London', label: 'UK (London) - UTC+0/+1' },
+ { value: 'Europe/Madrid', label: 'Spain (Madrid) - UTC+1/+2' },
+ { value: 'Europe/Paris', label: 'France (Paris) - UTC+1/+2' },
+ { value: 'Europe/Berlin', label: 'Germany (Berlin) - UTC+1/+2' },
+ { value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
+ ];
+
+ return c.json({ timezones });
+});
+
+// Update site settings (admin only)
+siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSiteSettingsSchema), async (c) => {
+ const data = c.req.valid('json');
+ const user = c.get('user');
+ const now = getNow();
+
+ // Check if settings exist
+ const existing = await (db as any).select().from(siteSettings).limit(1).get();
+
+ if (!existing) {
+ // Create new settings record
+ const id = generateId();
+ const newSettings = {
+ id,
+ timezone: data.timezone || 'America/Asuncion',
+ siteName: data.siteName || 'Spanglish',
+ siteDescription: data.siteDescription || null,
+ siteDescriptionEs: data.siteDescriptionEs || null,
+ contactEmail: data.contactEmail || null,
+ contactPhone: data.contactPhone || null,
+ facebookUrl: data.facebookUrl || null,
+ instagramUrl: data.instagramUrl || null,
+ twitterUrl: data.twitterUrl || null,
+ linkedinUrl: data.linkedinUrl || null,
+ maintenanceMode: data.maintenanceMode || false,
+ maintenanceMessage: data.maintenanceMessage || null,
+ maintenanceMessageEs: data.maintenanceMessageEs || null,
+ updatedAt: now,
+ updatedBy: user.id,
+ };
+
+ await (db as any).insert(siteSettings).values(newSettings);
+
+ return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
+ }
+
+ // Update existing settings
+ const updateData = {
+ ...data,
+ updatedAt: now,
+ updatedBy: user.id,
+ };
+
+ 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();
+
+ return c.json({ settings: updated, message: 'Settings updated successfully' });
+});
+
+export default siteSettingsRouter;
diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts
index 979567a..9eb41a7 100644
--- a/backend/src/routes/tickets.ts
+++ b/backend/src/routes/tickets.ts
@@ -7,6 +7,7 @@ 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';
const ticketsRouter = new Hono();
@@ -247,6 +248,70 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}, 201);
});
+// Download ticket as PDF
+ticketsRouter.get('/:id/pdf', async (c) => {
+ const id = c.req.param('id');
+ const user = await getAuthUser(c);
+
+ const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
+
+ if (!ticket) {
+ return c.json({ error: 'Ticket not found' }, 404);
+ }
+
+ // Check authorization - must be ticket owner or admin
+ if (user) {
+ const isAdmin = ['admin', 'organizer', 'staff'].includes(user.role);
+ const isOwner = user.id === ticket.userId;
+
+ if (!isAdmin && !isOwner) {
+ return c.json({ error: 'Unauthorized' }, 403);
+ }
+ } else {
+ // Allow unauthenticated access via ticket ID for email links
+ // The ticket ID itself serves as a secure token (UUID)
+ }
+
+ // Only generate PDF for confirmed or checked-in tickets
+ if (!['confirmed', 'checked_in'].includes(ticket.status)) {
+ return c.json({ error: 'Ticket is not confirmed' }, 400);
+ }
+
+ // Get event
+ const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get();
+
+ if (!event) {
+ return c.json({ error: 'Event not found' }, 404);
+ }
+
+ try {
+ const pdfBuffer = await generateTicketPDF({
+ 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,
+ },
+ });
+
+ // Set response headers for PDF download
+ return new Response(new Uint8Array(pdfBuffer), {
+ headers: {
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': `attachment; filename="spanglish-ticket-${ticket.qrCode}.pdf"`,
+ },
+ });
+ } catch (error: any) {
+ console.error('PDF generation error:', error);
+ return c.json({ error: 'Failed to generate PDF' }, 500);
+ }
+});
+
// Get ticket by ID
ticketsRouter.get('/:id', async (c) => {
const id = c.req.param('id');
@@ -301,9 +366,108 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
return c.json({ ticket: updated });
});
+// Validate ticket by QR code (for scanner)
+ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
+ const body = await c.req.json().catch(() => ({}));
+ const { code, eventId } = body;
+
+ if (!code) {
+ return c.json({ error: 'Code is required' }, 400);
+ }
+
+ // Try to find ticket by QR code or ID
+ let ticket = await (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)
+ .select()
+ .from(tickets)
+ .where(eq((tickets as any).id, code))
+ .get();
+ }
+
+ if (!ticket) {
+ return c.json({
+ valid: false,
+ error: 'Ticket not found',
+ status: 'invalid',
+ });
+ }
+
+ // If eventId is provided, verify the ticket is for that event
+ if (eventId && ticket.eventId !== eventId) {
+ return c.json({
+ valid: false,
+ error: 'Ticket is for a different event',
+ status: 'wrong_event',
+ });
+ }
+
+ // Get event details
+ const event = await (db as any)
+ .select()
+ .from(events)
+ .where(eq((events as any).id, ticket.eventId))
+ .get();
+
+ // Determine validity status
+ let validityStatus = 'invalid';
+ let canCheckIn = false;
+
+ if (ticket.status === 'cancelled') {
+ validityStatus = 'cancelled';
+ } else if (ticket.status === 'pending') {
+ validityStatus = 'pending_payment';
+ } else if (ticket.status === 'checked_in') {
+ validityStatus = 'already_checked_in';
+ } else if (ticket.status === 'confirmed') {
+ validityStatus = 'valid';
+ canCheckIn = true;
+ }
+
+ // Get admin who checked in (if applicable)
+ let checkedInBy = null;
+ if (ticket.checkedInByAdminId) {
+ const admin = await (db as any)
+ .select()
+ .from(users)
+ .where(eq((users as any).id, ticket.checkedInByAdminId))
+ .get();
+ checkedInBy = admin ? admin.name : null;
+ }
+
+ return c.json({
+ valid: validityStatus === 'valid',
+ status: validityStatus,
+ canCheckIn,
+ ticket: {
+ id: ticket.id,
+ qrCode: ticket.qrCode,
+ attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
+ attendeeEmail: ticket.attendeeEmail,
+ attendeePhone: ticket.attendeePhone,
+ status: ticket.status,
+ checkinAt: ticket.checkinAt,
+ checkedInBy,
+ },
+ event: event ? {
+ id: event.id,
+ title: event.title,
+ startDatetime: event.startDatetime,
+ location: event.location,
+ } : null,
+ });
+});
+
// Check-in ticket
ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
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();
@@ -319,14 +483,33 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
return c.json({ error: 'Ticket must be confirmed before check-in' }, 400);
}
+ const now = getNow();
+
await (db as any)
.update(tickets)
- .set({ status: 'checked_in', checkinAt: getNow() })
+ .set({
+ status: 'checked_in',
+ checkinAt: now,
+ checkedInByAdminId: adminUser?.id || 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();
- return c.json({ ticket: updated, message: 'Check-in successful' });
+ // Get event for response
+ const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get();
+
+ return c.json({
+ ticket: {
+ ...updated,
+ attendeeName: `${updated.attendeeFirstName} ${updated.attendeeLastName || ''}`.trim(),
+ },
+ event: event ? {
+ id: event.id,
+ title: event.title,
+ } : null,
+ message: 'Check-in successful'
+ });
});
// Mark payment as received (for cash payments - admin only)
diff --git a/frontend/package.json b/frontend/package.json
index 3499cd3..9e942c9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@heroicons/react": "^2.1.4",
"clsx": "^2.1.1",
+ "html5-qrcode": "^2.3.8",
"next": "^14.2.4",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx
index 8566db5..e66d20a 100644
--- a/frontend/src/app/(public)/book/[eventId]/page.tsx
+++ b/frontend/src/app/(public)/book/[eventId]/page.tsx
@@ -913,10 +913,12 @@ export default function BookingPage() {
{event.location}
-
-
- {event.availableSeats} {t('events.details.spotsLeft')}
-
+ {!event.externalBookingEnabled && (
+
+
+ {event.availableSeats} {t('events.details.spotsLeft')}
+
+ )}
diff --git a/frontend/src/app/(public)/booking/success/[ticketId]/page.tsx b/frontend/src/app/(public)/booking/success/[ticketId]/page.tsx
index c97d55e..d121984 100644
--- a/frontend/src/app/(public)/booking/success/[ticketId]/page.tsx
+++ b/frontend/src/app/(public)/booking/success/[ticketId]/page.tsx
@@ -13,6 +13,7 @@ import {
XCircleIcon,
TicketIcon,
ArrowPathIcon,
+ ArrowDownTrayIcon,
} from '@heroicons/react/24/outline';
export default function BookingSuccessPage() {
@@ -224,6 +225,20 @@ export default function BookingSuccessPage() {
)}
+ {/* Download Ticket Button */}
+ {isPaid && (
+
+ )}
+
{/* Actions */}
diff --git a/frontend/src/app/(public)/community/page.tsx b/frontend/src/app/(public)/community/page.tsx
index 710502f..ff71c5f 100644
--- a/frontend/src/app/(public)/community/page.tsx
+++ b/frontend/src/app/(public)/community/page.tsx
@@ -41,14 +41,14 @@ export default function CommunityPage() {
diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx
index aac1108..9936472 100644
--- a/frontend/src/app/admin/events/page.tsx
+++ b/frontend/src/app/admin/events/page.tsx
@@ -25,6 +25,8 @@ export default function AdminEventsPage() {
titleEs: string;
description: string;
descriptionEs: string;
+ shortDescription: string;
+ shortDescriptionEs: string;
startDatetime: string;
endDatetime: string;
location: string;
@@ -41,6 +43,8 @@ export default function AdminEventsPage() {
titleEs: '',
description: '',
descriptionEs: '',
+ shortDescription: '',
+ shortDescriptionEs: '',
startDatetime: '',
endDatetime: '',
location: '',
@@ -75,6 +79,8 @@ export default function AdminEventsPage() {
titleEs: '',
description: '',
descriptionEs: '',
+ shortDescription: '',
+ shortDescriptionEs: '',
startDatetime: '',
endDatetime: '',
location: '',
@@ -90,14 +96,27 @@ export default function AdminEventsPage() {
setEditingEvent(null);
};
+ // Convert ISO UTC string to local datetime-local format (YYYY-MM-DDTHH:MM)
+ const isoToLocalDatetime = (isoString: string): string => {
+ const date = new Date(isoString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ return `${year}-${month}-${day}T${hours}:${minutes}`;
+ };
+
const handleEdit = (event: Event) => {
setFormData({
title: event.title,
titleEs: event.titleEs || '',
description: event.description,
descriptionEs: event.descriptionEs || '',
- startDatetime: event.startDatetime.slice(0, 16),
- endDatetime: event.endDatetime?.slice(0, 16) || '',
+ shortDescription: event.shortDescription || '',
+ shortDescriptionEs: event.shortDescriptionEs || '',
+ startDatetime: isoToLocalDatetime(event.startDatetime),
+ endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
location: event.location,
locationUrl: event.locationUrl || '',
price: event.price,
@@ -134,6 +153,8 @@ export default function AdminEventsPage() {
titleEs: formData.titleEs || undefined,
description: formData.description,
descriptionEs: formData.descriptionEs || undefined,
+ shortDescription: formData.shortDescription || undefined,
+ shortDescriptionEs: formData.shortDescriptionEs || undefined,
startDatetime: new Date(formData.startDatetime).toISOString(),
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
location: formData.location,
@@ -288,6 +309,33 @@ export default function AdminEventsPage() {
rows={3}
/>
+
+
+
+
Short Description (English)
+
+
+
Short Description (Spanish)
+
+
{
diff --git a/frontend/src/app/admin/scanner/page.tsx b/frontend/src/app/admin/scanner/page.tsx
new file mode 100644
index 0000000..128eae3
--- /dev/null
+++ b/frontend/src/app/admin/scanner/page.tsx
@@ -0,0 +1,562 @@
+'use client';
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useLanguage } from '@/context/LanguageContext';
+import { ticketsApi, eventsApi, Event, TicketValidationResult } from '@/lib/api';
+import Card from '@/components/ui/Card';
+import Button from '@/components/ui/Button';
+import Input from '@/components/ui/Input';
+import {
+ QrCodeIcon,
+ CheckCircleIcon,
+ XCircleIcon,
+ ExclamationTriangleIcon,
+ MagnifyingGlassIcon,
+ VideoCameraIcon,
+ VideoCameraSlashIcon,
+ ArrowPathIcon,
+ ClockIcon,
+ UserIcon,
+ XMarkIcon,
+} from '@heroicons/react/24/outline';
+import toast from 'react-hot-toast';
+import clsx from 'clsx';
+
+type ScanState = 'idle' | 'scanning' | 'success' | 'error' | 'already_checked_in' | 'pending';
+
+interface ScanResult {
+ state: ScanState;
+ validation?: TicketValidationResult;
+ error?: string;
+}
+
+// Scanner component that manages its own DOM
+function QRScanner({
+ onScan,
+ isActive,
+ onActiveChange
+}: {
+ onScan: (code: string) => void;
+ isActive: boolean;
+ onActiveChange: (active: boolean) => void;
+}) {
+ const containerRef = useRef
(null);
+ const scannerRef = useRef(null);
+ const scannerElementId = useRef(`qr-scanner-${Math.random().toString(36).substr(2, 9)}`);
+
+ // Create scanner element on mount
+ useEffect(() => {
+ if (containerRef.current && !document.getElementById(scannerElementId.current)) {
+ const scannerDiv = document.createElement('div');
+ scannerDiv.id = scannerElementId.current;
+ scannerDiv.style.width = '100%';
+ containerRef.current.appendChild(scannerDiv);
+ }
+
+ return () => {
+ // Cleanup scanner on unmount
+ if (scannerRef.current) {
+ try {
+ scannerRef.current.stop().catch(() => {});
+ } catch (e) {
+ // Ignore
+ }
+ scannerRef.current = null;
+ }
+ };
+ }, []);
+
+ // Handle scanner start/stop
+ useEffect(() => {
+ let cancelled = false;
+
+ const startScanner = async () => {
+ const elementId = scannerElementId.current;
+ const element = document.getElementById(elementId);
+ if (!element) return;
+
+ try {
+ const { Html5Qrcode } = await import('html5-qrcode');
+
+ if (cancelled) return;
+
+ // Stop existing scanner
+ if (scannerRef.current) {
+ try {
+ await scannerRef.current.stop();
+ } catch (e) {
+ // Ignore
+ }
+ scannerRef.current = null;
+ }
+
+ if (cancelled) return;
+
+ const scanner = new Html5Qrcode(elementId);
+ scannerRef.current = scanner;
+
+ await scanner.start(
+ { facingMode: 'environment' },
+ {
+ fps: 10,
+ qrbox: { width: 250, height: 250 },
+ aspectRatio: 1,
+ },
+ (decodedText: string) => {
+ onScan(decodedText);
+ },
+ () => {
+ // QR parsing error - ignore
+ }
+ );
+ } catch (error: any) {
+ console.error('Scanner error:', error);
+ if (!cancelled) {
+ toast.error('Failed to start camera. Please check camera permissions.');
+ onActiveChange(false);
+ }
+ }
+ };
+
+ const stopScanner = async () => {
+ if (scannerRef.current) {
+ try {
+ await scannerRef.current.stop();
+ } catch (e) {
+ // Ignore
+ }
+ scannerRef.current = null;
+ }
+ };
+
+ if (isActive) {
+ startScanner();
+ } else {
+ stopScanner();
+ }
+
+ return () => {
+ cancelled = true;
+ };
+ }, [isActive, onScan, onActiveChange]);
+
+ return (
+
+
+ {!isActive && (
+
+
+
+
Click "Start Camera" to begin scanning
+
+
+ )}
+
+ );
+}
+
+// Scan Result Modal
+function ScanResultModal({
+ scanResult,
+ onCheckin,
+ onClose,
+ checkingIn,
+ formatDateTime,
+}: {
+ scanResult: ScanResult;
+ onCheckin: () => void;
+ onClose: () => void;
+ checkingIn: boolean;
+ formatDateTime: (dateStr: string) => string;
+}) {
+ if (scanResult.state === 'idle') return null;
+
+ const isSuccess = scanResult.state === 'success';
+ const isAlreadyCheckedIn = scanResult.state === 'already_checked_in';
+ const isPending = scanResult.state === 'pending';
+ const isError = scanResult.state === 'error';
+
+ // Determine colors based on state
+ const bgColor = isSuccess ? 'bg-green-500' : isAlreadyCheckedIn ? 'bg-yellow-500' : isPending ? 'bg-orange-500' : 'bg-red-500';
+ const bgColorLight = isSuccess ? 'bg-green-50' : isAlreadyCheckedIn ? 'bg-yellow-50' : isPending ? 'bg-orange-50' : 'bg-red-50';
+ const borderColor = isSuccess ? 'border-green-500' : isAlreadyCheckedIn ? 'border-yellow-500' : isPending ? 'border-orange-500' : 'border-red-500';
+ const textColor = isSuccess ? 'text-green-800' : isAlreadyCheckedIn ? 'text-yellow-800' : isPending ? 'text-orange-800' : 'text-red-800';
+ const textColorLight = isSuccess ? 'text-green-600' : isAlreadyCheckedIn ? 'text-yellow-600' : isPending ? 'text-orange-600' : 'text-red-600';
+ const iconBg = isSuccess ? 'bg-green-100' : isAlreadyCheckedIn ? 'bg-yellow-100' : isPending ? 'bg-orange-100' : 'bg-red-100';
+ const iconColor = isSuccess ? 'text-green-600' : isAlreadyCheckedIn ? 'text-yellow-600' : isPending ? 'text-orange-600' : 'text-red-600';
+
+ const StatusIcon = isSuccess ? CheckCircleIcon : isAlreadyCheckedIn ? ExclamationTriangleIcon : isPending ? ClockIcon : XCircleIcon;
+ const statusTitle = isSuccess ? 'Valid Ticket' : isAlreadyCheckedIn ? 'Already Checked In' : isPending ? 'Payment Pending' : 'Invalid Ticket';
+ const statusSubtitle = isSuccess ? 'Ready for check-in' :
+ isAlreadyCheckedIn ? (
+ scanResult.validation?.ticket?.checkinAt
+ ? `Checked in at ${new Date(scanResult.validation.ticket.checkinAt).toLocaleTimeString()}${scanResult.validation?.ticket?.checkedInBy ? ` by ${scanResult.validation.ticket.checkedInBy}` : ''}`
+ : 'This ticket was already used'
+ ) :
+ isPending ? 'Ticket not yet confirmed' :
+ (scanResult.validation?.error || scanResult.error || 'Ticket not found or cancelled');
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+ {/* Close button */}
+
+
+
+
+ {/* Header with color band */}
+
+
+
+ {/* Status Icon & Title */}
+
+
+
+
+
+
{statusTitle}
+
{statusSubtitle}
+
+
+
+ {/* Ticket Details */}
+ {scanResult.validation?.ticket && (
+
+
+
+
+
+
+
+ {scanResult.validation.ticket.attendeeName}
+
+ {scanResult.validation.ticket.attendeeEmail && (
+
+ {scanResult.validation.ticket.attendeeEmail}
+
+ )}
+
+
+
+ {scanResult.validation.event && (
+
+
{scanResult.validation.event.title}
+
{formatDateTime(scanResult.validation.event.startDatetime)}
+
{scanResult.validation.event.location}
+
+ )}
+
+
+ Ticket ID: {scanResult.validation.ticket.id.slice(0, 8)}...
+
+
+ )}
+
+ {/* Actions */}
+
+ {isSuccess && scanResult.validation?.canCheckIn && (
+
+
+ Check In
+
+ )}
+
+
+ Scan Next
+
+
+
+
+
+ );
+}
+
+export default function AdminScannerPage() {
+ const { locale } = useLanguage();
+ const [events, setEvents] = useState([]);
+ const [selectedEventId, setSelectedEventId] = useState('');
+ const [loading, setLoading] = useState(true);
+
+ // Scanner state
+ const [cameraActive, setCameraActive] = useState(false);
+ const [scanResult, setScanResult] = useState({ state: 'idle' });
+ const [lastScannedCode, setLastScannedCode] = useState('');
+ const [checkingIn, setCheckingIn] = useState(false);
+
+ // Manual search
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searching, setSearching] = useState(false);
+
+ // Stats
+ const [checkinCount, setCheckinCount] = useState(0);
+ const [recentCheckins, setRecentCheckins] = useState>([]);
+
+ // Refs for callbacks
+ const selectedEventIdRef = useRef('');
+ const lastScannedCodeRef = useRef('');
+
+ // Keep refs in sync
+ useEffect(() => {
+ selectedEventIdRef.current = selectedEventId;
+ }, [selectedEventId]);
+
+ useEffect(() => {
+ lastScannedCodeRef.current = lastScannedCode;
+ }, [lastScannedCode]);
+
+ // Load events
+ useEffect(() => {
+ eventsApi.getAll({ status: 'published' })
+ .then(res => {
+ setEvents(res.events);
+ const upcoming = res.events.filter(e => new Date(e.startDatetime) >= new Date());
+ if (upcoming.length === 1) {
+ setSelectedEventId(upcoming[0].id);
+ }
+ })
+ .catch(console.error)
+ .finally(() => setLoading(false));
+ }, []);
+
+ // Validate ticket
+ const validateTicket = useCallback(async (code: string) => {
+ try {
+ const result = await ticketsApi.validate(code, selectedEventIdRef.current || undefined);
+
+ let state: ScanState = 'idle';
+ if (result.status === 'valid') {
+ state = 'success';
+ } else if (result.status === 'already_checked_in') {
+ state = 'already_checked_in';
+ } else if (result.status === 'pending_payment') {
+ state = 'pending';
+ } else {
+ state = 'error';
+ }
+
+ setScanResult({ state, validation: result });
+ } catch (error: any) {
+ setScanResult({
+ state: 'error',
+ error: error.message || 'Failed to validate ticket'
+ });
+ }
+ }, []);
+
+ // Handle QR scan
+ const handleScan = useCallback((decodedText: string) => {
+ // Avoid duplicate scans
+ if (decodedText === lastScannedCodeRef.current) return;
+ lastScannedCodeRef.current = decodedText;
+ setLastScannedCode(decodedText);
+
+ // Extract ticket ID from URL if present
+ let code = decodedText;
+ const urlMatch = decodedText.match(/\/ticket\/([a-zA-Z0-9-_]+)/);
+ if (urlMatch) {
+ code = urlMatch[1];
+ }
+
+ validateTicket(code);
+ }, [validateTicket]);
+
+ // Check in ticket
+ const handleCheckin = async () => {
+ if (!scanResult.validation?.ticket?.id) return;
+
+ setCheckingIn(true);
+ try {
+ const result = await ticketsApi.checkin(scanResult.validation.ticket.id);
+
+ toast.success(`${result.ticket.attendeeName || 'Guest'} checked in!`);
+
+ setCheckinCount(prev => prev + 1);
+ setRecentCheckins(prev => [
+ {
+ name: result.ticket.attendeeName || 'Guest',
+ time: new Date().toLocaleTimeString()
+ },
+ ...prev.slice(0, 4),
+ ]);
+
+ handleCloseModal();
+ } catch (error: any) {
+ toast.error(error.message || 'Check-in failed');
+ } finally {
+ setCheckingIn(false);
+ }
+ };
+
+ // Close modal and reset
+ const handleCloseModal = () => {
+ setScanResult({ state: 'idle' });
+ setLastScannedCode('');
+ lastScannedCodeRef.current = '';
+ };
+
+ // Manual search
+ const handleSearch = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!searchQuery.trim()) return;
+
+ setSearching(true);
+ setScanResult({ state: 'idle' });
+
+ await validateTicket(searchQuery.trim());
+ setSearching(false);
+ setSearchQuery('');
+ };
+
+ // Format datetime
+ const formatDateTime = (dateStr: string) => {
+ return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Ticket Scanner
+
+
Scan QR codes to check in attendees
+
+
+ {/* Event Selector */}
+
+ Select Event (optional)
+ setSelectedEventId(e.target.value)}
+ className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
+ >
+ All Events
+ {events.map((event) => (
+
+ {event.title} - {formatDateTime(event.startDatetime)}
+
+ ))}
+
+
+
+ {/* Scanner Area */}
+
+
+
Camera Scanner
+ setCameraActive(!cameraActive)}
+ >
+ {cameraActive ? (
+ <>
+
+ Stop Camera
+ >
+ ) : (
+ <>
+
+ Start Camera
+ >
+ )}
+
+
+
+
+
+
+ {/* Manual Search */}
+
+ Manual Search
+
+
+
+ {/* Stats */}
+
+
+ {checkinCount}
+ Checked in this session
+
+
+
+ Recent Check-ins
+ {recentCheckins.length === 0 ? (
+ No check-ins yet
+ ) : (
+
+ {recentCheckins.map((checkin, i) => (
+
+ {checkin.name}
+ {checkin.time}
+
+ ))}
+
+ )}
+
+
+
+ {/* Scan Result Modal */}
+
+
+ );
+}
diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx
new file mode 100644
index 0000000..9bbfed7
--- /dev/null
+++ b/frontend/src/app/admin/settings/page.tsx
@@ -0,0 +1,376 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useLanguage } from '@/context/LanguageContext';
+import { siteSettingsApi, SiteSettings, TimezoneOption } from '@/lib/api';
+import Card from '@/components/ui/Card';
+import Button from '@/components/ui/Button';
+import Input from '@/components/ui/Input';
+import {
+ Cog6ToothIcon,
+ GlobeAltIcon,
+ ClockIcon,
+ EnvelopeIcon,
+ WrenchScrewdriverIcon,
+ CheckCircleIcon,
+} from '@heroicons/react/24/outline';
+import toast from 'react-hot-toast';
+
+export default function AdminSettingsPage() {
+ const { t, locale } = useLanguage();
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [timezones, setTimezones] = useState([]);
+
+ const [settings, setSettings] = useState({
+ timezone: 'America/Asuncion',
+ siteName: 'Spanglish',
+ siteDescription: null,
+ siteDescriptionEs: null,
+ contactEmail: null,
+ contactPhone: null,
+ facebookUrl: null,
+ instagramUrl: null,
+ twitterUrl: null,
+ linkedinUrl: null,
+ maintenanceMode: false,
+ maintenanceMessage: null,
+ maintenanceMessageEs: null,
+ });
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ const loadData = async () => {
+ try {
+ const [settingsRes, timezonesRes] = await Promise.all([
+ siteSettingsApi.get(),
+ siteSettingsApi.getTimezones(),
+ ]);
+ setSettings(settingsRes.settings);
+ setTimezones(timezonesRes.timezones);
+ } catch (error) {
+ toast.error('Failed to load settings');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ const response = await siteSettingsApi.update(settings);
+ setSettings(response.settings);
+ toast.success(locale === 'es' ? 'Configuración guardada' : 'Settings saved');
+ } catch (error: any) {
+ toast.error(error.message || 'Failed to save settings');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const updateSetting = (key: K, value: SiteSettings[K]) => {
+ setSettings((prev) => ({ ...prev, [key]: value }));
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {locale === 'es' ? 'Configuración del Sitio' : 'Site Settings'}
+
+
+ {locale === 'es'
+ ? 'Configura las opciones generales del sitio web'
+ : 'Configure general website settings'}
+
+
+
+
+ {locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
+
+
+
+
+ {/* Timezone Settings */}
+
+
+
+
+
+
+
+
+ {locale === 'es' ? 'Zona Horaria' : 'Timezone'}
+
+
+ {locale === 'es'
+ ? 'Zona horaria para mostrar las fechas de eventos'
+ : 'Timezone used for displaying event dates'}
+
+
+
+
+
+
+ {locale === 'es' ? 'Zona Horaria del Sitio' : 'Site Timezone'}
+
+
updateSetting('timezone', e.target.value)}
+ className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
+ >
+ {timezones.map((tz) => (
+
+ {tz.label}
+
+ ))}
+
+
+ {locale === 'es'
+ ? 'Esta zona horaria se usará como referencia para las fechas de eventos.'
+ : 'This timezone will be used as reference for event dates.'}
+
+
+
+
+
+ {/* Site Information */}
+
+
+
+
+
+
+
+
+ {locale === 'es' ? 'Información del Sitio' : 'Site Information'}
+
+
+ {locale === 'es'
+ ? 'Información básica del sitio web'
+ : 'Basic website information'}
+
+
+
+
+
+
+ updateSetting('siteName', e.target.value)}
+ placeholder="Spanglish"
+ />
+
+
+
+
+
+ {locale === 'es' ? 'Descripción (Inglés)' : 'Description (English)'}
+
+
+
+
+ {locale === 'es' ? 'Descripción (Español)' : 'Description (Spanish)'}
+
+
+
+
+
+
+
+ {/* Contact Information */}
+
+
+
+
+
+
+
+
+ {locale === 'es' ? 'Información de Contacto' : 'Contact Information'}
+
+
+ {locale === 'es'
+ ? 'Datos de contacto del sitio'
+ : 'Site contact details'}
+
+
+
+
+
+ updateSetting('contactEmail', e.target.value || null)}
+ placeholder="contact@example.com"
+ />
+ updateSetting('contactPhone', e.target.value || null)}
+ placeholder="+595 981 123456"
+ />
+
+
+
+
+
+
+ {/* Maintenance Mode */}
+
+
+
+
+
+
+
+
+ {locale === 'es' ? 'Modo de Mantenimiento' : 'Maintenance Mode'}
+
+
+ {locale === 'es'
+ ? 'Habilita el modo de mantenimiento cuando necesites hacer cambios'
+ : 'Enable maintenance mode when you need to make changes'}
+
+
+
+
+
+
+
+ {locale === 'es' ? 'Modo de Mantenimiento' : 'Maintenance Mode'}
+
+
+ {settings.maintenanceMode
+ ? (locale === 'es' ? 'El sitio está en mantenimiento' : 'The site is under maintenance')
+ : (locale === 'es' ? 'El sitio está activo' : 'The site is live')}
+
+
+
updateSetting('maintenanceMode', !settings.maintenanceMode)}
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
+ settings.maintenanceMode ? 'bg-orange-500' : 'bg-gray-300'
+ }`}
+ >
+
+
+
+
+ {settings.maintenanceMode && (
+
+
+
+ {locale === 'es'
+ ? '¡Advertencia! El modo de mantenimiento está activo.'
+ : 'Warning! Maintenance mode is active.'}
+
+
+ )}
+
+
+
+
+ {locale === 'es' ? 'Mensaje de Mantenimiento (Inglés)' : 'Maintenance Message (English)'}
+
+
+
+
+ {locale === 'es' ? 'Mensaje de Mantenimiento (Español)' : 'Maintenance Message (Spanish)'}
+
+
+
+
+
+
+ {/* Save Button at Bottom */}
+
+
+
+ {locale === 'es' ? 'Guardar Todos los Cambios' : 'Save All Changes'}
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/linktree/page.tsx b/frontend/src/app/linktree/page.tsx
index 5a53921..c87a0cd 100644
--- a/frontend/src/app/linktree/page.tsx
+++ b/frontend/src/app/linktree/page.tsx
@@ -57,7 +57,7 @@ export default function LinktreePage() {
: null;
return (
-
+
{/* Profile Header */}
@@ -102,9 +102,11 @@ export default function LinktreePage() {
? t('events.details.free')
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
-
- {nextEvent.availableSeats} {t('events.details.spotsLeft')}
-
+ {!nextEvent.externalBookingEnabled && (
+
+ {nextEvent.availableSeats} {t('events.details.spotsLeft')}
+
+ )}
diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx
index db38e15..8ae6d6f 100644
--- a/frontend/src/components/layout/Footer.tsx
+++ b/frontend/src/components/layout/Footer.tsx
@@ -31,13 +31,9 @@ export default function Footer() {
className="h-10 w-auto"
/>
-
+
{t('footer.tagline')}
- {/* Local SEO text */}
-
- Language Exchange Events in Asunción, Paraguay
-
{/* Quick Links */}
@@ -84,7 +80,7 @@ export default function Footer() {
{/* Social */}
{socialLinks.length > 0 && (
-
+
{t('footer.social')}
@@ -112,13 +108,14 @@ export default function Footer() {
{locale === 'es' ? link.es : link.en}
))}
-
+
{t('footer.copyright', { year: currentYear })}
diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx
index d550916..a0c1c65 100644
--- a/frontend/src/components/layout/Header.tsx
+++ b/frontend/src/components/layout/Header.tsx
@@ -3,6 +3,7 @@
import Link from 'next/link';
import Image from 'next/image';
import { useState } from 'react';
+import { usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import LanguageToggle from '@/components/LanguageToggle';
@@ -10,6 +11,37 @@ 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();
+ const isActive = pathname === href || (href !== '/' && pathname.startsWith(href));
+
+ return (
+
+ {children}
+
+ );
+}
+
+function MobileNavLink({ href, children, onClick }: { href: string; children: React.ReactNode; onClick: () => void }) {
+ const pathname = usePathname();
+ const isActive = pathname === href || (href !== '/' && pathname.startsWith(href));
+
+ return (
+
+ {children}
+
+ );
+}
+
export default function Header() {
const { t } = useLanguage();
const { user, isAdmin, logout } = useAuth();
@@ -41,13 +73,9 @@ export default function Header() {
{/* Desktop Navigation */}
{navLinks.map((link) => (
-
+
{link.label}
-
+
))}
@@ -115,14 +143,13 @@ export default function Header() {
>
{navLinks.map((link) => (
-
setMobileMenuOpen(false)}
>
{link.label}
-
+
))}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 41801ed..89ab2df 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -86,8 +86,15 @@ export const ticketsApi = {
return fetchApi<{ tickets: Ticket[] }>(`/api/tickets?${query}`);
},
+ // Validate ticket by QR code (for scanner)
+ validate: (code: string, eventId?: string) =>
+ fetchApi('/api/tickets/validate', {
+ method: 'POST',
+ body: JSON.stringify({ code, eventId }),
+ }),
+
checkin: (id: string) =>
- fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/checkin`, {
+ fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
method: 'POST',
}),
@@ -141,6 +148,9 @@ export const ticketsApi = {
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
`/api/lnbits/status/${ticketId}`
),
+
+ // Get PDF download URL (returns the URL, not the PDF itself)
+ getPdfUrl: (id: string) => `${API_BASE}/api/tickets/${id}/pdf`,
};
// Contacts API
@@ -413,6 +423,8 @@ export interface Event {
titleEs?: string;
description: string;
descriptionEs?: string;
+ shortDescription?: string;
+ shortDescriptionEs?: string;
startDatetime: string;
endDatetime?: string;
location: string;
@@ -441,6 +453,7 @@ export interface Ticket {
preferredLanguage?: string;
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
checkinAt?: string;
+ checkedInByAdminId?: string;
qrCode: string;
adminNote?: string;
createdAt: string;
@@ -449,6 +462,29 @@ export interface Ticket {
user?: User;
}
+export interface TicketValidationResult {
+ valid: boolean;
+ status: 'valid' | 'already_checked_in' | 'pending_payment' | 'cancelled' | 'invalid' | 'wrong_event';
+ canCheckIn: boolean;
+ ticket?: {
+ id: string;
+ qrCode: string;
+ attendeeName: string;
+ attendeeEmail?: string;
+ attendeePhone?: string;
+ status: string;
+ checkinAt?: string;
+ checkedInBy?: string;
+ };
+ event?: {
+ id: string;
+ title: string;
+ startDatetime: string;
+ location: string;
+ };
+ error?: string;
+}
+
export interface Payment {
id: string;
ticketId: string;
@@ -892,3 +928,42 @@ export const dashboardApi = {
unlinkGoogle: () =>
fetchApi<{ message: string }>('/api/dashboard/unlink-google', { method: 'POST' }),
};
+
+// ==================== Site Settings API ====================
+
+export interface SiteSettings {
+ id?: string;
+ timezone: string;
+ siteName: string;
+ siteDescription?: string | null;
+ siteDescriptionEs?: string | null;
+ contactEmail?: string | null;
+ contactPhone?: string | null;
+ facebookUrl?: string | null;
+ instagramUrl?: string | null;
+ twitterUrl?: string | null;
+ linkedinUrl?: string | null;
+ maintenanceMode: boolean;
+ maintenanceMessage?: string | null;
+ maintenanceMessageEs?: string | null;
+ updatedAt?: string;
+ updatedBy?: string;
+}
+
+export interface TimezoneOption {
+ value: string;
+ label: string;
+}
+
+export const siteSettingsApi = {
+ get: () => fetchApi<{ settings: SiteSettings }>('/api/site-settings'),
+
+ update: (data: Partial) =>
+ fetchApi<{ settings: SiteSettings; message: string }>('/api/site-settings', {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
+
+ getTimezones: () =>
+ fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'),
+};