Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15b73d5d9e | ||
|
|
c74a943709 | ||
|
|
28c8fe9715 | ||
|
|
2e7bf263ee | ||
|
|
377630473d | ||
|
|
286cf09dd8 |
@@ -7,6 +7,22 @@ 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
|
||||
|
||||
- The register form asks for an optional **Donation amount** under the ticket options. Pick a suggested amount (worked out from the ticket total), enter a custom amount (min R15), or choose None. The donation shows in the total and is paid in the same Yoco checkout as the tickets. It's recorded as a separate donation to the event, so it never counts towards the registration's balance.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Event pages with a waitlist crashed with "Attempted to call soldOutLines() from the server". The sold-out helper now lives in `lib/waitlist.ts` so the server page can call it.
|
||||
|
||||
## [1.29.1] - 2026-10-21
|
||||
|
||||
### Changed
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.29.1",
|
||||
"version": "1.30.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.29.1",
|
||||
"version": "1.30.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.29.1",
|
||||
"version": "1.30.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -935,7 +935,9 @@ function checkoutLineItems(registration) {
|
||||
|
||||
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
|
||||
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
|
||||
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
|
||||
// donationAmount (optional) is added on top as its own line; the webhook splits it off into a
|
||||
// separate donation payment using the amount carried in the checkout metadata.
|
||||
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount = 0 } = {}) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
@@ -961,7 +963,8 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
|
||||
if (remainingAmount <= 0) throw new Error('This registration is already paid in full');
|
||||
|
||||
const amountInCents = Math.round(remainingAmount * 100);
|
||||
const donationInCents = Math.round((Number(donationAmount) || 0) * 100);
|
||||
const amountInCents = Math.round(remainingAmount * 100) + donationInCents;
|
||||
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
|
||||
const lineItems = checkoutLineItems(registration);
|
||||
|
||||
@@ -974,11 +977,17 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
});
|
||||
}
|
||||
// Paying full remaining — no "Partial Payment" line needed
|
||||
if (donationInCents > 0) {
|
||||
lineItems.push({ displayName: 'Donation', quantity: 1, pricingDetails: { price: donationInCents } });
|
||||
}
|
||||
|
||||
const checkoutData = {
|
||||
amount: amountInCents,
|
||||
currency: 'ZAR',
|
||||
metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' },
|
||||
metadata: {
|
||||
registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment',
|
||||
...(donationInCents > 0 ? { donationAmount: (donationInCents / 100).toFixed(2) } : {})
|
||||
},
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
failureUrl,
|
||||
@@ -999,7 +1008,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
return {
|
||||
checkoutId: response.data.id,
|
||||
redirectUrl: response.data.redirectUrl,
|
||||
amount: remainingAmount,
|
||||
amount: amountInCents / 100,
|
||||
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title }
|
||||
};
|
||||
}
|
||||
@@ -1009,7 +1018,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
// @access Private
|
||||
const createYocoCheckout = async (req, res) => {
|
||||
try {
|
||||
const { registrationId, eventId, amount, successUrl, cancelUrl, failureUrl } = req.body;
|
||||
const { registrationId, eventId, amount, donationAmount, successUrl, cancelUrl, failureUrl } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Validate required parameters for either registration payment or donation
|
||||
@@ -1096,7 +1105,15 @@ const createYocoCheckout = async (req, res) => {
|
||||
return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } });
|
||||
}
|
||||
|
||||
const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl });
|
||||
// Optional donation added at registration ("Donation amount" on the register form)
|
||||
let donation = 0;
|
||||
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
|
||||
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
|
||||
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
|
||||
if (donation > 0 && donation < 15) { res.status(400); throw new Error('Minimum donation is R15'); }
|
||||
}
|
||||
|
||||
const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation });
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -314,11 +314,18 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
} catch (e) { /* ignore — payment will fail below if still null */ }
|
||||
}
|
||||
|
||||
// A registration checkout can carry a donation on top (metadata.donationAmount). It's
|
||||
// recorded as its own donation payment so it never counts towards the registration.
|
||||
const grossAmount = amount / 100; // Convert cents to your currency unit
|
||||
const donationPart = registration?.id
|
||||
? Math.min(Math.max(Number(metadata?.donationAmount) || 0, 0), grossAmount)
|
||||
: 0;
|
||||
|
||||
// Create payment record
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(), // Generate a UUID for the payment
|
||||
amount: amount / 100, // Convert cents to your currency unit
|
||||
amount: Math.round((grossAmount - donationPart) * 100) / 100,
|
||||
method: method.type, // The type of payment from the webhook
|
||||
status: status === 'succeeded' ? 'completed' : status,
|
||||
externalId: yocoPaymentId,
|
||||
@@ -358,6 +365,24 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
}
|
||||
});
|
||||
|
||||
let donationPayment = null;
|
||||
if (donationPart > 0) {
|
||||
donationPayment = await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
amount: donationPart,
|
||||
method: method.type,
|
||||
status: status === 'succeeded' ? 'completed' : status,
|
||||
userId: resolvedUserId,
|
||||
recordedById: resolvedUserId,
|
||||
registrationId: null,
|
||||
eventId: registration.eventId,
|
||||
isDonation: true,
|
||||
originalPaymentId: payment.id,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If this payment is for a registration, update the registration status
|
||||
let updatedRegistration = null;
|
||||
let generatedTickets = [];
|
||||
@@ -392,6 +417,7 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
|
||||
// Send emails: payment confirmation first, then tickets (guarantees order)
|
||||
const _whPaymentId = payment.id;
|
||||
const _whDonationId = donationPayment?.id;
|
||||
const _whUserId = registration?.userId;
|
||||
const _whRegId = registration?.id;
|
||||
const _whTicketsGenerated = generatedTickets.length > 0;
|
||||
@@ -402,6 +428,14 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
} catch (e) {
|
||||
console.error('Failed to send webhook payment emails:', e);
|
||||
}
|
||||
if (_whDonationId) {
|
||||
try {
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
await sendPaymentEmails(_whDonationId);
|
||||
} catch (e) {
|
||||
console.error('Failed to send webhook donation emails:', e);
|
||||
}
|
||||
}
|
||||
if (_whTicketsGenerated && _whUserId && _whRegId) {
|
||||
const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.29.1",
|
||||
"version": "1.30.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.29.1",
|
||||
"version": "1.30.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.29.1",
|
||||
"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">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import ClientActions from "@/app/events/[id]/ClientActions";
|
||||
import { ContactButton } from "@/components/events/ContactButton";
|
||||
import { LocationMap } from "@/components/events/LocationMap";
|
||||
import { JoinWaitlistButton, soldOutLines } from "@/components/events/JoinWaitlistButton";
|
||||
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
import { soldOutLines } from "@/lib/waitlist";
|
||||
import { SponsorSpot } from "@/components/events/SponsorSpot";
|
||||
import { FunnelTracker } from "@/components/events/FunnelTracker";
|
||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
@@ -18,6 +18,8 @@ function FormsContent() {
|
||||
const router = useRouter();
|
||||
const { token } = useAuth();
|
||||
const registrationId = search.get("registrationId");
|
||||
// Donation chosen on the register form — passed through so the success page can add it to the payment.
|
||||
const donationParam = search.get("donation") ? `&donation=${encodeURIComponent(search.get("donation")!)}` : "";
|
||||
// Set when a named attendee opens the link sent to them — show only their form.
|
||||
const onlyAttendeeId = search.get("attendeeId");
|
||||
|
||||
@@ -119,7 +121,7 @@ function FormsContent() {
|
||||
if (payload.length === 0) {
|
||||
// Nothing left to submit – still go to success page to continue the flow
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
|
||||
return;
|
||||
}
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
|
||||
@@ -129,7 +131,7 @@ function FormsContent() {
|
||||
});
|
||||
// On success, redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to submit forms');
|
||||
}
|
||||
@@ -149,7 +151,7 @@ function FormsContent() {
|
||||
try {
|
||||
// Redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failded to go to next step');
|
||||
}
|
||||
@@ -163,7 +165,7 @@ function FormsContent() {
|
||||
try {
|
||||
// Redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failded to skip');
|
||||
}
|
||||
|
||||
@@ -257,6 +257,77 @@ function OptionCard({
|
||||
);
|
||||
}
|
||||
|
||||
// Online donations have the same R15 minimum as the donate page and the backend.
|
||||
const MIN_DONATION = 15;
|
||||
|
||||
// Suggested donations: 10/15/20/25% of the ticket total rounded up to the next R10 (at least R20),
|
||||
// topped up with fixed amounts when the percentages collapse on a small total.
|
||||
function donationPresets(total: number): number[] {
|
||||
const out: number[] = [];
|
||||
for (const pct of [0.10, 0.15, 0.20, 0.25]) {
|
||||
const v = Math.max(20, Math.ceil((total * pct) / 10) * 10);
|
||||
if (!out.includes(v)) out.push(v);
|
||||
}
|
||||
for (const v of [50, 100, 200]) {
|
||||
if (out.length >= 3) break;
|
||||
if (!out.includes(v) && v > out[out.length - 1]) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type DonationChoice = { kind: "none" } | { kind: "preset"; index: number } | { kind: "custom" };
|
||||
|
||||
function DonationPicker({
|
||||
presets,
|
||||
choice,
|
||||
onChoiceChange,
|
||||
customAmount,
|
||||
onCustomAmountChange,
|
||||
}: {
|
||||
presets: number[];
|
||||
choice: DonationChoice;
|
||||
onChoiceChange: (c: DonationChoice) => void;
|
||||
customAmount: string;
|
||||
onCustomAmountChange: (v: string) => void;
|
||||
}) {
|
||||
const btn = (active: boolean) =>
|
||||
`px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${active ? "bg-brand-600 border-brand-600 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`;
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-white shadow-sm">
|
||||
<div className="font-medium text-sm">Donation amount</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5 mb-3">Would you like to add a donation to support this event? It's added to your payment.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presets.map((amt, i) => (
|
||||
<button key={i} type="button" className={btn(choice.kind === "preset" && choice.index === i)} onClick={() => onChoiceChange({ kind: "preset", index: i })}>
|
||||
R{amt}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className={btn(choice.kind === "custom")} onClick={() => onChoiceChange({ kind: "custom" })}>
|
||||
Custom
|
||||
</button>
|
||||
<button type="button" className={btn(choice.kind === "none")} onClick={() => onChoiceChange({ kind: "none" })}>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
{choice.kind === "custom" && (
|
||||
<div className="mt-3">
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_DONATION}
|
||||
step="1"
|
||||
inputMode="decimal"
|
||||
autoFocus
|
||||
placeholder={`Amount in Rand (min R${MIN_DONATION})`}
|
||||
value={customAmount}
|
||||
onChange={(e) => onCustomAmountChange(e.target.value)}
|
||||
className="w-full sm:w-60 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
|
||||
const { token, user } = useAuth();
|
||||
const eventId = baseEvent.id;
|
||||
@@ -367,6 +438,17 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
// a code, so its total wins.
|
||||
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : total;
|
||||
|
||||
// Optional donation on top of the tickets — paid in the same Yoco checkout and recorded
|
||||
// as a separate donation payment. Only offered when there's something to pay online.
|
||||
const [donationChoice, setDonationChoice] = useState<DonationChoice>({ kind: "none" });
|
||||
const [customDonation, setCustomDonation] = useState("");
|
||||
const presets = useMemo(() => donationPresets(total), [total]);
|
||||
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut;
|
||||
const donation = !showDonation ? 0
|
||||
: donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0)
|
||||
: donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100)
|
||||
: 0;
|
||||
|
||||
function buildItems() {
|
||||
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
|
||||
Object.entries(quantities).forEach(([key, qty]) => {
|
||||
@@ -391,6 +473,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate);
|
||||
if (missing) { setError(missing); return; }
|
||||
}
|
||||
if (showDonation && donationChoice.kind === "custom" && donation > 0 && donation < MIN_DONATION) {
|
||||
setError(`The minimum donation is R${MIN_DONATION}. Choose None to skip it.`);
|
||||
return;
|
||||
}
|
||||
const donationParam = donation > 0 ? `&donation=${encodeURIComponent(donation.toFixed(2))}` : "";
|
||||
|
||||
// Claim link without logging in: the link identifies the person.
|
||||
if (waitlistToken && !isLoggedIn) {
|
||||
@@ -435,8 +522,8 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
|
||||
const due = res.totalDueComputed ?? payable;
|
||||
const next = hasForm
|
||||
? `/forms?registrationId=${encodeURIComponent(res.id)}`
|
||||
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}`;
|
||||
? `/forms?registrationId=${encodeURIComponent(res.id)}${donationParam}`
|
||||
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}${donationParam}`;
|
||||
if (res.couponError) {
|
||||
// The code ran out between checking and registering — the registration is
|
||||
// saved at full price, so say so before moving on.
|
||||
@@ -573,6 +660,18 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDonation && (
|
||||
<div className="mt-3">
|
||||
<DonationPicker
|
||||
presets={presets}
|
||||
choice={donationChoice}
|
||||
onChoiceChange={setDonationChoice}
|
||||
customAmount={customDonation}
|
||||
onCustomAmountChange={setCustomDonation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
// Collect all upcoming early-bird tiers across all options
|
||||
const now = new Date();
|
||||
@@ -623,7 +722,10 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
{coupon && coupon.discount > 0 && (
|
||||
<div className="text-sm text-green-700">Code {coupon.code} −R{coupon.discount.toFixed(2)}</div>
|
||||
)}
|
||||
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
|
||||
{donation > 0 && (
|
||||
<div className="text-sm text-gray-600">Donation +R{donation.toFixed(2)}</div>
|
||||
)}
|
||||
<div className="text-lg font-semibold">Total: R{(payable + donation).toFixed(2)}</div>
|
||||
</div>
|
||||
{eventSoldOut ? (
|
||||
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
|
||||
|
||||
@@ -21,6 +21,13 @@ function RegistrationSuccessContent() {
|
||||
return isNaN(n) ? 0 : n;
|
||||
}, [fallbackTotalParam]);
|
||||
|
||||
// Optional donation picked on the register form; added to the Yoco checkout as its own line.
|
||||
const donationParam = search.get("donation");
|
||||
const donation = React.useMemo(() => {
|
||||
const n = donationParam ? Number(donationParam) : 0;
|
||||
return isNaN(n) || n < 15 ? 0 : Math.round(n * 100) / 100;
|
||||
}, [donationParam]);
|
||||
|
||||
const [reg, setReg] = React.useState<Registration | null>(null);
|
||||
const [form, setForm] = React.useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
@@ -81,7 +88,7 @@ function RegistrationSuccessContent() {
|
||||
setError(null);
|
||||
setPayLoading(true);
|
||||
const { createFullPaymentCheckout } = await import('@/lib/api');
|
||||
const res = await createFullPaymentCheckout(token, registrationId);
|
||||
const res = await createFullPaymentCheckout(token, registrationId, donation);
|
||||
if (res.priceUpdated) {
|
||||
// Early-bird price changed since registration — surface the new total and
|
||||
// reload the registration so the displayed totalDue reflects it, instead of redirecting.
|
||||
@@ -123,7 +130,14 @@ function RegistrationSuccessContent() {
|
||||
<div className="text-sm">Please complete one form per main ticket to receive tickets. You can also do this later from your dashboard, but tickets cannot be generated until completed.</div>
|
||||
</div>
|
||||
) : (totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? (
|
||||
<p className="text-gray-700 mb-6">Would you like to pay with Yoco now?</p>
|
||||
<>
|
||||
{donation > 0 && (
|
||||
<p className="text-gray-700 mb-2">
|
||||
Thank you for adding a donation of <strong>R{donation.toFixed(2)}</strong>. It's included when you pay now.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-700 mb-6">Would you like to pay with Yoco now?</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 mt-4">
|
||||
|
||||
@@ -4,8 +4,9 @@ import React, { useState } from "react";
|
||||
import { ListPlus, X } from "lucide-react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import type { WaitlistLine } from "@/lib/waitlist";
|
||||
|
||||
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
|
||||
export type { WaitlistLine };
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
@@ -125,21 +126,3 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
|
||||
function keyOf(l?: WaitlistLine) {
|
||||
return l ? `${l.eventOptionId}::${l.variantId || ""}` : "";
|
||||
}
|
||||
|
||||
/** The sold-out lines of an event, for the waitlist button. */
|
||||
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
|
||||
const out: WaitlistLine[] = [];
|
||||
for (const o of options || []) {
|
||||
const optOut = (o.stockLimit ?? 0) > 0 && o.availableCount !== undefined && o.availableCount !== null && o.availableCount <= 0;
|
||||
const variants = o.variants || [];
|
||||
if (variants.length > 0) {
|
||||
for (const v of variants) {
|
||||
const vOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount !== null && v.availableCount <= 0;
|
||||
if (optOut || vOut) out.push({ eventOptionId: o.id, variantId: v.id, label: `${o.name} (${v.name})` });
|
||||
}
|
||||
} else if (optOut) {
|
||||
out.push({ eventOptionId: o.id, label: o.name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -203,12 +203,13 @@ export function fetchAllPayments(token: string, params: Record<string, string> =
|
||||
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
|
||||
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
|
||||
|
||||
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<CheckoutResponse> {
|
||||
export function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
|
||||
return apiFetch<CheckoutResponse>("/api/payments/yoco-checkout", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId,
|
||||
...(donationAmount && donationAmount > 0 ? { donationAmount } : {}),
|
||||
successUrl: window.location.origin + "/payment/success",
|
||||
cancelUrl: window.location.origin + "/payment/cancel",
|
||||
failureUrl: window.location.origin + "/payment/failure",
|
||||
|
||||
@@ -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 `
|
||||
<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);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Pure helpers shared by server and client components. Must not be "use client",
|
||||
// or server pages calling these get a client reference instead of the function.
|
||||
|
||||
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
|
||||
|
||||
/** The sold-out lines of an event, for the waitlist button. */
|
||||
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
|
||||
const out: WaitlistLine[] = [];
|
||||
for (const o of options || []) {
|
||||
const optOut = (o.stockLimit ?? 0) > 0 && o.availableCount !== undefined && o.availableCount !== null && o.availableCount <= 0;
|
||||
const variants = o.variants || [];
|
||||
if (variants.length > 0) {
|
||||
for (const v of variants) {
|
||||
const vOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount !== null && v.availableCount <= 0;
|
||||
if (optOut || vOut) out.push({ eventOptionId: o.id, variantId: v.id, label: `${o.name} (${v.name})` });
|
||||
}
|
||||
} else if (optOut) {
|
||||
out.push({ eventOptionId: o.id, label: o.name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.29.1",
|
||||
"version": "1.30.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.29.1",
|
||||
"version": "1.30.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.29.1",
|
||||
"version": "1.30.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user