- Email: formatDate/formatTime use site timezone from settings - PDF tickets: date/time formatted in site timezone - Tickets routes: fetch timezone and pass to PDF generation
253 lines
8.3 KiB
TypeScript
253 lines
8.3 KiB
TypeScript
// PDF Ticket Generation Service
|
|
import PDFDocument from 'pdfkit';
|
|
import QRCode from 'qrcode';
|
|
|
|
interface TicketData {
|
|
id: string;
|
|
qrCode: string;
|
|
attendeeName: string;
|
|
attendeeEmail?: string;
|
|
event: {
|
|
title: string;
|
|
startDatetime: string;
|
|
endDatetime?: string;
|
|
location: string;
|
|
locationUrl?: string;
|
|
};
|
|
timezone?: string;
|
|
}
|
|
|
|
/**
|
|
* Generate a QR code as a data URL
|
|
*/
|
|
async function generateQRCode(data: string): Promise<Buffer> {
|
|
return QRCode.toBuffer(data, {
|
|
type: 'png',
|
|
width: 200,
|
|
margin: 2,
|
|
errorCorrectionLevel: 'M',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Format date for display using site timezone
|
|
*/
|
|
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,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Format time for display using site timezone
|
|
*/
|
|
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,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate a combined PDF with multiple tickets
|
|
*/
|
|
export async function generateCombinedTicketsPDF(tickets: 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';
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
export default {
|
|
generateTicketPDF,
|
|
generateCombinedTicketsPDF,
|
|
};
|