Dev #33
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
+354
-197
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user