Dev #33

Merged
Michilis merged 6 commits from dev into main 2026-08-23 06:04:27 +00:00
3 changed files with 376 additions and 212 deletions
Showing only changes of commit 3f7b2d51db - Show all commits
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