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>
This commit is contained in:
2026-10-21 16:21:05 +02:00
co-authored by Claude Opus 5.5
parent 6b186b3481
commit f33d04a57c
14 changed files with 683 additions and 195 deletions
+14
View File
@@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
+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** |
@@ -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;
+27 -4
View File
@@ -914,15 +914,37 @@ 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?
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?
@@ -936,7 +958,8 @@ model EventBundle {
@@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,
+84 -28
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) => {
/** 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;
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price;
return s + Number(price || 0) * Number(i.quantity || 0);
}, 0));
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,21 +98,52 @@ 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 : [];
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
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 raw) {
for (const it of Array.isArray(rawItems) ? rawItems : []) {
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 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: (prev?.quantity || 0) + quantity });
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('Add at least one ticket to the bundle');
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) } },
@@ -98,7 +154,6 @@ async function parseBundleBody(body, eventId, { isCreate }) {
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,
+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' },
+136 -41
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));
/**
* 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.
*/
function tryBundle(bundle, pool) {
const left = pool.map(p => p.remaining);
/** Takes up to `need` units from matching pool lines, dearest first. Mutates `left`. */
function takeDearest(pool, left, need, matches) {
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))
.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;
charged += q * p.line.unitPrice;
takes.push({ index, quantity: q });
takes.push({ index, quantity: q, unitPrice: p.line.unitPrice });
}
if (need > 0) return null;
return need > 0 ? null : takes;
}
return { takes, charged, saving: charged - Number(bundle.price || 0) };
// 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, newPrice }], saving } or null when the
* bundle can't apply.
*/
function tryBundle(bundle, pool) {
const left = pool.map(p => p.remaining);
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);
}
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;
}
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', () => {
@@ -8,7 +8,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
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[] };
@@ -17,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;
@@ -25,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,
});
@@ -39,7 +43,10 @@ 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,
});
@@ -51,6 +58,13 @@ const fieldCls = "border rounded-lg px-3 py-2 text-sm focus:outline-none focus:r
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 }) {
const o = options.find(x => x.id === item.eventOptionId);
@@ -247,7 +261,7 @@ export default function BundlesPage() {
function BundleCard({ bundle: b, onEdit, onToggle, onDelete }: { bundle: EventBundle; onEdit: () => void; onToggle: () => void; onDelete: () => void }) {
const status = bundleStatus(b);
const save = b.standardValue - b.price;
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;
@@ -261,20 +275,36 @@ function BundleCard({ bundle: b, onEdit, onToggle, onDelete }: { bundle: EventBu
<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">
<span className="font-semibold">{i.quantity} ×</span> {i.eventOption?.name || "Ticket"}{i.variant ? ` (${i.variant.name})` : ""}
{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-400 line-through">{fmtRand(b.standardValue)}</span>
<span className={`text-xs font-medium ${save > 0 ? "text-green-700" : "text-amber-700"}`}>
{save > 0 ? `saves ${fmtRand(save)}` : "no saving at the standard 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">
@@ -314,9 +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 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();
@@ -326,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,
@@ -371,7 +411,22 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">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);
@@ -379,8 +434,12 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
return (
<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>
@@ -405,12 +464,50 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
</button>
</div>
{!exact && (
<div className="grid 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" inputMode="decimal" className={`${inputCls} sm:max-w-[12rem]`} value={d.price} onChange={e => set("price", e.target.value)} required />
<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>
)}
{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">Separately</div>
<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">
@@ -423,7 +520,11 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
</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>
+126 -51
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 (
{bundles.map(b => (
<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>}
<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,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.
+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[];
};