diff --git a/CHANGELOG.md b/CHANGELOG.md index df05d83..145157a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.30.1] - 2026-10-22 + +### Changed + +- All tickets now share the design of the tickets printed from the user dashboard: rounded white cards with a light border and soft shadow, the date beside the event name, and the QR code in the middle. This covers the PDF sent by email and WhatsApp (now 6 per page, so the QR codes are bigger) and printing from Event Tickets (staff) and At the door (supervisor). Printed names are now escaped, so a name with `<` or `&` prints correctly. + ## [1.30.0] - 2026-10-22 ### Added diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js index cbaf366..813477e 100644 --- a/backend/src/controllers/ticketController.js +++ b/backend/src/controllers/ticketController.js @@ -845,6 +845,66 @@ function allocationLabel(attendee) { .join(' · '); } +// One ticket card on the PDF, matching the printed design in frontend/src/lib/ticketPrint.ts. +function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) { + const pad = 12; const radius = 9; + const innerW = w - pad * 2; + const gray700 = '#374151'; const gray500 = '#6b7280'; + + doc.save(); + doc.roundedRect(x, y + 1.5, w, h, radius).fillOpacity(0.06).fill('#000000'); + doc.restore(); + doc.save(); + doc.roundedRect(x, y, w, h, radius).lineWidth(0.75).fillAndStroke('#ffffff', '#e5e7eb'); + doc.restore(); + + // Event title, with the date on the right of the same line + let cy = y + pad; + const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null; + const dateStr = eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : ''; + doc.font('Helvetica').fontSize(9); + const dateW = dateStr ? doc.widthOfString(dateStr) : 0; + if (dateStr) doc.fillColor(gray500).text(dateStr, x + w - pad - dateW, cy + 1, { width: dateW, lineBreak: false }); + const titleW = innerW - (dateStr ? dateW + 8 : 0); + doc.font('Helvetica-Bold').fontSize(11).fillColor('#111827'); + doc.text(ticket.event.title, x + pad, cy, { width: titleW }); + cy += doc.heightOfString(ticket.event.title, { width: titleW }) + 4; + + // Ticket type + const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : ''; + const type = `${ticket.registrationOption.eventOption.name}${variantSuffix}`; + doc.font('Helvetica').fontSize(10).fillColor(gray700).text(type, x + pad, cy, { width: innerW }); + cy += doc.heightOfString(type, { width: innerW }) + 2; + + // Name (named ticket) or quantity, value in bold + doc.font('Helvetica').fontSize(9).fillColor(gray700) + .text(ticket.attendee ? 'Name: ' : 'Qty: ', x + pad, cy, { continued: true }) + .font('Helvetica-Bold').text(ticket.attendee ? ticket.attendee.name : String(ticket.quantity || 1)); + cy += 12; + const groupLabel = allocationLabel(ticket.attendee); + if (groupLabel) { + doc.font('Helvetica').fontSize(9).fillColor(gray500).text(groupLabel, x + pad, cy, { width: innerW }); + cy += doc.heightOfString(groupLabel, { width: innerW }) + 2; + } + + // Footer lines are measured first so the QR code takes the remaining space + const footer = [`Ticket ID: ${ticket.id}`, ...(buyerName ? [`Purchased by: ${buyerName}`] : [])]; + doc.font('Helvetica').fontSize(8.5); + const footerH = footer.reduce((sum, line) => sum + doc.heightOfString(line, { width: innerW }) + 2, 0); + const qrSpace = (y + h - pad - footerH) - (cy + 6) - 6; + const qrSize = Math.max(60, Math.min(130, qrSpace)); + const qrY = cy + 6 + Math.max(0, (qrSpace - qrSize) / 2); + doc.image(qrDataUrl, x + (w - qrSize) / 2, qrY, { width: qrSize, height: qrSize }); + + let fy = y + h - pad - footerH; + doc.fillColor(gray500); + for (const line of footer) { + doc.text(line, x + pad, fy, { width: innerW, align: 'center' }); + fy += doc.heightOfString(line, { width: innerW }) + 2; + } + doc.fillColor('#000000'); +} + // Internal helper used by both emailTickets and sendTicketsTo async function emailTicketsInternal(req, res, userOverride) { const { ticketIds, registrationId } = req.body; @@ -951,32 +1011,22 @@ async function emailTicketsInternal(req, res, userOverride) { const doc = new PDFDocument({ size: 'A4', margin: 20 }); const writeStream = fs.createWriteStream(tempFilePath); doc.pipe(writeStream); - const pageWidth = doc.page.width - 40; const pageHeight = doc.page.height - 40; - const ticketsPerRow = 2; const ticketsPerColumn = 4; - const ticketWidth = pageWidth / ticketsPerRow; const ticketHeight = pageHeight / ticketsPerColumn; + // Same card design as the printed tickets (frontend/src/lib/ticketPrint.ts): rounded white + // cards with a light border and soft shadow, two columns, three rows per page. + const margin = 20; const gap = 12; + const ticketsPerRow = 2; const ticketsPerColumn = 3; + const ticketWidth = (doc.page.width - margin * 2 - gap * (ticketsPerRow - 1)) / ticketsPerRow; + const ticketHeight = (doc.page.height - margin * 2 - gap * (ticketsPerColumn - 1)) / ticketsPerColumn; const qrCodes = await Promise.all(tickets.map(ticket => new Promise((resolve, reject) => { - QRCode.toDataURL(ticket.qrCode, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url })); + QRCode.toDataURL(ticket.qrCode, { margin: 1, width: 400 }, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url })); }))); const qrCodeMap = qrCodes.reduce((map, item) => { map[item.ticketId] = item.qrDataUrl; return map; }, {}); let ticketIndex = 0; for (const ticket of tickets) { - const row = Math.floor(ticketIndex % ticketsPerColumn); - const col = Math.floor((ticketIndex / ticketsPerColumn) % ticketsPerRow); - const x = col * ticketWidth + 20; const y = row * ticketHeight + 20; - doc.rect(x, y, ticketWidth, ticketHeight).stroke(); - doc.font('Helvetica-Bold').fontSize(12).text(ticket.event.title, x + 10, y + 10, { width: ticketWidth - 20 }); - doc.font('Helvetica').fontSize(10); - const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : ''; - doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 }); - const groupLabel = allocationLabel(ticket.attendee); - doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}${groupLabel ? ` · ${groupLabel}` : ''}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 }); - const eventDate = new Date(ticket.event.startDate); - doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 }); - doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 }); - const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45; - const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90; - doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize }); - doc.fontSize(8).text(`Ticket ID: ${ticket.id}`, x + 10, qrY + qrCodeSize + 5, { width: ticketWidth - 20, align: 'center' }); + const slot = ticketIndex % (ticketsPerRow * ticketsPerColumn); + const x = margin + (slot % ticketsPerRow) * (ticketWidth + gap); + const y = margin + Math.floor(slot / ticketsPerRow) * (ticketHeight + gap); + drawTicketCard(doc, { x, y, w: ticketWidth, h: ticketHeight }, ticket, qrCodeMap[ticket.id], ticket.user?.name || user.name); ticketIndex++; if (ticketIndex % (ticketsPerRow * ticketsPerColumn) === 0 && ticketIndex < tickets.length) doc.addPage(); } diff --git a/frontend/src/app/dashboard/staff/event-tickets/page.tsx b/frontend/src/app/dashboard/staff/event-tickets/page.tsx index c57d2fc..eee056e 100644 --- a/frontend/src/app/dashboard/staff/event-tickets/page.tsx +++ b/frontend/src/app/dashboard/staff/event-tickets/page.tsx @@ -6,6 +6,7 @@ import { apiFetch } from "@/lib/api"; import { useDismissingState } from "@/hooks/useDismissingState"; import { useRouter, useSearchParams } from "next/navigation"; import { formatDate } from "@/lib/date"; +import { printTicketCards } from "@/lib/ticketPrint"; import { QrImage } from "@/components/shared/QrImage"; import { Ticket } from "lucide-react"; import { errorMessage } from "@/lib/utils"; @@ -142,96 +143,7 @@ function EventTicketsContent() { return list; }, [tickets, eventId, selectedUserId, selectedVariantId]); - // Printing helpers - const buildTicketHtmlCard = (t: TicketRecord) => { - const eventTitle = t.event?.title || t.eventId || "Event"; - const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null; - const optBase = t.registrationOption?.eventOption?.name || "Ticket"; - const optVariant = t.registrationOption?.variant?.name; - const type = optVariant ? `${optBase} — ${optVariant}` : optBase; - const qty = t.quantity || 1; - const holder = t.attendee?.name ? `${t.attendee.name}${t.user?.name ? ` (booked by ${t.user.name})` : ""}` : (t.user?.name || t.userId || ""); - const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent( - t.qrCode || t.id - )}`; - const dateStr = eventDate ? formatDate(eventDate) : ""; - return ` -