Compare commits

..
15 Commits
Author SHA1 Message Date
joshua 6a8ef62630 Merge branch 'feat/early-bird-ribbon' 2026-10-22 13:48:29 +02:00
joshuaandClaude Opus 5.5 dbac248d8f Bump to 1.32.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:48:28 +02:00
joshuaandClaude Opus 5.5 b1af901d6a Show an early-bird ribbon on event cards
Event cards on the home page and events list get a diagonal "Early bird"
ribbon in the top-right corner while an early-bird tier is running and
registration is open. GET /api/events returns earlyBirdEndsAt per event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:48:22 +02:00
joshua 0bddb329e3 Merge branch 'feat/google-site-verification' 2026-10-22 11:43:01 +02:00
joshuaandClaude Opus 5.5 be4b6f02ea Bump to 1.31.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:43:00 +02:00
joshuaandClaude Opus 5.5 880e3206cc Add Google Search Console verification setting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:42:56 +02:00
joshuaandClaude Opus 5.5 7b2bfc31e6 Merge branch 'feat/seo-and-traffic-sources'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:17 +02:00
joshuaandClaude Opus 5.5 963c1824bf Bump to 1.31.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:15 +02:00
joshuaandClaude Opus 5.5 38262022ef Add event structured data, canonical URLs and referrer-based traffic sources
- Event pages emit schema.org Event JSON-LD (dates, venue, image,
  organizer, ZAR offers, sold-out status) for Google event results.
- Event pages, the events list and home page set a canonical URL so
  tagged and alias links don't split rankings.
- Funnel tracking falls back to the referring site (Facebook, Instagram,
  Google, ...) and fbclid/gclid when a link has no utm_source/src.
- Share and Save QR on the event page tag their links as share / qr.
- getServerSettings moves to lib/serverSettings.ts for reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:26:02 +02:00
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
joshuaandClaude Opus 5.5 2e7bf263ee Merge branch 'feat/registration-donation'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:29:17 +02:00
joshuaandClaude Opus 5.5 377630473d Bump to 1.30.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:29:11 +02:00
joshuaandClaude Opus 5.5 286cf09dd8 Add optional donation to registration; fix soldOutLines server crash
The register form now offers a "Donation amount" (suggested amounts,
custom, or none) under the ticket options. The donation is added to the
Yoco checkout as its own line and the webhook records it as a separate
donation payment linked to the registration payment.

Move soldOutLines() out of the "use client" JoinWaitlistButton module
into lib/waitlist.ts so the server-rendered event page can call it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:27:47 +02:00
37 changed files with 701 additions and 328 deletions
+40
View File
@@ -7,6 +7,46 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.32.0] - 2026-10-22
### Added
- Event cards on the home page and events list show a diagonal "Early bird" ribbon in the top-right corner while the event has an early-bird tier running (deadline not passed and places left on that tier). It isn't shown when the event is sold out, registration is closed or not open yet, or the event is contact-only.
## [1.31.1] - 2026-10-22
### Added
- Settings → Organisation has a "Google Search Console verification" field. Paste the HTML tag from Search Console and the site outputs it on every page, so each deployment can verify its own domain without uploading a file.
## [1.31.0] - 2026-10-22
### Added
- The registration funnel's traffic source works without tagged links: visits from Facebook, Instagram, Google, X, LinkedIn and other sites are recognised from where the visitor came from (and Facebook's/Google's click ids). Links without a tag used to count as "direct". WhatsApp still only shows from tagged links (`?utm_source=whatsapp`), since it doesn't pass that on.
- The event page's Share button shares the link tagged `?utm_source=share`, and Save QR encodes it tagged `?utm_source=qr`, so those visits show separately.
- Event pages tell search engines about the event in Google's event format (schema.org `Event`): name, dates, venue, picture, organiser, ticket prices in Rand and whether it's sold out. Google can then show events with their date, venue and ticket link in search results and in its event listings. Contact-only events are listed without tickets.
### Changed
- Event pages, the events list and the home page tell search engines their one official address, so `?utm_source=…`, `?src=…` and alias links all count towards the same page in search rankings. Shared event links also carry the event's address.
## [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
+3 -1
View File
@@ -56,7 +56,9 @@ Four roles, each including everything the roles below it can do:
and in-progress events, and a "Join Us" button (becomes "My Dashboard" when
logged in).
- **Events list** (`/events`) — event cards with picture, dates, location,
price, and a Register / Sold Out / Contact us button.
price, and a Register / Sold Out / Contact us button. A diagonal "Early bird"
ribbon in the top-right corner while an early-bird tier is running and
registration is open.
- **Event detail** (`/events/[id]`) — description, ticket options with
variant and early-bird pricing, low-stock and sold-out badges, downloadable
attachments, embedded Google Map with "Directions" link, and an
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.29.1",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.29.1",
"version": "1.32.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.29.1",
"version": "1.32.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+44 -2
View File
@@ -190,6 +190,45 @@ const createEvent = async (req, res) => {
}
};
// Map of eventId → when its early bird ends, for events with at least one early-bird
// tier still running: deadline not passed and (no stock limit, or places left, counted
// across tranches the same way as utils/pricing.js). The date is the latest deadline
// among the running tiers.
async function earlyBirdEndsByEvent(events) {
const now = new Date();
const running = [];
for (const ev of events) {
for (const opt of ev.eventOptions || []) {
for (const tier of opt.earlyBirdTiers || []) {
if (new Date(tier.deadline) > now) running.push({ eventId: ev.id, tier });
}
}
}
const limitedIds = running.filter(r => r.tier.stockLimit > 0).map(r => r.tier.id);
const sold = new Map();
if (limitedIds.length > 0) {
const totals = await prisma.registrationOptionTranche.groupBy({
by: ['appliedTierId'],
where: {
appliedTierId: { in: limitedIds },
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
_sum: { quantity: true }
});
for (const t of totals) sold.set(t.appliedTierId, t._sum?.quantity || 0);
}
const ends = new Map();
for (const { eventId, tier } of running) {
if (tier.stockLimit > 0 && (sold.get(tier.id) || 0) >= tier.stockLimit) continue;
const deadline = new Date(tier.deadline);
const current = ends.get(eventId);
if (!current || deadline > current) ends.set(eventId, deadline);
}
return ends;
}
// @desc Get all events
// @route GET /api/events
// @access Public
@@ -220,9 +259,12 @@ const getEvents = async (req, res) => {
});
}
const earlyBirdEnds = await earlyBirdEndsByEvent(events).catch(() => new Map());
// Compute isSoldOut per event: true when every option that has a stock limit
// is fully sold out. Events with no limited options are never sold out.
const withStock = await Promise.all(events.map(async ev => {
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
try {
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
let isSoldOut = false;
@@ -231,9 +273,9 @@ const getEvents = async (req, res) => {
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
}
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut, earlyBirdEndsAt };
} catch {
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false };
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false, earlyBirdEndsAt };
}
}));
+23 -6
View File
@@ -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);
}
@@ -21,6 +21,8 @@ const PUBLIC_KEYS = [
// the admin re-saves the Branding tab).
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
'setup_complete', 'app_base_url',
// Google Search Console "HTML tag" code, output as a meta tag on every page
'google_site_verification',
// Legal pages
'legal_operator_name', 'legal_io_name', 'legal_io_email',
'legal_website_url', 'legal_effective_date',
+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();
}
+35 -1
View File
@@ -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: () => {} };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.29.1",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.29.1",
"version": "1.32.0",
"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.29.1",
"version": "1.32.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -32,6 +32,12 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
/** The code from Search Console's verification meta tag; accepts the whole tag or just the code. */
function googleVerificationCode(input: string): string {
const m = input.match(/content\s*=\s*["']([^"']*)["']/i);
return (m ? m[1] : input).trim();
}
function Field({
label, hint, required, children,
}: {
@@ -104,6 +110,7 @@ function SiteSettingsPageInner() {
const [orgPhone, setOrgPhone] = useState("");
const [orgAddress, setOrgAddress] = useState("");
const [appBaseUrl, setAppBaseUrl] = useState("");
const [googleVerification, setGoogleVerification] = useState("");
// ── Branding ──
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
@@ -153,6 +160,7 @@ function SiteSettingsPageInner() {
setOrgPhone(s.org_phone || "");
setOrgAddress(s.org_address || "");
setAppBaseUrl(s.app_base_url || "");
setGoogleVerification(s.google_site_verification || "");
// primary_color falls back to the legacy accent_color value (which used
// to be "the one brand color" before this 3-color system existed).
// accent_color is only trusted as the NEW tertiary color once
@@ -209,6 +217,7 @@ function SiteSettingsPageInner() {
org_phone: orgPhone.trim(),
org_address: orgAddress.trim(),
app_base_url: appBaseUrl.trim(),
google_site_verification: googleVerificationCode(googleVerification),
});
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
@@ -432,6 +441,10 @@ function SiteSettingsPageInner() {
<input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field>
<Field label="Google Search Console verification" hint="In Search Console, add this site as a URL-prefix property and choose the HTML tag method. Paste the tag (or just its content code) here and save, then click Verify in Search Console. Leave it set to stay verified.">
<input className={inputCls} placeholder='<meta name="google-site-verification" content="..." />'
value={googleVerification} onChange={e => setGoogleVerification(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveOrganisation} result={result} />
</div>
)}
@@ -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(() => {
@@ -6,6 +6,12 @@ import { API_BASE } from "@/lib/api";
type Props = { event: { id: string; title: string; description?: string | null } };
// The event's own address tagged with where the link was handed out, so visits from it
// show under that source in the funnel report (the page's canonical drops the tag for SEO).
function taggedEventUrl(source: string): string {
return `${window.location.origin}${window.location.pathname}?utm_source=${source}`;
}
export default function ClientActions({ event }: Props) {
return (
<div className="flex gap-2">
@@ -15,7 +21,7 @@ export default function ClientActions({ event }: Props) {
await navigator.share({
title: event.title,
text: event.description ?? undefined,
url: window.location.href,
url: taggedEventUrl("share"),
});
} catch (err) {
console.error("Error sharing:", err);
@@ -29,7 +35,7 @@ export default function ClientActions({ event }: Props) {
<button
onClick={async () => {
try {
const url = window.location.href;
const url = taggedEventUrl("qr");
const qrDataUrl = await QRCode.toDataURL(url);
const link = document.createElement("a");
link.href = qrDataUrl;
+19 -1
View File
@@ -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";
@@ -53,6 +54,9 @@ import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
import { BundleOffers } from "@/components/shared/BundleDeals";
import type { PublicBundle } from "@/types";
import { appUrl, appName } from "@/lib/siteConfig";
import { getServerSettings } from "@/lib/serverSettings";
import { eventJsonLd, jsonLdString } from "@/lib/eventSchema";
// Tiered low-stock threshold: larger events use a smaller percentage.
// Math.round avoids Math.ceil inflating the threshold (e.g. ceil(1.5)=2 made
@@ -146,10 +150,14 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
return {
title: event.title,
description,
// One address per event for search engines, however the page was reached
// (?utm_source=…, ?src=…, an alias link).
alternates: { canonical: `/events/${event.id}` },
openGraph: {
title: event.title,
description,
type: "website",
url: `/events/${event.id}`,
...(imageUrl ? { images: [{ url: imageUrl }] } : {}),
},
twitter: {
@@ -166,8 +174,18 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
const event = await loadEvent(id);
if (!event) notFound();
const settings = await getServerSettings();
const siteUrl = appUrl.replace(/\/$/, "");
const jsonLd = eventJsonLd(event, {
pageUrl: `${siteUrl}/events/${event.id}`,
imageUrl: event.picture ? resolveToApiOrigin(event.picture) : null,
organizerName: settings.org_name || appName,
siteUrl,
});
return (
<div className="min-h-screen flex flex-col">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(jsonLd) }} />
<Navbar />
<main className="flex-1 py-8 px-4 max-w-6xl mx-auto w-full">
<FunnelTracker eventId={event.id} stage="view" />
+1
View File
@@ -10,6 +10,7 @@ export const revalidate = 60;
export const metadata: Metadata = {
title: "All Events",
description: "Browse and register for upcoming events.",
alternates: { canonical: "/events" },
};
type Event = {
+6 -4
View File
@@ -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');
}
+4 -23
View File
@@ -2,40 +2,19 @@ import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext";
import type { SiteSettings } from "@/contexts/SiteSettingsContext";
import { ToastProvider } from "@/components/shared/ToastProvider";
import { BannerBar } from "@/components/shared/BannerBar";
import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { CookieConsent } from "@/components/shared/CookieConsent";
import { ImpersonationBanner } from "@/components/shared/ImpersonationBanner";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { resolveToApiOrigin } from "@/lib/api";
import { getServerSettings } from "@/lib/serverSettings";
import { buildThemeCssVars } from "@/lib/colorScale";
import { appUrl } from "@/lib/siteConfig";
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
/**
* Server-side settings fetch, shared by generateMetadata() and RootLayout()
* below. Both call this with the identical URL + options so Next's per-request
* fetch memoization dedupes them into a single network call. Degrades to `{}`
* (static defaults everywhere downstream) if the backend is unreachable at
* request time — intentional, not a bug: branding just isn't critical enough
* to fail the whole page render over.
*/
async function getServerSettings(): Promise<SiteSettings> {
// No API base configured: a relative fetch never settles during prerender and
// would time out every page, so skip straight to the defaults.
if (!API_BASE) return {};
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return {};
return await res.json();
} catch {
return {};
}
}
export async function generateMetadata(): Promise<Metadata> {
const settings = await getServerSettings();
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
@@ -63,6 +42,8 @@ export async function generateMetadata(): Promise<Metadata> {
description,
...(logoUrl ? { images: [logoUrl] } : {}),
},
// Per-deployment Search Console ownership check (Settings → Organisation).
...(settings.google_site_verification ? { verification: { google: settings.google_site_verification } } : {}),
};
}
+5
View File
@@ -5,8 +5,13 @@ import { Footer } from "@/components/layout/Footer";
import { JoinUsButton } from "@/components/home/JoinUsButton";
import { appName } from "@/lib/siteConfig";
import { API_BASE } from "@/lib/api";
import type { Metadata } from "next";
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
export const metadata: Metadata = {
alternates: { canonical: "/" },
};
type Event = {
id: string;
title: string;
@@ -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&apos;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">
+16 -2
View File
@@ -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&apos;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">
+24 -1
View File
@@ -19,6 +19,8 @@ import { ContactButton } from "@/components/events/ContactButton";
contactPhone?: string | null;
contactEmail?: string | null;
location?: string | null;
// Set by GET /api/events while an early-bird tier is still running.
earlyBirdEndsAt?: string | null;
};
import { formatDateTimeRange } from "@/lib/date";
@@ -26,9 +28,30 @@ import { formatDateTimeRange } from "@/lib/date";
export const EventCard = ({ event }: { event: Event }) => {
const dateRange = formatDateTimeRange(event.startDate, event.endDate);
const now = new Date();
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
const registrationOpen = event.requiresRegistration !== false
&& !(goLive && now < goLive)
&& !(deadline && now >= deadline)
&& now < new Date(event.endDate);
const showEarlyBird = !!event.earlyBirdEndsAt && registrationOpen && !event.isSoldOut;
return (
<div className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md hover:border-brand-200 transition">
<div className="relative border rounded-xl overflow-hidden shadow-sm hover:shadow-md hover:border-brand-200 transition">
{showEarlyBird && (
<div className="pointer-events-none absolute top-0 right-0 z-10 h-32 w-32 overflow-hidden" aria-hidden="true">
<div className="absolute top-[26px] -right-[38px] w-[150px] rotate-45 bg-amber-400 py-1 text-center text-xs font-bold uppercase tracking-wide text-amber-950 shadow-md">
Early bird
</div>
</div>
)}
<a href={`/events/${event.id}`} className="block">
{showEarlyBird && (
<span className="sr-only">
Early bird pricing until {new Date(event.earlyBirdEndsAt!).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}.
</span>
)}
{event.picture ? (
<ApiImage src={event.picture} alt={event.title} className="h-48 w-full object-cover" />
) : (
@@ -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;
}
+1 -1
View File
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
Preview your report, adjust filters, and choose how you want the data to appear.
</GuideItem>
<GuideItem icon={Download} title="Export or email" tone="amber">
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source (short links and utm_source links) and device. It counts only visitors who allowed cookies.
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026). The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page&apos;s Share and Save QR buttons tag their links as &quot;share&quot; and &quot;qr&quot;. WhatsApp doesn&apos;t say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as &quot;direct&quot;. It counts only visitors who allowed cookies.
</GuideItem>
</div>
</div>
@@ -25,6 +25,9 @@ export const siteSettingsHelpContent: HelpContent = {
<GuideItem icon={Building2} title="Site URL" tone="gray">
The public URL of this site, used in email links like password resets and ticket delivery.
</GuideItem>
<GuideItem icon={Building2} title="Google Search Console verification" tone="gray">
Proves to Google that you own this site, so you can see search traffic and submit <code>/sitemap.xml</code> in Search Console. Add the site there as a URL-prefix property (e.g. <code>https://events.yourchurch.org/</code>), pick the <strong>HTML tag</strong> method, paste the tag here and save, then click Verify. Each site has its own code. Google finds your events without this; it only unlocks the Search Console reports.
</GuideItem>
</div>
</div>
),
@@ -20,7 +20,7 @@ export const supervisorEventOptionsHelpContent: HelpContent = {
<p>Pick an event, then edit each option&apos;s name, price, and &quot;Main ticket&quot; flag directly in the list — changes save when you click away from the field. Use the form below the list to add a new option.</p>
<div className="space-y-4">
<GuideItem icon={Tag} title="Early-bird tiers" tone="amber">
Expand &quot;Manage early-bird tiers&quot; on an option to add deadline-and-price rows that apply automatically before the cutoff.
Expand &quot;Manage early-bird tiers&quot; on an option to add deadline-and-price rows that apply automatically before the cutoff. While a tier is running (before its deadline, with places left), the event&apos;s card on the home page and events list shows an &quot;Early bird&quot; ribbon in the top-right corner.
</GuideItem>
</div>
</div>
@@ -18,6 +18,7 @@ export type SiteSettings = {
logo_url?: string;
favicon_url?: string;
setup_complete?: string;
google_site_verification?: string;
// Legal pages
legal_operator_name?: string;
legal_io_name?: string;
+36 -2
View File
@@ -56,11 +56,45 @@ export function analyticsSessionId(): string | undefined {
}
}
/** Where the visitor came from: ?utm_source=… or ?src=<short-link alias>. */
// Referring sites we name ourselves; any other site is recorded by its hostname.
const REFERRER_SOURCES: [RegExp, string][] = [
[/(^|\.)(facebook\.com|fb\.com|fb\.me)$/, "facebook"],
[/(^|\.)instagram\.com$/, "instagram"],
[/(^|\.)(whatsapp\.com|wa\.me)$/, "whatsapp"],
[/(^|\.)google\.[a-z.]+$/, "google"],
[/(^|\.)bing\.com$/, "bing"],
[/(^|\.)duckduckgo\.com$/, "duckduckgo"],
[/(^|\.)yahoo\.com$/, "yahoo"],
[/(^|\.)(twitter\.com|x\.com|t\.co)$/, "x"],
[/(^|\.)(linkedin\.com|lnkd\.in)$/, "linkedin"],
[/(^|\.)(youtube\.com|youtu\.be)$/, "youtube"],
[/(^|\.)tiktok\.com$/, "tiktok"],
];
/** The referring site as a source label; undefined when there's none or it's this site. */
function sourceFromReferrer(): string | undefined {
try {
if (!document.referrer) return undefined;
const host = new URL(document.referrer).hostname.toLowerCase();
if (!host || host === window.location.hostname.toLowerCase()) return undefined;
for (const [re, label] of REFERRER_SOURCES) if (re.test(host)) return label;
return host.replace(/^www\./, "");
} catch {
return undefined;
}
}
/**
* Where the visitor came from: ?utm_source=… or ?src=<short-link alias> when the link
* says so, else the referring site (Facebook, Google, …), else Facebook's/Google's click
* ids. WhatsApp sends no referrer, so it's only known from a tagged link.
*/
function sourceFromUrl(): string | undefined {
try {
const p = new URLSearchParams(window.location.search);
return p.get("utm_source") || p.get("src") || undefined;
return p.get("utm_source") || p.get("src") || sourceFromReferrer()
|| (p.has("fbclid") ? "facebook" : undefined)
|| (p.has("gclid") ? "google" : undefined);
} catch {
return undefined;
}
+2 -1
View File
@@ -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",
+85
View File
@@ -0,0 +1,85 @@
// schema.org Event data for an event page, so Google can show the event with its date,
// venue and ticket link (event rich results / "events near me").
// Checked with https://search.google.com/test/rich-results
type SchemaOption = {
price: number;
stockLimit?: number;
availableCount?: number;
earlyBirdTiers?: { deadline: string; price: number }[];
variants?: { price: number | null; stockLimit?: number; availableCount?: number }[];
};
export type SchemaEvent = {
id: string;
title: string;
description?: string;
startDate: string;
endDate: string;
goLiveAt?: string;
picture?: string;
location?: string | null;
requiresRegistration?: boolean;
eventOptions?: SchemaOption[];
};
/** Today's price of each ticket (early-bird when one is open, else the variant/base price). */
function currentPrices(options: SchemaOption[], now: Date): number[] {
const prices: number[] = [];
for (const o of options) {
if (o.variants && o.variants.length > 0) {
for (const v of o.variants) prices.push(v.price ?? o.price);
continue;
}
const open = (o.earlyBirdTiers || []).filter((t) => now < new Date(t.deadline)).map((t) => t.price);
prices.push(open.length > 0 ? Math.min(...open) : o.price);
}
return prices.filter((p) => Number.isFinite(p));
}
export function eventJsonLd(
event: SchemaEvent,
{ pageUrl, imageUrl, organizerName, siteUrl }: { pageUrl: string; imageUrl: string | null; organizerName: string; siteUrl: string },
) {
const now = new Date();
const options = event.eventOptions || [];
const limited = options.filter((o) => (o.stockLimit ?? 0) > 0);
const soldOut = limited.length > 0 && limited.every((o) => o.availableCount !== undefined && o.availableCount <= 0);
// Contact-only events (requiresRegistration false) have no tickets to offer.
let offers: Record<string, unknown> | undefined;
if (event.requiresRegistration !== false) {
const prices = options.length > 0 ? currentPrices(options, now) : [0];
const low = Math.min(...prices);
const high = Math.max(...prices);
offers = {
"@type": low === high ? "Offer" : "AggregateOffer",
...(low === high ? { price: low.toFixed(2) } : { lowPrice: low.toFixed(2), highPrice: high.toFixed(2) }),
priceCurrency: "ZAR",
availability: soldOut ? "https://schema.org/SoldOut" : "https://schema.org/InStock",
url: `${siteUrl}/register/${event.id}`,
...(event.goLiveAt ? { validFrom: new Date(event.goLiveAt).toISOString() } : {}),
};
}
return {
"@context": "https://schema.org",
"@type": "Event",
name: event.title,
...(event.description ? { description: event.description.slice(0, 5000) } : {}),
startDate: new Date(event.startDate).toISOString(),
endDate: new Date(event.endDate).toISOString(),
eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
...(event.location ? { location: { "@type": "Place", name: event.location, address: event.location } } : {}),
...(imageUrl ? { image: [imageUrl] } : {}),
url: pageUrl,
organizer: { "@type": "Organization", name: organizerName, url: siteUrl },
...(offers ? { offers } : {}),
};
}
/** JSON for a <script type="application/ld+json">, safe against "</script>" in event text. */
export function jsonLdString(data: unknown): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
+24
View File
@@ -0,0 +1,24 @@
import type { SiteSettings } from "@/contexts/SiteSettingsContext";
import { API_BASE } from "@/lib/api";
/**
* Server-side settings fetch, shared by the root layout (generateMetadata and
* RootLayout) and server pages that need branding, e.g. the event page's organizer.
* Every caller uses the identical URL + options, so Next's per-request fetch
* memoization dedupes them into a single network call. Degrades to `{}`
* (static defaults everywhere downstream) if the backend is unreachable at
* request time — intentional, not a bug: branding just isn't critical enough
* to fail the whole page render over.
*/
export async function getServerSettings(): Promise<SiteSettings> {
// No API base configured: a relative fetch never settles during prerender and
// would time out every page, so skip straight to the defaults.
if (!API_BASE) return {};
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return {};
return await res.json();
} catch {
return {};
}
}
+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);
}
+22
View File
@@ -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;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.29.1",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.29.1",
"version": "1.32.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.29.1",
"version": "1.32.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",