diff --git a/CHANGELOG.md b/CHANGELOG.md index 6648130..020f4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/DEMO_DATA.md b/backend/DEMO_DATA.md index a114cf9..2a73c1d 100644 --- a/backend/DEMO_DATA.md +++ b/backend/DEMO_DATA.md @@ -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** | diff --git a/backend/prisma/migrations/20261021141045_add_bundle_kinds/migration.sql b/backend/prisma/migrations/20261021141045_add_bundle_kinds/migration.sql new file mode 100644 index 0000000..8272df8 --- /dev/null +++ b/backend/prisma/migrations/20261021141045_add_bundle_kinds/migration.sql @@ -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; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 68da141..03a7cbf 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/scripts/set-demo.js b/backend/scripts/set-demo.js index 9528ec5..28a50b6 100644 --- a/backend/scripts/set-demo.js +++ b/backend/scripts/set-demo.js @@ -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, diff --git a/backend/src/controllers/bundleController.js b/backend/src/controllers/bundleController.js index 8014c5f..06437c2 100644 --- a/backend/src/controllers/bundleController.js +++ b/backend/src/controllers/bundleController.js @@ -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 })), }; }); diff --git a/backend/src/controllers/couponController.js b/backend/src/controllers/couponController.js index 4a54562..a0309c9 100644 --- a/backend/src/controllers/couponController.js +++ b/backend/src/controllers/couponController.js @@ -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, diff --git a/backend/src/index.js b/backend/src/index.js index b15ce0f..6fa20ec 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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' }, diff --git a/backend/src/utils/bundles.js b/backend/src/utils/bundles.js index 3dcdce3..6e08559 100644 --- a/backend/src/utils/bundles.js +++ b/backend/src/utils/bundles.js @@ -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, diff --git a/backend/tests/bundles.test.js b/backend/tests/bundles.test.js index 4ee0a9a..15ddf84 100644 --- a/backend/tests/bundles.test.js +++ b/backend/tests/bundles.test.js @@ -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', () => { diff --git a/frontend/src/app/dashboard/supervisor/bundles/page.tsx b/frontend/src/app/dashboard/supervisor/bundles/page.tsx index 371e26a..13b0888 100644 --- a/frontend/src/app/dashboard/supervisor/bundles/page.tsx +++ b/frontend/src/app/dashboard/supervisor/bundles/page.tsx @@ -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 = { + 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 {status.label} +
{KIND_INFO[kind].label}{kind === "exact" ? "" : ` · ${kind === "group" ? "qualifying" : "any of"}:`}
+
{b.items.map(i => ( - {i.quantity} × {i.eventOption?.name || "Ticket"}{i.variant ? ` (${i.variant.name})` : ""} + {kind === "exact" && {i.quantity} ×} {i.eventOption?.name || "Ticket"}{i.variant ? ` (${i.variant.name})` : ""} ))}
-
- {fmtRand(b.price)} - {fmtRand(b.standardValue)} - 0 ? "text-green-700" : "text-amber-700"}`}> - {save > 0 ? `saves ${fmtRand(save)}` : "no saving at the standard price"} - +
+
+ {kind === "exact" || kind === "mix" ? ( + <> + {fmtRand(b.price)} + {kind === "mix" ? `for any ${b.setSize}` : "for the set"} + {b.standardValue !== null && {fmtRand(b.standardValue)}} + + ) : kind === "free" ? ( + Buy {(b.setSize || 0) - (b.freeCount || 0)}, get {b.freeCount} free + ) : ( + <> + {b.groupPricing === "percent" ? `${b.price}% off` : `${fmtRand(b.price)} each`} + from {b.setSize} tickets + + )} +
+
+ {b.savingText || (kind === "free" || (kind === "group" && b.groupPricing === "percent") ? "" : "No saving at the standard prices")} +
@@ -314,9 +344,15 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: { const options = event.eventOptions || []; const setItem = (i: number, patch: Partial) => 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 }: {
- + +
+ {(Object.keys(KIND_INFO) as BundleKind[]).map(k => ( + + ))} +
+
+ +
+ + {!exact &&

Any of these count towards the deal.

}
{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 (
- setItem(i, { quantity: e.target.value })} aria-label="Quantity" required /> - × + {exact && ( + <> + setItem(i, { quantity: e.target.value })} aria-label="Quantity" required /> + × + + )} @@ -405,12 +464,50 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
+ {!exact && ( +
+
+ + set("setSize", e.target.value)} required /> +
+ {d.kind === "free" && ( +
+ + set("freeCount", e.target.value)} required /> +
+ )} + {d.kind === "group" && ( +
+ + +
+ )} +
+ )} + + {d.kind === "free" ? ( +
+ {size > free && free > 0 + ? <>Buy {size - free}, get {free} free. In every {size} qualifying tickets, the cheapest {free === 1 ? "one is" : `${free} are`} free. + : The set needs more tickets than are free.} +
+ ) : (
- - set("price", e.target.value)} required /> + + set("price", e.target.value)} required /> + {d.kind === "group" ? ( +

+ {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).`} +

+ ) : ( + <>
-
Separately
+
{exact ? "Separately" : "Dearest set"}
{fmtRand(standardValue)}
@@ -423,7 +520,11 @@ function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
{d.price !== "" && saving_ <= 0 &&

The bundle isn't cheaper than the standard prices, so it won't apply unless prices change.

} + {!exact &&

The saving depends on which tickets are picked; it only applies when the set price is cheaper.

} + + )}
+ )}
diff --git a/frontend/src/components/shared/BundleDeals.tsx b/frontend/src/components/shared/BundleDeals.tsx index 59f042a..13049bd 100644 --- a/frontend/src/components/shared/BundleDeals.tsx +++ b/frontend/src/components/shared/BundleDeals.tsx @@ -54,18 +54,15 @@ export function BundleOffers({ bundles, className = "" }: { bundles?: PublicBund
Bundle deals
    - {bundles.map(b => { - const save = b.standardValue - b.price; - return ( -
  • - {b.name}: {b.summary} for {fmt(b.price)} - {save > 0.005 && (save {fmt(save)})} - {b.description && {b.description}} -
  • - ); - })} + {bundles.map(b => ( +
  • + {b.name}: {b.headline} + {b.savingText && ({b.savingText})} + {b.description && {b.description}} +
  • + ))}
-

Picked automatically when your tickets match, as many times as they fit.

+

Applied automatically when your tickets match.

); } @@ -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(); + 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>) => 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 (
- {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 (
-
+
{bundle.name}
- {bundle.summary} for {fmt(bundle.price)}{save > 0.005 ? ` (save ${fmt(save)})` : ""} + {bundle.headline}{bundle.savingText ? ` (${bundle.savingText})` : ""}
{partial ? (
- 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"}.
) : ( -
✓ {sets > 1 ? `${sets} bundles` : "Bundle"} in your basket
+
{done}
)} - {!next &&
Not enough tickets left for {partial ? "this bundle" : "another one"}.
} + {!next && (partial || canAddMore) &&
Not enough tickets left for {partial ? "this bundle" : "another one"}.
}
{partial ? ( - ) : ( + ) : canAddMore ? (
×{sets}
- )} + ) : null}
); })} diff --git a/frontend/src/content/help/supervisor-bundles.tsx b/frontend/src/content/help/supervisor-bundles.tsx index 181f810..00f3786 100644 --- a/frontend/src/content/help/supervisor-bundles.tsx +++ b/frontend/src/content/help/supervisor-bundles.tsx @@ -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: (
- - 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 "Any variant". + + These tickets in these numbers for one price, e.g. 2 × Adult + 3 × Child for R220. + + Any mix of the qualifying tickets, e.g. any 4 of Adult or Child for R250: 2 + 2 and 4 + 0 both count. + + + In every set of qualifying tickets the cheapest are free, e.g. buy 3, get 1 free (4 per set, 1 free). + + + 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. + +

For options with variants (sizes, room types) choose one variant or "Any variant".

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. @@ -36,7 +46,7 @@ export const supervisorBundlesHelpContent: HelpContent = {
- 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 "Complete bundle" button tops up the tickets. A bundle only applies when it'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. diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 50eb6b3..fa6c861 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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[]; };