Compare commits

..
Author SHA1 Message Date
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
joshuaandClaude Opus 5.5 27c870d46d Bump to 1.29.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:17 +02:00
joshuaandClaude Opus 5.5 a0c5a6a98c Merge branch 'feat/coupons-redesign'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:16 +02:00
joshuaandClaude Opus 5.5 e7969429b6 Redesign the Coupon codes page to match Bundle deals
Cards instead of the table, phone-friendly sheets for the form and the uses list,
inactive codes hidden by default, and the events picker shows upcoming events only
(with search and a Show past events option).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:16 +02:00
joshuaandClaude Opus 5.5 4a492a8b16 Bump to 1.29.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 294fff0b41 Merge branch 'feat/bundle-types'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 f33d04a57c Add bundle types: pick any, buy X get Y free, group discount
Bundles get a kind: exact (as before), mix (any setSize of the qualifying tickets for
a set price), free (cheapest freeCount in each set free) and group (setSize or more
qualifying tickets at a per-ticket price or % off, applied once). The supervisor page
has a type picker, buyers see a one-line description of each deal, and the Complete
bundle cards handle every type. Demo: Family Fun Day adds a Lunch for four deal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 6b186b3481 Bump to 1.28.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:03:22 +02:00
joshuaandClaude Opus 5.5 7ba40163db Fix the bundle edit form's ticket rows
The quantity input had both w-full and w-16; w-full won, filling the row and pushing
the ticket picker off the side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:03:21 +02:00
joshuaandClaude Opus 5.5 428b438251 Bump to 1.28.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:56:04 +02:00
joshuaandClaude Opus 5.5 6ce59af0ea Redesign the Bundle deals page
Upcoming events only by default (with a Show past events option), bundles as cards
instead of a table, and a mobile-friendly edit form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:56:03 +02:00
51 changed files with 1947 additions and 912 deletions
+74
View File
@@ -7,6 +7,80 @@ 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
- The Coupon codes page matches the Bundle deals page and works on phones. Codes show as cards with the discount, events, uses, restrictions, dates and limits. On phones, the edit form and the list of uses open full-width from the bottom, and the discount type is picked from three buttons.
- The coupons list hides codes that are off, expired or used up unless you tick "Show codes that are off, expired or used up". Search also matches event names.
- When creating or editing a code, the Events list shows upcoming events only, soonest first, with a search box and a "Show past events" option. Past events already on the code stay listed so they can be unticked.
## [1.29.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
### Added
- Bundle deals now come in four types, chosen when you create one:
- **Exact set**: these tickets in these numbers for one price, e.g. 2 adults + 3 kids for R220. This is how bundles worked before.
- **Pick any**: any mix of the qualifying tickets for a set price, e.g. any 4 of Adult or Child for R250, so 2 + 2 and 4 + 0 both count.
- **Buy X get Y free**: in every set of qualifying tickets the cheapest are free, e.g. buy 3, get 1 free.
- **Group discount**: from a group size up, every qualifying ticket costs less (a price per ticket or a percentage off), e.g. 10+ tickets: R70 each. It applies once, to the whole group, and never makes a ticket dearer.
- The deals list and the "Complete bundle" cards on the register form, at the door, manual registration and the kiosk describe each type in one line, e.g. "Any 4 of Adult, Child for R250". For a group discount the button reads "Complete group" and adds the tickets still needed.
- `npm run set-demo`: Family Fun Day also has a pick-any deal, "Lunch for four" (any 4 lunch packs for R220).
## [1.28.3] - 2026-10-21
### Fixed
- Bundle deals edit form: each ticket row's quantity box filled the whole row, which pushed the ticket picker off the side and added a sideways scrollbar. The quantity box is narrow again, with the ticket picker beside it.
## [1.28.2] - 2026-10-21
### Changed
- The Bundle deals page has a new layout that works on phones. It shows upcoming events only, soonest first, with a "Show past events" option. Bundles show as cards with their tickets, price, saving, uses and dates. On a phone, the edit form opens full-width from the bottom, and its price section shows the standard price, the bundle price and the saving side by side.
## [1.28.1] - 2026-10-21
### Added
+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
+1 -1
View File
@@ -68,7 +68,7 @@ The data is generated from a fixed seed, so every run looks the same.
| When (from today) | Event | Shows off |
|---|---|---|
| ~1½ weeks, Sat 07:30 | **Men's Breakfast: Built to Last** | **Sold out** (60 places), 5 people on the **waitlist**, card / cash / EFT payments recorded by staff, 2 cancelled registrations |
| ~2½ weeks, Sat 10:00 | **Family Fun Day** | Adult and child tickets with a **bundle deal** (Family deal: 2 adults + 3 kids for R220 instead of R280, used by about a third of registrations), a non-ticket **add-on** (lunch pack), a fixed-amount **coupon** (`FUNDAY20`), entry and food-stall check-in sections |
| ~2½ weeks, Sat 10:00 | **Family Fun Day** | Adult and child tickets with a **bundle deal** (Family deal: 2 adults + 3 kids for R220 instead of R280, used by about a third of registrations) and a **pick-any** deal (Lunch for four: any 4 lunch packs for R220), a non-ticket **add-on** (lunch pack), a fixed-amount **coupon** (`FUNDAY20`), entry and food-stall check-in sections |
| ~5 weeks, Fri–Sat | **Rooted Women's Conference** | Two **early-bird tiers** (one already expired, one still open) plus a Saturday-only ticket, T-shirt **variants** (S–XXL), a **required sign-up form** (dietary needs, workshop choice, prayer requests), a 20% **group coupon** (`ROOTED20`) used by 4 registrations |
| ~7 weeks, Mon–Thu | **Ignite Summer Camp** | **Named attendees** (campers aged 12–17), signed **parental consent**, gender collected, **deposits and part-payments**, campers placed in boys' and girls' **cabins** with staff leaders, **sponsor-a-spot** donations towards a 10-spot goal |
| ~8 weeks, Sun 19:00 | **Carols by Candlelight** | A **free event** with group tickets (1–6 people), plus general **donations** |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.28.1",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.28.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.28.1",
"version": "1.32.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,12 @@
-- CreateEnum
CREATE TYPE "BundleKind" AS ENUM ('exact', 'mix', 'free', 'group');
-- CreateEnum
CREATE TYPE "BundleGroupPricing" AS ENUM ('per_ticket', 'percent');
-- AlterTable
ALTER TABLE "EventBundle" ADD COLUMN "freeCount" INTEGER,
ADD COLUMN "groupPricing" "BundleGroupPricing",
ADD COLUMN "kind" "BundleKind" NOT NULL DEFAULT 'exact',
ADD COLUMN "setSize" INTEGER;
+41 -18
View File
@@ -914,29 +914,52 @@ model CouponRedemption {
// ─── Bundle deals ─────────────────────────────────────────────────────────────
// A set of tickets sold together for one price, e.g. 5 Adult + 3 Child for R500.
// Applied automatically, as many times as a registration's items allow, and only
// when it beats what the items would cost anyway (see utils/bundles.js).
// How a bundle is made up (see utils/bundles.js):
// exact: these tickets in these numbers, for `price`, e.g. 5 Adult + 3 Child for R500
// mix: any `setSize` of the qualifying tickets, for `price`
// free: any `setSize` of the qualifying tickets, the cheapest `freeCount` free
// group: `setSize` or more qualifying tickets, all at `price` each (or `price`% off)
enum BundleKind {
exact
mix
free
group
}
enum BundleGroupPricing {
per_ticket
percent
}
// Tickets sold together at a better price. Applied automatically, as many times as a
// registration's items allow (a group discount once), and only when it beats what the
// items would cost anyway (see utils/bundles.js).
model EventBundle {
id String @id @default(uuid())
eventId String
name String
description String?
price Float
isActive Boolean @default(true)
validFrom DateTime?
validUntil DateTime?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
items EventBundleItem[]
uses RegistrationBundle[]
id String @id @default(uuid())
eventId String
name String
description String?
kind BundleKind @default(exact)
// exact/mix: the set's price. group: the per-ticket price, or the percentage off.
price Float
setSize Int?
freeCount Int?
groupPricing BundleGroupPricing?
isActive Boolean @default(true)
validFrom DateTime?
validUntil DateTime?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
items EventBundleItem[]
uses RegistrationBundle[]
@@index([eventId])
}
// One line of a bundle. variantId null = any variant of the option.
// One line of a bundle: for exact bundles a ticket and how many; for the others a
// qualifying ticket (quantity unused). variantId null = any variant of the option.
model EventBundleItem {
id String @id @default(uuid())
bundleId String
+3 -2
View File
@@ -243,9 +243,9 @@ function addSection(event, name, options) {
return section;
}
/** A bundle deal: items are [option, quantity] pairs. */
/** A bundle deal: items are [option, quantity] pairs (quantity 1 for pick-any and the other kinds). */
function addBundle(event, data, items) {
const bundle = add('eventBundle', { id: uuidv4(), eventId: event.id, description: null, isActive: true, validFrom: null, validUntil: null, order: 0, createdAt: addTime(NOW, -30 * DAY), updatedAt: NOW, ...data });
const bundle = add('eventBundle', { id: uuidv4(), eventId: event.id, description: null, kind: 'exact', setSize: null, freeCount: null, groupPricing: null, isActive: true, validFrom: null, validUntil: null, order: 0, createdAt: addTime(NOW, -30 * DAY), updatedAt: NOW, ...data });
for (const [option, quantity] of items) add('eventBundleItem', { id: uuidv4(), bundleId: bundle.id, eventOptionId: option.id, variantId: null, quantity });
return bundle;
}
@@ -454,6 +454,7 @@ function buildFamilyFunDay(people) {
const child = addOption(event, { name: 'Child (3–12)', price: 40 });
const lunch = addOption(event, { name: 'Lunch pack', price: 65, isMainTicket: false });
addBundle(event, { name: 'Family deal', price: 220, description: 'Two adults and three kids, R60 less than buying them separately.' }, [[adult, 2], [child, 3]]);
addBundle(event, { name: 'Lunch for four', kind: 'mix', setSize: 4, price: 220, description: 'Any four lunch packs for R220.' }, [[lunch, 1]]);
add('coupon', {
id: uuidv4(), code: 'FUNDAY20', description: 'R20 off for first-time visitors', type: 'fixed', value: 20,
+101 -45
View File
@@ -2,11 +2,13 @@ const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { describeBundleItems, isBundleAvailable } = require('../utils/bundles');
const { KINDS, describeBundle, describeBundleItems, isBundleAvailable } = require('../utils/bundles');
const { previewBundles } = require('../utils/coupons');
const { basketBuyer, priceBasket } = require('./couponController');
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
const GROUP_PRICING = ['per_ticket', 'percent'];
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
@@ -16,13 +18,37 @@ const optionalDate = (v, label) => {
return d;
};
/** What the items would cost separately at the standard (variant or option) price. */
function standardValue(bundle) {
return round2((bundle.items || []).reduce((s, i) => {
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price;
return s + Number(price || 0) * Number(i.quantity || 0);
}, 0));
/** Standard (variant or option) price of one unit of an item. */
function itemPrice(i) {
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
}
/**
* What the deal is worth at standard prices: `standardValue` is what an exact set costs
* separately (or the dearest pick-any set); `savingText` is a short "save …" line, or
* null when the headline already says it.
*/
function valueOf(bundle) {
const kind = bundle.kind || 'exact';
const items = bundle.items || [];
const dearest = items.reduce((m, i) => Math.max(m, itemPrice(i)), 0);
const price = Number(bundle.price || 0);
if (kind === 'exact') {
const standard = round2(items.reduce((s, i) => s + itemPrice(i) * Number(i.quantity || 0), 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
}
if (kind === 'mix') {
const standard = round2(dearest * Number(bundle.setSize || 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
}
if (kind === 'free') {
return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
}
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) {
return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
}
return { standardValue: null, savingText: null };
}
// Items with prices loaded, so the standard value can be shown next to the bundle price.
@@ -41,16 +67,20 @@ function shape(bundle, now = new Date()) {
...bundle,
items,
summary: describeBundleItems(bundle),
standardValue: standardValue(bundle),
headline: describeBundle(bundle),
...valueOf(bundle),
available: isBundleAvailable(bundle, now),
};
}
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
/**
* Turns a create/update body into Prisma data plus the item list. On update, fields
* left out of the body are left alone. Items must belong to the bundle's event.
* left out of the body are left alone; when any pricing field is sent, the bundle as a
* whole (saved values plus the new ones) is checked. Items must belong to the event.
*/
async function parseBundleBody(body, eventId, { isCreate }) {
async function parseBundleBody(body, eventId, { isCreate, current = null }) {
const data = {};
if (isCreate || body.name !== undefined) {
const name = String(body.name || '').trim();
@@ -58,11 +88,6 @@ async function parseBundleBody(body, eventId, { isCreate }) {
if (name.length > 80) throw new Error('Keep the name under 80 characters');
data.name = name;
}
if (isCreate || body.price !== undefined) {
const price = Number(body.price);
if (!(price >= 0) || body.price === '' || body.price === null) throw new Error('Enter the bundle price (zero or more)');
data.price = round2(price);
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.isActive !== undefined) data.isActive = !!body.isActive;
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
@@ -73,31 +98,61 @@ async function parseBundleBody(body, eventId, { isCreate }) {
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
let items;
if (isCreate || body.items !== undefined) {
const raw = Array.isArray(body.items) ? body.items : [];
// The same option/variant twice becomes one line.
const merged = new Map();
for (const it of raw) {
if (!it || !it.eventOptionId) continue;
const quantity = Number(it.quantity);
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each item needs a quantity of at least 1');
const key = `${it.eventOptionId}::${it.variantId || ''}`;
const prev = merged.get(key);
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: (prev?.quantity || 0) + quantity });
}
items = [...merged.values()];
if (items.length === 0) throw new Error('Add at least one ticket to the bundle');
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
const options = await prisma.eventOption.findMany({
where: { id: { in: items.map(i => i.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const i of items) {
const row = options.find(o => o.id === i.eventOptionId);
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
}
const pick = (f) => (body[f] !== undefined ? body[f] : current?.[f]);
const kind = pick('kind') || 'exact';
if (!KINDS.includes(kind)) throw new Error('Unknown bundle type');
data.kind = kind;
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
// The same option/variant twice becomes one line.
const merged = new Map();
for (const it of Array.isArray(rawItems) ? rawItems : []) {
if (!it || !it.eventOptionId) continue;
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each ticket needs a quantity of at least 1');
const key = `${it.eventOptionId}::${it.variantId || ''}`;
const prev = merged.get(key);
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
}
items = [...merged.values()];
if (items.length === 0) throw new Error(kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
const price = Number(pick('price'));
const setSize = Number(pick('setSize'));
const freeCount = Number(pick('freeCount'));
const groupPricing = pick('groupPricing');
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
if (kind === 'exact') {
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the bundle price (zero or more)');
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
} else if (kind === 'mix') {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A pick-any bundle needs at least 2 tickets per set');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the price for the set (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
} else if (kind === 'free') {
if (!Number.isInteger(freeCount) || freeCount < 1) throw new Error('At least one ticket must be free');
if (!Number.isInteger(setSize) || setSize <= freeCount) throw new Error('Buy at least one ticket to get the free ones');
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
} else {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A group needs at least 2 tickets');
if (!GROUP_PRICING.includes(groupPricing)) throw new Error('Choose a price per ticket or a percentage off');
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) throw new Error('Enter a percentage between 1 and 100');
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) throw new Error('Enter the price per ticket (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
}
const options = await prisma.eventOption.findMany({
where: { id: { in: items.map(i => i.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const i of items) {
const row = options.find(o => o.id === i.eventOptionId);
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
}
return { data, items };
}
@@ -155,7 +210,7 @@ const createBundle = async (req, res) => {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: event.id, name: bundle.name, price: bundle.price, items: describeBundleItems(bundle) },
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: describeBundle(bundle) },
ip: getClientIp(req),
});
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
@@ -170,11 +225,11 @@ const createBundle = async (req, res) => {
// @access Private/Supervisor
const updateBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id }, include: { items: true } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false });
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false, current: existing });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.update({
where: { id: existing.id },
@@ -259,8 +314,9 @@ async function publicBundlesForEvent(eventId) {
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
const s = shape(b, now);
return {
id: s.id, name: s.name, description: s.description, price: s.price, validUntil: s.validUntil,
summary: s.summary, standardValue: s.standardValue,
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
};
});
+2 -2
View File
@@ -4,7 +4,7 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
const { describeBundleItems } = require('../utils/bundles');
const { describeBundle } = require('../utils/bundles');
const TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
@@ -355,7 +355,7 @@ const listRedemptions = async (req, res) => {
uses: b.count,
discountAmount: b.discount,
createdAt: b.registration.createdAt,
summary: `${describeBundleItems(b.bundle)} for R${Number(b.bundle.price).toFixed(2)}`,
summary: describeBundle(b.bundle),
coupon: { id: b.bundleId, code: b.bundle.name },
user: b.registration.user,
appliedBy: null,
+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: () => {} };
+1 -1
View File
@@ -772,7 +772,7 @@ app.get('/docs', async (req, res) => {
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }] }},
responses:[{ status:200, desc:'Success', body:{ subtotal:550, bundleDiscount:50, bundles:[{ bundleId:'bnd-uuid-...', name:'Family deal', count:1, discount:50 }], total:500 }}]},
{ method:'GET', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'An event\'s bundles with uses and total discount given' },
{ method:'POST', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'Create a bundle. items: [{ eventOptionId, variantId?, quantity }] (variantId left out = any variant).',
{ method:'POST', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'Create a bundle. kind: exact (items with quantities, price for the set) | mix (any setSize of the items for price) | free (any setSize, cheapest freeCount free) | group (setSize or more of the items: groupPricing per_ticket price each, or percent off). items: [{ eventOptionId, variantId?, quantity }] (variantId left out = any variant; quantity only for exact).',
request:{ body:{ name:'Family deal', price:500, items:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }], validUntil:null }}},
{ method:'PUT', path:'/api/bundles/:id', auth:'supervisor+', desc:'Edit a bundle or switch it off ({ isActive:false }). Registrations keep a bundle they already have.' },
{ method:'DELETE', path:'/api/bundles/:id', auth:'supervisor+', desc:'Delete a bundle; one that has been used is switched off instead' },
+140 -45
View File
@@ -1,14 +1,21 @@
/**
* Bundle deals — a set of tickets sold together for one price, e.g. 5 Adult + 3 Child
* for R500.
* Bundle deals — tickets sold together at a better price. Four kinds:
* - exact: these tickets in these numbers for a set price, e.g. 5 Adult + 3 Child for R500.
* - mix: any `setSize` of the qualifying tickets for a set price, e.g. any 4 for R250.
* - free: any `setSize` qualifying tickets with the cheapest `freeCount` free, e.g.
* buy 3 get 1 free (setSize 4, freeCount 1).
* - group: `setSize` or more qualifying tickets, all of them at `price` each
* (groupPricing per_ticket) or `price`% off (percent).
*
* Rules:
* - Applied automatically: no code needed. A bundle applies as many times as the
* registration's items allow; leftover items are charged as usual.
* - Best price wins: each application only counts when the bundle price beats what its
* items cost at the prices actually charged (early-bird, staff price), so a bundle
* never makes anything dearer. The most expensive matching units go into a bundle
* first, and when several bundles compete the one saving the most is applied first.
* - Applied automatically: no code needed. exact, mix and free apply as many times as
* the registration's items allow; leftover items are charged as usual. A group
* discount applies once, to every qualifying ticket.
* - Best price wins: a bundle only counts when it beats what its items cost at the
* prices actually charged (early-bird, staff price), so it never makes anything
* dearer. A group discount never raises a ticket's price. The most expensive matching
* units go into a bundle first, and when several bundles compete the one saving the
* most is applied first.
* - A bundle item with no variant matches any variant of its option.
* - Coupons are worked out after bundles, on what's left: bundled units are passed on
* to the coupon code at their share of the bundle price (see utils/coupons.js).
@@ -21,60 +28,127 @@
const prisma = require('../config/db');
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
/** Whether a bundle can be offered right now (switched on, within its dates, has items). */
const KINDS = ['exact', 'mix', 'free', 'group'];
/** Whether a bundle's settings make sense (bad rows are skipped, never applied). */
function isBundleUsable(bundle) {
const items = (bundle?.items || []).filter(i => i && i.eventOptionId);
if (items.length === 0 || !(Number(bundle.price) >= 0)) return false;
const kind = bundle.kind || 'exact';
const size = Number(bundle.setSize);
if (kind === 'exact') return items.some(i => Number(i.quantity) > 0);
if (!Number.isInteger(size) || size < 1) return false;
if (kind === 'free') return Number.isInteger(Number(bundle.freeCount)) && bundle.freeCount >= 1 && bundle.freeCount < size;
if (kind === 'group' && bundle.groupPricing === 'percent') return Number(bundle.price) > 0 && Number(bundle.price) <= 100;
return KINDS.includes(kind);
}
/** Whether a bundle can be offered right now (switched on, within its dates, set up properly). */
function isBundleAvailable(bundle, now = new Date()) {
if (!bundle || !bundle.isActive) return false;
if (bundle.validFrom && now < new Date(bundle.validFrom)) return false;
if (bundle.validUntil && now > new Date(bundle.validUntil)) return false;
return (bundle.items || []).some(i => Number(i.quantity) > 0);
return isBundleUsable(bundle);
}
const itemMatches = (item, line) => item.eventOptionId === line.eventOptionId && (!item.variantId || item.variantId === line.variantId);
const qualifies = (bundle, line) => (bundle.items || []).some(i => itemMatches(i, line));
/** Takes up to `need` units from matching pool lines, dearest first. Mutates `left`. */
function takeDearest(pool, left, need, matches) {
const takes = [];
const candidates = pool
.map((p, index) => ({ p, index }))
.filter(({ p, index }) => left[index] > 0 && matches(p.line))
.sort((a, b) => b.p.line.unitPrice - a.p.line.unitPrice);
for (const { p, index } of candidates) {
if (need === 0) break;
const q = Math.min(need, left[index]);
left[index] -= q;
need -= q;
takes.push({ index, quantity: q, unitPrice: p.line.unitPrice });
}
return need > 0 ? null : takes;
}
// Every unit at the same share of a set price.
const atSetPrice = (takes, price) => {
const charged = takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0);
const ratio = charged > 0 ? price / charged : 0;
return { charged, takes: takes.map(t => ({ ...t, newPrice: t.unitPrice * ratio })) };
};
/**
* Works out one application of `bundle` against what's left in `pool`, without changing
* it. Returns { takes: [{ index, quantity }], charged, saving } or null if the items
* aren't there.
* it. Returns { takes: [{ index, quantity, newPrice }], saving } or null when the
* bundle can't apply.
*/
function tryBundle(bundle, pool) {
const left = pool.map(p => p.remaining);
const takes = [];
let charged = 0;
// Variant-specific items first, so an "any variant" item can't use up the units a
// specific one needs.
const items = [...bundle.items].filter(i => Number(i.quantity) > 0).sort((a, b) => (b.variantId ? 1 : 0) - (a.variantId ? 1 : 0));
for (const item of items) {
let need = Number(item.quantity);
const candidates = pool
.map((p, index) => ({ p, index }))
.filter(({ p, index }) => left[index] > 0 && itemMatches(item, p.line))
.sort((a, b) => b.p.line.unitPrice - a.p.line.unitPrice);
for (const { p, index } of candidates) {
if (need === 0) break;
const q = Math.min(need, left[index]);
left[index] -= q;
need -= q;
charged += q * p.line.unitPrice;
takes.push({ index, quantity: q });
const kind = bundle.kind || 'exact';
const price = Number(bundle.price || 0);
let priced;
if (kind === 'exact') {
// Variant-specific items first, so an "any variant" item can't use up the units a
// specific one needs.
const items = [...bundle.items].filter(i => Number(i.quantity) > 0).sort((a, b) => (b.variantId ? 1 : 0) - (a.variantId ? 1 : 0));
const takes = [];
for (const item of items) {
const got = takeDearest(pool, left, Number(item.quantity), line => itemMatches(item, line));
if (!got) return null;
takes.push(...got);
}
if (need > 0) return null;
priced = atSetPrice(takes, price);
} else if (kind === 'mix' || kind === 'free') {
const takes = takeDearest(pool, left, Number(bundle.setSize), line => qualifies(bundle, line));
if (!takes) return null;
if (kind === 'mix') {
priced = atSetPrice(takes, price);
} else {
// The cheapest freeCount units cost nothing; split takes so only those are free.
let free = Number(bundle.freeCount);
const out = [];
for (const t of [...takes].sort((a, b) => a.unitPrice - b.unitPrice)) {
const f = Math.min(free, t.quantity);
free -= f;
if (f > 0) out.push({ ...t, quantity: f, newPrice: 0 });
if (t.quantity - f > 0) out.push({ ...t, quantity: t.quantity - f, newPrice: t.unitPrice });
}
priced = { charged: takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0), takes: out };
}
} else if (kind === 'group') {
const total = pool.reduce((s, p, i) => s + (qualifies(bundle, p.line) ? left[i] : 0), 0);
if (total < Number(bundle.setSize)) return null;
const takes = takeDearest(pool, left, total, line => qualifies(bundle, line));
const pct = Math.min(100, Math.max(0, price)) / 100;
priced = {
charged: takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0),
takes: takes.map(t => ({ ...t, newPrice: bundle.groupPricing === 'percent' ? t.unitPrice * (1 - pct) : Math.min(t.unitPrice, price) })),
};
} else {
return null;
}
return { takes, charged, saving: charged - Number(bundle.price || 0) };
const after = priced.takes.reduce((s, t) => s + t.quantity * t.newPrice, 0);
return { takes: priced.takes, saving: priced.charged - after };
}
/**
* Applies bundles to coupon-style lines (see coupons.js buildCouponLines). Pure.
*
* @param {Array} bundles - [{ id, name, price, items: [{ eventOptionId, variantId?, quantity }] }]
* @param {Array} bundles - [{ id, name, kind, price, setSize, freeCount, groupPricing,
* items: [{ eventOptionId, variantId?, quantity }] }]
* already filtered to the ones this registration may use
* @param {Array} lines - [{ eventOptionId, variantId, quantity, unitPrice, basePrice }]
* @returns {{ discount: number, applications: Array<{ bundleId, name, count, discount }>, lines: Array }}
* `lines` are the input lines with bundled units split out at their share of the
* bundle price (flagged `bundled: true`), ready for the coupon calculation.
* `lines` are the input lines with bundled units split out at their bundle
* price (flagged `bundled: true`), ready for the coupon calculation.
*/
function applyBundles(bundles, lines) {
const usable = (bundles || []).filter(b => (b.items || []).some(i => Number(i.quantity) > 0) && Number(b.price) >= 0);
const usable = (bundles || []).filter(isBundleUsable);
const pool = (lines || []).map(line => ({ line, remaining: Number(line.quantity || 0), bundled: [] }));
const byBundle = new Map();
@@ -83,15 +157,16 @@ function applyBundles(bundles, lines) {
for (let round = 0; round < maxRounds; round++) {
let best = null;
for (const bundle of usable) {
// A group discount covers every qualifying ticket in one go, so it applies once.
if (bundle.kind === 'group' && byBundle.has(bundle.id)) continue;
const attempt = tryBundle(bundle, pool);
if (attempt && attempt.saving > 0.005 && (!best || attempt.saving > best.attempt.saving + 0.0001)) best = { bundle, attempt };
}
if (!best) break;
const { bundle, attempt } = best;
const ratio = attempt.charged > 0 ? Number(bundle.price) / attempt.charged : 0;
for (const t of attempt.takes) {
pool[t.index].remaining -= t.quantity;
pool[t.index].bundled.push({ quantity: t.quantity, ratio });
pool[t.index].bundled.push({ quantity: t.quantity, unitPrice: t.newPrice });
}
const entry = byBundle.get(bundle.id) || { bundleId: bundle.id, name: bundle.name, count: 0, discount: 0 };
entry.count += 1;
@@ -104,18 +179,35 @@ function applyBundles(bundles, lines) {
for (const p of pool) {
if (p.remaining > 0) outLines.push({ ...p.line, quantity: p.remaining });
for (const b of p.bundled) {
const unitPrice = p.line.unitPrice * b.ratio;
outLines.push({ ...p.line, quantity: b.quantity, unitPrice, basePrice: unitPrice, bundled: true });
outLines.push({ ...p.line, quantity: b.quantity, unitPrice: b.unitPrice, basePrice: b.unitPrice, bundled: true });
}
}
return { discount: round2(applications.reduce((s, a) => s + a.discount, 0)), applications, lines: outLines };
}
/** "5 × Adult + 3 × Child (Large)" — needs items[].eventOption / variant names loaded. */
const itemLabel = (i) => `${i.eventOption?.name || 'ticket'}${i.variant?.name ? ` (${i.variant.name})` : ''}`;
/** "5 × Adult + 3 × Child (Large)", or for the other kinds "Adult, Child". Needs names loaded. */
function describeBundleItems(bundle) {
return (bundle?.items || [])
.map(i => `${i.quantity} × ${i.eventOption?.name || 'ticket'}${i.variant?.name ? ` (${i.variant.name})` : ''}`)
.join(' + ');
const items = bundle?.items || [];
if ((bundle?.kind || 'exact') === 'exact') return items.map(i => `${i.quantity} × ${itemLabel(i)}`).join(' + ');
return items.map(itemLabel).join(', ');
}
/**
* The deal in one line, e.g. "2 × Adult + 3 × Child for R220", "Any 4 of Adult, Child
* for R250", "Buy 3, get 1 free: Adult, Child", "10+ tickets (Adult, Child): R70 each".
*/
function describeBundle(bundle) {
const kind = bundle?.kind || 'exact';
const items = describeBundleItems(bundle);
const price = Number(bundle?.price || 0);
if (kind === 'mix') return `Any ${bundle.setSize} of ${items} for ${fmtR(price)}`;
if (kind === 'free') return `Buy ${bundle.setSize - bundle.freeCount}, get ${bundle.freeCount} free: ${items}`;
if (kind === 'group') {
return `${bundle.setSize}+ tickets (${items}): ${bundle.groupPricing === 'percent' ? `${price}% off` : `${fmtR(price)} each`}`;
}
return `${items} for ${fmtR(price)}`;
}
// ─── Database helpers ─────────────────────────────────────────────────────────
@@ -134,7 +226,7 @@ const BUNDLE_APPS_INCLUDE = { select: { id: true, bundleId: true, count: true, d
async function loadBundlesFor(eventId, { keepIds = [], now = new Date() } = {}, db = prisma) {
if (!eventId) return [];
const bundles = await db.eventBundle.findMany({ where: { eventId }, include: BUNDLE_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
return bundles.filter(b => isBundleAvailable(b, now) || (keepIds.includes(b.id) && (b.items || []).length > 0));
return bundles.filter(b => isBundleAvailable(b, now) || (keepIds.includes(b.id) && isBundleUsable(b)));
}
/** Replaces a registration's RegistrationBundle rows when the applications changed. */
@@ -150,9 +242,12 @@ async function saveBundleApplications(registrationId, existing, applications, db
}
module.exports = {
KINDS,
isBundleUsable,
isBundleAvailable,
applyBundles,
describeBundleItems,
describeBundle,
loadBundlesFor,
saveBundleApplications,
BUNDLE_INCLUDE,
+86
View File
@@ -96,6 +96,92 @@ describe('applyBundles', () => {
});
});
describe('pick any (mix)', () => {
const anyFour = { id: 'm', name: 'Any 4', kind: 'mix', price: 250, setSize: 4, items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
test('2 adults + 2 children or 4 adults both make a set', () => {
expect(applyBundles([anyFour], [line('adult', 2, 80), line('child', 2, 50)]).discount).toBe(10);
expect(applyBundles([anyFour], [line('adult', 4, 80)]).discount).toBe(70);
});
test('repeats, dearest tickets first, leftovers at full price', () => {
const r = applyBundles([anyFour], [line('adult', 5, 80), line('child', 4, 50)]);
// Set 1: 4 adults (320 → 250). Set 2: 1 adult + 3 children (230): no saving, so not applied.
expect(r.applications[0].count).toBe(1);
expect(r.discount).toBe(70);
expect(charged(r.lines)).toBeCloseTo(250 + 80 + 200, 6);
});
test('non-qualifying tickets are left alone', () => {
const r = applyBundles([anyFour], [line('adult', 2, 80), line('lunch', 5, 65)]);
expect(r.discount).toBe(0);
});
});
describe('buy X get Y free', () => {
const b3g1 = { id: 'f', name: 'Buy 3 get 1', kind: 'free', price: 0, setSize: 4, freeCount: 1, items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
test('the cheapest ticket in the set is free', () => {
const r = applyBundles([b3g1], [line('adult', 3, 80), line('child', 1, 50)]);
expect(r.discount).toBe(50);
expect(r.lines.find(l => l.unitPrice === 0)).toMatchObject({ eventOptionId: 'child', quantity: 1, bundled: true });
});
test('8 tickets make two sets', () => {
const r = applyBundles([b3g1], [line('adult', 8, 80)]);
expect(r.applications[0].count).toBe(2);
expect(r.discount).toBe(160);
});
test('three tickets get nothing', () => {
expect(applyBundles([b3g1], [line('adult', 3, 80)]).discount).toBe(0);
});
test('a free count that is not below the set size is ignored', () => {
expect(applyBundles([{ ...b3g1, freeCount: 4 }], [line('adult', 8, 80)]).discount).toBe(0);
});
});
describe('group discount', () => {
const tenPlus = { id: 'g', name: 'Groups', kind: 'group', price: 70, setSize: 10, groupPricing: 'per_ticket', items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
test('10+ qualifying tickets all get the group price, once, never dearer', () => {
const r = applyBundles([tenPlus], [line('adult', 8, 80), line('child', 4, 50)]);
// Adults 80 → 70 (8 × 10 = 80 saved); children already cheaper, unchanged.
expect(r.discount).toBe(80);
expect(r.applications).toEqual([{ bundleId: 'g', name: 'Groups', count: 1, discount: 80 }]);
expect(charged(r.lines)).toBeCloseTo(8 * 70 + 4 * 50, 6);
});
test('below the minimum: nothing', () => {
expect(applyBundles([tenPlus], [line('adult', 9, 80)]).discount).toBe(0);
});
test('percentage off everything in the group', () => {
const pct = { ...tenPlus, groupPricing: 'percent', price: 10 };
expect(applyBundles([pct], [line('adult', 10, 80), line('child', 2, 50)]).discount).toBe(90);
});
test('an exact bundle beats a group discount when it saves more', () => {
const r = applyBundles([family, tenPlus], [line('adult', 5, 80), line('child', 5, 50)]);
// Group: 5 × 10 = 50 saved. Family: 50 saved on 5A+3C, then group can't reach 10 again.
// Equal savings: the first found wins, either way the total is 50.
expect(r.discount).toBe(50);
});
});
describe('describeBundle', () => {
const { describeBundle } = require('../src/utils/bundles');
const names = [{ eventOptionId: 'a', quantity: 2, eventOption: { name: 'Adult' } }, { eventOptionId: 'c', quantity: 3, eventOption: { name: 'Child' } }];
test('one line per kind', () => {
expect(describeBundle({ kind: 'exact', price: 220, items: names })).toBe('2 × Adult + 3 × Child for R220');
expect(describeBundle({ kind: 'mix', price: 250, setSize: 4, items: names })).toBe('Any 4 of Adult, Child for R250');
expect(describeBundle({ kind: 'free', setSize: 4, freeCount: 1, items: names })).toBe('Buy 3, get 1 free: Adult, Child');
expect(describeBundle({ kind: 'group', price: 70, setSize: 10, groupPricing: 'per_ticket', items: names })).toBe('10+ tickets (Adult, Child): R70 each');
expect(describeBundle({ kind: 'group', price: 15, setSize: 10, groupPricing: 'percent', items: names })).toBe('10+ tickets (Adult, Child): 15% off');
});
});
describe('isBundleAvailable', () => {
const now = new Date('2026-06-01T10:00:00Z');
test('switched off, not started, ended and empty bundles are not available', () => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.28.1",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.28.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.28.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">
@@ -5,11 +5,10 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Gift, Plus, X, Trash2 } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import type { EventBundle } from "@/types";
import type { BundleGroupPricing, BundleKind, EventBundle } from "@/types";
type OptionLite = { id: string; name: string; price: number; variants?: { id: string; name: string; price?: number | null }[] };
type EventLite = { id: string; title: string; startDate: string; endDate?: string; eventOptions: OptionLite[] };
@@ -18,7 +17,11 @@ type ItemDraft = { eventOptionId: string; variantId: string | null; quantity: st
type Draft = {
name: string;
description: string;
kind: BundleKind;
price: string;
setSize: string;
freeCount: string;
groupPricing: BundleGroupPricing;
items: ItemDraft[];
validFrom: string;
validUntil: string;
@@ -26,7 +29,7 @@ type Draft = {
};
const blankDraft = (options: OptionLite[]): Draft => ({
name: "", description: "", price: "",
name: "", description: "", kind: "exact", price: "", setSize: "4", freeCount: "1", groupPricing: "per_ticket",
items: options.slice(0, 2).map(o => ({ eventOptionId: o.id, variantId: null, quantity: "1" })),
validFrom: "", validUntil: "", isActive: true,
});
@@ -40,14 +43,27 @@ const toLocalInput = (iso: string | null | undefined) => {
};
const draftFromBundle = (b: EventBundle): Draft => ({
name: b.name, description: b.description || "", price: String(b.price),
name: b.name, description: b.description || "", kind: b.kind || "exact",
price: b.kind === "free" ? "" : String(b.price),
setSize: b.setSize ? String(b.setSize) : "4", freeCount: b.freeCount ? String(b.freeCount) : "1",
groupPricing: b.groupPricing || "per_ticket",
items: b.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId || null, quantity: String(i.quantity) })),
validFrom: toLocalInput(b.validFrom), validUntil: toLocalInput(b.validUntil), isActive: b.isActive,
});
const fmtDate = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
// fieldCls has no width, for fields sized by their row (inputCls is full width).
const fieldCls = "border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const inputCls = `w-full ${fieldCls}`;
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
const KIND_INFO: Record<BundleKind, { label: string; hint: string; example: string }> = {
exact: { label: "Exact set", hint: "These tickets in these numbers", example: "2 adults + 3 kids for R220" },
mix: { label: "Pick any", hint: "Any mix of the chosen tickets", example: "Any 4 tickets for R250" },
free: { label: "Buy X get Y free", hint: "Cheapest ones in each set are free", example: "Buy 3, get 1 free" },
group: { label: "Group discount", hint: "A lower price from a group size up", example: "10+ tickets: R70 each" },
};
/** Standard price of one unit of an item (variant price, else the option's). */
function unitPrice(options: OptionLite[], item: { eventOptionId: string; variantId: string | null }) {
@@ -75,7 +91,8 @@ export default function BundlesPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<EventLite[]>([]);
const [allEvents, setAllEvents] = useState<EventLite[]>([]);
const [showPast, setShowPast] = useState(false);
const [eventId, setEventId] = useState("");
const [bundles, setBundles] = useState<EventBundle[]>([]);
const [fetching, setFetching] = useState(false);
@@ -86,20 +103,27 @@ export default function BundlesPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token })
.then(list => {
const sorted = (list || []).slice().sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
setEvents(sorted);
// Start on the next upcoming event, the one most likely to need a deal.
const upcoming = sorted.filter(e => new Date(e.endDate || e.startDate).getTime() >= Date.now());
setEventId(prev => prev || (upcoming[upcoming.length - 1] || sorted[0])?.id || "");
})
.then(list => setAllEvents(list || []))
.catch(e => setError(errorMessage(e) || "Failed to load events"));
}, [token, canView, setError]);
// Upcoming events soonest first; past ones (when shown) after them, most recent first.
const events = useMemo(() => {
const upcoming = allEvents.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const past = allEvents.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
return showPast ? [...upcoming, ...past] : upcoming;
}, [allEvents, showPast]);
// Keep the picked event while it's in the list; otherwise start on the next upcoming one.
useEffect(() => {
if (!events.some(e => e.id === eventId)) setEventId(events[0]?.id || "");
}, [events, eventId]);
const event = events.find(e => e.id === eventId) || null;
const options = event?.eventOptions || [];
const load = useCallback(async () => {
if (!token || !eventId) return;
if (!token || !eventId) { setBundles([]); return; }
setFetching(true);
try {
setBundles(await apiFetch<EventBundle[]>(`/api/bundles/event/${eventId}`, { authToken: token }) || []);
@@ -133,9 +157,12 @@ export default function BundlesPage() {
}
};
const startNew = () => setEditing({ id: null, draft: blankDraft(options) });
const canAdd = !!event && options.length > 0;
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Gift className="w-5 h-5 text-brand-600" />
@@ -143,14 +170,14 @@ export default function BundlesPage() {
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Bundle deals</h1>
<p className="text-sm text-gray-500">Sell a set of tickets together for one price, e.g. 5 adults + 3 children for R500</p>
<p className="text-sm text-gray-500">Tickets sold together for one price</p>
</div>
</div>
{canView && event && (
{canView && (
<button
onClick={() => setEditing({ id: null, draft: blankDraft(event.eventOptions || []) })}
disabled={(event.eventOptions || []).length === 0}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
onClick={startNew}
disabled={!canAdd}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50 w-full sm:w-auto"
>
<Plus className="w-4 h-4" /> New bundle
</button>
@@ -165,70 +192,56 @@ export default function BundlesPage() {
{canView && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="mb-3 w-full sm:w-96">
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select className={inputCls} value={eventId} onChange={e => setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} · {fmtDate(ev.startDate)}</option>)}
</select>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 mb-4">
<div className="w-full sm:w-auto">
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm w-full sm:min-w-[20rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">{showPast ? "No events" : "No upcoming events"}</option>}
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title} ({fmtDate(ev.startDate)}){isPast(ev) ? " · past" : ""}</option>
))}
</select>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 sm:pb-2">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
</label>
</div>
<p className="text-xs text-gray-500 mb-3">
Bundles apply automatically, as many times as someone&apos;s tickets allow, and only when they&apos;re cheaper than the tickets
at today&apos;s prices (early-bird or staff prices included). Coupon codes come off afterwards.
</p>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Bundle</TableHead>
<TableHead>Price</TableHead>
<TableHead>Used</TableHead>
<TableHead>Available</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bundles.map(b => {
const status = bundleStatus(b);
const save = b.standardValue - b.price;
return (
<TableRow key={b.id}>
<TableCell>
<div className="font-semibold text-gray-900">{b.name}</div>
<div className="text-xs text-gray-600">{b.summary}</div>
{b.description && <div className="text-xs text-gray-500">{b.description}</div>}
</TableCell>
<TableCell className="text-sm">
{fmtRand(b.price)}
<div className={`text-xs ${save > 0 ? "text-green-700" : "text-amber-700"}`}>
{save > 0 ? `Saves ${fmtRand(save)} on ${fmtRand(b.standardValue)}` : `No saving on ${fmtRand(b.standardValue)}`}
</div>
</TableCell>
<TableCell className="text-sm">
{b.uses || 0}× <span className="text-xs text-gray-500">on {b.registrations || 0} registration{b.registrations === 1 ? "" : "s"}</span>
<div className="text-xs text-gray-500">{fmtRand(b.totalDiscount || 0)} given</div>
</TableCell>
<TableCell className="text-xs text-gray-600">
{b.validFrom || b.validUntil ? `${b.validFrom ? fmtDate(b.validFrom) : "Now"} – ${b.validUntil ? fmtDate(b.validUntil) : "no end"}` : "Always"}
</TableCell>
<TableCell><span className={`text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span></TableCell>
<TableCell className="text-right whitespace-nowrap">
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => setEditing({ id: b.id, draft: draftFromBundle(b) })}>Edit</button>
<button className={`px-2 py-1 text-xs rounded text-white mr-1 ${b.isActive ? "bg-amber-500 hover:bg-amber-600" : "bg-green-600 hover:bg-green-700"}`} onClick={() => toggleActive(b)}>
{b.isActive ? "Turn off" : "Turn on"}
</button>
<button className="px-2 py-1 text-xs rounded border text-red-700 hover:bg-red-50" onClick={() => remove(b)} aria-label={`Delete ${b.name}`}><Trash2 className="w-3.5 h-3.5" /></button>
</TableCell>
</TableRow>
);
})}
{!fetching && bundles.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-sm text-gray-500 py-8">{event ? "No bundle deals for this event yet." : "Choose an event."}</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
{event && options.length === 0 && (
<p className="text-sm text-gray-500 py-6 text-center">This event has no tickets yet. Add ticket options to the event first.</p>
)}
{event && options.length > 0 && !fetching && bundles.length === 0 && (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-emerald-50 flex items-center justify-center mx-auto mb-3">
<Gift className="w-6 h-6 text-emerald-600" />
</div>
<p className="text-sm font-medium text-gray-900">No bundle deals for this event yet</p>
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">e.g. &quot;Family deal: 2 adults + 3 kids for R220&quot;. Buyers get it automatically when their tickets match.</p>
<button onClick={startNew} className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
<Plus className="w-4 h-4" /> Create a bundle
</button>
</div>
)}
{bundles.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{bundles.map(b => (
<BundleCard
key={b.id}
bundle={b}
onEdit={() => setEditing({ id: b.id, draft: draftFromBundle(b) })}
onToggle={() => toggleActive(b)}
onDelete={() => remove(b)}
/>
))}
</div>
)}
<p className="text-xs text-gray-500 mt-4">
Bundles apply automatically, as many times as someone&apos;s tickets allow, and only when cheaper than the tickets at the prices
actually charged (early-bird and staff prices included). Coupon codes come off afterwards.
</p>
</div>
)}
@@ -246,6 +259,76 @@ export default function BundlesPage() {
);
}
function BundleCard({ bundle: b, onEdit, onToggle, onDelete }: { bundle: EventBundle; onEdit: () => void; onToggle: () => void; onDelete: () => void }) {
const status = bundleStatus(b);
const kind = b.kind || "exact";
const dates = b.validFrom || b.validUntil
? `${b.validFrom ? fmtDate(b.validFrom) : "Now"} – ${b.validUntil ? fmtDate(b.validUntil) : "no end date"}`
: null;
return (
<div className={`border rounded-xl p-4 flex flex-col gap-3 ${b.isActive ? "bg-white" : "bg-gray-50"}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-gray-900 break-words">{b.name}</div>
{b.description && <div className="text-xs text-gray-500 mt-0.5 break-words">{b.description}</div>}
</div>
<span className={`shrink-0 text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span>
</div>
<div className="text-xs font-medium text-brand-700">{KIND_INFO[kind].label}{kind === "exact" ? "" : ` · ${kind === "group" ? "qualifying" : "any of"}:`}</div>
<div className="flex flex-wrap gap-1.5">
{b.items.map(i => (
<span key={i.id || `${i.eventOptionId}-${i.variantId}`} className="inline-flex items-center gap-1 rounded-full bg-emerald-50 text-emerald-800 border border-emerald-100 px-2.5 py-1 text-xs">
{kind === "exact" && <span className="font-semibold">{i.quantity} ×</span>} {i.eventOption?.name || "Ticket"}{i.variant ? ` (${i.variant.name})` : ""}
</span>
))}
</div>
<div>
<div className="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
{kind === "exact" || kind === "mix" ? (
<>
<span className="text-xl font-semibold text-gray-900">{fmtRand(b.price)}</span>
<span className="text-sm text-gray-500">{kind === "mix" ? `for any ${b.setSize}` : "for the set"}</span>
{b.standardValue !== null && <span className="text-sm text-gray-400 line-through">{fmtRand(b.standardValue)}</span>}
</>
) : kind === "free" ? (
<span className="text-xl font-semibold text-gray-900">Buy {(b.setSize || 0) - (b.freeCount || 0)}, get {b.freeCount} free</span>
) : (
<>
<span className="text-xl font-semibold text-gray-900">{b.groupPricing === "percent" ? `${b.price}% off` : `${fmtRand(b.price)} each`}</span>
<span className="text-sm text-gray-500">from {b.setSize} tickets</span>
</>
)}
</div>
<div className={`text-xs font-medium ${b.savingText || kind === "free" || (kind === "group" && b.groupPricing === "percent") ? "text-green-700" : "text-amber-700"}`}>
{b.savingText || (kind === "free" || (kind === "group" && b.groupPricing === "percent") ? "" : "No saving at the standard prices")}
</div>
</div>
<div className="text-xs text-gray-600 space-y-0.5">
<div>
Used <strong>{b.uses || 0}×</strong> on {b.registrations || 0} registration{b.registrations === 1 ? "" : "s"} · {fmtRand(b.totalDiscount || 0)} given
</div>
{dates && <div className="flex items-center gap-1"><CalendarClock className="w-3.5 h-3.5" /> {dates}</div>}
</div>
<div className="flex flex-wrap gap-2 pt-1 border-t border-gray-100 mt-auto">
<button onClick={onEdit} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
<button onClick={onToggle} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border mt-2 ${b.isActive ? "text-amber-700 border-amber-200 hover:bg-amber-50" : "text-green-700 border-green-200 hover:bg-green-50"}`}>
<Power className="w-3.5 h-3.5" /> {b.isActive ? "Turn off" : "Turn on"}
</button>
<button onClick={onDelete} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border text-red-700 border-red-200 hover:bg-red-50 mt-2 sm:ml-auto">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
</div>
);
}
function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
token: string;
event: EventLite;
@@ -261,8 +344,15 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
const options = event.eventOptions || [];
const setItem = (i: number, patch: Partial<ItemDraft>) => set("items", d.items.map((it, j) => (j === i ? { ...it, ...patch } : it)));
const standardValue = d.items.reduce((s, it) => s + unitPrice(options, it) * (Number(it.quantity) || 0), 0);
const saving_ = standardValue - (Number(d.price) || 0);
const exact = d.kind === "exact";
const price = Number(d.price) || 0;
const size = Math.max(0, Math.floor(Number(d.setSize) || 0));
const free = Math.max(0, Math.floor(Number(d.freeCount) || 0));
const dearest = d.items.reduce((m, it) => Math.max(m, unitPrice(options, it)), 0);
// Exact: what the set costs separately. Pick any: the dearest possible set.
const standardValue = exact ? d.items.reduce((s, it) => s + unitPrice(options, it) * (Number(it.quantity) || 0), 0) : dearest * size;
const saving_ = standardValue - price;
const priceLabel = exact ? "Bundle price (R)" : d.kind === "mix" ? "Price for the set (R)" : d.groupPricing === "percent" ? "Percentage off (%)" : "Price per ticket (R)";
const save = async (e: React.FormEvent) => {
e.preventDefault();
@@ -272,8 +362,12 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
const body = {
name: d.name.trim(),
description: d.description.trim() || null,
price: Number(d.price),
items: d.items.map(it => ({ eventOptionId: it.eventOptionId, variantId: it.variantId, quantity: Number(it.quantity) })),
kind: d.kind,
price: d.kind === "free" ? 0 : Number(d.price),
setSize: exact ? null : Number(d.setSize),
freeCount: d.kind === "free" ? Number(d.freeCount) : null,
groupPricing: d.kind === "group" ? d.groupPricing : null,
items: d.items.map(it => ({ eventOptionId: it.eventOptionId, variantId: it.variantId, quantity: exact ? Number(it.quantity) : 1 })),
validFrom: d.validFrom ? new Date(d.validFrom).toISOString() : null,
validUntil: d.validUntil ? new Date(d.validUntil).toISOString() : null,
isActive: d.isActive,
@@ -293,14 +387,17 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
};
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">{bundleId ? "Edit bundle" : "New bundle deal"} <span className="text-sm font-normal text-gray-500">· {event.title}</span></h2>
<button type="button" onClick={onClose} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center sm:p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between gap-3 p-4 border-b">
<div className="min-w-0">
<h2 className="font-semibold">{bundleId ? "Edit bundle" : "New bundle deal"}</h2>
<p className="text-xs text-gray-500 truncate">{event.title}</p>
</div>
<button type="button" onClick={onClose} aria-label="Close" className="p-1 -m-1"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 space-y-5 overflow-y-auto">
<div className="p-4 space-y-5 overflow-y-auto overflow-x-hidden overscroll-contain">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
@@ -308,76 +405,147 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
<p className="text-xs text-gray-500 mt-1">Shown to buyers and on receipts.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Description <span className="font-normal text-gray-400">(optional)</span></label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Bring the whole family" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tickets in the bundle</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Type of deal</label>
<div className="grid grid-cols-2 gap-2">
{(Object.keys(KIND_INFO) as BundleKind[]).map(k => (
<button type="button" key={k} onClick={() => set("kind", k)} aria-pressed={d.kind === k}
className={`text-left rounded-lg border p-2.5 transition-colors ${d.kind === k ? "border-brand-500 bg-brand-50 ring-1 ring-brand-500" : "hover:bg-gray-50"}`}>
<div className="text-sm font-medium text-gray-900">{KIND_INFO[k].label}</div>
<div className="text-xs text-gray-500">{KIND_INFO[k].hint}</div>
<div className="text-[11px] text-gray-400 mt-0.5 hidden sm:block">e.g. {KIND_INFO[k].example}</div>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{exact ? "Tickets in the bundle" : "Qualifying tickets"}</label>
{!exact && <p className="text-xs text-gray-500 -mt-1 mb-2">Any of these count towards the deal.</p>}
<div className="space-y-2">
{d.items.map((it, i) => {
const opt = options.find(o => o.id === it.eventOptionId);
const hasVariants = (opt?.variants || []).length > 0;
return (
<div key={i} className="flex flex-wrap sm:flex-nowrap items-center gap-2">
<input type="number" min="1" step="1" className={`${inputCls} w-20 shrink-0`} value={it.quantity} onChange={e => setItem(i, { quantity: e.target.value })} aria-label="Quantity" required />
<span className="text-sm text-gray-500">×</span>
<select className={`${inputCls} min-w-0`} value={it.eventOptionId} onChange={e => setItem(i, { eventOptionId: e.target.value, variantId: null })}>
{options.map(o => <option key={o.id} value={o.id}>{o.name} ({fmtRand(o.price)})</option>)}
</select>
{(opt?.variants || []).length > 0 && (
<select className={`${inputCls} min-w-0`} value={it.variantId || ""} onChange={e => setItem(i, { variantId: e.target.value || null })} aria-label="Variant">
<option value="">Any variant</option>
<div key={i} className="rounded-lg border bg-gray-50 p-2.5">
<div className="flex items-center gap-2">
{exact && (
<>
<input type="number" min="1" step="1" inputMode="numeric" className={`${fieldCls} w-16 shrink-0 px-2 bg-white text-center`} value={it.quantity} onChange={e => setItem(i, { quantity: e.target.value })} aria-label="Quantity" required />
<span className="text-sm text-gray-500 shrink-0">×</span>
</>
)}
<select className={`${fieldCls} min-w-0 flex-1 bg-white`} value={it.eventOptionId} onChange={e => setItem(i, { eventOptionId: e.target.value, variantId: null })} aria-label="Ticket">
{options.map(o => <option key={o.id} value={o.id}>{o.name} ({fmtRand(o.price)})</option>)}
</select>
<button type="button" onClick={() => set("items", d.items.filter((_, j) => j !== i))} disabled={d.items.length <= 1}
className="p-2 shrink-0 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 disabled:opacity-30 disabled:hover:bg-transparent" aria-label="Remove ticket">
<Trash2 className="w-4 h-4" />
</button>
</div>
{hasVariants && (
<select className={`${inputCls} mt-2 bg-white`} value={it.variantId || ""} onChange={e => setItem(i, { variantId: e.target.value || null })} aria-label="Variant">
<option value="">Any {opt?.name.toLowerCase()} variant</option>
{(opt?.variants || []).map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
)}
<button type="button" onClick={() => set("items", d.items.filter((_, j) => j !== i))} disabled={d.items.length <= 1}
className="p-2 rounded border text-gray-500 hover:bg-gray-50 disabled:opacity-40" aria-label="Remove ticket">
<X className="w-4 h-4" />
</button>
</div>
);
})}
</div>
<button type="button" onClick={() => set("items", [...d.items, { eventOptionId: options[0]?.id || "", variantId: null, quantity: "1" }])}
className="mt-2 inline-flex items-center gap-1 text-sm text-brand-700 hover:underline">
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-brand-700 hover:underline">
<Plus className="w-4 h-4" /> Add a ticket type
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Bundle price (R)</label>
<input type="number" min="0" step="0.01" className={inputCls} value={d.price} onChange={e => set("price", e.target.value)} required />
{!exact && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{d.kind === "group" ? "Minimum tickets" : "Tickets per set"}</label>
<input type="number" min={d.kind === "free" ? 2 : 2} step="1" inputMode="numeric" className={inputCls} value={d.setSize} onChange={e => set("setSize", e.target.value)} required />
</div>
{d.kind === "free" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Free in each set</label>
<input type="number" min="1" step="1" inputMode="numeric" className={inputCls} value={d.freeCount} onChange={e => set("freeCount", e.target.value)} required />
</div>
)}
{d.kind === "group" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Group price</label>
<select className={inputCls} value={d.groupPricing} onChange={e => set("groupPricing", e.target.value as BundleGroupPricing)}>
<option value="per_ticket">Price per ticket</option>
<option value="percent">Percentage off</option>
</select>
</div>
)}
</div>
<div className="flex items-end">
<div className={`text-sm ${saving_ > 0 ? "text-green-700" : "text-amber-700"}`}>
Separately: {fmtRand(standardValue)}
{d.price !== "" && (saving_ > 0 ? ` · saves ${fmtRand(saving_)}` : " · no saving at the standard price")}
)}
{d.kind === "free" ? (
<div className="rounded-lg bg-emerald-50 p-3 text-sm text-emerald-900">
{size > free && free > 0
? <>Buy {size - free}, get {free} free. In every {size} qualifying tickets, the cheapest {free === 1 ? "one is" : `${free} are`} free.</>
: <span className="text-amber-700">The set needs more tickets than are free.</span>}
</div>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{priceLabel}</label>
<input type="number" min="0" max={d.kind === "group" && d.groupPricing === "percent" ? 100 : undefined} step="0.01" inputMode="decimal" className={`${inputCls} sm:max-w-[12rem]`} value={d.price} onChange={e => set("price", e.target.value)} required />
{d.kind === "group" ? (
<p className="text-xs text-gray-500 mt-2">
{size >= 2 ? `From ${size} qualifying tickets, every one of them ` : "Every qualifying ticket "}
{d.groupPricing === "percent" ? `gets ${d.price || "…"}% off.` : `costs ${d.price === "" ? "…" : fmtRand(price)} (tickets already cheaper stay as they are).`}
</p>
) : (
<>
<div className="mt-2 grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-gray-50 p-2">
<div className="text-[11px] uppercase tracking-wide text-gray-500">{exact ? "Separately" : "Dearest set"}</div>
<div className="text-sm font-semibold text-gray-900">{fmtRand(standardValue)}</div>
</div>
<div className="rounded-lg bg-gray-50 p-2">
<div className="text-[11px] uppercase tracking-wide text-gray-500">Bundle</div>
<div className="text-sm font-semibold text-gray-900">{d.price === "" ? "—" : fmtRand(price)}</div>
</div>
<div className={`rounded-lg p-2 ${d.price !== "" && saving_ <= 0 ? "bg-amber-50" : "bg-emerald-50"}`}>
<div className="text-[11px] uppercase tracking-wide text-gray-500">Saving</div>
<div className={`text-sm font-semibold ${d.price !== "" && saving_ <= 0 ? "text-amber-700" : "text-emerald-700"}`}>{d.price === "" ? "—" : fmtRand(Math.max(0, saving_))}</div>
</div>
</div>
{d.price !== "" && saving_ <= 0 && <p className="text-xs text-amber-700 mt-1">The bundle isn&apos;t cheaper than the standard prices, so it won&apos;t apply unless prices change.</p>}
{!exact && <p className="text-xs text-gray-500 mt-1">The saving depends on which tickets are picked; it only applies when the set price is cheaper.</p>}
</>
)}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Available from</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Available from <span className="font-normal text-gray-400">(optional)</span></label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Available until</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Available until <span className="font-normal text-gray-400">(optional)</span></label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={d.isActive} onChange={e => set("isActive", e.target.checked)} /> On sale
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.isActive} onChange={e => set("isActive", e.target.checked)} />
<span>On sale <span className="block text-xs text-gray-500">Registrations that already have this bundle keep it if you turn it off later.</span></span>
</label>
<p className="text-xs text-gray-500 -mt-3">Registrations that already have this bundle keep it if you turn it off or change it later.</p>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
<div className="flex justify-end gap-3 p-4 border-t">
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 p-4 border-t">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : bundleId ? "Save changes" : "Create bundle"}
@@ -5,8 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TicketPercent, Plus, X, Search } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
@@ -17,6 +16,7 @@ type EventLite = {
id: string;
title: string;
startDate: string;
endDate?: string;
eventOptions: { id: string; name: string; variants?: { id: string; name: string }[] }[];
};
@@ -122,17 +122,19 @@ function bodyFromDraft(d: Draft) {
};
}
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtDate = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
function couponStatus(c: Coupon): { label: string; cls: string } {
type Status = { key: "active" | "scheduled" | "off" | "expired" | "used"; label: string; cls: string };
function couponStatus(c: Coupon): Status {
const now = Date.now();
if (!c.active) return { label: "Off", cls: "bg-gray-100 text-gray-600" };
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { label: "Expired", cls: "bg-gray-100 text-gray-600" };
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
if (c.maxUses && c.uses >= c.maxUses) return { label: "Used up", cls: "bg-gray-100 text-gray-600" };
return { label: "Active", cls: "bg-green-100 text-green-800" };
if (!c.active) return { key: "off", label: "Off", cls: "bg-gray-100 text-gray-600" };
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { key: "expired", label: "Expired", cls: "bg-gray-100 text-gray-600" };
if (c.maxUses && c.uses >= c.maxUses) return { key: "used", label: "Used up", cls: "bg-gray-100 text-gray-600" };
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { key: "scheduled", label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
return { key: "active", label: "Active", cls: "bg-green-100 text-green-800" };
}
export default function CouponsPage() {
@@ -151,7 +153,7 @@ export default function CouponsPage() {
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [query, setQuery] = useState("");
const [showOff, setShowOff] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
const [viewing, setViewing] = useState<{ coupon: Coupon; redemptions: Redemption[] } | null>(null);
@@ -175,10 +177,17 @@ export default function CouponsPage() {
useEffect(() => { if (token && canView) load(); }, [token, canView, load]);
// Codes that can still be used first; off, expired and used-up ones only when asked for.
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return coupons.filter(c => (showOff || c.active) && (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q)));
}, [coupons, query, showOff]);
const order: Record<Status["key"], number> = { active: 0, scheduled: 1, used: 2, expired: 3, off: 4 };
return coupons
.map(c => ({ c, status: couponStatus(c) }))
.filter(({ c, status }) => (showInactive || status.key === "active" || status.key === "scheduled")
&& (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q) || c.events.some(e => e.event.title.toLowerCase().includes(q))))
.sort((a, b) => order[a.status.key] - order[b.status.key]);
}, [coupons, query, showInactive]);
const hiddenCount = coupons.filter(c => !["active", "scheduled"].includes(couponStatus(c).key)).length;
const toggleActive = async (c: Coupon) => {
try {
@@ -199,9 +208,11 @@ export default function CouponsPage() {
}
};
const startNew = () => setEditing({ id: null, draft: blankDraft() });
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<TicketPercent className="w-5 h-5 text-brand-600" />
@@ -214,8 +225,8 @@ export default function CouponsPage() {
</div>
{canView && (
<button
onClick={() => setEditing({ id: null, draft: blankDraft() })}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700"
onClick={startNew}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 w-full sm:w-auto"
>
<Plus className="w-4 h-4" /> New code
</button>
@@ -230,72 +241,46 @@ export default function CouponsPage() {
{canView && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-center gap-3 mb-3">
<div className="relative w-full sm:w-64">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div className="relative w-full sm:w-72">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
<input className={`${inputCls} pl-8`} placeholder="Search codes" value={query} onChange={e => setQuery(e.target.value)} />
<input className={`${inputCls} pl-8`} placeholder="Search codes or events" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={showOff} onChange={e => setShowOff(e.target.checked)} /> Show codes that are off
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} />
Show codes that are off, expired or used up{hiddenCount > 0 ? ` (${hiddenCount})` : ""}
</label>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Discount</TableHead>
<TableHead>Events</TableHead>
<TableHead>Uses</TableHead>
<TableHead>Valid</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map(c => {
const status = couponStatus(c);
return (
<TableRow key={c.id}>
<TableCell>
<div className="font-mono font-semibold text-gray-900">{c.code}</div>
{c.description && <div className="text-xs text-gray-500">{c.description}</div>}
{(c.restrictRoles.length > 0 || c.allowedUsers.length > 0 || c.restrictDomain) && (
<div className="text-xs text-amber-700 mt-0.5">
Restricted: {c.allowedUsers.length ? `${c.allowedUsers.length} person${c.allowedUsers.length === 1 ? "" : "s"}` : c.restrictRoles.length ? c.restrictRoles.join(", ") : `@${c.restrictDomain}`}
</div>
)}
</TableCell>
<TableCell>
<div>{c.summary}</div>
{c.options.length > 0 && <div className="text-xs text-gray-500">{c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</div>}
</TableCell>
<TableCell className="text-sm">{c.events.length ? c.events.map(e => e.event.title).join(", ") : <span className="text-gray-500">All events</span>}</TableCell>
<TableCell className="text-sm">
{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}
<div className="text-xs text-gray-500">{fmtRand(c.totalDiscount)} given</div>
</TableCell>
<TableCell className="text-xs text-gray-600">
{c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end"}` : "Always"}
</TableCell>
<TableCell><span className={`text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span></TableCell>
<TableCell className="text-right whitespace-nowrap">
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => openUses(c)}>Uses</button>
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}>Edit</button>
<button className={`px-2 py-1 text-xs rounded text-white ${c.active ? "bg-amber-500 hover:bg-amber-600" : "bg-green-600 hover:bg-green-700"}`} onClick={() => toggleActive(c)}>
{c.active ? "Turn off" : "Turn on"}
</button>
</TableCell>
</TableRow>
);
})}
{!fetching && visible.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-sm text-gray-500 py-8">No coupon codes yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
{!fetching && visible.length === 0 && (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-brand-50 flex items-center justify-center mx-auto mb-3">
<TicketPercent className="w-6 h-6 text-brand-600" />
</div>
<p className="text-sm font-medium text-gray-900">{coupons.length === 0 ? "No coupon codes yet" : "No codes match"}</p>
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">e.g. &quot;STAFF50&quot;: 50% off for staff, or &quot;WELCOME20&quot;: R20 off for first-time visitors.</p>
{coupons.length === 0 && (
<button onClick={startNew} className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
<Plus className="w-4 h-4" /> Create a code
</button>
)}
</div>
)}
{visible.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{visible.map(({ c, status }) => (
<CouponCard
key={c.id}
coupon={c}
status={status}
onUses={() => openUses(c)}
onEdit={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}
onToggle={() => toggleActive(c)}
/>
))}
</div>
)}
</div>
)}
@@ -311,47 +296,119 @@ export default function CouponsPage() {
)}
{viewing && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setViewing(null)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">Uses of <span className="font-mono">{viewing.coupon.code}</span></h2>
<button onClick={() => setViewing(null)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 overflow-y-auto">
{viewing.redemptions.length === 0 ? (
<p className="text-sm text-gray-500">This code hasn&apos;t been used yet.</p>
) : (
<Table>
<TableHeader>
<TableRow><TableHead>Person</TableHead><TableHead>Event</TableHead><TableHead>Discount</TableHead><TableHead>When</TableHead></TableRow>
</TableHeader>
<TableBody>
{viewing.redemptions.map(r => (
<TableRow key={r.id} className={r.releasedAt ? "opacity-60" : ""}>
<TableCell>
<div className="text-sm">{r.user.name}</div>
<div className="text-xs text-gray-500">{r.user.email}</div>
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
</TableCell>
<TableCell className="text-sm">{r.registration.event.title}</TableCell>
<TableCell className="text-sm">
{fmtRand(r.discountAmount)}
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
</TableCell>
<TableCell className="text-xs text-gray-600">{fmtDate(r.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</div>
<Sheet title={<>Uses of <span className="font-mono">{viewing.coupon.code}</span></>} onClose={() => setViewing(null)}>
{viewing.redemptions.length === 0 ? (
<p className="text-sm text-gray-500">This code hasn&apos;t been used yet.</p>
) : (
<ul className="divide-y">
{viewing.redemptions.map(r => (
<li key={r.id} className={`py-2.5 flex items-start justify-between gap-3 ${r.releasedAt ? "opacity-60" : ""}`}>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{r.user.name}</div>
<div className="text-xs text-gray-500 truncate">{r.user.email}</div>
<div className="text-xs text-gray-500">{r.registration.event.title} · {fmtDate(r.createdAt)}</div>
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
</div>
<div className="text-right shrink-0">
<div className="text-sm font-semibold">{fmtRand(r.discountAmount)}</div>
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
</div>
</li>
))}
</ul>
)}
</Sheet>
)}
</div>
);
}
/** A dialog that slides up full-width on phones and sits centred on bigger screens. */
function Sheet({ title, subtitle, onClose, children, footer, onSubmit }: {
title: React.ReactNode;
subtitle?: React.ReactNode;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
onSubmit?: (e: React.FormEvent) => void;
}) {
const Box = onSubmit ? "form" : "div";
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center sm:p-4" onClick={onClose}>
<Box onSubmit={onSubmit} className="bg-white w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between gap-3 p-4 border-b">
<div className="min-w-0">
<h2 className="font-semibold">{title}</h2>
{subtitle && <p className="text-xs text-gray-500 truncate">{subtitle}</p>}
</div>
<button type="button" onClick={onClose} aria-label="Close" className="p-1 -m-1"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 overflow-y-auto overflow-x-hidden overscroll-contain">{children}</div>
{footer && <div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 p-4 border-t">{footer}</div>}
</Box>
</div>
);
}
function CouponCard({ coupon: c, status, onUses, onEdit, onToggle }: { coupon: Coupon; status: Status; onUses: () => void; onEdit: () => void; onToggle: () => void }) {
const restricted = c.allowedUsers.length
? `${c.allowedUsers.length} ${c.allowedUsers.length === 1 ? "person" : "people"}`
: c.restrictRoles.length ? c.restrictRoles.map(r => (r === "user" ? "members" : r)).join(", ") : c.restrictDomain ? `@${c.restrictDomain}` : null;
const dates = c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end date"}` : null;
const limits = [
c.maxUses ? `${c.maxUses} uses in total` : null,
c.maxUsesPerUser ? `${c.maxUsesPerUser} per person` : null,
c.minQuantity ? `min ${c.minQuantity} tickets` : null,
].filter(Boolean).join(" · ");
const live = status.key === "active" || status.key === "scheduled";
return (
<div className={`border rounded-xl p-4 flex flex-col gap-3 ${live ? "bg-white" : "bg-gray-50"}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-mono font-semibold text-gray-900 break-all">{c.code}</div>
{c.description && <div className="text-xs text-gray-500 mt-0.5 break-words">{c.description}</div>}
</div>
<span className={`shrink-0 text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span>
</div>
<div className="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
<span className="text-xl font-semibold text-gray-900">{c.summary}</span>
{c.options.length > 0 && (
<span className="text-xs text-gray-500">on {c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{c.events.length === 0
? <span className="rounded-full bg-gray-100 text-gray-700 px-2.5 py-1 text-xs">All events</span>
: c.events.map(e => (
<span key={e.eventId} className="rounded-full bg-brand-50 text-brand-800 border border-brand-100 px-2.5 py-1 text-xs break-words">{e.event.title}</span>
))}
</div>
<div className="text-xs text-gray-600 space-y-0.5">
<div>Used <strong>{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}×</strong> · {fmtRand(c.totalDiscount)} given</div>
{restricted && <div className="flex items-center gap-1 text-amber-700"><Users className="w-3.5 h-3.5 shrink-0" /> Only for {restricted}</div>}
{dates && <div className="flex items-center gap-1"><CalendarClock className="w-3.5 h-3.5 shrink-0" /> {dates}</div>}
{limits && <div>{limits}</div>}
</div>
<div className="flex flex-wrap gap-2 pt-1 border-t border-gray-100 mt-auto">
<button onClick={onUses} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<ListChecks className="w-3.5 h-3.5" /> Uses
</button>
<button onClick={onEdit} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
<button onClick={onToggle} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border mt-2 sm:ml-auto ${c.active ? "text-amber-700 border-amber-200 hover:bg-amber-50" : "text-green-700 border-green-200 hover:bg-green-50"}`}>
<Power className="w-3.5 h-3.5" /> {c.active ? "Turn off" : "Turn on"}
</button>
</div>
</div>
);
}
function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
token: string;
events: EventLite[];
@@ -365,8 +422,22 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
const [error, setError] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [userResults, setUserResults] = useState<{ id: string; name: string; email: string }[]>([]);
const [eventQuery, setEventQuery] = useState("");
const [showPast, setShowPast] = useState(false);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
// Upcoming events soonest first. Past events only when asked for, but ones already
// picked always stay in the list so they can be unticked.
const eventChoices = useMemo(() => {
const q = eventQuery.trim().toLowerCase();
const upcoming = events.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const past = events.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
return [...upcoming, ...past]
.filter(e => showPast || !isPast(e) || d.eventIds.includes(e.id))
.filter(e => !q || e.title.toLowerCase().includes(q));
}, [events, eventQuery, showPast, d.eventIds]);
const pastCount = events.filter(isPast).length;
// Ticket options only make sense within the chosen events.
const scopedEvents = useMemo(() => events.filter(e => d.eventIds.includes(e.id)), [events, d.eventIds]);
@@ -415,184 +486,200 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
}
};
const label = "block text-sm font-medium text-gray-700 mb-1";
const optional = <span className="font-normal text-gray-400">(optional)</span>;
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">{couponId ? "Edit code" : "New coupon code"}</h2>
<button type="button" onClick={onClose} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 space-y-5 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Code</label>
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (internal)</label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Discount type</label>
<select className={inputCls} value={d.type} onChange={e => set("type", e.target.value as CouponType)}>
<option value="percent">Percentage</option>
<option value="fixed">Fixed amount (R)</option>
<option value="comp">Free (100%)</option>
</select>
</div>
{d.type !== "comp" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
<input type="number" min="0" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
</div>
)}
<div className="flex items-end">
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Events</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked for every event.</p>
<div className="max-h-40 overflow-y-auto border rounded-lg divide-y">
{events.map(ev => (
<label key={ev.id} className="flex items-center gap-2 px-3 py-2 text-sm">
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
<span>{ev.title}</span>
<span className="text-xs text-gray-500 ml-auto">{fmtDate(ev.startDate)}</span>
</label>
))}
{events.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">No events.</p>}
</div>
</div>
{scopedEvents.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Only these tickets / items</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
<div className="space-y-2">
{scopedEvents.map(ev => (
<div key={ev.id} className="border rounded-lg p-3">
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
{ev.eventOptions.map(o => (
<div key={o.id}>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
</label>
{(o.variants || []).map(v => (
<label key={v.id} className="flex items-center gap-2 text-sm ml-6">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
{v.name}
</label>
))}
</div>
))}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid from</label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid until</label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max total uses</label>
<input type="number" min="1" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max uses per person</label>
<input type="number" min="1" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Minimum tickets</label>
<input type="number" min="1" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Who can use it</label>
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
<option value="anyone">Anyone with the code</option>
<option value="roles">People with certain roles</option>
<option value="users">Specific people</option>
<option value="domain">An email domain</option>
</select>
{d.restrictTo !== "anyone" && (
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can&apos;t be shared in group chats.</p>
)}
{d.restrictTo === "roles" && (
<div className="flex flex-wrap gap-4 mt-2">
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
<label key={r} className="flex items-center gap-2 text-sm capitalize">
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
{r === "user" ? "Member" : r}
</label>
))}
</div>
)}
{d.restrictTo === "domain" && (
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
)}
{d.restrictTo === "users" && (
<div className="mt-2">
<div className="flex flex-wrap gap-2 mb-2">
{d.users.map(u => (
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
{u.name}
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
</span>
))}
</div>
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
{userResults.length > 0 && (
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={d.active} onChange={e => set("active", e.target.checked)} /> Active
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
<div className="flex justify-end gap-3 p-4 border-t">
<Sheet
title={couponId ? "Edit code" : "New coupon code"}
onClose={onClose}
onSubmit={save}
footer={(
<>
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : couponId ? "Save changes" : "Create code"}
</button>
</>
)}
>
<div className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={label}>Code</label>
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
</div>
<div>
<label className={label}>Description {optional}</label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
<p className="text-xs text-gray-500 mt-1">Only staff see this.</p>
</div>
</div>
</form>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Discount</label>
<div className="grid grid-cols-3 gap-2">
{([["percent", "Percentage", "e.g. 20% off"], ["fixed", "Amount off", "e.g. R50 off"], ["comp", "Free", "100% off"]] as [CouponType, string, string][]).map(([t, name, hint]) => (
<button type="button" key={t} onClick={() => set("type", t)} aria-pressed={d.type === t}
className={`text-left rounded-lg border p-2.5 transition-colors ${d.type === t ? "border-brand-500 bg-brand-50 ring-1 ring-brand-500" : "hover:bg-gray-50"}`}>
<div className="text-sm font-medium text-gray-900">{name}</div>
<div className="text-xs text-gray-500">{hint}</div>
</button>
))}
</div>
{d.type !== "comp" && (
<div className="mt-3 sm:max-w-[12rem]">
<label className={label}>{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
<input type="number" min="0" inputMode="decimal" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
</div>
)}
<label className="mt-3 flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
</label>
</div>
<div>
<label className={label}>Events</label>
<p className="text-xs text-gray-500 mb-2">
{d.eventIds.length === 0 ? "None picked: the code works for every event." : `${d.eventIds.length} picked. The code only works for ${d.eventIds.length === 1 ? "this event" : "these events"}.`}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
<input className={`${inputCls} pl-8`} placeholder="Find an event" value={eventQuery} onChange={e => setEventQuery(e.target.value)} />
</div>
{pastCount > 0 && (
<label className="flex items-center gap-2 text-sm text-gray-700 shrink-0">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
</label>
)}
</div>
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
{eventChoices.map(ev => (
<label key={ev.id} className={`flex items-center gap-2 px-3 py-2 text-sm ${d.eventIds.includes(ev.id) ? "bg-brand-50/60" : ""}`}>
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
<span className="min-w-0 flex-1 truncate">{ev.title}</span>
<span className="text-xs text-gray-500 shrink-0">{fmtDate(ev.startDate)}{isPast(ev) ? " · past" : ""}</span>
</label>
))}
{eventChoices.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">{eventQuery ? "No events match." : "No upcoming events."}</p>}
</div>
</div>
{scopedEvents.length > 0 && (
<div>
<label className={label}>Only these tickets / items {optional}</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
<div className="space-y-2">
{scopedEvents.map(ev => (
<div key={ev.id} className="border rounded-lg p-3 bg-gray-50">
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
{ev.eventOptions.map(o => (
<div key={o.id}>
<label className="flex items-center gap-2 text-sm py-0.5">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
</label>
{(o.variants || []).map(v => (
<label key={v.id} className="flex items-center gap-2 text-sm ml-6 py-0.5">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
{v.name}
</label>
))}
</div>
))}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={label}>Valid from {optional}</label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className={label}>Valid until {optional}</label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className={label}>Max total uses</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className={label}>Max uses per person</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className={label}>Minimum tickets</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
</div>
</div>
<div>
<label className={label}>Who can use it</label>
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
<option value="anyone">Anyone with the code</option>
<option value="roles">People with certain roles</option>
<option value="users">Specific people</option>
<option value="domain">An email domain</option>
</select>
{d.restrictTo !== "anyone" && (
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can&apos;t be shared in group chats.</p>
)}
{d.restrictTo === "roles" && (
<div className="flex flex-wrap gap-x-4 gap-y-2 mt-2">
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
<label key={r} className="flex items-center gap-2 text-sm capitalize">
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
{r === "user" ? "Member" : r}
</label>
))}
</div>
)}
{d.restrictTo === "domain" && (
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
)}
{d.restrictTo === "users" && (
<div className="mt-2">
<div className="flex flex-wrap gap-2 mb-2">
{d.users.map(u => (
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
{u.name}
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
</span>
))}
</div>
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
{userResults.length > 0 && (
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.active} onChange={e => set("active", e.target.checked)} />
<span>Active <span className="block text-xs text-gray-500">Discounts already given keep their amount if you turn it off later.</span></span>
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
</Sheet>
);
}
+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;
}
+129 -54
View File
@@ -54,18 +54,15 @@ export function BundleOffers({ bundles, className = "" }: { bundles?: PublicBund
<div className={`rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900 ${className}`}>
<div className="flex items-center gap-1.5 font-semibold"><Gift className="h-4 w-4" /> Bundle deals</div>
<ul className="mt-1.5 space-y-1">
{bundles.map(b => {
const save = b.standardValue - b.price;
return (
<li key={b.id}>
<span className="font-medium">{b.name}:</span> {b.summary} for <span className="font-semibold">{fmt(b.price)}</span>
{save > 0.005 && <span className="text-emerald-700"> (save {fmt(save)})</span>}
{b.description && <span className="block text-xs text-emerald-800/80">{b.description}</span>}
</li>
);
})}
{bundles.map(b => (
<li key={b.id}>
<span className="font-medium">{b.name}:</span> {b.headline}
{b.savingText && <span className="text-emerald-700"> ({b.savingText})</span>}
{b.description && <span className="block text-xs text-emerald-800/80">{b.description}</span>}
</li>
))}
</ul>
<p className="mt-1.5 text-xs text-emerald-800/80">Picked automatically when your tickets match, as many times as they fit.</p>
<p className="mt-1.5 text-xs text-emerald-800/80">Applied automatically when your tickets match.</p>
</div>
);
}
@@ -98,24 +95,49 @@ type BuilderOption = {
availableCount?: number | null;
variants?: { id: string; name: string; stockLimit?: number | null; availableCount?: number | null }[] | null;
};
type Qty = (optionId: string, variantId: string | null) => number;
type Change = { optionId: string; variantId: string | null; quantity: number };
const leftOf = (stockLimit?: number | null, availableCount?: number | null) => ((stockLimit ?? 0) > 0 && availableCount !== undefined && availableCount !== null ? availableCount : Infinity);
/** Tickets of this item already in the basket. An "any variant" item counts every variant. */
function itemHave(item: BundleItem, opt: BuilderOption | undefined, qty: (optionId: string, variantId: string | null) => number) {
function itemHave(item: BundleItem, opt: BuilderOption | undefined, qty: Qty) {
if (!opt) return 0;
if (item.variantId) return qty(opt.id, item.variantId);
const variants = opt.variants || [];
return variants.length > 0 ? variants.reduce((s, v) => s + qty(opt.id, v.id), 0) : qty(opt.id, null);
}
const leftOf = (stockLimit?: number | null, availableCount?: number | null) => ((stockLimit ?? 0) > 0 && availableCount !== undefined && availableCount !== null ? availableCount : Infinity);
/** Every basket line (option, or option + variant) that counts towards a bundle, without repeats. */
function qualifyingKeys(bundle: PublicBundle, options: BuilderOption[]) {
const keys: { opt: BuilderOption; variantId: string | null }[] = [];
const seen = new Set<string>();
for (const item of bundle.items) {
const opt = options.find(o => o.id === item.eventOptionId);
if (!opt) continue;
const variants = opt.variants || [];
const ids = variants.length === 0 ? [null] : item.variantId ? [item.variantId] : variants.map(v => v.id);
for (const variantId of ids) {
const k = `${opt.id}::${variantId || ""}`;
if (!seen.has(k)) { seen.add(k); keys.push({ opt, variantId }); }
}
}
return keys;
}
/** Places left for one basket line, or Infinity. */
function roomFor(opt: BuilderOption, variantId: string | null, qty: Qty) {
const variant = variantId ? (opt.variants || []).find(v => v.id === variantId) : null;
return Math.min(leftOf(opt.stockLimit, opt.availableCount), variant ? leftOf(variant.stockLimit, variant.availableCount) : Infinity) - qty(opt.id, variantId);
}
/**
* Quantities that make up `sets` full bundles, or null when there aren't enough tickets
* left. Only ever raises quantities. An "any variant" item tops up the variant already
* picked most (or the first one with places left).
* Exact bundles: quantities that make up `sets` full sets, or null when there aren't
* enough tickets left. Only ever raises quantities. An "any variant" item tops up the
* variant already picked most (or the first one with places left).
*/
function quantitiesForSets(bundle: PublicBundle, options: BuilderOption[], qty: (o: string, v: string | null) => number, sets: number) {
const changes: { optionId: string; variantId: string | null; quantity: number }[] = [];
function exactChanges(bundle: PublicBundle, options: BuilderOption[], qty: Qty, sets: number): Change[] | null {
const changes: Change[] = [];
for (const item of bundle.items) {
const opt = options.find(o => o.id === item.eventOptionId);
if (!opt) return null;
@@ -123,75 +145,128 @@ function quantitiesForSets(bundle: PublicBundle, options: BuilderOption[], qty:
if (short <= 0) continue;
let variantId = item.variantId || null;
if (!variantId && (opt.variants || []).length > 0) {
const withRoom = (opt.variants || []).filter(v => leftOf(v.stockLimit, v.availableCount) - qty(opt.id, v.id) >= short);
const pick = withRoom.sort((a, b) => qty(opt.id, b.id) - qty(opt.id, a.id))[0];
const pick = (opt.variants || []).filter(v => roomFor(opt, v.id, qty) >= short).sort((a, b) => qty(opt.id, b.id) - qty(opt.id, a.id))[0];
if (!pick) return null;
variantId = pick.id;
}
const current = qty(opt.id, variantId);
const variant = variantId ? (opt.variants || []).find(v => v.id === variantId) : null;
if (current + short > Math.min(leftOf(opt.stockLimit, opt.availableCount), variant ? leftOf(variant.stockLimit, variant.availableCount) : Infinity)) return null;
changes.push({ optionId: opt.id, variantId, quantity: current + short });
if (roomFor(opt, variantId, qty) < short) return null;
changes.push({ optionId: opt.id, variantId, quantity: qty(opt.id, variantId) + short });
}
return changes;
}
/** Pick-any and group bundles: add `count` tickets to the qualifying line picked most. */
function topUpChange(bundle: PublicBundle, options: BuilderOption[], qty: Qty, count: number): Change[] | null {
if (count <= 0) return [];
const pick = qualifyingKeys(bundle, options)
.filter(k => roomFor(k.opt, k.variantId, qty) >= count)
.sort((a, b) => qty(b.opt.id, b.variantId) - qty(a.opt.id, a.variantId))[0];
if (!pick) return null;
return [{ optionId: pick.opt.id, variantId: pick.variantId, quantity: qty(pick.opt.id, pick.variantId) + count }];
}
const orList = (summary: string) => summary.replace(/, ([^,]*)$/, " or $1");
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
type Row = {
bundle: PublicBundle;
// Something from the bundle is in the basket.
touched: boolean;
// Full sets in the basket (for a group discount: 1 once it applies).
sets: number;
// A set (or the group) has been started but isn't complete.
partial: boolean;
// What's missing, in words.
missing: string;
// Quantities to complete it (partial) or add another set (complete); null = no room.
next: Change[] | null;
// false for a group discount once it applies: it has no "another one".
canAddMore: boolean;
};
function rowFor(bundle: PublicBundle, options: BuilderOption[], qty: Qty): Row {
const kind = bundle.kind || "exact";
if (kind === "exact") {
const have = bundle.items.map(item => {
const opt = options.find(o => o.id === item.eventOptionId);
return { item, opt, n: itemHave(item, opt, qty) };
});
const sets = Math.min(...have.map(h => Math.floor(h.n / h.item.quantity)));
// Tickets beyond the full sets mean the next set has been started.
const partial = have.some(h => h.n > sets * h.item.quantity);
const missing = have
.filter(h => h.n < (sets + 1) * h.item.quantity)
.map(h => {
const v = h.item.variantId ? (h.opt?.variants || []).find(x => x.id === h.item.variantId) : null;
return `${(sets + 1) * h.item.quantity - h.n} × ${h.opt?.name || "ticket"}${v ? ` (${v.name})` : ""}`;
})
.join(" and ");
return { bundle, touched: have.some(h => h.n > 0), sets, partial, missing, next: exactChanges(bundle, options, qty, sets + 1), canAddMore: true };
}
const size = Number(bundle.setSize) || 1;
const have = qualifyingKeys(bundle, options).reduce((s, k) => s + qty(k.opt.id, k.variantId), 0);
const names = orList(bundle.summary);
if (kind === "group") {
const short = Math.max(0, size - have);
return {
bundle, touched: have > 0, sets: short === 0 ? 1 : 0, partial: short > 0,
missing: `${plural(short, "more ticket")} (${names})`,
next: topUpChange(bundle, options, qty, short), canAddMore: false,
};
}
const sets = Math.floor(have / size);
const short = have % size === 0 ? size : size - (have % size);
return {
bundle, touched: have > 0, sets, partial: have % size > 0,
missing: `${plural(short, "more ticket")} (${names})`,
next: topUpChange(bundle, options, qty, short), canAddMore: true,
};
}
/**
* On the register form: each bundle the basket has started on, with what's missing and a
* "Complete bundle" button, then "+" to add another full set. Buttons set the ticket
* quantities through `onSetQuantity`.
* On the register form, at the door, manual registration and the kiosk: each bundle the
* basket has started on, with what's missing and a "Complete" button, then "+" to add
* another full set. Buttons set the ticket quantities through `onSetQuantity`.
*/
export function BundleBuilder({ bundles, options, qty, onSetQuantity, className = "" }: {
bundles?: PublicBundle[] | null;
options: BuilderOption[];
qty: (optionId: string, variantId: string | null) => number;
qty: Qty;
onSetQuantity: (optionId: string, variantId: string | null, quantity: number) => void;
className?: string;
}) {
const rows = (bundles || []).map(bundle => {
const have = bundle.items.map(item => ({ item, opt: options.find(o => o.id === item.eventOptionId), n: itemHave(item, options.find(o => o.id === item.eventOptionId), qty) }));
const sets = Math.min(...have.map(h => Math.floor(h.n / h.item.quantity)));
const touched = have.some(h => h.n > 0);
// Tickets beyond the full sets mean the next set has been started.
const partial = have.some(h => h.n > sets * h.item.quantity);
const missing = partial ? have.filter(h => h.n < (sets + 1) * h.item.quantity).map(h => ({ ...h, short: (sets + 1) * h.item.quantity - h.n })) : [];
const next = quantitiesForSets(bundle, options, qty, sets + 1);
return { bundle, touched, sets, partial, missing, next };
}).filter(r => r.touched);
const rows = (bundles || []).map(b => rowFor(b, options, qty)).filter(r => r.touched);
if (rows.length === 0) return null;
const apply = (changes: NonNullable<ReturnType<typeof quantitiesForSets>>) => changes.forEach(c => onSetQuantity(c.optionId, c.variantId, c.quantity));
const itemName = (item: BundleItem, opt?: BuilderOption) => {
const v = item.variantId ? (opt?.variants || []).find(x => x.id === item.variantId) : null;
return `${opt?.name || "ticket"}${v ? ` (${v.name})` : ""}`;
};
const apply = (changes: Change[]) => changes.forEach(c => onSetQuantity(c.optionId, c.variantId, c.quantity));
return (
<div className={`space-y-2 ${className}`}>
{rows.map(({ bundle, sets, partial, missing, next }) => {
const save = bundle.standardValue - bundle.price;
{rows.map(({ bundle, sets, partial, missing, next, canAddMore }) => {
const isGroup = bundle.kind === "group";
const done = isGroup ? `✓ Group price on all your ${orList(bundle.summary)} tickets` : `✓ ${sets > 1 ? `${sets} bundles` : "Bundle"} in your basket`;
return (
<div key={bundle.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 font-semibold"><Gift className="h-4 w-4 shrink-0" /> {bundle.name}</div>
<div className="text-xs text-emerald-800">
{bundle.summary} for {fmt(bundle.price)}{save > 0.005 ? ` (save ${fmt(save)})` : ""}
{bundle.headline}{bundle.savingText ? ` (${bundle.savingText})` : ""}
</div>
{partial ? (
<div className="text-xs text-emerald-800 mt-0.5">
Add {missing.map(m => `${m.short} × ${itemName(m.item, m.opt)}`).join(" and ")} to {sets > 0 ? "complete another bundle" : "complete it"}.
Add {missing} to {isGroup ? "get the group price" : sets > 0 ? "complete another bundle" : "complete it"}.
</div>
) : (
<div className="text-xs font-medium text-emerald-700 mt-0.5">✓ {sets > 1 ? `${sets} bundles` : "Bundle"} in your basket</div>
<div className="text-xs font-medium text-emerald-700 mt-0.5">{done}</div>
)}
{!next && <div className="text-xs text-amber-700 mt-0.5">Not enough tickets left for {partial ? "this bundle" : "another one"}.</div>}
{!next && (partial || canAddMore) && <div className="text-xs text-amber-700 mt-0.5">Not enough tickets left for {partial ? "this bundle" : "another one"}.</div>}
</div>
{partial ? (
<button type="button" onClick={() => next && apply(next)} disabled={!next}
className="shrink-0 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50">
Complete bundle
{isGroup ? "Complete group" : "Complete bundle"}
</button>
) : (
) : canAddMore ? (
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs font-medium">×{sets}</span>
<button type="button" onClick={() => next && apply(next)} disabled={!next} aria-label={`Add another ${bundle.name}`} title="Add another bundle"
@@ -199,7 +274,7 @@ export function BundleBuilder({ bundles, options, qty, onSetQuantity, className
+
</button>
</div>
)}
) : null}
</div>
);
})}
+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>
),
@@ -1,12 +1,12 @@
import React from "react";
import { Gift, Plus, Layers, BadgePercent, CalendarClock, TicketPercent, Repeat, BarChart2, Lock } from "lucide-react";
import { Gift, Plus, Layers, BadgePercent, CalendarClock, TicketPercent, Repeat, BarChart2, Lock, Shuffle, Users } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/bundles. */
export const supervisorBundlesHelpContent: HelpContent = {
title: "Bundle deals",
subtitle: "Sell a set of tickets together for one price.",
subtitle: "Tickets sold together at a better price.",
tabs: [
{
key: "create",
@@ -15,9 +15,19 @@ export const supervisorBundlesHelpContent: HelpContent = {
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={Gift} title="What's in it" tone="brand">
Pick the event, then add each ticket type and how many, e.g. 5 × Adult + 3 × Child, and the price for the lot. For options with variants (sizes, room types) choose one variant or &quot;Any variant&quot;.
<GuideItem icon={Gift} title="Exact set" tone="brand">
These tickets in these numbers for one price, e.g. 2 × Adult + 3 × Child for R220.
</GuideItem>
<GuideItem icon={Shuffle} title="Pick any" tone="violet">
Any mix of the qualifying tickets, e.g. any 4 of Adult or Child for R250: 2 + 2 and 4 + 0 both count.
</GuideItem>
<GuideItem icon={TicketPercent} title="Buy X get Y free" tone="rose">
In every set of qualifying tickets the cheapest are free, e.g. buy 3, get 1 free (4 per set, 1 free).
</GuideItem>
<GuideItem icon={Users} title="Group discount" tone="blue">
From a group size up, every qualifying ticket gets a lower price or a percentage off, e.g. 10+ tickets: R70 each. Tickets already cheaper than the group price stay as they are.
</GuideItem>
<p className="text-xs text-gray-500">For options with variants (sizes, room types) choose one variant or &quot;Any variant&quot;.</p>
<GuideItem icon={BadgePercent} title="The saving" tone="green">
The form shows what the tickets cost separately at the standard price and how much the bundle saves. Buyers see the deal on the event page and when they register.
</GuideItem>
@@ -36,7 +46,7 @@ export const supervisorBundlesHelpContent: HelpContent = {
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={Repeat} title="Automatic, as many times as fits" tone="brand">
No code needed. 10 adults + 6 children gets the 5 + 3 deal twice; anything left over is charged as usual. This works the same online, for manual registrations, at the door and on the kiosk.
No code needed. Exact, pick-any and buy-X-get-Y-free deals apply as many times as the tickets allow (10 adults + 6 children gets a 5 + 3 deal twice); anything left over is charged as usual. A group discount applies once, to every qualifying ticket. This works the same online, for manual registrations, at the door and on the kiosk, where a &quot;Complete bundle&quot; button tops up the tickets.
</GuideItem>
<GuideItem icon={BadgePercent} title="Best price wins" tone="green">
A bundle only applies when it&apos;s cheaper than the tickets at the prices actually charged, early-bird and staff prices included, so it never makes anything dearer. If an event has several bundles, the one that saves the most is used first.
@@ -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;
}
+17 -2
View File
@@ -154,16 +154,31 @@ export type Event = {
export type BundleItem = { id?: string; eventOptionId: string; variantId?: string | null; quantity: number; eventOption?: { id: string; name: string; price?: number } | null; variant?: { id: string; name: string } | null };
// exact: these tickets in these numbers for a set price. mix: any setSize of the
// qualifying tickets for a set price. free: any setSize, the cheapest freeCount free.
// group: setSize or more qualifying tickets at price each (or price% off).
export type BundleKind = "exact" | "mix" | "free" | "group";
export type BundleGroupPricing = "per_ticket" | "percent";
// A bundle deal as shown to buyers on the event page.
export type PublicBundle = {
id: string;
name: string;
description?: string | null;
kind: BundleKind;
price: number;
setSize?: number | null;
freeCount?: number | null;
groupPricing?: BundleGroupPricing | null;
validUntil?: string | null;
// The tickets: "5 × Adult + 3 × Child" (exact) or "Adult, Child" (the others).
summary: string;
// What the items cost separately at the standard price.
standardValue: number;
// The deal in one line, e.g. "Any 4 of Adult, Child for R250".
headline: string;
// What an exact set (or the dearest pick-any set) costs at standard prices.
standardValue: number | null;
// e.g. "save R60", "save up to R10 a ticket"; null when the headline says it.
savingText: string | null;
items: BundleItem[];
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.28.1",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.28.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.28.1",
"version": "1.32.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",