Compare commits

...
3 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 15b73d5d9e Merge branch 'feat/unified-ticket-design'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:59:24 +02:00
joshuaandClaude Opus 5.5 c74a943709 Bump to 1.30.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:59:21 +02:00
joshuaandClaude Opus 5.5 28c8fe9715 Use the dashboard ticket design for all tickets
The emailed/WhatsApp ticket PDF and the staff and at-the-door print
views now match the user dashboard's printed tickets: rounded cards with
a light border and soft shadow. Printing goes through a shared helper in
lib/ticketPrint.ts, which also escapes names in the print HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:59:07 +02:00
12 changed files with 182 additions and 258 deletions
+6
View File
@@ -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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.30.0",
"version": "1.30.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.30.0",
"version": "1.30.1",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.30.0",
"version": "1.30.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+71 -21
View File
@@ -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();
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.30.0",
"version": "1.30.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.30.0",
"version": "1.30.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.30.0",
"version": "1.30.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -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 `
<div class="ticket">
<div class="evt">${eventTitle}</div>
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
<div class="type">${type}</div>
<div class="qty">Qty: <strong>${qty}</strong></div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">ID: ${t.id}</div>
${holder ? `<div class="meta">${holder}</div>` : ""}
</div>
`;
};
const openPrintWindow = (pagesHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><title>Tickets</title>
<style>
@page { size: A4; margin: 10mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
.page {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(4, 1fr);
height: 277mm;
gap: 6mm;
page-break-after: always;
break-after: page;
}
.page:last-child { page-break-after: avoid; break-after: avoid; }
.ticket { border: 1px solid #000; padding: 5mm; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; }
.evt { font-weight: bold; font-size: 14px; }
.type { font-size: 12px; margin-top: 2mm; }
.qty { font-size: 11px; margin-top: 1mm; }
.date { font-size: 10px; color: #555; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; margin: 2mm 0; }
.qrwrap img { width: 30mm; height: 30mm; }
.meta { font-size: 10px; text-align: center; }
</style>
</head><body>
${pagesHtml}
<script>
(function(){
function printWhenReady(){
var imgs = Array.prototype.slice.call(document.images);
if(imgs.length === 0){ window.print(); return; }
var remaining = imgs.length;
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 100); } }
imgs.forEach(function(img){
if (img.complete) { done(); }
else {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}
});
}
if (document.readyState === 'complete') printWhenReady();
else window.addEventListener('load', printWhenReady);
})();
</script>
</body></html>`);
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(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
}
openPrintWindow(pages.join(""));
};
const printTickets = (ticketList: TicketRecord[]) => printTicketCards(ticketList, (t) => t.user?.name);
return (
<div className="max-w-6xl mx-auto w-full p-6">
@@ -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<string, number> {
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(`<!doctype html><html><head><title>Ticket</title>
<style>
@page { size: A6; margin: 6mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
.ticket { border: 1.5px solid #222; border-radius: 3mm; padding: 5mm; height: calc(148mm - 12mm); display: flex; flex-direction: column; justify-content: space-between; }
.evt { font-weight: bold; font-size: 15pt; line-height: 1.2; }
.date { font-size: 10pt; color: #555; margin-top: 2mm; }
.type { font-size: 12pt; margin-top: 2mm; color: #222; }
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
.qty { font-size: 10pt; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1; }
.qrwrap img { width: 55mm; height: 55mm; display: block; }
.meta { font-size: 9pt; text-align: center; color: #666; word-break: break-all; }
</style>
</head><body>
<div class="ticket">
<div>
<div class="evt">${eventTitle}</div>
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
<div class="type">${type}</div>
${holder ? `<div class="holder">${holder}</div>` : ""}
<div class="qty">Qty: <strong>${qty}</strong></div>
</div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">${ticket.id}</div>
</div>
<script>
(function(){
function go(){
var imgs=Array.prototype.slice.call(document.images);
if(!imgs.length){window.print();return;}
var n=imgs.length;
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
}
if(document.readyState==='complete')go();else window.addEventListener('load',go);
})();
</script>
</body></html>`);
w.document.close();
w.focus();
};
const print = (ticket: TicketRecord) => printTicketCards([ticket], (t) => t.user?.name);
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
+2 -77
View File
@@ -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 `
<div class="ticket">
<div class="row between">
<div class="evt">${eventTitle}</div>
${dateStr ? `<div class="date">${dateStr}</div>` : ''}
</div>
<div class="type">${type}</div>
${t.attendee?.name ? `<div class="qty">Name: <strong>${t.attendee.name}</strong></div>` : `<div class="qty">Qty: <strong>${qty}</strong></div>`}
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">Ticket ID: ${t.id}</div>
${buyerName ? `<div class="meta">Purchased by: ${buyerName}</div>` : ''}
</div>
`;
};
const openPrintWindow = (title: string, cardsHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><title>${title}</title>
<style>
*{box-sizing:border-box}
body{font-family:Arial,Helvetica,sans-serif;margin:0;padding:16px;background:#f7f7f7}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px}
.ticket{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,0.06);break-inside:avoid;page-break-inside:avoid}
.evt{font-weight:700;font-size:14px}
.type{margin-top:4px;color:#374151}
.qty{margin-top:2px;font-size:12px;color:#374151}
.date{color:#6b7280;font-size:12px}
.row{display:flex;gap:8px;align-items:center}
.between{justify-content:space-between}
.qrwrap{display:flex;justify-content:center;align-items:center;margin:10px 0}
.qrwrap img{width:160px;height:160px}
.meta{font-size:12px;color:#6b7280;text-align:center;margin-top:6px}
@media print { body{background:#fff} .print-hidden{display:none !important} }
</style>
</head><body>
<h2 class="print-hidden" style="margin:8px 0 16px 0;font-size:18px">${title}</h2>
<div class="grid">${cardsHtml}</div>
<script>
(function(){
function printWhenReady(){
var imgs = Array.prototype.slice.call(document.images);
if(imgs.length === 0){ window.print(); return; }
var remaining = imgs.length;
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 200); } }
imgs.forEach(function(img){
if (img.complete) { done(); }
else {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}
});
}
if (document.readyState === 'complete') printWhenReady();
else window.addEventListener('load', printWhenReady);
})();
</script>
</body></html>`);
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(() => {
+90
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 `
<div class="ticket">
<div class="row between">
<div class="evt">${esc(eventTitle)}</div>
${dateStr ? `<div class="date">${esc(dateStr)}</div>` : ""}
</div>
<div class="type">${esc(ticketTypeLabel(t))}</div>
${t.attendee?.name ? `<div class="qty">Name: <strong>${esc(t.attendee.name)}</strong></div>` : `<div class="qty">Qty: <strong>${t.quantity || 1}</strong></div>`}
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">Ticket ID: ${esc(t.id)}</div>
${buyerName ? `<div class="meta">Purchased by: ${esc(buyerName)}</div>` : ""}
</div>
`;
}
/** 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(`<!doctype html><html><head><title>${esc(title)}</title>
<style>
*{box-sizing:border-box}
body{font-family:Arial,Helvetica,sans-serif;margin:0;padding:16px;background:#f7f7f7}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px}
.ticket{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,0.06);break-inside:avoid;page-break-inside:avoid}
.evt{font-weight:700;font-size:14px}
.type{margin-top:4px;color:#374151}
.qty{margin-top:2px;font-size:12px;color:#374151}
.date{color:#6b7280;font-size:12px}
.row{display:flex;gap:8px;align-items:center}
.between{justify-content:space-between}
.qrwrap{display:flex;justify-content:center;align-items:center;margin:10px 0}
.qrwrap img{width:160px;height:160px}
.meta{font-size:12px;color:#6b7280;text-align:center;margin-top:6px;word-break:break-all}
@media print { body{background:#fff} .print-hidden{display:none !important} }
</style>
</head><body>
<h2 class="print-hidden" style="margin:8px 0 16px 0;font-size:18px">${esc(title)}</h2>
<div class="grid">${cardsHtml}</div>
<script>
(function(){
function printWhenReady(){
var imgs = Array.prototype.slice.call(document.images);
if(imgs.length === 0){ window.print(); return; }
var remaining = imgs.length;
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 200); } }
imgs.forEach(function(img){
if (img.complete) { done(); }
else {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}
});
}
if (document.readyState === 'complete') printWhenReady();
else window.addEventListener('load', printWhenReady);
})();
</script>
</body></html>`);
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);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.30.0",
"version": "1.30.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.30.0",
"version": "1.30.1",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.30.0",
"version": "1.30.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",