Dev #33
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
+354
-197
@@ -1,6 +1,8 @@
|
|||||||
// PDF Ticket Generation Service
|
// PDF Ticket Generation Service
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
interface TicketData {
|
interface TicketData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,235 +17,390 @@ interface TicketData {
|
|||||||
locationUrl?: string;
|
locationUrl?: string;
|
||||||
};
|
};
|
||||||
timezone?: 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> {
|
async function generateQRCode(data: string): Promise<Buffer> {
|
||||||
return QRCode.toBuffer(data, {
|
return QRCode.toBuffer(data, {
|
||||||
type: 'png',
|
type: 'png',
|
||||||
width: 200,
|
width: 600,
|
||||||
margin: 2,
|
margin: 1,
|
||||||
errorCorrectionLevel: 'M',
|
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 {
|
function formatWhen(
|
||||||
const date = new Date(dateStr);
|
startStr: string,
|
||||||
return date.toLocaleDateString('en-US', {
|
endStr: string | undefined,
|
||||||
weekday: 'long',
|
timezone: string,
|
||||||
year: 'numeric',
|
locale: string
|
||||||
month: 'long',
|
): string {
|
||||||
day: 'numeric',
|
const isEs = locale === 'es';
|
||||||
timeZone: timezone,
|
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 {
|
function splitLocation(location: string): { name: string; address?: string } {
|
||||||
const date = new Date(dateStr);
|
const idx = location.indexOf(',');
|
||||||
return date.toLocaleTimeString('en-US', {
|
if (idx === -1) return { name: location.trim() };
|
||||||
hour: '2-digit',
|
return {
|
||||||
minute: '2-digit',
|
name: location.slice(0, idx).trim(),
|
||||||
hour12: true,
|
address: location.slice(idx + 1).trim() || undefined,
|
||||||
timeZone: timezone,
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 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
|
* Generate a PDF ticket for a single ticket
|
||||||
*/
|
*/
|
||||||
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
|
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
|
||||||
return new Promise(async (resolve, reject) => {
|
return generateCombinedTicketsPDF([ticket]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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> {
|
export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> {
|
||||||
return new Promise(async (resolve, reject) => {
|
const doc = createDoc();
|
||||||
try {
|
const done = collect(doc);
|
||||||
const doc = new PDFDocument({
|
const { base, domain } = siteUrl();
|
||||||
size: 'A4',
|
|
||||||
margin: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
try {
|
||||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
for (let i = 0; i < tickets.length; i++) {
|
||||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
const ticket = tickets[i];
|
||||||
doc.on('error', reject);
|
if (i > 0) doc.addPage();
|
||||||
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
const qrBuffer = await generateQRCode(`${base}/ticket/${ticket.id}`);
|
||||||
|
renderTicketPage(doc, ticket, qrBuffer, domain, i, tickets.length);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
doc.end();
|
||||||
|
} catch (error) {
|
||||||
|
doc.end();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return done;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -548,20 +548,24 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => {
|
|||||||
);
|
);
|
||||||
const timezone = settings?.timezone || 'America/Asuncion';
|
const timezone = settings?.timezone || 'America/Asuncion';
|
||||||
|
|
||||||
const ticketsData = confirmedTickets.map((ticket: any) => ({
|
const ticketsData = confirmedTickets.map((ticket: any) => {
|
||||||
id: ticket.id,
|
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
|
||||||
qrCode: ticket.qrCode,
|
return {
|
||||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
id: ticket.id,
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
qrCode: ticket.qrCode,
|
||||||
event: {
|
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
title: event.title,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
startDatetime: event.startDatetime,
|
event: {
|
||||||
endDatetime: event.endDatetime,
|
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
|
||||||
location: event.location,
|
startDatetime: event.startDatetime,
|
||||||
locationUrl: event.locationUrl,
|
endDatetime: event.endDatetime,
|
||||||
},
|
location: event.location,
|
||||||
timezone,
|
locationUrl: event.locationUrl,
|
||||||
}));
|
},
|
||||||
|
timezone,
|
||||||
|
locale,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
|
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
|
||||||
|
|
||||||
@@ -625,19 +629,22 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
|||||||
);
|
);
|
||||||
const timezone = settings?.timezone || 'America/Asuncion';
|
const timezone = settings?.timezone || 'America/Asuncion';
|
||||||
|
|
||||||
|
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
|
||||||
|
|
||||||
const pdfBuffer = await generateTicketPDF({
|
const pdfBuffer = await generateTicketPDF({
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
qrCode: ticket.qrCode,
|
qrCode: ticket.qrCode,
|
||||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
event: {
|
event: {
|
||||||
title: event.title,
|
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
|
||||||
startDatetime: event.startDatetime,
|
startDatetime: event.startDatetime,
|
||||||
endDatetime: event.endDatetime,
|
endDatetime: event.endDatetime,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
locationUrl: event.locationUrl,
|
locationUrl: event.locationUrl,
|
||||||
},
|
},
|
||||||
timezone,
|
timezone,
|
||||||
|
locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set response headers for PDF download
|
// Set response headers for PDF download
|
||||||
|
|||||||
Reference in New Issue
Block a user