Redesign the PDF ticket around the branded card layout.

The old ticket was a centred stack of Helvetica on white that read as a
receipt: the QR sat in open space, the event, attendee and code lines
were indistinguishable at a glance, and nothing on the page identified
Spanglish beyond a text heading. The page is now a full-bleed card --
orange rule, cream field, navy footer -- with the logo and event title
in the header, the QR raised into a white rounded panel above its code,
and labelled venue and ticket holder blocks below it, so door staff can
find the code and the name without reading the page.

The ticket is bilingual, driven by the ticket's preferredLanguage: the
labels, the terms line, the Spanish event title and the date format all
follow it, with 24h time and day-first ordering in Spanish. Events
store the venue as one string, so the text before the first comma is
treated as the venue name and the remainder as its address.

Layout adapts rather than overflowing: long titles wrap to two lines at
a smaller size, and the QR panel flexes so the detail block always
lands just above the footer. The single and combined generators were
copies of each other and now share one page renderer, with multi-ticket
bookings marked by a small counter in the panel.

The logo ships as backend/assets/logo-spanglish.png, resolved from both
src/lib and dist/lib, falling back to the frontend copy and then to a
text wordmark, so a deployment that misses the asset still produces a
ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-08-23 05:16:41 +00:00
co-authored by Claude Opus 5
parent 5970d707af
commit 3f7b2d51db
3 changed files with 376 additions and 212 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+354 -197
View File
@@ -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<Buffer> {
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<Buffer> {
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<Buffer> {
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<Buffer> {
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 {
+22 -15
View File
@@ -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