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 ` -
-
${eventTitle}
- ${dateStr ? `
${dateStr}
` : ""} -
${type}
-
Qty: ${qty}
-
QR
-
ID: ${t.id}
- ${holder ? `
${holder}
` : ""} -
- `; - }; - - const openPrintWindow = (pagesHtml: string) => { - const w = window.open("", "_blank"); - if (!w) return; - w.document.write(`Tickets - - - ${pagesHtml} - - `); - w.document.close(); - w.focus(); - }; - - const printTickets = (ticketList: TicketRecord[]) => { - if (!ticketList || ticketList.length === 0) return; - // Chunk into pages of 8 (2 columns × 4 rows per A4 sheet) - const PAGE_SIZE = 8; - const pages: string[] = []; - for (let i = 0; i < ticketList.length; i += PAGE_SIZE) { - const chunk = ticketList.slice(i, i + PAGE_SIZE); - pages.push(`
${chunk.map(buildTicketHtmlCard).join("")}
`); - } - openPrintWindow(pages.join("")); - }; + const printTickets = (ticketList: TicketRecord[]) => printTicketCards(ticketList, (t) => t.user?.name); return (
diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx index b3479e6..493ecd5 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -14,6 +14,7 @@ import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react"; import { errorMessage } from "@/lib/utils"; import { optionUnitNow, variantUnitNow } from "@/lib/pricing"; +import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint"; import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types"; type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" }; @@ -87,12 +88,6 @@ function initQuantities(eventOptions: EventOption[]): Record { return map; } -function ticketLabel(t: TicketRecord): string { - const opt = t.registrationOption?.eventOption?.name || "Ticket"; - const variant = t.registrationOption?.variant?.name; - return variant ? `${opt} — ${variant}` : opt; -} - export default function AtTheDoorPage() { const { user, loading, token } = useAuth(); const router = useRouter(); @@ -915,61 +910,7 @@ function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: s } }; - const print = (ticket: TicketRecord) => { - const w = window.open("", "_blank"); - if (!w) return; - const eventTitle = ticket.event?.title || ticket.eventId || "Event"; - const eventDate = ticket.event?.startDate ? new Date(ticket.event.startDate) : null; - const dateStr = eventDate ? eventDate.toLocaleDateString("en-ZA", { day: "numeric", month: "long", year: "numeric" }) : ""; - const type = ticketLabel(ticket); - const qty = ticket.quantity || 1; - const holder = ticket.user?.name || ticket.userId || ""; - const qrData = encodeURIComponent(ticket.qrCode || ticket.id); - const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${qrData}`; - - w.document.write(`Ticket - - -
-
-
${eventTitle}
- ${dateStr ? `
${dateStr}
` : ""} -
${type}
- ${holder ? `
${holder}
` : ""} -
Qty: ${qty}
-
-
QR
-
${ticket.id}
-
- -`); - w.document.close(); - w.focus(); - }; + const print = (ticket: TicketRecord) => printTicketCards([ticket], (t) => t.user?.name); return (
diff --git a/frontend/src/app/dashboard/user/page.tsx b/frontend/src/app/dashboard/user/page.tsx index a531647..2e4bd99 100644 --- a/frontend/src/app/dashboard/user/page.tsx +++ b/frontend/src/app/dashboard/user/page.tsx @@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/useAuth"; import { apiFetch, createFullPaymentCheckout } from "@/lib/api"; import { useRouter } from "next/navigation"; import { formatDate } from "@/lib/date"; +import { printTicketCards } from "@/lib/ticketPrint"; import { formatPaymentMethod } from "@/lib/paymentMethod"; import { QrImage } from "@/components/shared/QrImage"; import { ApiImage } from "@/components/shared/ApiImage"; @@ -345,83 +346,7 @@ export default function UserDashboardPage() { } }; - // Print helpers - const buildTicketHtmlCard = (t: TicketRecord, buyerName?: string) => { - const eventTitle = t.event?.title || t.eventId || "Event"; - const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null; - const optionName = t.registrationOption?.eventOption?.name || "Ticket"; - const variantName = t.registrationOption?.variant?.name; - const type = variantName ? `${optionName} — ${variantName}` : optionName; - const qty = t.quantity || 1; - const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(t.qrCode || t.id)}`; - const dateStr = eventDate ? formatDate(eventDate) : ''; - return ` -
-
-
${eventTitle}
- ${dateStr ? `
${dateStr}
` : ''} -
-
${type}
- ${t.attendee?.name ? `
Name: ${t.attendee.name}
` : `
Qty: ${qty}
`} -
QR
-
Ticket ID: ${t.id}
- ${buyerName ? `
Purchased by: ${buyerName}
` : ''} -
- `; - }; - - const openPrintWindow = (title: string, cardsHtml: string) => { - const w = window.open("", "_blank"); - if (!w) return; - w.document.write(`${title} - - - -
${cardsHtml}
- - `); - w.document.close(); - w.focus(); - }; - - const printTickets = (ticketList: TicketRecord[]) => { - if (!ticketList || ticketList.length === 0) return; - const cards = ticketList.map(t => buildTicketHtmlCard(t, user?.name)).join(""); - openPrintWindow(`Tickets (${ticketList.length})`, cards); - }; + const printTickets = (ticketList: TicketRecord[]) => printTicketCards(ticketList, () => user?.name); // Upcoming events derived from registrations const upcomingEvents = useMemo(() => { diff --git a/frontend/src/lib/ticketPrint.ts b/frontend/src/lib/ticketPrint.ts new file mode 100644 index 0000000..a7f31f1 --- /dev/null +++ b/frontend/src/lib/ticketPrint.ts @@ -0,0 +1,90 @@ +import type { Ticket as TicketRecord } from "@/types"; +import { formatDate } from "@/lib/date"; + +// One ticket design for every printed ticket (user dashboard, staff event tickets, at the +// door). The emailed/WhatsApp PDF in backend/src/controllers/ticketController.js mirrors it. + +function esc(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!)); +} + +export function ticketTypeLabel(t: TicketRecord): string { + const opt = t.registrationOption?.eventOption?.name || "Ticket"; + const variant = t.registrationOption?.variant?.name; + return variant ? `${opt} — ${variant}` : opt; +} + +/** A ticket card. `buyerName` adds a "Purchased by" line. */ +export function ticketCardHtml(t: TicketRecord, buyerName?: string | null): string { + const eventTitle = t.event?.title || t.eventId || "Event"; + const dateStr = t.event?.startDate ? formatDate(t.event.startDate) : ""; + const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(t.qrCode || t.id)}`; + return ` +
+
+
${esc(eventTitle)}
+ ${dateStr ? `
${esc(dateStr)}
` : ""} +
+
${esc(ticketTypeLabel(t))}
+ ${t.attendee?.name ? `
Name: ${esc(t.attendee.name)}
` : `
Qty: ${t.quantity || 1}
`} +
QR
+
Ticket ID: ${esc(t.id)}
+ ${buyerName ? `
Purchased by: ${esc(buyerName)}
` : ""} +
+ `; +} + +/** Opens a print window with the given cards and prints once the QR images have loaded. */ +export function openTicketPrintWindow(title: string, cardsHtml: string) { + const w = window.open("", "_blank"); + if (!w) return; + w.document.write(`${esc(title)} + + + +
${cardsHtml}
+ + `); + w.document.close(); + w.focus(); +} + +/** Prints tickets in the shared design. */ +export function printTicketCards(tickets: TicketRecord[], buyerName?: (t: TicketRecord) => string | null | undefined) { + if (!tickets || tickets.length === 0) return; + const cards = tickets.map((t) => ticketCardHtml(t, buyerName?.(t))).join(""); + openTicketPrintWindow(tickets.length === 1 ? "Ticket" : `Tickets (${tickets.length})`, cards); +}