diff --git a/backend/assets/logo-spanglish.png b/backend/assets/logo-spanglish.png new file mode 100644 index 0000000..e2865c0 Binary files /dev/null and b/backend/assets/logo-spanglish.png differ diff --git a/backend/src/lib/pdf.ts b/backend/src/lib/pdf.ts index b25f4c4..153afe2 100644 --- a/backend/src/lib/pdf.ts +++ b/backend/src/lib/pdf.ts @@ -1,6 +1,8 @@ // PDF Ticket Generation Service import PDFDocument from 'pdfkit'; import QRCode from 'qrcode'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; interface TicketData { id: string; @@ -15,235 +17,390 @@ interface TicketData { locationUrl?: string; }; timezone?: string; + /** 'en' | 'es' - drives the labels and the date/time format on the ticket */ + locale?: string; + /** Optional perk line shown under the ticket holder (falls back to the terms line) */ + note?: string; +} + +// ==================== Brand ==================== + +const COLORS = { + navy: '#002F44', + orange: '#F5821F', + cream: '#FDF8F0', + card: '#FFFFFF', + cardBorder: '#EFE6D8', + divider: '#E7DFD1', + label: '#9AA3AC', + muted: '#6B7580', + footerMuted: '#7FA3B5', +}; + +const PAGE_W = 595.28; +const PAGE_H = 841.89; +const MARGIN = 48; +const CONTENT_W = PAGE_W - MARGIN * 2; +const ACCENT_H = 10; +const FOOTER_H = 48; + +const LOGO_RATIO = 1158 / 324; + +const STRINGS = { + en: { + scan: 'SCAN AT THE ENTRANCE', + venue: 'VENUE', + holder: 'TICKET HOLDER', + terms: 'This ticket is non-transferable. One scan per entry.', + }, + es: { + scan: 'ESCANEÁ AL INGRESAR', + venue: 'LUGAR', + holder: 'TITULAR', + terms: 'Esta entrada es personal e intransferible. Un escaneo por ingreso.', + }, +} as const; + +function strings(locale?: string) { + return locale === 'es' ? STRINGS.es : STRINGS.en; } /** - * Generate a QR code as a data URL + * Locate the logo. `../../assets` resolves to backend/assets from both + * src/lib (tsx) and dist/lib (compiled), with the frontend copy as a fallback. + */ +function loadLogo(): Buffer | null { + const candidates = [ + new URL('../../assets/logo-spanglish.png', import.meta.url), + new URL('../../../frontend/public/images/logo-spanglish.png', import.meta.url), + ].map((u) => fileURLToPath(u)); + + for (const path of candidates) { + if (existsSync(path)) return readFileSync(path); + } + return null; +} + +let logoCache: Buffer | null | undefined; +function getLogo(): Buffer | null { + if (logoCache === undefined) logoCache = loadLogo(); + return logoCache; +} + +/** + * Generate a QR code as a PNG buffer */ async function generateQRCode(data: string): Promise { return QRCode.toBuffer(data, { type: 'png', - width: 200, - margin: 2, + width: 600, + margin: 1, errorCorrectionLevel: 'M', + color: { dark: '#000000', light: '#FFFFFF' }, }); } /** - * Format date for display using site timezone + * Short date + time as shown in the ticket header: + * en -> "JUL 25 · 4:30 PM" es -> "25 JUL · 16:30" */ -function formatDate(dateStr: string, timezone: string = 'America/Asuncion'): string { - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - timeZone: timezone, - }); +function formatWhen( + startStr: string, + endStr: string | undefined, + timezone: string, + locale: string +): string { + const isEs = locale === 'es'; + const start = new Date(startStr); + const tag = isEs ? 'es-ES' : 'en-US'; + + const day = start.toLocaleDateString(tag, { day: 'numeric', timeZone: timezone }); + const month = start + .toLocaleDateString(tag, { month: 'short', timeZone: timezone }) + .replace(/\.$/, '') + .toUpperCase(); + + const time = (d: Date) => + d + .toLocaleTimeString(tag, { + hour: isEs ? '2-digit' : 'numeric', + minute: '2-digit', + hour12: !isEs, + timeZone: timezone, + }) + .toUpperCase(); + + const date = isEs ? `${day} ${month}` : `${month} ${day}`; + const end = endStr ? new Date(endStr) : null; + const when = end ? `${time(start)} – ${time(end)}` : time(start); + + return `${date} · ${when}`; } /** - * Format time for display using site timezone + * Events store the venue as a single string; the part before the first comma + * reads as the venue name and the remainder as its address. */ -function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string { - const date = new Date(dateStr); - return date.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: true, - timeZone: timezone, +function splitLocation(location: string): { name: string; address?: string } { + const idx = location.indexOf(','); + if (idx === -1) return { name: location.trim() }; + return { + name: location.slice(0, idx).trim(), + address: location.slice(idx + 1).trim() || undefined, + }; +} + +// ==================== Drawing helpers ==================== + +function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) { + doc + .font('Helvetica-Bold') + .fontSize(8) + .fillColor(COLORS.label) + .text(text.toUpperCase(), x, y, { width, characterSpacing: 1.6 }); +} + +function drawDivider(doc: PDFKit.PDFDocument, y: number) { + doc + .moveTo(MARGIN, y) + .lineTo(PAGE_W - MARGIN, y) + .lineWidth(1) + .strokeColor(COLORS.divider) + .stroke(); +} + +/** Centered text with letter spacing: pdfkit also spaces the last glyph, so nudge it back. */ +function drawSpacedCentered( + doc: PDFKit.PDFDocument, + text: string, + x: number, + y: number, + width: number, + spacing: number +) { + doc.text(text, x - spacing / 2, y, { width, align: 'center', characterSpacing: spacing }); +} + +interface DetailBlock { + label: string; + value: string; + sub?: string; +} + +/** + * Draw (or, with `measureOnly`, just measure) the venue / ticket holder / note + * block. Returns its total height so the caller can anchor it above the footer. + */ +function renderDetails( + doc: PDFKit.PDFDocument, + blocks: DetailBlock[], + note: string, + yStart: number, + measureOnly: boolean +): number { + let y = yStart; + + blocks.forEach((block, i) => { + if (i > 0) { + y += 14; + if (!measureOnly) drawDivider(doc, y); + y += 18; + } + + if (!measureOnly) drawLabel(doc, block.label, y); + y += 15; + + doc.font('Helvetica-Bold').fontSize(13); + if (!measureOnly) doc.fillColor(COLORS.navy).text(block.value, MARGIN, y, { width: CONTENT_W }); + y += doc.heightOfString(block.value, { width: CONTENT_W }) + 3; + + if (block.sub) { + doc.font('Helvetica').fontSize(10.5); + if (!measureOnly) doc.fillColor(COLORS.muted).text(block.sub, MARGIN, y, { width: CONTENT_W }); + y += doc.heightOfString(block.sub, { width: CONTENT_W }) + 3; + } }); + + y += 16; + doc.font('Helvetica').fontSize(10.5); + if (!measureOnly) doc.fillColor(COLORS.muted).text(note, MARGIN, y, { width: CONTENT_W }); + y += doc.heightOfString(note, { width: CONTENT_W }); + + return y - yStart; +} + +/** + * Render one full-page ticket. Assumes the page is already added. + */ +function renderTicketPage( + doc: PDFKit.PDFDocument, + ticket: TicketData, + qrBuffer: Buffer, + siteDomain: string, + index = 0, + total = 1 +) { + const locale = ticket.locale === 'es' ? 'es' : 'en'; + const t = strings(locale); + const tz = ticket.timezone || 'America/Asuncion'; + const footerY = PAGE_H - FOOTER_H; + + // ==================== Background ==================== + doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream); + doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange); + + // ==================== Logo ==================== + const logo = getLogo(); + let headerY = MARGIN + 6; + + if (logo) { + const logoW = 158; + doc.image(logo, MARGIN, headerY, { width: logoW }); + headerY += logoW / LOGO_RATIO; + } else { + doc.font('Helvetica-Bold').fontSize(21).fillColor(COLORS.navy).text('spanglish social', MARGIN, headerY); + headerY += 26; + } + + // ==================== Title + date ==================== + const titleY = headerY + 30; + const when = formatWhen(ticket.event.startDatetime, ticket.event.endDatetime, tz, locale); + + doc.font('Helvetica-Bold').fontSize(11.5); + const whenW = Math.min(doc.widthOfString(when) + 2, CONTENT_W * 0.5); + const titleW = CONTENT_W - whenW - 20; + + doc.font('Helvetica-Bold').fontSize(26); + if (doc.widthOfString(ticket.event.title) > titleW) doc.fontSize(20); + doc.fillColor(COLORS.navy).text(ticket.event.title, MARGIN, titleY, { width: titleW }); + const titleBottom = doc.y; + + doc + .font('Helvetica-Bold') + .fontSize(11.5) + .fillColor(COLORS.orange) + .text(when, PAGE_W - MARGIN - whenW, titleY + 9, { width: whenW, align: 'right' }); + + // ==================== Layout: card fills what the detail block leaves ==================== + const venue = splitLocation(ticket.event.location); + const note = ticket.note || t.terms; + const blocks: DetailBlock[] = [ + { label: t.venue, value: venue.name, sub: venue.address }, + { label: t.holder, value: ticket.attendeeName, sub: ticket.attendeeEmail }, + ]; + + const detailsH = renderDetails(doc, blocks, note, 0, true); + const detailsY = footerY - 46 - detailsH; + + const cardY = Math.max(titleBottom, titleY + 36) + 24; + const cardX = MARGIN; + const cardW = CONTENT_W; + const cardH = Math.max(300, Math.min(detailsY - 32 - cardY, 430)); + + doc + .roundedRect(cardX, cardY, cardW, cardH, 14) + .lineWidth(1) + .fillAndStroke(COLORS.card, COLORS.cardBorder); + + // ==================== QR card contents ==================== + const labelH = 12; + const codeH = 24; + const qrSize = Math.min(236, cardH - (labelH + 20 + 22 + codeH + 44)); + const stackH = labelH + 20 + qrSize + 22 + codeH; + let inner = cardY + (cardH - stackH) / 2; + + doc.font('Helvetica-Bold').fontSize(8.5).fillColor(COLORS.label); + drawSpacedCentered(doc, t.scan, cardX, inner, cardW, 2); + + if (total > 1) { + doc + .font('Helvetica-Bold') + .fontSize(8.5) + .fillColor(COLORS.label) + .text(`${index + 1} / ${total}`, cardX, inner, { width: cardW - 22, align: 'right', characterSpacing: 1 }); + } + + inner += labelH + 20; + doc.image(qrBuffer, (PAGE_W - qrSize) / 2, inner, { width: qrSize, height: qrSize }); + inner += qrSize + 22; + + const code = ticket.qrCode || ticket.id.slice(0, 8).toUpperCase(); + doc.font('Courier-Bold').fontSize(19).fillColor(COLORS.navy); + drawSpacedCentered(doc, code, cardX, inner, cardW, 3); + + // ==================== Venue / ticket holder / note ==================== + renderDetails(doc, blocks, note, detailsY, false); + + // ==================== Footer ==================== + doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy); + + doc + .font('Courier') + .fontSize(7.5) + .fillColor(COLORS.footerMuted) + .text(ticket.id, MARGIN, footerY + FOOTER_H / 2 - 4, { width: CONTENT_W * 0.6, lineBreak: false }); + + doc + .font('Helvetica') + .fontSize(10) + .fillColor('#FFFFFF') + .text(siteDomain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' }); +} + +function createDoc(): PDFKit.PDFDocument { + return new PDFDocument({ size: 'A4', margin: 0 }); +} + +function collect(doc: PDFKit.PDFDocument): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + }); +} + +function siteUrl(): { base: string; domain: string } { + const base = process.env.FRONTEND_URL || 'https://spanglishcommunity.com'; + let domain = base; + try { + domain = new URL(base).host.replace(/^www\./, ''); + } catch { + domain = base.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); + } + return { base, domain }; } /** * 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 (using site timezone) - const tz = ticket.timezone || 'America/Asuncion'; - doc.fontSize(14).fillColor('#333'); - doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' }); - - const startTime = formatTime(ticket.event.startDatetime, tz); - const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null; - const timeRange = endTime ? `${startTime} - ${endTime}` : startTime; - doc.text(timeRange, { align: 'center' }); - - 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); - } - }); + return generateCombinedTicketsPDF([ticket]); } /** - * Generate a combined PDF with multiple tickets + * Generate a combined PDF with multiple tickets (one page each) */ export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise { - return new Promise(async (resolve, reject) => { - try { - const doc = new PDFDocument({ - size: 'A4', - margin: 50, - }); + const doc = createDoc(); + const done = collect(doc); + const { base, domain } = siteUrl(); - const chunks: Buffer[] = []; - doc.on('data', (chunk: Buffer) => chunks.push(chunk)); - doc.on('end', () => resolve(Buffer.concat(chunks))); - doc.on('error', reject); + try { + for (let i = 0; i < tickets.length; i++) { + const ticket = tickets[i]; + if (i > 0) doc.addPage(); - 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); - - // Date and time (using site timezone) - const tz = ticket.timezone || 'America/Asuncion'; - doc.fontSize(14).fillColor('#333'); - doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' }); - - const startTime = formatTime(ticket.event.startDatetime, tz); - const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null; - const timeRange = endTime ? `${startTime} - ${endTime}` : startTime; - doc.text(timeRange, { align: 'center' }); - - 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); + const qrBuffer = await generateQRCode(`${base}/ticket/${ticket.id}`); + renderTicketPage(doc, ticket, qrBuffer, domain, i, tickets.length); } - }); + doc.end(); + } catch (error) { + doc.end(); + throw error; + } + + return done; } export default { diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 9cee47a..ea3ad38 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -548,20 +548,24 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => { ); const timezone = settings?.timezone || 'America/Asuncion'; - const ticketsData = confirmedTickets.map((ticket: any) => ({ - id: ticket.id, - qrCode: ticket.qrCode, - attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), - attendeeEmail: ticket.attendeeEmail, - event: { - title: event.title, - startDatetime: event.startDatetime, - endDatetime: event.endDatetime, - location: event.location, - locationUrl: event.locationUrl, - }, - timezone, - })); + const ticketsData = confirmedTickets.map((ticket: any) => { + const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en'; + return { + id: ticket.id, + qrCode: ticket.qrCode, + attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), + attendeeEmail: ticket.attendeeEmail, + event: { + title: locale === 'es' && event.titleEs ? event.titleEs : event.title, + startDatetime: event.startDatetime, + endDatetime: event.endDatetime, + location: event.location, + locationUrl: event.locationUrl, + }, + timezone, + locale, + }; + }); const pdfBuffer = await generateCombinedTicketsPDF(ticketsData); @@ -625,19 +629,22 @@ ticketsRouter.get('/:id/pdf', async (c) => { ); const timezone = settings?.timezone || 'America/Asuncion'; + const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en'; + const pdfBuffer = await generateTicketPDF({ id: ticket.id, qrCode: ticket.qrCode, attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), attendeeEmail: ticket.attendeeEmail, event: { - title: event.title, + title: locale === 'es' && event.titleEs ? event.titleEs : event.title, startDatetime: event.startDatetime, endDatetime: event.endDatetime, location: event.location, locationUrl: event.locationUrl, }, timezone, + locale, }); // Set response headers for PDF download