// 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; qrCode: string; attendeeName: string; attendeeEmail?: string; event: { title: string; startDatetime: string; endDatetime?: string; location: string; 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; } /** * 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: 600, margin: 1, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#FFFFFF' }, }); } /** * Short date + time as shown in the ticket header: * en -> "JUL 25 · 4:30 PM" es -> "25 JUL · 16:30" */ 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}`; } /** * 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 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 generateCombinedTicketsPDF([ticket]); } /** * Generate a combined PDF with multiple tickets (one page each) */ export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise { const doc = createDoc(); const done = collect(doc); const { base, domain } = siteUrl(); try { for (let i = 0; i < tickets.length; i++) { const ticket = tickets[i]; if (i > 0) doc.addPage(); 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 { generateTicketPDF, generateCombinedTicketsPDF, };