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:

- Check-in QR Code -
+ {{#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:

- Código QR de 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() {
{/* WhatsApp Card */} {whatsappUrl && ( - +

{t('community.whatsapp.title')}

-

{t('community.whatsapp.description')}

+

{t('community.whatsapp.description')}

+

{t('community.telegram.title')}

-

{t('community.telegram.description')}

+

{t('community.telegram.description')}

+

{t('community.instagram.title')}

-

{t('community.instagram.description')}

+

{t('community.instagram.description')}

- @@ -108,14 +108,14 @@ export default function CommunityPage() { {/* TikTok Card */} {tiktokUrl && ( - +

{t('community.tiktok.title')}

-

{t('community.tiktok.description')}

+

{t('community.tiktok.description')}

-

+

{t('home.hero.title')}

-

+

{t('home.hero.subtitle')}

diff --git a/frontend/src/app/(public)/components/NewsletterSection.tsx b/frontend/src/app/(public)/components/NewsletterSection.tsx index e56fe7a..1b25008 100644 --- a/frontend/src/app/(public)/components/NewsletterSection.tsx +++ b/frontend/src/app/(public)/components/NewsletterSection.tsx @@ -8,7 +8,7 @@ export default function NewsletterSection() { const { t } = useLanguage(); return ( -
+
diff --git a/frontend/src/app/(public)/components/NextEventSection.tsx b/frontend/src/app/(public)/components/NextEventSection.tsx index b0999bb..c24989c 100644 --- a/frontend/src/app/(public)/components/NextEventSection.tsx +++ b/frontend/src/app/(public)/components/NextEventSection.tsx @@ -62,10 +62,10 @@ export default function NextEventSection() {

{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}

-

- {locale === 'es' && nextEvent.descriptionEs - ? nextEvent.descriptionEs - : nextEvent.description} +

+ {locale === 'es' + ? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description) + : (nextEvent.shortDescription || nextEvent.description)}

@@ -93,9 +93,11 @@ export default function NextEventSection() { ? t('events.details.free') : `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`} -

- {nextEvent.availableSeats} {t('events.details.spotsLeft')} -

+ {!nextEvent.externalBookingEnabled && ( +

+ {nextEvent.availableSeats} {t('events.details.spotsLeft')} +

+ )}
@@ -142,15 +145,17 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
-
- -
-

{t('events.details.capacity')}

-

- {event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} -

+ {!event.externalBookingEnabled && ( +
+ +
+

{t('events.details.capacity')}

+

+ {event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} +

+
-
+ )}
@@ -213,9 +218,11 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail )} -

- {event.availableSeats} {t('events.details.spotsLeft')} -

+ {!event.externalBookingEnabled && ( +

+ {event.availableSeats} {t('events.details.spotsLeft')} +

+ )}
diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index 33e6517..8cb8cec 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -11,6 +11,8 @@ interface Event { titleEs?: string; description: string; descriptionEs?: string; + shortDescription?: string; + shortDescriptionEs?: string; startDatetime: string; endDatetime?: string; location: string; @@ -47,10 +49,12 @@ export async function generateMetadata({ params }: { params: { id: string } }): } const title = event.title; - // Use the beginning of the event description, truncated to ~155 chars for SEO - const description = event.description.length > 155 - ? event.description.slice(0, 152).trim() + '...' - : event.description; + // Use short description if available, otherwise fall back to truncated full description + const description = event.shortDescription + ? event.shortDescription + : (event.description.length > 155 + ? event.description.slice(0, 152).trim() + '...' + : event.description); // Convert relative banner URL to absolute URL for SEO const imageUrl = event.bannerUrl diff --git a/frontend/src/app/(public)/events/page.tsx b/frontend/src/app/(public)/events/page.tsx index 275df9a..4f7910e 100644 --- a/frontend/src/app/(public)/events/page.tsx +++ b/frontend/src/app/(public)/events/page.tsx @@ -135,12 +135,14 @@ export default function EventsPage() { {event.location}
-
- - - {event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} - -
+ {!event.externalBookingEnabled && ( +
+ + + {event.availableSeats} / {event.capacity} {t('events.details.spotsLeft')} + +
+ )}
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} />
+ +
+
+ +