Add ticket system with QR scanner and PDF generation
- Add ticket validation and check-in API endpoints - Add PDF ticket generation with QR codes (pdfkit) - Add admin QR scanner page with camera support - Add admin site settings page - Update email templates with PDF ticket download link - Add checked_in_by_admin_id field for audit tracking - Update booking success page with ticket download - Various UI improvements to events and booking pages
This commit is contained in:
@@ -548,6 +548,10 @@ export const emailService = {
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
// Generate ticket PDF URL
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
const ticketPdfUrl = `${apiUrl}/api/tickets/${ticket.id}/pdf`;
|
||||
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
return this.sendTemplateEmail({
|
||||
templateSlug: 'booking-confirmation',
|
||||
@@ -560,6 +564,7 @@ export const emailService = {
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
qrCode: ticket.qrCode || '',
|
||||
ticketPdfUrl,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
|
||||
@@ -35,6 +35,7 @@ export const bookingVariables: EmailVariable[] = [
|
||||
{ name: 'attendeeEmail', description: 'Attendee email', example: 'john@example.com' },
|
||||
{ name: 'ticketId', description: 'Unique ticket ID', example: 'TKT-ABC123' },
|
||||
{ name: 'qrCode', description: 'QR code for check-in', example: 'data:image/png;base64,...' },
|
||||
{ name: 'ticketPdfUrl', description: 'URL to download ticket PDF', example: 'https://api.spanglish.com/api/tickets/abc123/pdf' },
|
||||
{ name: 'eventTitle', description: 'Event title', example: 'Spanglish Night - January Edition' },
|
||||
{ name: 'eventDate', description: 'Event date formatted', example: 'January 28, 2026' },
|
||||
{ name: 'eventTime', description: 'Event time', example: '7:00 PM' },
|
||||
@@ -228,18 +229,17 @@ export const defaultTemplates: DefaultTemplate[] = [
|
||||
|
||||
<div class="ticket-box">
|
||||
<p>Your Ticket ID</p>
|
||||
<p class="ticket-id">{{ticketId}}</p>
|
||||
<p class="ticket-id">{{qrCode}}</p>
|
||||
</div>
|
||||
|
||||
{{#if qrCode}}
|
||||
<div class="qr-code">
|
||||
<p><strong>Show this QR code at check-in:</strong></p>
|
||||
<img src="{{qrCode}}" alt="Check-in QR Code" />
|
||||
</div>
|
||||
{{#if ticketPdfUrl}}
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{ticketPdfUrl}}" class="btn" style="background-color: #1a1a1a; color: #f4d03f;">📄 Download Your Ticket (PDF)</a>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<div class="note">
|
||||
<strong>💡 Important:</strong> Please arrive 10-15 minutes early for check-in. Bring your ticket ID or show this email.
|
||||
<strong>💡 Important:</strong> Please arrive 10-15 minutes early for check-in. Show the PDF ticket or this email at the entrance.
|
||||
</div>
|
||||
|
||||
<p>See you at Spanglish!</p>
|
||||
@@ -263,18 +263,17 @@ export const defaultTemplates: DefaultTemplate[] = [
|
||||
|
||||
<div class="ticket-box">
|
||||
<p>Tu ID de Ticket</p>
|
||||
<p class="ticket-id">{{ticketId}}</p>
|
||||
<p class="ticket-id">{{qrCode}}</p>
|
||||
</div>
|
||||
|
||||
{{#if qrCode}}
|
||||
<div class="qr-code">
|
||||
<p><strong>Muestra este código QR en el check-in:</strong></p>
|
||||
<img src="{{qrCode}}" alt="Código QR de Check-in" />
|
||||
</div>
|
||||
{{#if ticketPdfUrl}}
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{ticketPdfUrl}}" class="btn" style="background-color: #1a1a1a; color: #f4d03f;">📄 Descargar Tu Ticket (PDF)</a>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<div class="note">
|
||||
<strong>💡 Importante:</strong> Por favor llega 10-15 minutos antes para el check-in. Trae tu ID de ticket o muestra este email.
|
||||
<strong>💡 Importante:</strong> Por favor llega 10-15 minutos antes para el check-in. Muestra el PDF del ticket o este email en la entrada.
|
||||
</div>
|
||||
|
||||
<p>¡Nos vemos en Spanglish!</p>
|
||||
|
||||
246
backend/src/lib/pdf.ts
Normal file
246
backend/src/lib/pdf.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for display
|
||||
*/
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
doc.fontSize(14).fillColor('#333');
|
||||
doc.text(formatDate(ticket.event.startDatetime), { align: 'center' });
|
||||
|
||||
const startTime = formatTime(ticket.event.startDatetime);
|
||||
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : 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);
|
||||
|
||||
doc.fontSize(14).fillColor('#333');
|
||||
doc.text(formatDate(ticket.event.startDatetime), { align: 'center' });
|
||||
|
||||
const startTime = formatTime(ticket.event.startDatetime);
|
||||
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : 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,
|
||||
};
|
||||
Reference in New Issue
Block a user