Compare commits
4
Commits
cc44e62379
...
7c3f4694e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c3f4694e2 | ||
|
|
b441ffac60 | ||
|
|
6010641629 | ||
|
|
e338768f6e |
@@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.35.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Pick sessions.** A multi-day event can let people choose which sessions to come to — day passes for a conference, or a weekly drop-in class. In the event wizard choose "How people book: Pick sessions", then in Items & Pricing set a ticket to "Pick sessions (price per session)". People tick the sessions they want when they register; each session is its own line and its own ticket, at the per-session price (early-bird and staff prices apply per session; late-joiner pricing doesn't). A session can be booked until it starts (staff can book until it ends). Whole-course tickets can still be sold alongside, and stop selling when the course starts.
|
||||
- **Session capacity**: in pick-sessions events each session can have a capacity, covering picked places plus whole-course tickets. Full sessions can't be ticked. A per-session ticket's stock limit is places per session.
|
||||
- Picked-session tickets only scan on their own session ("This ticket is for Class 2 (2026-11-11)…"), on and offline. The attendance register shows one row per person with a dash for sessions they didn't book, and completion and certificates count the sessions they booked. Session reminders go only to the people booked on that session. Tickets, printouts and the ticket email show the session's date.
|
||||
- The session picker is on the public registration page, Manual registration and the self-service kiosk.
|
||||
- The roadmap to 2.0 and a handover note are now in the repo: `docs/ROADMAP-v2.md`, `docs/HANDOVER.md` (plus the previous round's spec, `docs/FEATURES-ROUND-1.md`).
|
||||
|
||||
### Changed
|
||||
|
||||
- Per-session tickets can't be changed from My Events' "edit booking" (book more sessions by registering again); they still count towards the total. At the door, per-session tickets are booked from Manual registration (the page says so).
|
||||
|
||||
## [1.34.0] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -97,6 +97,14 @@ Feedback → Attendance**.
|
||||
with the next session highlighted; cards show "5 sessions · Wed 4 Nov – Wed
|
||||
2 Dec" and stay listed until the last session. Calendar files get one entry
|
||||
per session. Saving moved or cancelled sessions offers to message attendees.
|
||||
- **Pick sessions** (multi-day) — instead of the whole course, people can choose
|
||||
which sessions to come to (day passes, drop-in classes). Tickets set to "Pick
|
||||
sessions" are priced and bought per session — one line and one ticket per session,
|
||||
bookable until the session starts (staff: until it ends) — alongside optional
|
||||
whole-course tickets. Sessions can have a capacity (picked places plus whole-course
|
||||
tickets); a per-session ticket's stock limit is places per session. Picked tickets
|
||||
only scan on their own session; reminders, the register and certificates follow
|
||||
the sessions each person booked. Not available on youth events.
|
||||
- **Late joining** (multi-day) — registration closes when the first session
|
||||
starts, or later (until session N) with late joining on. Late joiners pay
|
||||
full price, a pro-rata price for the sessions left (rounded to the nearest
|
||||
|
||||
@@ -451,13 +451,24 @@ Create/update fields (`POST`/`PUT /api/events`):
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `scheduleType` | `single` (default) or `sessions`. Switching back to `single` is refused once there are session check-ins |
|
||||
| `sessions` | `[{ id?, label?, startsAt, endsAt, location?, cancelled? }]` — the full list. Rows left out are deleted (refused when they have check-ins — send `cancelled: true` instead). The event and its sessions save together, so a refused change saves nothing |
|
||||
| `attendanceMode` | `whole` (default) or `pick` — people choose sessions. Not allowed with `requiresParentalConsent` |
|
||||
| `sessions` | `[{ id?, label?, startsAt, endsAt, location?, cancelled?, capacity? }]` — the full list. Rows left out are deleted (refused when they have check-ins — send `cancelled: true` instead). The event and its sessions save together, so a refused change saves nothing |
|
||||
| `lateJoinEnabled`, `lateJoinUntilSessionIndex` | Registration stays open until that session (index into the sent list, sorted by start) starts; otherwise it closes when the first session starts |
|
||||
| `latePricing`, `lateFixedPrice` | `full`, `prorata` (price × sessions left ÷ sessions, nearest R10, at least R10) or `fixed` (only ever lowers the price). Main tickets only |
|
||||
| `checkInWindow` | `whole_day` (default — any time on the session's date, in `APP_TIMEZONE`, default Africa/Johannesburg) or `session_only` (2 h before start until the end) |
|
||||
| `sessionReminders` | `null` (off) or `{ offsets: [60 \| 180 \| 1440], channels: ["email", "whatsapp"] }` |
|
||||
| `certificatesEnabled`, `certificateConfig` | `{ template: classic \| modern \| church \| kids, title, body ({name} {event} {sessionsAttended} {totalSessions} {endDate}), signatureName, signatureRole, signatureImage, rule: { type: all \| count \| percent, value }, sendOnIssue }` |
|
||||
|
||||
Event options take `sessionMode: "all" | "pick"` (`POST /api/events/:id/options`, `PUT /api/events/options/:id`).
|
||||
In a pick-mode event, a `pick` option is bought per session: registration lines send
|
||||
`sessionIds: [...]` (`POST /api/registrations`, `/manual`, coupon/bundle quotes), and each
|
||||
session becomes its own registration line (`RegistrationOption.sessionId`) and ticket.
|
||||
Capacity counts picked places plus whole-course main tickets; a pick option's
|
||||
`stockLimit` is per session. `PUT /api/registrations/:id/options` refuses pick options and
|
||||
leaves picked lines untouched. Pick-mode events stay open until the last session starts
|
||||
(`wholeCourseClosesAt` gives when whole-course tickets stop). Single-event responses add
|
||||
`booked` and `placesLeft` per session and `sessionAvailability` per pick option.
|
||||
|
||||
Responses for events include `sessions[]` (with `cancelled` and `hasCheckIns`),
|
||||
`registrationClosesAt`, `nextSession` and `lateJoin: { active, mode, sessionsLeft, totalSessions, fixedPrice }`.
|
||||
`PUT` also returns `sessionChanges: { added, moved, cancelled, restored, removed }`.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OptionSessionMode" AS ENUM ('all', 'pick');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventOption" ADD COLUMN "sessionMode" "OptionSessionMode" NOT NULL DEFAULT 'all';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventSession" ADD COLUMN "capacity" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "RegistrationOption" ADD COLUMN "sessionId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationOption_sessionId_idx" ON "RegistrationOption"("sessionId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -237,6 +237,9 @@ model EventOption {
|
||||
stockLimit Int @default(0)
|
||||
// Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price.
|
||||
staffPrice Float?
|
||||
// Multi-day events in "pick sessions" mode: `pick` options are bought per session
|
||||
// (price is per session, stockLimit is places per session); `all` options cover every session.
|
||||
sessionMode OptionSessionMode @default(all)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
registrationOptions RegistrationOption[]
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
@@ -337,11 +340,16 @@ model RegistrationOption {
|
||||
tickets Ticket[]
|
||||
tranches RegistrationOptionTranche[]
|
||||
attendees Attendee[]
|
||||
// A "pick sessions" option: one line (and ticket) per session booked. Null for
|
||||
// everything else.
|
||||
sessionId String?
|
||||
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([eventOptionId])
|
||||
@@index([variantId])
|
||||
@@index([appliedTierId])
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
|
||||
@@ -1445,6 +1453,11 @@ enum LatePricing {
|
||||
fixed
|
||||
}
|
||||
|
||||
enum OptionSessionMode {
|
||||
all
|
||||
pick
|
||||
}
|
||||
|
||||
enum CheckInWindow {
|
||||
whole_day
|
||||
session_only
|
||||
@@ -1462,11 +1475,14 @@ model EventSession {
|
||||
// and left out of attendance totals.
|
||||
cancelledAt DateTime?
|
||||
order Int @default(0)
|
||||
// Optional limit on people at this session (picked places + whole-course tickets).
|
||||
capacity Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
usages TicketUsage[]
|
||||
reminders SessionReminderLog[]
|
||||
registrationOptions RegistrationOption[]
|
||||
|
||||
@@index([eventId, startsAt])
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ async function buildRegister(eventId) {
|
||||
holder: { select: { name: true } },
|
||||
user: { select: { name: true, email: true, phoneNumber: true } },
|
||||
usages: { select: { quantityRedeemed: true, sessionId: true, manual: true } },
|
||||
registrationOption: { select: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
|
||||
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
|
||||
certificates: { select: { id: true, code: true, issuedAt: true, revokedAt: true, manual: true, emailedAt: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -50,18 +50,27 @@ async function buildRegister(eventId) {
|
||||
|
||||
const activeIds = new Set(active.map(s => s.id));
|
||||
const config = sessionsUtil.normalizeCertificateConfig(event.certificateConfig);
|
||||
const rows = tickets.map(t => {
|
||||
// A picked-session booking (one ticket per session) shows as one row.
|
||||
const rows = sessionsUtil.groupTickets(tickets).map(g => {
|
||||
const t = g.tickets[0];
|
||||
const bySession = {};
|
||||
for (const u of t.usages) {
|
||||
const key = multi ? u.sessionId : WHOLE_EVENT;
|
||||
if (!key) continue;
|
||||
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
|
||||
for (const tk of g.tickets) {
|
||||
for (const u of tk.usages) {
|
||||
const key = multi ? u.sessionId : WHOLE_EVENT;
|
||||
if (!key) continue;
|
||||
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
|
||||
}
|
||||
}
|
||||
const attended = multi ? Object.keys(bySession).filter(k => activeIds.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
|
||||
const total = multi ? active.length : 1;
|
||||
const booked = g.sessionIds ? g.sessionIds.filter(id => activeIds.has(id)) : null;
|
||||
const counted = booked ? new Set(booked) : activeIds;
|
||||
const attended = multi ? Object.keys(bySession).filter(k => counted.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
|
||||
const total = multi ? counted.size : 1;
|
||||
const cert = t.certificates[0] || null;
|
||||
return {
|
||||
ticketId: t.id,
|
||||
// Picked sessions: which sessions this person booked, and the ticket for each.
|
||||
bookedSessions: g.sessionIds,
|
||||
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.map(tk => [tk.registrationOption.sessionId, tk.id])) : null,
|
||||
name: t.attendee?.name || t.holder?.name || t.user?.name || '',
|
||||
bookedBy: t.attendee || t.holder ? t.user?.name || '' : '',
|
||||
email: t.user?.email || '',
|
||||
@@ -108,7 +117,7 @@ const markAttendance = async (req, res) => {
|
||||
const { eventId } = req.params;
|
||||
const { ticketId, present } = req.body || {};
|
||||
const sessionKey = String(req.body?.sessionId || '');
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } } } });
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } }, registrationOption: { select: { sessionId: true } } } });
|
||||
if (!ticket || ticket.eventId !== eventId) throw notFound('Ticket not found for this event');
|
||||
if (ticket.event.cashupStatus === 'closed') throw badRequest('This event is closed — reopen it to change attendance.');
|
||||
const multi = ticket.event.scheduleType === 'sessions';
|
||||
@@ -117,6 +126,7 @@ const markAttendance = async (req, res) => {
|
||||
const session = await prisma.eventSession.findUnique({ where: { id: sessionKey } });
|
||||
if (!session || session.eventId !== eventId) throw notFound('Session not found for this event');
|
||||
if (session.cancelledAt) throw badRequest('That session was cancelled');
|
||||
if (ticket.registrationOption?.sessionId && ticket.registrationOption.sessionId !== session.id) throw badRequest('That ticket is for a different session');
|
||||
sessionId = session.id;
|
||||
} else if (sessionKey !== WHOLE_EVENT) {
|
||||
throw badRequest('This event has no sessions');
|
||||
@@ -202,8 +212,9 @@ const exportRegister = async (req, res) => {
|
||||
cols.forEach((c, i) => {
|
||||
const qty = r.checkIns[c.id] || 0;
|
||||
const x = 36 + nameW + i * colW;
|
||||
const notBooked = r.bookedSessions && !r.bookedSessions.includes(c.id);
|
||||
if (qty && r.quantity <= 1) tick(x + (colW - 2) / 2, y + 4);
|
||||
else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false });
|
||||
else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : notBooked ? '-' : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false });
|
||||
});
|
||||
doc.fillColor('#1e293b').text(`${r.attended}/${r.total}`, 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center', lineBreak: false });
|
||||
doc.y = y + 14;
|
||||
@@ -223,7 +234,7 @@ const exportRegister = async (req, res) => {
|
||||
for (const r of reg.rows) {
|
||||
ws.addRow([
|
||||
r.name, r.bookedBy, r.variant ? `${r.option} — ${r.variant}` : r.option, r.email, r.phone, r.quantity,
|
||||
...cols.map(c => r.checkIns[c.id] ? (r.quantity > 1 ? r.checkIns[c.id] : '✓') : ''),
|
||||
...cols.map(c => r.checkIns[c.id] ? (r.quantity > 1 ? r.checkIns[c.id] : '✓') : (r.bookedSessions && !r.bookedSessions.includes(c.id) ? 'not booked' : '')),
|
||||
`${r.attended}/${r.total}`, r.completed ? 'Yes' : 'No',
|
||||
r.certificate && !r.certificate.revokedAt ? r.certificate.code : '',
|
||||
]);
|
||||
|
||||
@@ -250,7 +250,9 @@ async function basketBuyer(req, forUserId) {
|
||||
/** Prices a basket the same way registration will, as coupon/bundle lines. */
|
||||
async function priceBasket(event, options, buyer) {
|
||||
const registrationOptions = [];
|
||||
for (const o of options || []) {
|
||||
// A "pick sessions" line counts once per chosen session.
|
||||
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
|
||||
for (const o of expanded) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
|
||||
@@ -13,14 +13,21 @@ const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { se
|
||||
|
||||
// Adds the multi-day schedule to an event for API responses: sessions (cancelled ones
|
||||
// flagged), when registration closes, the next session, and late-joiner pricing.
|
||||
function withSchedule(ev) {
|
||||
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({ ...s, cancelled: !!s.cancelledAt, hasCheckIns: (_count?.usages || 0) > 0 }));
|
||||
// `bookings` (Map sessionId → people booked) adds `booked` and `placesLeft` per session.
|
||||
function withSchedule(ev, bookings = null) {
|
||||
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({
|
||||
...s,
|
||||
cancelled: !!s.cancelledAt,
|
||||
hasCheckIns: (_count?.usages || 0) > 0,
|
||||
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
|
||||
}));
|
||||
const active = sessionsUtil.activeSessions(sessions);
|
||||
const now = new Date();
|
||||
return {
|
||||
...ev,
|
||||
sessions,
|
||||
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
|
||||
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
|
||||
nextSession: ev.scheduleType === 'sessions' ? active.find(s => new Date(s.endsAt) > now) || null : null,
|
||||
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
|
||||
};
|
||||
@@ -32,6 +39,10 @@ function scheduleData(body, current = {}) {
|
||||
const has = (k) => body[k] !== undefined;
|
||||
const data = {};
|
||||
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
|
||||
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
|
||||
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
|
||||
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
|
||||
}
|
||||
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
|
||||
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
|
||||
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
|
||||
@@ -342,7 +353,8 @@ const getEvents = async (req, res) => {
|
||||
const withStock = await Promise.all(events.map(async ev => {
|
||||
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
|
||||
try {
|
||||
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
|
||||
// Picked-session options have per-session limits, so they never make the event sold out.
|
||||
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0 && !sessionsUtil.isPickOption(ev, o));
|
||||
let isSoldOut = false;
|
||||
if (limitedOpts.length > 0) {
|
||||
const { takenCount } = require('../utils/stock');
|
||||
@@ -551,6 +563,16 @@ const getEventById = async (req, res) => {
|
||||
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
|
||||
// Taken = non-cancelled registrations + places held for live waitlist offers
|
||||
const { takenCount } = require('../utils/stock');
|
||||
// Picked-session options: the stock limit is places per session.
|
||||
if (sessionsUtil.isPickOption(event, opt)) {
|
||||
const sessionAvailability = {};
|
||||
if (opt.stockLimit > 0) {
|
||||
for (const s of sessionsUtil.activeSessions(event.sessions)) {
|
||||
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
|
||||
}
|
||||
}
|
||||
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
|
||||
}
|
||||
const soldCount = await takenCount({ eventOptionId: opt.id });
|
||||
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
|
||||
|
||||
@@ -569,6 +591,7 @@ const getEventById = async (req, res) => {
|
||||
}
|
||||
|
||||
if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []);
|
||||
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
|
||||
const mapped = withSchedule({
|
||||
...event,
|
||||
eventOptions: eventOptionsWithStock,
|
||||
@@ -579,7 +602,7 @@ const getEventById = async (req, res) => {
|
||||
})),
|
||||
form,
|
||||
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
|
||||
});
|
||||
}, bookings);
|
||||
res.json(mapped);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
@@ -915,7 +938,7 @@ const createEventOption = async (req, res) => {
|
||||
|
||||
await assertEventOpen(req.params.id, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit, staffPrice } = req.body;
|
||||
const { name, price, isMainTicket, stockLimit, staffPrice, sessionMode } = req.body;
|
||||
|
||||
const eventOption = await prisma.eventOption.create({
|
||||
data: {
|
||||
@@ -926,8 +949,10 @@ const createEventOption = async (req, res) => {
|
||||
isMainTicket: isMainTicket || false,
|
||||
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(staffPrice),
|
||||
sessionMode: sessionMode === 'pick' ? 'pick' : 'all',
|
||||
}
|
||||
});
|
||||
sessionsUtil.forgetSchedule(req.params.id);
|
||||
|
||||
res.status(201).json(eventOption);
|
||||
} catch (error) {
|
||||
@@ -952,7 +977,7 @@ const updateEventOption = async (req, res) => {
|
||||
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice } = req.body;
|
||||
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice, sessionMode } = req.body;
|
||||
|
||||
const updatedEventOption = await prisma.eventOption.update({
|
||||
where: { id: req.params.id },
|
||||
@@ -962,6 +987,7 @@ const updateEventOption = async (req, res) => {
|
||||
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
|
||||
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
|
||||
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ const TICKET_DELIVERY = ['payer', 'each'];
|
||||
*/
|
||||
function placesAfter(existingLines, requested) {
|
||||
const out = {};
|
||||
for (const l of [...(existingLines || []), ...(requested || [])]) {
|
||||
// Picked-session lines take no attendee names.
|
||||
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
|
||||
const k = lineKey(l.eventOptionId, l.variantId);
|
||||
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
||||
}
|
||||
@@ -52,7 +53,7 @@ const ACTIVE_COUPONS_INCLUDE = {
|
||||
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
||||
};
|
||||
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
|
||||
const { registrationClosesAt } = require('../utils/sessions');
|
||||
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption } = require('../utils/sessions');
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
@@ -92,18 +93,18 @@ async function priceRequestedOption(eventOption, variantId, qty, buyer) {
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
* Returns { available: boolean, remaining: number|null }
|
||||
*/
|
||||
async function checkOptionStock(option, requestedQty, excludeEntryId = null) {
|
||||
async function checkOptionStock(option, requestedQty, excludeEntryId = null, sessionId = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id }, { excludeEntryId });
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check stock availability for a specific variant (same rules as checkOptionStock).
|
||||
*/
|
||||
async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
|
||||
async function checkVariantStock(variant, requestedQty, excludeEntryId = null, sessionId = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id }, { excludeEntryId });
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
@@ -222,11 +223,20 @@ const createRegistration = async (req, res) => {
|
||||
throw new Error('At least one option must be selected');
|
||||
}
|
||||
|
||||
// "Pick sessions" options become one line per chosen session; whole-course tickets
|
||||
// on such an event still close when the whole course does.
|
||||
const lines = expandPickLines(event, options, { now });
|
||||
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId) && now >= wholeCourseClosesAt(event, event.sessions)) {
|
||||
res.status(400);
|
||||
throw new Error('Whole-course tickets are no longer on sale — choose the sessions you want instead.');
|
||||
}
|
||||
await assertSessionCapacity(event, lines);
|
||||
|
||||
// Validate each option and resolve prices / check stock.
|
||||
// Options are independent of each other, so resolve them concurrently. Errors are marked
|
||||
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
|
||||
// across concurrent iterations) — the outer catch always responds 400 regardless.
|
||||
const resolvedOptions = await Promise.all(options.map(async (option) => {
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption) {
|
||||
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
|
||||
@@ -239,7 +249,7 @@ const createRegistration = async (req, res) => {
|
||||
|
||||
// Check overall option stock
|
||||
try {
|
||||
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id);
|
||||
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id, option.sessionId);
|
||||
if (!stockCheck.available) {
|
||||
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
@@ -259,7 +269,7 @@ const createRegistration = async (req, res) => {
|
||||
throw new Error(`Variant not found for option "${eventOption.name}"`);
|
||||
}
|
||||
try {
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id);
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId);
|
||||
if (!vStock.available) {
|
||||
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
@@ -320,9 +330,9 @@ const createRegistration = async (req, res) => {
|
||||
// tickets bought at different early-bird prices into a single (wrong) price. Each
|
||||
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
|
||||
for (const opt of resolvedOptions) {
|
||||
// Match on eventOptionId + variantId for correct row
|
||||
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
|
||||
);
|
||||
if (existing) {
|
||||
await prisma.$transaction([
|
||||
@@ -348,6 +358,7 @@ const createRegistration = async (req, res) => {
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -395,6 +406,7 @@ const createRegistration = async (req, res) => {
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -941,7 +953,7 @@ const createManualRegistration = async (req, res) => {
|
||||
//Check event
|
||||
const event = await prisma.event.findUnique({
|
||||
where: {id: eventId},
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
@@ -962,9 +974,13 @@ const createManualRegistration = async (req, res) => {
|
||||
throw new Error('At least one option must be selected');
|
||||
}
|
||||
|
||||
// Picked sessions become one line each; staff can still book a session until it ends.
|
||||
const lines = expandPickLines(event, options, { staff: true });
|
||||
await assertSessionCapacity(event, lines);
|
||||
|
||||
// Validate each option up front, before any account is created or changed —
|
||||
// including stock, which staff registrations previously skipped.
|
||||
for (const option of options) {
|
||||
for (const option of lines) {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption) {
|
||||
res.status(400);
|
||||
@@ -975,8 +991,8 @@ const createManualRegistration = async (req, res) => {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
const variant = option.variantId ? (eventOption.variants || []).find(v => v.id === option.variantId) : null;
|
||||
const oStock = await checkOptionStock(eventOption, option.quantity);
|
||||
const vStock = variant ? await checkVariantStock(variant, option.quantity) : { available: true };
|
||||
const oStock = await checkOptionStock(eventOption, option.quantity, null, option.sessionId);
|
||||
const vStock = variant ? await checkVariantStock(variant, option.quantity, null, option.sessionId) : { available: true };
|
||||
if (!oStock.available || !vStock.available) {
|
||||
res.status(400);
|
||||
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
|
||||
@@ -986,7 +1002,7 @@ const createManualRegistration = async (req, res) => {
|
||||
|
||||
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
|
||||
// above get the staff price — so it runs once the account is resolved below.
|
||||
const priceManualOptions = (attendee) => Promise.all(options.map(async (option) => {
|
||||
const priceManualOptions = (attendee) => Promise.all(lines.map(async (option) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
const variantId = option.variantId || null;
|
||||
const priced = await priceRequestedOption(eventOption, variantId, option.quantity, attendee);
|
||||
@@ -1158,7 +1174,7 @@ const createManualRegistration = async (req, res) => {
|
||||
// tickets bought at different early-bird prices would blend into a single wrong price.
|
||||
await Promise.all(resolvedManualOptions.map(opt => {
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
|
||||
);
|
||||
if (existing) {
|
||||
return prisma.$transaction([
|
||||
@@ -1188,6 +1204,7 @@ const createManualRegistration = async (req, res) => {
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -1256,6 +1273,7 @@ const createManualRegistration = async (req, res) => {
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -1465,6 +1483,7 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
}
|
||||
|
||||
const validOptionIds = new Set((registration.event?.eventOptions || []).map(eo => eo.id));
|
||||
const pickOptionIds = new Set((registration.event?.eventOptions || []).filter(eo => isPickOption(registration.event, eo)).map(eo => eo.id));
|
||||
for (const opt of options) {
|
||||
if (!opt || !opt.eventOptionId || !Number.isInteger(opt.quantity) || opt.quantity < 1) {
|
||||
res.status(400);
|
||||
@@ -1474,8 +1493,17 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
res.status(400);
|
||||
throw new Error('One or more options do not belong to this event');
|
||||
}
|
||||
if (pickOptionIds.has(opt.eventOptionId)) {
|
||||
res.status(400);
|
||||
throw new Error("Sessions are booked by registering for them — they can't be changed here.");
|
||||
}
|
||||
}
|
||||
|
||||
// Picked-session lines aren't edited here: leave them exactly as they are (they still
|
||||
// count towards the total due).
|
||||
const pickedLines = registration.registrationOptions.filter(ro => ro.sessionId);
|
||||
registration.registrationOptions = registration.registrationOptions.filter(ro => !ro.sessionId);
|
||||
|
||||
// Compute totalPaid
|
||||
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
|
||||
@@ -1576,6 +1604,7 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan });
|
||||
}
|
||||
|
||||
newTotalDue += rowsTotal(pickedLines);
|
||||
if (newTotalDue < totalPaid) {
|
||||
res.status(400);
|
||||
throw new Error('Cannot reduce items below the amount already paid');
|
||||
|
||||
@@ -92,6 +92,7 @@ const getUserTickets = async (req, res) => {
|
||||
include: {
|
||||
eventOption: true,
|
||||
variant: true,
|
||||
session: true,
|
||||
registration: true
|
||||
}
|
||||
},
|
||||
@@ -262,6 +263,7 @@ const getScanPreview = async (req, res) => {
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
ro.id AS "registrationOptionId",
|
||||
ro."sessionId" AS "pinnedSessionId",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
ov.id AS "variantId",
|
||||
@@ -314,7 +316,7 @@ const getScanPreview = async (req, res) => {
|
||||
if (r.scheduleType === 'sessions') {
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
const schedule = await sessionsUtil.eventSchedule(r.eventId);
|
||||
const found = sessionsUtil.scanSession(schedule, schedule.sessions, req.query.sessionId || null);
|
||||
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null);
|
||||
sessionError = found.error;
|
||||
if (found.session) {
|
||||
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
|
||||
@@ -361,6 +363,7 @@ const scanTicket = async (req, res) => {
|
||||
t."eventId",
|
||||
e.title AS "eventTitle",
|
||||
e."scheduleType" AS "scheduleType",
|
||||
ro."sessionId" AS "pinnedSessionId",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
eo."isMainTicket" AS "isMainTicket",
|
||||
@@ -381,7 +384,7 @@ const scanTicket = async (req, res) => {
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, e.id, eo.id
|
||||
GROUP BY t.id, e.id, eo.id, ro.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
@@ -438,7 +441,7 @@ const scanTicket = async (req, res) => {
|
||||
if (r.scheduleType === 'sessions') {
|
||||
sessionsUtil = require('../utils/sessions');
|
||||
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
|
||||
const found = sessionsUtil.scanSession(schedule, schedule.sessions, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null);
|
||||
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null);
|
||||
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
|
||||
session = found.session;
|
||||
}
|
||||
@@ -544,7 +547,7 @@ const getTicketsByEvent = async (req, res) => {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: {
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
|
||||
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { id: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
attendee: { select: { id: true, name: true, email: true, phone: true } },
|
||||
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true, scheduleType: true, sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } } } },
|
||||
@@ -914,7 +917,7 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
|
||||
// Event title, with the date on the right of the same line
|
||||
let cy = y + pad;
|
||||
const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null;
|
||||
const dateStr = require('../utils/sessions').ticketDateText(ticket.event)
|
||||
const dateStr = require('../utils/sessions').ticketDateText(ticket.event, 'short', ticket.registrationOption.session)
|
||||
|| (eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : '');
|
||||
doc.font('Helvetica').fontSize(9);
|
||||
const dateW = dateStr ? doc.widthOfString(dateStr) : 0;
|
||||
@@ -980,7 +983,7 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
|
||||
const ticketInclude = {
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
|
||||
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
|
||||
user: { select: { name: true } },
|
||||
usages: true,
|
||||
@@ -1088,7 +1091,8 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
||||
|
||||
const eventTitle = tickets[0].event.title;
|
||||
const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long') || (tickets[0].event.startDate
|
||||
const pinnedSessions = tickets.map(t => t.registrationOption?.session).filter(Boolean);
|
||||
const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long', pinnedSessions.length === tickets.length && pinnedSessions.length === 1 ? pinnedSessions[0] : null, pinnedSessions.length === tickets.length ? pinnedSessions : null) || (tickets[0].event.startDate
|
||||
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '');
|
||||
const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
|
||||
@@ -17,7 +17,7 @@ const path = require('path');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const QRCode = require('qrcode');
|
||||
const prisma = require('../config/db');
|
||||
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, TIMEZONE } = require('./sessions');
|
||||
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, groupTickets, TIMEZONE } = require('./sessions');
|
||||
|
||||
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const AUTO_ISSUE_DAYS = 14;
|
||||
@@ -57,18 +57,25 @@ async function attendanceForCertificates(eventId) {
|
||||
holder: { select: { id: true, name: true } },
|
||||
user: { select: { id: true, name: true } },
|
||||
usages: { select: { quantityRedeemed: true, sessionId: true } },
|
||||
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (event.scheduleType === 'sessions') {
|
||||
const active = activeSessions(event.sessions);
|
||||
const activeIds = new Set(active.map(s => s.id));
|
||||
const bySession = await attendanceBySession(eventId);
|
||||
// A picked-session booking (one ticket per session) is one person: its first ticket
|
||||
// carries the certificate, and it's measured against the sessions it booked.
|
||||
return {
|
||||
event,
|
||||
rows: tickets.map(t => {
|
||||
const m = bySession.get(t.id) || new Map();
|
||||
const attended = [...m.entries()].filter(([sid, qty]) => activeIds.has(sid) && qty > 0).length;
|
||||
return { ticket: t, attended, total: active.length };
|
||||
rows: groupTickets(tickets).map(g => {
|
||||
const booked = g.sessionIds ? new Set(g.sessionIds.filter(id => activeIds.has(id))) : activeIds;
|
||||
const attendedIds = new Set();
|
||||
for (const t of g.tickets) {
|
||||
for (const [sid, qty] of (bySession.get(t.id) || new Map())) if (booked.has(sid) && qty > 0) attendedIds.add(sid);
|
||||
}
|
||||
return { ticket: g.tickets[0], attended: attendedIds.size, total: booked.size };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ function judgeSyncedScan(ticket, qty) {
|
||||
if (!ticket) return { redeem: 0, conflict: 'not_found' };
|
||||
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
|
||||
if (ticket.noSession) return { redeem: 0, conflict: 'no_session' };
|
||||
if (ticket.wrongSession) return { redeem: 0, conflict: 'wrong_session' };
|
||||
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
|
||||
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
|
||||
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
|
||||
@@ -73,6 +74,7 @@ const CONFLICT_TEXT = {
|
||||
partly_used: 'Fewer places were left than were checked in offline',
|
||||
consent: 'Checked in without parental consent',
|
||||
no_session: 'Scanned on a day (or at a time) with no session open for check-in',
|
||||
wrong_session: 'Ticket is for a different session',
|
||||
};
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
@@ -145,8 +147,10 @@ async function snapshotFor(lock) {
|
||||
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
|
||||
noSessionToday: schedule?.scheduleType === 'sessions' && !session,
|
||||
tickets: tickets.map(t => {
|
||||
// A picked-session ticket for another session has nothing left today.
|
||||
const otherSession = !!(t.registrationOption.sessionId && t.registrationOption.sessionId !== session?.id);
|
||||
const redeemed = schedule?.scheduleType === 'sessions'
|
||||
? (session ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
|
||||
? (session && !otherSession ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
|
||||
: t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
return {
|
||||
id: t.id,
|
||||
@@ -200,6 +204,7 @@ async function syncScans(lock, scans) {
|
||||
state = {
|
||||
sessionId: session?.id || null,
|
||||
noSession: hasSessions && !session,
|
||||
wrongSession: !!(session && ticket.registrationOption.sessionId && ticket.registrationOption.sessionId !== session.id),
|
||||
remaining: (ticket.quantity || 1) - redeemed,
|
||||
cancelled: ticket.registrationOption.registration.status === 'cancelled',
|
||||
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* before" and "1 day before" each session, by email and/or WhatsApp.
|
||||
*
|
||||
* Rules:
|
||||
* - Goes to everyone with a ticket for the event (the booker, and named attendees with
|
||||
* - Goes to everyone with a ticket for the session (the booker, and named attendees with
|
||||
* an account), skipping cancelled registrations. One message per person per session.
|
||||
* - Each (session, offset) is sent once (SessionReminderLog). When several offsets are
|
||||
* already due — e.g. reminders were switched on 2 h before a session — only the
|
||||
@@ -21,10 +21,10 @@ function fmtWhen(d) {
|
||||
return new Date(d).toLocaleString('en-GB', { timeZone: TIMEZONE, weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** People to remind for an event: Map userId → user. */
|
||||
async function recipientsFor(eventId) {
|
||||
/** People to remind about a session: whole-course tickets plus picked places on it. Map userId → user. */
|
||||
async function recipientsFor(eventId, sessionId) {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } } } },
|
||||
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } }, OR: [{ sessionId: null }, { sessionId }] } },
|
||||
select: {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
|
||||
holder: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
|
||||
@@ -84,7 +84,6 @@ async function runSessionReminderJob(now = new Date()) {
|
||||
const offsets = (config.offsets || []).map(Number).filter(n => n > 0).sort((a, b) => a - b);
|
||||
if (offsets.length === 0) continue;
|
||||
const allSessions = await prisma.eventSession.findMany({ where: { eventId: event.id } });
|
||||
let recipients = null;
|
||||
for (const session of event.sessions) {
|
||||
const minutesLeft = (new Date(session.startsAt) - now) / 60000;
|
||||
const loggedOffsets = new Set(session.reminders.map(r => r.offset));
|
||||
@@ -102,7 +101,7 @@ async function runSessionReminderJob(now = new Date()) {
|
||||
}
|
||||
}
|
||||
if (!claimed.includes(due[0])) continue; // the closest one was claimed elsewhere
|
||||
recipients = recipients || await recipientsFor(event.id);
|
||||
const recipients = await recipientsFor(event.id, session.id);
|
||||
let count = 0;
|
||||
for (const user of recipients.values()) {
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
* session N starts. A registration deadline, if set, still wins when it's earlier.
|
||||
* - Late pricing applies to main tickets only, for people registering after the first
|
||||
* session has started: pro-rata on the sessions left (nearest R10), or a fixed price.
|
||||
* - "Pick sessions" events (attendanceMode pick): options with sessionMode `pick` are
|
||||
* bought per session — one registration line (and ticket) per session, priced per
|
||||
* session, bookable until that session starts. `all` options cover every session and
|
||||
* follow the whole-course rules above. A session's capacity counts picked places plus
|
||||
* whole-course main tickets; a pick option's stock limit is places per session.
|
||||
* Not available on youth (parental consent) events, and picked lines take no names.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
@@ -70,6 +76,7 @@ function normalizeSessionsInput(list) {
|
||||
endsAt,
|
||||
location: s.location ? String(s.location).trim().slice(0, 300) || null : null,
|
||||
cancelled: s.cancelled === true,
|
||||
capacity: s.capacity === null || s.capacity === undefined || s.capacity === '' ? null : Math.max(1, parseInt(s.capacity, 10) || 1),
|
||||
};
|
||||
});
|
||||
out.sort((a, b) => a.startsAt - b.startsAt);
|
||||
@@ -102,7 +109,8 @@ function currentSession(sessions, now = new Date(), window = 'whole_day') {
|
||||
}
|
||||
|
||||
/** When public registration closes for an event (Date). */
|
||||
function registrationClosesAt(event, sessions) {
|
||||
/** When whole-course (all-sessions) tickets stop selling (Date). */
|
||||
function wholeCourseClosesAt(event, sessions) {
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
let closes = new Date(event.endDate);
|
||||
if (event.scheduleType === 'sessions') {
|
||||
@@ -115,6 +123,98 @@ function registrationClosesAt(event, sessions) {
|
||||
return deadline && deadline < closes ? deadline : closes;
|
||||
}
|
||||
|
||||
/**
|
||||
* When public registration closes (Date). "Pick sessions" events stay open until the
|
||||
* last session starts (each session can be booked until it starts).
|
||||
*/
|
||||
function registrationClosesAt(event, sessions) {
|
||||
const whole = wholeCourseClosesAt(event, sessions);
|
||||
if (event.scheduleType !== 'sessions' || event.attendanceMode !== 'pick') return whole;
|
||||
const active = activeSessions(sessions);
|
||||
if (active.length === 0) return whole;
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
const last = new Date(active[active.length - 1].startsAt);
|
||||
const closes = last > whole ? last : whole;
|
||||
return deadline && deadline < closes ? deadline : closes;
|
||||
}
|
||||
|
||||
/** Whether an option is bought per session (only in "pick sessions" events). */
|
||||
function isPickOption(event, option) {
|
||||
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick' && option?.sessionMode === 'pick';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns requested lines into registration lines: a pick option with sessionIds becomes
|
||||
* one line per session (same quantity); other options stay as they are. Checks the
|
||||
* sessions can still be booked: public — before the session starts; staff — until it ends.
|
||||
* @param {object} event - with eventOptions and sessions
|
||||
* @param {object[]} lines - [{ eventOptionId, variantId?, quantity, sessionIds? }]
|
||||
*/
|
||||
function expandPickLines(event, lines, { now = new Date(), staff = false } = {}) {
|
||||
const out = [];
|
||||
for (const line of lines || []) {
|
||||
const option = (event.eventOptions || []).find(o => o.id === line.eventOptionId);
|
||||
const pick = isPickOption(event, option);
|
||||
const ids = Array.isArray(line.sessionIds) ? [...new Set(line.sessionIds.map(String))] : [];
|
||||
if (!pick) {
|
||||
if (ids.length) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
|
||||
out.push({ ...line, sessionId: null });
|
||||
continue;
|
||||
}
|
||||
if (event.requiresParentalConsent) throw sessionError('Choosing sessions isn\'t available on youth events.');
|
||||
if (ids.length === 0) throw sessionError(`Choose the sessions for "${option.name}".`);
|
||||
for (const id of ids) {
|
||||
const session = (event.sessions || []).find(s => s.id === id);
|
||||
if (!session || session.cancelledAt) throw sessionError(`A chosen session for "${option.name}" isn't available.`);
|
||||
const cutoff = staff ? new Date(session.endsAt) : new Date(session.startsAt);
|
||||
if (cutoff <= now) throw sessionError(`${sessionLabel(session, event.sessions)} has ${staff ? 'ended' : 'already started'} — it can't be booked any more.`);
|
||||
const { sessionIds, ...rest } = line;
|
||||
void sessionIds;
|
||||
out.push({ ...rest, sessionId: id });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* People booked per session: picked places on that session plus whole-course main
|
||||
* tickets (which attend every session). Cancelled registrations don't count.
|
||||
* @returns {Promise<Map<string, number>>}
|
||||
*/
|
||||
async function sessionBookings(eventId, db = prisma) {
|
||||
const lines = await db.registrationOption.findMany({
|
||||
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
|
||||
select: { quantity: true, sessionId: true },
|
||||
});
|
||||
const sessions = await db.eventSession.findMany({ where: { eventId, cancelledAt: null }, select: { id: true } });
|
||||
const whole = lines.filter(l => !l.sessionId).reduce((s, l) => s + (l.quantity || 0), 0);
|
||||
const out = new Map(sessions.map(s => [s.id, whole]));
|
||||
for (const l of lines) if (l.sessionId && out.has(l.sessionId)) out.set(l.sessionId, out.get(l.sessionId) + (l.quantity || 0));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks requested lines fit each session's capacity. Whole-course main tickets count
|
||||
* against every session; picked main tickets against their own.
|
||||
*/
|
||||
async function assertSessionCapacity(event, lines, db = prisma) {
|
||||
const limited = activeSessions(event.sessions).filter(s => s.capacity > 0);
|
||||
if (limited.length === 0) return;
|
||||
const mains = new Set((event.eventOptions || []).filter(o => o.isMainTicket).map(o => o.id));
|
||||
const wanted = new Map();
|
||||
const whole = lines.filter(l => !l.sessionId && mains.has(l.eventOptionId)).reduce((s, l) => s + (Number(l.quantity) || 0), 0);
|
||||
for (const s of limited) wanted.set(s.id, whole);
|
||||
for (const l of lines) if (l.sessionId && mains.has(l.eventOptionId) && wanted.has(l.sessionId)) wanted.set(l.sessionId, wanted.get(l.sessionId) + (Number(l.quantity) || 0));
|
||||
if ([...wanted.values()].every(n => n === 0)) return;
|
||||
const booked = await sessionBookings(event.id, db);
|
||||
for (const s of limited) {
|
||||
const left = s.capacity - (booked.get(s.id) || 0);
|
||||
if ((wanted.get(s.id) || 0) > left) {
|
||||
throw sessionError(`${sessionLabel(s, event.sessions)} is ${left <= 0 ? 'full' : `nearly full — only ${left} place${left === 1 ? '' : 's'} left`}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Late-joiner pricing state at `now`: active once the first session has started (for
|
||||
* events with late joining and a late price). sessionsLeft counts sessions not yet started.
|
||||
@@ -204,7 +304,7 @@ async function syncEventSessions(tx, eventId, input) {
|
||||
|
||||
for (let i = 0; i < wanted.length; i++) {
|
||||
const w = wanted[i];
|
||||
const data = { label: w.label, startsAt: w.startsAt, endsAt: w.endsAt, location: w.location, order: i };
|
||||
const data = { label: w.label, startsAt: w.startsAt, endsAt: w.endsAt, location: w.location, capacity: w.capacity, order: i };
|
||||
const old = w.id ? byId.get(w.id) : null;
|
||||
if (old) {
|
||||
const cancelledAt = w.cancelled ? (old.cancelledAt || new Date()) : null;
|
||||
@@ -225,7 +325,7 @@ async function syncEventSessions(tx, eventId, input) {
|
||||
}
|
||||
|
||||
const SCHEDULE_SELECT = {
|
||||
id: true, endDate: true, registrationDeadline: true, scheduleType: true, checkInWindow: true,
|
||||
id: true, endDate: true, registrationDeadline: true, scheduleType: true, attendanceMode: true, checkInWindow: true,
|
||||
lateJoinEnabled: true, lateJoinUntilSessionId: true, latePricing: true, lateFixedPrice: true,
|
||||
sessions: { orderBy: { startsAt: 'asc' } },
|
||||
};
|
||||
@@ -284,11 +384,22 @@ function scanSession(event, sessions, requestedId = null, now = new Date()) {
|
||||
* (short) or "Wednesday 4 November 2026 (first of 5 sessions — one ticket for all)" (long).
|
||||
* Returns null for single-date events (callers keep their own date format).
|
||||
*/
|
||||
function ticketDateText(event, style = 'short') {
|
||||
function ticketDateText(event, style = 'short', session = null, sessions = null) {
|
||||
if (event?.scheduleType !== 'sessions') return null;
|
||||
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
|
||||
// A picked-session ticket is for its own session only.
|
||||
if (session) {
|
||||
const label = sessionLabel(session, event.sessions);
|
||||
return style === 'long'
|
||||
? `${fmt(session.startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} (${label})`
|
||||
: `${label} · ${fmt(session.startsAt, { day: '2-digit', month: 'short', year: 'numeric' })}`;
|
||||
}
|
||||
if (sessions && sessions.length > 1 && style === 'long') {
|
||||
const sorted = [...sessions].sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt));
|
||||
return `${sorted.map(s => fmt(s.startsAt, { day: 'numeric', month: 'short' })).join(', ')} (one ticket per session)`;
|
||||
}
|
||||
const active = activeSessions(event.sessions);
|
||||
if (active.length === 0) return null;
|
||||
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
|
||||
if (style === 'long') {
|
||||
const first = fmt(active[0].startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
return active.length === 1 ? first : `${first} (first of ${active.length} sessions — one ticket for all)`;
|
||||
@@ -299,6 +410,46 @@ function ticketDateText(event, style = 'short') {
|
||||
: `${active.length} sessions · ${fmt(active[0].startsAt, short)} – ${fmt(active[active.length - 1].startsAt, short)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* scanSession for a ticket: a picked-session ticket only checks in on its own session
|
||||
* (whatever the scanner has selected).
|
||||
*/
|
||||
function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null, now = new Date()) {
|
||||
if (!pinnedSessionId) return scanSession(event, sessions, requestedId, now);
|
||||
const pinned = (sessions || []).find(s => s.id === pinnedSessionId);
|
||||
if (!pinned) return { session: null, error: 'This ticket\'s session no longer exists' };
|
||||
const label = sessionLabel(pinned, sessions);
|
||||
if (requestedId && requestedId !== pinnedSessionId) {
|
||||
return { session: null, error: `This ticket is only for ${label} (${dayKey(pinned.startsAt)}), not the selected session` };
|
||||
}
|
||||
const found = scanSession(event, sessions, pinnedSessionId, now);
|
||||
return found.session ? found : { session: null, error: `This ticket is for ${label} (${dayKey(pinned.startsAt)}) — ${found.error.charAt(0).toLowerCase()}${found.error.slice(1)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups an event's tickets into people for the register and certificates: a
|
||||
* picked-session booking has one ticket per session, so its tickets (same registration,
|
||||
* option and variant) form one group. Every other ticket is its own group.
|
||||
* Each ticket needs registrationOption: { registrationId, eventOptionId, variantId, sessionId }.
|
||||
* @returns {{ key: string, tickets: object[], sessionIds: string[]|null }[]} sessionIds null = all sessions
|
||||
*/
|
||||
function groupTickets(tickets) {
|
||||
const groups = new Map();
|
||||
for (const t of tickets) {
|
||||
const ro = t.registrationOption || {};
|
||||
if (!ro.sessionId) {
|
||||
groups.set(t.id, { key: t.id, tickets: [t], sessionIds: null });
|
||||
continue;
|
||||
}
|
||||
const key = `pick:${ro.registrationId}:${ro.eventOptionId}:${ro.variantId || ''}`;
|
||||
const g = groups.get(key) || { key, tickets: [], sessionIds: [] };
|
||||
g.tickets.push(t);
|
||||
g.sessionIds.push(ro.sessionId);
|
||||
groups.set(key, g);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/** Places already checked in for one session, from a ticket's usages. */
|
||||
function redeemedForSession(usages, sessionId) {
|
||||
return (usages || []).filter(u => u.sessionId === sessionId).reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
|
||||
@@ -306,7 +457,8 @@ function redeemedForSession(usages, sessionId) {
|
||||
|
||||
/** Applies late pricing to a resolved option price ({ price, tierId }). */
|
||||
async function withLatePrice(option, resolved) {
|
||||
if (!option?.isMainTicket || !option.eventId || !(resolved.price > 0)) return resolved;
|
||||
// Picked sessions are priced per session, so late joining doesn't change them.
|
||||
if (!option?.isMainTicket || option.sessionMode === 'pick' || !option.eventId || !(resolved.price > 0)) return resolved;
|
||||
const event = await eventSchedule(option.eventId);
|
||||
if (!event || event.scheduleType !== 'sessions') return resolved;
|
||||
const price = latePrice(resolved.price, lateInfo(event, event.sessions));
|
||||
@@ -334,7 +486,8 @@ async function attendanceBySession(eventId) {
|
||||
module.exports = {
|
||||
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
|
||||
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
|
||||
sessionLabel, scanSession, redeemedForSession, ticketDateText,
|
||||
registrationClosesAt, lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
|
||||
sessionLabel, scanSession, ticketScanSession, redeemedForSession, ticketDateText, groupTickets,
|
||||
registrationClosesAt, wholeCourseClosesAt, isPickOption, expandPickLines, sessionBookings, assertSessionCapacity,
|
||||
lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
|
||||
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
|
||||
};
|
||||
|
||||
@@ -3,18 +3,20 @@
|
||||
*
|
||||
* Taken = places on registrations that aren't cancelled + places held for a live
|
||||
* waitlist offer (until it expires). An option's stock limit covers all its variants;
|
||||
* a variant can also have its own limit. 0 means unlimited.
|
||||
* a variant can also have its own limit. 0 means unlimited. For a "pick sessions"
|
||||
* option the limit is per session (pass sessionId).
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
/**
|
||||
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
|
||||
* @param {{ eventOptionId: string, variantId?: string|null, sessionId?: string|null }} line -
|
||||
* variantId given → that variant only; sessionId given → that session's picked places only
|
||||
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
|
||||
* waitlist offer's hold (the person claiming it)
|
||||
*/
|
||||
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
|
||||
const scope = variantId ? { variantId } : { eventOptionId };
|
||||
async function takenCount({ eventOptionId, variantId = null, sessionId = null }, { excludeEntryId = null, db = prisma } = {}) {
|
||||
const scope = { ...(variantId ? { variantId } : { eventOptionId }), ...(sessionId ? { sessionId } : {}) };
|
||||
const [registered, held] = await Promise.all([
|
||||
db.registrationOption.aggregate({
|
||||
where: { ...scope, registration: { status: { not: 'cancelled' } } },
|
||||
|
||||
@@ -48,11 +48,11 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
}
|
||||
} catch (_) { /* form models unavailable — don't block */ }
|
||||
|
||||
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId) ──
|
||||
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId, session) ──
|
||||
// Key on both fields so that different variants of the same option are never merged
|
||||
const byOption = new Map();
|
||||
for (const ro of registration.registrationOptions) {
|
||||
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
|
||||
const key = `${ro.eventOptionId}::${ro.variantId || ''}::${ro.sessionId || ''}`;
|
||||
if (!byOption.has(key)) byOption.set(key, []);
|
||||
byOption.get(key).push(ro);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ const {
|
||||
scanSession,
|
||||
redeemedForSession,
|
||||
sessionLabel,
|
||||
isPickOption,
|
||||
expandPickLines,
|
||||
ticketScanSession,
|
||||
groupTickets,
|
||||
wholeCourseClosesAt,
|
||||
} = require('../src/utils/sessions');
|
||||
|
||||
// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local.
|
||||
@@ -268,3 +273,62 @@ describe('redeemedForSession / sessionLabel', () => {
|
||||
expect(sessionLabel(list[2], list)).toBe('Session 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pick sessions', () => {
|
||||
const pickEvent = {
|
||||
scheduleType: 'sessions', attendanceMode: 'pick', endDate: wed(32, 18, 30), registrationDeadline: null,
|
||||
lateJoinEnabled: false, sessions: COURSE,
|
||||
eventOptions: [{ id: 'drop', name: 'Drop-in', sessionMode: 'pick' }, { id: 'full', name: 'Full course', sessionMode: 'all' }],
|
||||
};
|
||||
|
||||
test('isPickOption needs a pick-mode multi-day event and a pick option', () => {
|
||||
expect(isPickOption(pickEvent, pickEvent.eventOptions[0])).toBe(true);
|
||||
expect(isPickOption(pickEvent, pickEvent.eventOptions[1])).toBe(false);
|
||||
expect(isPickOption({ ...pickEvent, attendanceMode: 'whole' }, pickEvent.eventOptions[0])).toBe(false);
|
||||
});
|
||||
|
||||
test('a pick line becomes one line per session; others stay as they are', () => {
|
||||
const out = expandPickLines(pickEvent, [
|
||||
{ eventOptionId: 'drop', quantity: 2, sessionIds: ['s3', 's4', 's3'] },
|
||||
{ eventOptionId: 'full', quantity: 1 },
|
||||
], { now: wed(5) });
|
||||
expect(out).toEqual([
|
||||
{ eventOptionId: 'drop', quantity: 2, sessionId: 's3' },
|
||||
{ eventOptionId: 'drop', quantity: 2, sessionId: 's4' },
|
||||
{ eventOptionId: 'full', quantity: 1, sessionId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('sessions must be chosen, upcoming, and not on whole-course tickets', () => {
|
||||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1 }])).toThrow(/Choose the sessions/);
|
||||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
|
||||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s3'] }])).toThrow(/covers every session/);
|
||||
expect(() => expandPickLines({ ...pickEvent, requiresParentalConsent: true }, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s3'] }], { now: wed(5) })).toThrow(/youth/);
|
||||
});
|
||||
|
||||
test('staff can still book a session that is in progress', () => {
|
||||
const during = new Date(wed(4).getTime() + 30 * 60000);
|
||||
expect(expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: during, staff: true })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('registration stays open until the last session; whole-course tickets close at the first', () => {
|
||||
expect(registrationClosesAt(pickEvent, COURSE)).toEqual(wed(32));
|
||||
expect(wholeCourseClosesAt(pickEvent, COURSE)).toEqual(wed(4));
|
||||
});
|
||||
|
||||
test('a picked ticket only checks in on its own session', () => {
|
||||
const event = { checkInWindow: 'whole_day' };
|
||||
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
|
||||
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-11T09:00:00Z')).error).toMatch(/This ticket is for Session 3 \(2026-11-18\)/);
|
||||
expect(ticketScanSession(event, COURSE, 's3', 's2', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only for Session 3/);
|
||||
expect(ticketScanSession(event, COURSE, null, null, new Date('2026-11-11T09:00:00Z')).session.id).toBe('s2');
|
||||
});
|
||||
|
||||
test('groupTickets puts one booking\'s picked tickets together', () => {
|
||||
const t = (id, sessionId, registrationId = 'r1', eventOptionId = 'drop') => ({ id, registrationOption: { registrationId, eventOptionId, variantId: null, sessionId } });
|
||||
const groups = groupTickets([t('a', 's1'), t('b', null, 'r1', 'full'), t('c', 's2'), t('d', 's1', 'r2')]);
|
||||
expect(groups.map(g => g.tickets.map(x => x.id))).toEqual([['a', 'c'], ['b'], ['d']]);
|
||||
expect(groups[0].sessionIds).toEqual(['s1', 's2']);
|
||||
expect(groups[1].sessionIds).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
# Hope Events — New Features
|
||||
|
||||
Planned features for the next round of development. Each section covers what
|
||||
the feature does, who uses it, how it fits the existing platform, and the edge
|
||||
cases to handle. See [FEATURES.md](./FEATURES.md) for what already exists.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Coupon codes](#1-coupon-codes)
|
||||
2. [Waitlist](#2-waitlist)
|
||||
3. [Group tickets](#3-group-tickets)
|
||||
4. [Offline scanning](#4-offline-scanning)
|
||||
5. [Parental consent (youth events)](#5-parental-consent-youth-events)
|
||||
6. [Sponsor a spot](#6-sponsor-a-spot)
|
||||
7. [Ticket transfer](#7-ticket-transfer)
|
||||
8. [Post-event feedback form](#8-post-event-feedback-form)
|
||||
9. [Group / room allocation](#9-group--room-allocation)
|
||||
10. [Registration funnel stats](#10-registration-funnel-stats)
|
||||
11. [2FA for Admins and Supervisors](#11-2fa-for-admins-and-supervisors)
|
||||
12. [Admin "impersonate user"](#12-admin-impersonate-user)
|
||||
13. [Suggested build order](#suggested-build-order)
|
||||
|
||||
---
|
||||
|
||||
## 1. Coupon codes
|
||||
|
||||
Discount codes applied at registration. The main use case is **staff
|
||||
registering for events** at a reduced or free rate, but codes also work for
|
||||
promos, leaders, and volunteers.
|
||||
|
||||
**Who:** Supervisors create and manage codes. Users enter them at checkout.
|
||||
Supervisors can also apply them in Manual registration, At the door and the
|
||||
kiosk.
|
||||
|
||||
### Code settings
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Code | Unique text, case-insensitive (e.g. `STAFF50`) |
|
||||
| Discount type | Percentage · Fixed amount · Comp (100% free) |
|
||||
| Scope | Whole event · Specific ticket option(s) · Specific variant(s) |
|
||||
| Events | One event, several events, or all events |
|
||||
| Valid from / until | Optional date window |
|
||||
| Max total uses | Optional |
|
||||
| Max uses per user | Optional (default 1) |
|
||||
| Min quantity | Optional (e.g. 4+ tickets) |
|
||||
| Restricted to | Anyone · Specific users · Roles (e.g. Staff and above) · Email domain |
|
||||
| Stack with early-bird | Yes (apply on top of early-bird price) / No (best price wins) |
|
||||
| Active | On / off toggle |
|
||||
|
||||
### How it works
|
||||
|
||||
- The user enters a code on the registration page. It is validated server-side
|
||||
and the discount shows as its own line before payment.
|
||||
- The discount is stored as a **separate discount line on the registration**,
|
||||
with its value **locked at apply time**, the same way early-bird tranches
|
||||
are locked. Pricing refresh never re-prices an applied coupon, even if the
|
||||
code later expires or is disabled.
|
||||
- **Restricted codes** (by user, role or domain) need the user to be logged
|
||||
in. Guest registration can't use them. This stops staff codes leaking in
|
||||
group chats.
|
||||
- **Role-based staff pricing (optional):** a ticket option can also carry a
|
||||
"Staff price" that applies automatically for Staff+ users without a code.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Edit options:** percentage codes recalculate over the new item total.
|
||||
Fixed-amount codes stay fixed. Comp codes cover only in-scope options.
|
||||
- **Cancellation / refund:** a cancelled registration releases its use count.
|
||||
- **Discount can't go below R0.** A comp code on a fully covered registration
|
||||
marks it Paid and generates tickets.
|
||||
- **Concurrency:** check use limits inside a transaction so the last use
|
||||
can't be claimed twice.
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Invoices and receipts show the code and discount line.
|
||||
- **New report: Discounts.** Uses per code, total value given away, per event.
|
||||
- Finance and profit reports subtract discounts from expected revenue.
|
||||
- **Audit log:** code created, edited, disabled, and manually applied by staff.
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
model Coupon {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
type CouponType // PERCENT | FIXED | COMP
|
||||
value Decimal?
|
||||
stackEarlyBird Boolean @default(false)
|
||||
validFrom DateTime?
|
||||
validUntil DateTime?
|
||||
maxUses Int?
|
||||
maxUsesPerUser Int? @default(1)
|
||||
minQuantity Int?
|
||||
restrictRoles Role[]
|
||||
restrictDomain String?
|
||||
active Boolean @default(true)
|
||||
events CouponEvent[]
|
||||
options CouponOption[]
|
||||
allowedUsers CouponUser[]
|
||||
redemptions CouponRedemption[]
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model CouponRedemption {
|
||||
id String @id @default(cuid())
|
||||
couponId String
|
||||
registrationId String
|
||||
userId String
|
||||
discountAmount Decimal // locked at apply time
|
||||
appliedById String? // staff member if applied manually
|
||||
createdAt DateTime @default(now())
|
||||
releasedAt DateTime? // set on cancellation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Waitlist
|
||||
|
||||
When an event or ticket option is sold out, users can join a waitlist instead
|
||||
of hitting a dead end.
|
||||
|
||||
**Who:** Users join. Supervisors view and manage the queue.
|
||||
|
||||
### How it works
|
||||
|
||||
- Sold-out options show a **"Join waitlist"** button instead of "Sold Out".
|
||||
- The user picks an option, variant and quantity. Login is required if the
|
||||
event requires login. Otherwise a guest account is created as usual.
|
||||
- When stock frees up (a cancellation, a refund, or a supervisor raising the
|
||||
stock limit), the **next person in line** gets an email/WhatsApp with a
|
||||
**claim link**.
|
||||
- The claim link **reserves the stock for a set time** (default 24 h, set per
|
||||
event). If it isn't used in time, the offer moves to the next person.
|
||||
- A claimed offer goes through normal registration and payment, with
|
||||
early-bird and coupon rules applied as usual.
|
||||
|
||||
### Supervisor tools
|
||||
|
||||
- A waitlist tab per event shows position, person, option, quantity, joined
|
||||
date and offer status.
|
||||
- Supervisors can manually offer a spot to someone, reorder the queue, or
|
||||
remove an entry.
|
||||
- Supervisors can toggle the waitlist on or off per event.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Reserved-but-unclaimed stock counts as sold until the offer expires, so the
|
||||
public site doesn't oversell.
|
||||
- If an offer is for quantity 3 and only 2 free up, offer the 2 with a note,
|
||||
or skip to the next person who fits. This is configurable per event.
|
||||
- Users can leave the waitlist from their dashboard.
|
||||
- Add the waitlist to the Daily summary (count per event).
|
||||
|
||||
---
|
||||
|
||||
## 3. Group tickets
|
||||
|
||||
One payer registers **several named attendees**, each with their own ticket
|
||||
and their own form response. For example, a parent registers three kids for
|
||||
camp.
|
||||
|
||||
**This is optional.** The payer can still choose to receive all tickets
|
||||
themselves.
|
||||
|
||||
### How it works
|
||||
|
||||
- On the registration page, a toggle reads **"Registering for other people?"**
|
||||
- For each attendee: name, plus optional email/phone, plus the event form if
|
||||
there is one.
|
||||
- **Ticket delivery choice per registration:**
|
||||
- **Send all tickets to me** (default), so the payer gets every ticket.
|
||||
- **Send each person their own ticket**, using their email/WhatsApp. If an
|
||||
attendee has no contact details, their ticket falls back to the payer.
|
||||
- Tickets are **individual, named tickets** rather than a quantity ticket, so
|
||||
each one can be checked in, transferred or allocated to a room/group
|
||||
separately.
|
||||
- The payer stays the account owner for billing, invoices and payments.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Works alongside the existing quantity tickets, since an event can allow
|
||||
either.
|
||||
- If an attendee's email/phone matches an existing account, link the ticket
|
||||
to that account (with the same duplicate-account protection as today).
|
||||
- Attendee forms: each attendee has their own response. The payer can fill
|
||||
them in, or each attendee can get a link to fill in their own.
|
||||
- Feeds into parental consent (one consent request per minor attendee) and
|
||||
group/room allocation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Offline scanning
|
||||
|
||||
Lets the scanner keep working when venue Wi-Fi or mobile data drops.
|
||||
|
||||
> ⚠️ **Rules: offline mode is per section, and only one device can scan a
|
||||
> section offline at a time.**
|
||||
> While offline, devices can't see each other's scans. If two devices scanned
|
||||
> the same section offline, the same ticket could be redeemed twice. Locking
|
||||
> each section to a single device removes that risk without needing conflict
|
||||
> resolution.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Before going offline (while online):** a staff member picks an event and
|
||||
section and taps **"Enable offline mode"**.
|
||||
2. The server **locks that section to this device** and records the device ID
|
||||
and the staff member. Other devices see "Offline-locked by [name] on [device]"
|
||||
and can't scan that section, online or offline, until it's released.
|
||||
3. The device **downloads a snapshot** of every ticket valid for that section
|
||||
(ticket ID, holder name, type, quantity remaining) into IndexedDB.
|
||||
4. **While offline:** scans validate against the local snapshot and update
|
||||
remaining quantities locally. Every scan is queued with a timestamp.
|
||||
5. **When back online:** queued scans sync to the server automatically. Any
|
||||
conflicts (for example, a ticket transferred or cancelled while offline)
|
||||
show in a **sync report** for a supervisor to review.
|
||||
6. Staff tap **"Release section"** to end offline mode and unlock the section.
|
||||
|
||||
### Rules and edge cases
|
||||
|
||||
- One section can only be locked to one device at a time. Other sections of
|
||||
the same event can be locked to other devices.
|
||||
- A supervisor can **force-release** a lock if a phone dies or goes missing.
|
||||
Unsynced scans on that device are lost unless the device reconnects later,
|
||||
in which case they sync and are flagged.
|
||||
- **Snapshot age:** show "Ticket list downloaded at 17:42". Tickets bought
|
||||
after that time won't be on the device, and the scanner shows "Not found in
|
||||
offline list. Check at the door desk."
|
||||
- The snapshot is stored only for the lock's duration and cleared on release.
|
||||
- Check-in confirmation messages to holders are sent after sync, not
|
||||
instantly.
|
||||
- **Audit log:** lock, release, force-release, and sync with scan count.
|
||||
- Built as a **PWA** (service worker caches the scanner page and assets).
|
||||
|
||||
---
|
||||
|
||||
## 5. Parental consent (youth events)
|
||||
|
||||
For youth events, a parent or guardian must give consent before an attendee
|
||||
under 18 can attend. The parent gets an **email and a WhatsApp** with a link
|
||||
to review and give consent.
|
||||
|
||||
### Event setup
|
||||
|
||||
- New event setting: **"Youth event: requires parental consent"**.
|
||||
- Supervisors write the **consent text** (indemnity, activities, photo
|
||||
permission, transport, medical treatment authorisation) in the event wizard.
|
||||
- Optional extra sections on the consent page: **medical info** (allergies,
|
||||
medication, conditions, medical aid), **emergency contact**, and **photo
|
||||
consent** (Yes/No, recorded separately).
|
||||
|
||||
### How it works
|
||||
|
||||
1. During registration, each attendee gives their **date of birth**. If they
|
||||
are under 18, **parent/guardian name, email and phone** are required.
|
||||
2. On registration, the parent gets an **email and a WhatsApp** (always both,
|
||||
regardless of anyone's notification preference) with a unique, secure
|
||||
consent link.
|
||||
3. The consent page shows the event details, attendee name and consent text.
|
||||
The parent fills in any medical/emergency fields, **types their full name**
|
||||
as a signature, ticks "I am the parent/legal guardian", and taps
|
||||
**Give consent** (or **Decline**).
|
||||
4. The system records the timestamp, IP address, device, typed name and the
|
||||
**version of the consent text** they agreed to.
|
||||
5. The registrant and the event's notify recipients get a confirmation.
|
||||
|
||||
### Rules
|
||||
|
||||
- **Tickets are generated but marked "Consent pending"** until consent is
|
||||
given. Scanners show a clear warning and block check-in for that ticket
|
||||
(supervisors can override, with an audit log entry).
|
||||
- **Reminders:** automatic reminder email/WhatsApp after 2 days, then every
|
||||
3 days until the event, or until consent is given or declined.
|
||||
- **Declined consent:** notifies the registrant and supervisors. The
|
||||
registration stays but can't be checked in. A supervisor handles the refund.
|
||||
- **Consent links expire** at the event's end date, and can be resent from
|
||||
the registration detail screen.
|
||||
- If the consent text is edited after some parents have consented, they keep
|
||||
their record of the old version. Supervisors can choose to re-request
|
||||
consent.
|
||||
|
||||
### Supervisor tools
|
||||
|
||||
- **Consent status column** on registrations and At the door (Pending /
|
||||
Given / Declined).
|
||||
- **New reports:** Consent status per event, and **Medical & emergency list**
|
||||
(printable, for camp leaders). Access to the medical list is restricted to
|
||||
Supervisor+ and logged.
|
||||
- Bulk "Resend consent request" to everyone still pending.
|
||||
|
||||
### Privacy
|
||||
|
||||
Under POPIA, processing a child's personal information needs the consent of a
|
||||
competent person (parent/guardian). Medical data is special personal
|
||||
information. Store it encrypted, restrict access, include it in the existing
|
||||
anonymise/delete flows, and delete it a set period after the event.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sponsor a spot
|
||||
|
||||
A public donation flow linked to a specific event, so people can fund a spot
|
||||
for someone who can't afford it (bursaries).
|
||||
|
||||
### How it works
|
||||
|
||||
- A new event setting: **"Allow sponsorships"**. This adds a **"Sponsor a
|
||||
spot"** button on the event page.
|
||||
- Sponsors choose: **one full spot** (the main ticket price), **multiple
|
||||
spots**, or **a custom amount**. Payment goes through Yoco as usual.
|
||||
- A sponsor can be anonymous, or add a name and message.
|
||||
- The money is recorded as a **donation tagged to the event**, and lands in a
|
||||
**sponsorship pool** for that event.
|
||||
- Supervisors assign pool funds to registrations using the existing donation
|
||||
assign/unassign flow, fully or partly.
|
||||
- An optional public progress bar shows "12 of 20 spots sponsored".
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Sponsors get a receipt. They can optionally get a thank-you after the event
|
||||
through Automations.
|
||||
- The Donations breakdown report gains a sponsorship pool column (received,
|
||||
assigned, remaining) per event.
|
||||
- Recipients' identities are never shown to sponsors.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ticket transfer
|
||||
|
||||
Users can hand their ticket to someone else before the event.
|
||||
|
||||
### How it works
|
||||
|
||||
- From My Events, a user taps **Transfer** on a ticket, and enters the new
|
||||
holder's name and email/phone.
|
||||
- The new holder gets the ticket by email/WhatsApp. If they don't have an
|
||||
account, an inactive one is created with an activation link (same as guest
|
||||
registration).
|
||||
- The **old QR code is invalidated** and a new one is issued, so screenshots
|
||||
of the old ticket won't scan.
|
||||
- The original payer stays responsible for the registration (billing doesn't
|
||||
move).
|
||||
|
||||
### Rules
|
||||
|
||||
- Allowed only before the event starts, and only for unredeemed tickets
|
||||
(partially redeemed quantity tickets can't be transferred).
|
||||
- Can be disabled per event. Supervisors can always transfer.
|
||||
- If the event needs a form or parental consent, the new holder must complete
|
||||
their own.
|
||||
- **Audit log:** transfer from, to, by whom and when. Offline scanning sync
|
||||
flags any scan of a transferred ticket's old code.
|
||||
|
||||
---
|
||||
|
||||
## 8. Post-event feedback form
|
||||
|
||||
An automatic survey sent after the event.
|
||||
|
||||
### How it works
|
||||
|
||||
- In the event wizard, a new **Feedback** step reuses the form builder, plus
|
||||
new field types: **Rating (1–5 stars)** and **Multiple choice**.
|
||||
- Supervisors pick a **send time** (e.g. the morning after the event ends).
|
||||
It's sent by the existing scheduled job to everyone who **checked in**, or
|
||||
to all registrants, as chosen.
|
||||
- Responses can be anonymous or named (per event setting).
|
||||
- One automatic reminder after 3 days for anyone who hasn't responded.
|
||||
|
||||
### Reporting
|
||||
|
||||
- **New report: Feedback summary.** Response rate, average rating per
|
||||
question, multiple-choice breakdown (bar charts), and a list of text
|
||||
answers. Exports to PDF/Excel like other reports.
|
||||
- Ratings can be compared across events (e.g. Camp 2025 vs Camp 2026).
|
||||
|
||||
---
|
||||
|
||||
## 9. Group / room allocation
|
||||
|
||||
Assign attendees to cabins, rooms, small groups, teams or buses. Built for
|
||||
camps and lockdowns.
|
||||
|
||||
### How it works
|
||||
|
||||
- Per event, supervisors create **allocation sets**, e.g. "Rooms", "Small
|
||||
groups", "Buses". An event can have several sets.
|
||||
- Each set has **groups** with an optional **capacity**, an optional
|
||||
**gender restriction** and an optional **leader** (a staff user).
|
||||
- **Drag-and-drop** board: unassigned attendees on the left, groups on the
|
||||
right, with capacity counters.
|
||||
- **Auto-allocate:** fill groups evenly, respecting capacity and gender, and
|
||||
optionally keeping registrations together (siblings) or splitting them
|
||||
(friends).
|
||||
- Works per attendee, so it relies on group tickets (named attendees).
|
||||
|
||||
### Outputs
|
||||
|
||||
- Printable lists per group (with leader), and a master list.
|
||||
- Optionally show the group/room on the attendee's ticket and in My Events.
|
||||
- Group leaders (staff) can see their group's list, including medical
|
||||
info if they're allowed to.
|
||||
- Bulk message by group ("Bus 2 leaves at 07:30").
|
||||
|
||||
---
|
||||
|
||||
## 10. Registration funnel stats
|
||||
|
||||
Shows where people drop off between seeing an event and paying.
|
||||
|
||||
### Funnel stages (per event)
|
||||
|
||||
1. **Event page views** (unique visitors)
|
||||
2. **Registration started** (opened the registration page)
|
||||
3. **Registration submitted**
|
||||
4. **Paid** (fully paid or free)
|
||||
|
||||
### How it works
|
||||
|
||||
- Lightweight first-party tracking (no third-party analytics). Log page views
|
||||
and stage events with an anonymous session ID. No personal data until the
|
||||
user logs in or registers.
|
||||
- **New report: Registration funnel.** Count and conversion % at each stage,
|
||||
over a date range, with a daily trend chart.
|
||||
- Optional breakdowns: by traffic source (UTM tags or short link alias, e.g.
|
||||
`/camp-2025` vs direct), by device (mobile/desktop), and by ticket option.
|
||||
- A dashboard tile shows conversion % for active events.
|
||||
|
||||
### Privacy
|
||||
|
||||
Respect cookie consent. Aggregate raw page-view data after 90 days.
|
||||
|
||||
---
|
||||
|
||||
## 11. 2FA for Admins and Supervisors
|
||||
|
||||
Two-factor authentication, **required** for Admin and Supervisor accounts.
|
||||
|
||||
### How it works
|
||||
|
||||
- **Method:** authenticator app (TOTP, e.g. Google Authenticator, Microsoft
|
||||
Authenticator). WhatsApp code can be offered as a backup method since WAWP
|
||||
is already set up.
|
||||
- **Enrolment:** on the next login after being given the Supervisor/Admin
|
||||
role, the user must set up 2FA by scanning a QR code and confirming a code.
|
||||
- **Recovery codes:** 10 one-time codes shown once at setup, downloadable.
|
||||
- **Remember this device** for 30 days (optional, per login).
|
||||
- Optional for Staff and User roles, from Profile & Security.
|
||||
|
||||
### Admin controls
|
||||
|
||||
- Admins can **reset another user's 2FA** (e.g. a lost phone). This revokes
|
||||
their sessions and forces re-enrolment.
|
||||
- Security event log entries: 2FA enabled, disabled, reset, recovery code
|
||||
used, failed code attempts.
|
||||
- Failed 2FA attempts count toward the existing login lockout.
|
||||
- The setup wizard enrols the first admin in 2FA.
|
||||
|
||||
---
|
||||
|
||||
## 12. Admin "impersonate user"
|
||||
|
||||
Lets an Admin see the site exactly as a specific user sees it, to debug
|
||||
problems ("my ticket isn't showing").
|
||||
|
||||
### How it works
|
||||
|
||||
- From Manage users, an Admin taps **Impersonate** on a user and must enter a
|
||||
**reason** (required).
|
||||
- A short-lived session is created (max 30 minutes) that acts as the user.
|
||||
- A **bright banner on every page**: "Viewing as [Name]. [End impersonation]".
|
||||
|
||||
### Safety rules
|
||||
|
||||
- **Read-only by default.** Payments, refunds, cancellations, password/email
|
||||
changes, deleting data, giving consent and sending messages are blocked
|
||||
while impersonating.
|
||||
- Can't impersonate another Admin.
|
||||
- Requires 2FA to be active on the admin's account.
|
||||
- **Audit log:** start, end, reason, and every page/action during the
|
||||
session.
|
||||
- Optional: notify the user that an admin viewed their account (an
|
||||
organisation-level setting).
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Features | Why |
|
||||
|-------|----------|-----|
|
||||
| 1 | 2FA, Coupon codes | Security first. Coupons are self-contained and give an immediate win for staff. |
|
||||
| 2 | Group tickets | Foundation for consent, transfer and room allocation (named attendees). |
|
||||
| 3 | Parental consent, Waitlist | The biggest wins for youth events. |
|
||||
| 4 | Offline scanning, Ticket transfer | Door-day reliability. Transfer must exist before offline sync handles it. |
|
||||
| 5 | Group/room allocation, Sponsor a spot | Camp-specific and bursary support. |
|
||||
| 6 | Feedback form, Funnel stats, Impersonate user | Insights and support tooling. |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, a CHANGELOG entry, and updates to FEATURES.md and
|
||||
SETTINGS.md.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-23, after **1.35.0**. The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
## Done so far (road to 2.0)
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.32.0 | Early-bird ribbon on event cards |
|
||||
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
|
||||
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
|
||||
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.36.0 — payment provider interface.** Move Yoco behind a `PaymentProvider`
|
||||
interface (`createCheckout`, `handleWebhook`, `getTransaction`, `refund`,
|
||||
`listTransactions`) with no behaviour change; generalise `YocoTransaction` to a
|
||||
provider transaction table; Site settings → Payments with an Enabled toggle per
|
||||
provider and display order. Decisions: fees are **never** deducted or passed on
|
||||
(people handle fees at cashup); minimum online payment **R15** for all providers.
|
||||
2. **1.37.0 PayFast → 1.38.0 SnapScan → 1.39.0 Zapper** (one release each). Get the
|
||||
current API documentation links from Joshua before building each one, and don't
|
||||
guess at an API — sandbox/test credentials needed for SnapScan and Zapper.
|
||||
3. **1.40.0–1.41.0 — POPIA toolkit** (spec §3 in the roadmap).
|
||||
4. **1.42.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
`joshua/hope-events-hub`).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
- **Release routine:** branch `feat/<name>` → commit → bump with
|
||||
`npm version X.Y.Z --no-git-tag-version` in `.`, `backend` and `frontend` → add the
|
||||
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
|
||||
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
|
||||
version per feature.
|
||||
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
|
||||
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
|
||||
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
|
||||
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
|
||||
- **Prisma migrations:** `npx prisma migrate dev --create-only --name x` against a local
|
||||
DB. Prisma 7 keeps adding an unrelated `_EventNotifyRecipients` primary-key/index
|
||||
change — delete those two blocks from the generated SQL. It also rewrites the comment
|
||||
in `migration_lock.toml`; `git checkout` that file back. New `AdminAuditAction` values
|
||||
need a migration.
|
||||
- **Server Node version:** the deployment notes install Node 20, so dependencies must
|
||||
support Node 20 (that's why pg-boss is pinned to 10.x).
|
||||
- **Pushing:** pushing from some shells fails with "Failed to authenticate user" (a stale
|
||||
saved credential); pushing from your own terminal works.
|
||||
|
||||
## Tests
|
||||
|
||||
- Backend unit tests: `cd backend && npm test` (Jest).
|
||||
- End-to-end: `npm run test:e2e` from the repo root (first time: `npm install` and
|
||||
`npx playwright install chromium`). They start their own backend (5100) and
|
||||
frontend (3100) against `<your DATABASE_URL database>_e2e`, which is dropped and
|
||||
re-seeded every run (`e2e/support/prepare-db.js`). The DB user must be able to
|
||||
create databases. ~5 minutes in dev mode.
|
||||
Locally, Playwright reuses servers already running on 5100/3100. If the run times out
|
||||
waiting for the frontend (the home page returns 500 until the e2e backend is up), start
|
||||
them yourself — `node e2e/support/prepare-db.js`, then the backend from `backend/`
|
||||
with `PORT=5100`, the e2e `DATABASE_URL`, `JOBS_ENABLED=false`,
|
||||
`TWO_FACTOR_REQUIRED_ROLES=false`, and `npx next dev -p 3100` from `frontend/` with
|
||||
`NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_API_URL=http://localhost:5100` — then run
|
||||
`npx playwright test`. Don't edit frontend files while it runs.
|
||||
- **Careful with ad-hoc scripts against a dev/test database that has SMTP set up:**
|
||||
registrations and check-ins send real email/WhatsApp. Use made-up `@test.local`
|
||||
people only.
|
||||
- CI: `.gitea/workflows/ci.yml` runs everything once a Gitea Actions runner is
|
||||
registered (none yet). Tag deploys need the `DEPLOY_HOST`, `DEPLOY_USER`,
|
||||
`DEPLOY_SSH_KEY` (+ optional `DEPLOY_PATH`) secrets.
|
||||
|
||||
## Where the multi-day code lives
|
||||
|
||||
- `backend/src/utils/sessions.js` — all session rules (dates, check-in window, late
|
||||
pricing, pick sessions, capacity, grouping); unit tests in `backend/tests/sessions.test.js`.
|
||||
- `backend/src/utils/certificates.js`, `sessionReminders.js`,
|
||||
`controllers/attendanceController.js` (register + certificates API).
|
||||
- `backend/src/jobs/index.js` — the background job registry.
|
||||
- Frontend: `src/lib/sessions.ts`, `src/components/events/ScheduleEditor.tsx`,
|
||||
`SessionPicker.tsx`, `SessionSchedule.tsx`, `src/app/dashboard/supervisor/attendance/`.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- The at-the-door booking pop-up doesn't have the session picker; per-session tickets
|
||||
are booked from Manual registration (the page says so).
|
||||
- The waitlist works per ticket option, not per session.
|
||||
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
|
||||
camera); scans are tested through the API.
|
||||
@@ -0,0 +1,774 @@
|
||||
# Hope Events — Road to 2.0.0
|
||||
|
||||
Planned features and platform work to take Hope Events from 1.32.x to 2.0.0.
|
||||
Each section covers what the feature does, who uses it, how it fits the
|
||||
existing platform, and the edge cases to handle. See [FEATURES.md](../FEATURES.md)
|
||||
for what already exists, and [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md) for the
|
||||
previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./HANDOVER.md).
|
||||
|
||||
**Theme for 2.0:** turn a feature-rich church app into a product that can be
|
||||
sold — trustworthy (POPIA), flexible (multi-day events, several payment
|
||||
providers), and maintainable (tests, CI, typed backend, versioned API).
|
||||
|
||||
**Status (2026-10-23):** all features in this document are approved. Shipped
|
||||
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
|
||||
phase A (sessions, late joining, per-session check-in, attendance register,
|
||||
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
|
||||
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
|
||||
(pick sessions, per-session capacity). **Next: 1.36.0 payment provider interface (Yoco
|
||||
moved behind it), then PayFast, SnapScan, Zapper.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
| Repo | What |
|
||||
|------|------|
|
||||
| `joshua/hope-events` (this repo) | The church app. Everything in this document except the Hub app itself — including the Hub *client* (licence step, "Connect to Hub", heartbeat, licence checks) |
|
||||
| `joshua/hope-events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
|
||||
2. [Payment providers: PayFast, SnapScan, Zapper](#2-payment-providers-payfast-snapscan-zapper)
|
||||
3. [POPIA toolkit](#3-popia-toolkit)
|
||||
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
|
||||
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
|
||||
6. [Event series and cloning](#6-event-series-and-cloning)
|
||||
7. [Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)
|
||||
8. [Outbound webhooks](#8-outbound-webhooks)
|
||||
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
|
||||
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
|
||||
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
|
||||
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
|
||||
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
|
||||
14. [Foundations (platform work)](#14-foundations-platform-work)
|
||||
15. [Suggested build order](#suggested-build-order)
|
||||
16. [Decisions](#decisions-2026-10-22)
|
||||
|
||||
---
|
||||
|
||||
## 1. Multi-day events (sessions)
|
||||
|
||||
One event that runs over several dates, e.g. **"Alpha Course — every Wednesday
|
||||
18:30–20:30 for 5 weeks"**, a 3-day conference, or a camp with an evening
|
||||
session each day. People register and pay **once**, get **one ticket**, and are
|
||||
checked in **per session**.
|
||||
|
||||
> Not the same as [Event series](#6-event-series-and-cloning). A series groups
|
||||
> *separate* events (Camp 2026, Camp 2027) that each have their own
|
||||
> registrations. Sessions are dates *inside one* event.
|
||||
|
||||
**Who:** Supervisors set up the schedule. Users see it on the event page, in
|
||||
their tickets and in their calendar. Staff check in per session.
|
||||
|
||||
### Event setup
|
||||
|
||||
New **Schedule** section on the event form:
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Schedule type | Single date (today's behaviour) · Multiple sessions |
|
||||
| Pattern builder | Repeat **daily / weekly / every N weeks** on chosen weekday(s), start date, time from–to, **number of sessions** or **until date** |
|
||||
| Generated sessions | Editable list: change date/time/location/label of any one session, delete, or add an extra one-off date |
|
||||
| Session label | Optional, e.g. "Week 1 — Is there more to life?" (defaults to "Session 1, 2, …") |
|
||||
| Session location | Optional; falls back to the event location |
|
||||
| Attendance mode | **Whole course** (one registration covers all sessions) · **Pick sessions** (see phase B below) |
|
||||
| Late joining | Per event: Off (registration closes when the first session starts) · Allow until the start of session N |
|
||||
| Late-joiner price | Per event, only when late joining is on: Full price · **Pro-rata** (price × remaining sessions ÷ total sessions, rounded to the **nearest R10**, e.g. R183 → R180, R185 → R190) · Fixed late price |
|
||||
| Check-in window | Per event: **Whole day** (default — any time on the session's date, in case someone was let in without being scanned) · Session time only (2 h before start until the session ends) |
|
||||
| Session reminders | Per event, **off by default**: when on, choose timing (e.g. 3 h before, the day before at 18:00, or both) and channels (email / WhatsApp) |
|
||||
| Certificates | Per event, **off by default**: when on, choose a template and the completion rule (see [Certificates](#certificates)) |
|
||||
|
||||
The pattern builder is only a helper: it generates rows, then the session list
|
||||
is the source of truth. Typing "every Wednesday for 5 weeks" and then skipping
|
||||
a public holiday is just deleting one row.
|
||||
|
||||
### How it works
|
||||
|
||||
- `Event.startDate` / `endDate` **stay** and are kept in sync automatically
|
||||
(first session start, last session end). Everything that already reads them —
|
||||
consent age checks, transfer cut-off, early-bird, feedback timing, ICS,
|
||||
listings, sitemap — keeps working unchanged. Only places that need
|
||||
per-session behaviour are updated.
|
||||
- **Public event page:** shows the full schedule, with past sessions greyed out
|
||||
and a "Next session" highlight. Event cards show "5 sessions · Wed 4 Nov –
|
||||
Wed 2 Dec".
|
||||
- **Event listings:** a multi-session event stays "upcoming / running" until
|
||||
its **last** session ends, and sorts by its **next** session.
|
||||
- **Tickets (PDF, email, WhatsApp):** list all session dates. One QR code.
|
||||
- **Calendar (ICS):** one VEVENT per session (not an RRULE, so edited or
|
||||
skipped sessions are exact).
|
||||
- **Check-in:** the scanner auto-selects today's session (on a day with more
|
||||
than one session, the one happening now or next), with a dropdown to
|
||||
override. Whether a scan is accepted depends on the event's **check-in
|
||||
window** (whole day or session time only). A ticket can be checked in
|
||||
**once per session** (per person for group tickets). Scanning again in the
|
||||
same session shows "Already checked in for Session 3 at 18:42".
|
||||
- **Manual check-in:** supervisors can tick someone as present for a session
|
||||
from the attendance register afterwards (e.g. let in without a scan), logged
|
||||
in the audit log with who marked it.
|
||||
- **Reminders:** off by default; when switched on for the event, a reminder
|
||||
goes out before **each** session (email/WhatsApp, via the scheduled-message
|
||||
worker), e.g. "See you tonight at 18:30".
|
||||
- **Late joiners:** when late joining is on, the registration page shows which
|
||||
sessions are left and the late price ("3 of 5 sessions left — R180").
|
||||
- **Changes:** moving or cancelling a session after people registered prompts
|
||||
the supervisor to notify registrants (pre-filled broadcast), and updates the
|
||||
ICS/tickets.
|
||||
|
||||
### Phase B: pick sessions (drop-in)
|
||||
|
||||
Second step, released separately. Lets people register for **some** sessions,
|
||||
e.g. a conference where each day is sold separately or a weekly drop-in class.
|
||||
|
||||
- Ticket options can be scoped to sessions: "Day pass (pick days)" priced per
|
||||
session, "Full conference pass" covering all.
|
||||
- Optional **capacity per session** on top of the option stock limit.
|
||||
- The ticket stores which sessions it covers; scanning on a session it doesn't
|
||||
cover shows "Not registered for this session" (with "Add & charge" at the
|
||||
door for supervisors).
|
||||
- Waitlist works per session when sessions have their own capacity.
|
||||
|
||||
### Reporting
|
||||
|
||||
- **Attendance register:** grid of attendees × sessions with ticks, totals per
|
||||
session, attendance % per person. Export to Excel and PDF.
|
||||
- **Completion filter:** "attended at least 4 of 5" — useful for certificates
|
||||
and for follow-up ("missed the last 2 weeks").
|
||||
- Daily summary email includes "Sessions today".
|
||||
- Funnel stats unchanged (they are per event).
|
||||
|
||||
### Certificates
|
||||
|
||||
Optional completion certificates (PDF), chosen per event.
|
||||
|
||||
- **3–4 built-in templates**, e.g. *Classic* (border, serif), *Modern* (clean,
|
||||
accent colour bar), *Church* (logo-led, scripture line), *Kids* (playful,
|
||||
for children's programmes). All use the site logo and brand colour from Site
|
||||
settings.
|
||||
- **Event settings:** Certificates on/off · Template · Title ("Certificate of
|
||||
Completion") · Body text with placeholders (`{name}`, `{event}`,
|
||||
`{sessionsAttended}`, `{totalSessions}`, `{endDate}`) · Signature name and
|
||||
role (optional uploaded signature image) · **Completion rule**: attended at
|
||||
least N sessions or N % (default: all sessions).
|
||||
- **Preview** button in the event form renders a sample PDF.
|
||||
- **Who gets one:** attendees meeting the completion rule after the last
|
||||
session. Supervisors can issue/withdraw one manually (audit logged).
|
||||
- **Delivery:** download from My Events, bulk download (ZIP) from the
|
||||
attendance register, and optional email/WhatsApp send.
|
||||
- Each certificate has a short verification code + QR linking to a public
|
||||
"certificate valid" page (name, event, date only).
|
||||
- Generated with `pdfkit`, like tickets and receipts (`utils/pdfDocs.js`).
|
||||
- Also usable on single-date events (completion = checked in).
|
||||
|
||||
### Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|------|-----------|
|
||||
| Delete a session that has check-ins | Blocked; offer **Cancel session** instead (kept for history, hidden from schedule, excluded from attendance %) |
|
||||
| Switch Multiple → Single after registrations | Allowed only if no session check-ins exist |
|
||||
| Ticket transfer | Allowed until the **first** session starts (current rule). Optional later: allow mid-course transfers with check-in history kept on the original holder |
|
||||
| Parental consent | Age is measured at the first session (current `startDate` rule) |
|
||||
| Medical details deletion | Runs after the **last** session (end date), as today |
|
||||
| Feedback form | Sent after the last session |
|
||||
| Cashup | Still per event. At-the-door cash per session is captured with the session on the payment so reports can split it |
|
||||
| Offline scanning | Session list and per-session usages are part of the offline lock bundle; sync keeps the session id per scan |
|
||||
| Timezones | Sessions stored as UTC instants, same as `Event.startDate` today (see `icsUtils.js`) |
|
||||
| Session overlaps / out of order | Allowed (conferences have parallel tracks later), list always sorted by start |
|
||||
| Kiosk / At the door | Shows the current session; check-in counts against it |
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
enum ScheduleType { single sessions }
|
||||
enum AttendanceMode { whole pick }
|
||||
|
||||
enum LatePricing { full prorata fixed }
|
||||
enum CheckInWindow { whole_day session_only }
|
||||
|
||||
model Event {
|
||||
// ...existing fields
|
||||
scheduleType ScheduleType @default(single)
|
||||
attendanceMode AttendanceMode @default(whole)
|
||||
lateJoinEnabled Boolean @default(false)
|
||||
lateJoinUntilSessionId String?
|
||||
latePricing LatePricing @default(full)
|
||||
lateFixedPrice Float?
|
||||
checkInWindow CheckInWindow @default(whole_day)
|
||||
sessionReminders Json? // null = off; { offsets: [...], channels: [...] }
|
||||
certificatesEnabled Boolean @default(false)
|
||||
certificateConfig Json? // template, title, body, signature, completion rule
|
||||
sessions EventSession[]
|
||||
certificates Certificate[]
|
||||
}
|
||||
|
||||
model Certificate {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
attendeeId String?
|
||||
userId String?
|
||||
name String
|
||||
code String @unique // verification code
|
||||
manual Boolean @default(false)
|
||||
issuedAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
model EventSession {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
label String?
|
||||
startsAt DateTime
|
||||
endsAt DateTime
|
||||
location String?
|
||||
capacity Int? // phase B
|
||||
cancelledAt DateTime?
|
||||
order Int @default(0)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
usages TicketUsage[]
|
||||
ticketSessions TicketSession[]
|
||||
|
||||
@@index([eventId, startsAt])
|
||||
}
|
||||
|
||||
model TicketUsage {
|
||||
// ...existing fields
|
||||
sessionId String? // null on single-date events
|
||||
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
// Phase B only: which sessions a "pick sessions" ticket covers.
|
||||
model TicketSession {
|
||||
ticketId String
|
||||
sessionId String
|
||||
@@id([ticketId, sessionId])
|
||||
}
|
||||
```
|
||||
|
||||
`Ticket.isUsed` keeps its meaning for single-date events. For session events
|
||||
it's not used for check-in decisions; "checked in" is per session
|
||||
(`TicketUsage.sessionId`).
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Session CRUD + pattern generator in `backend/src/utils/sessions.js` with
|
||||
Jest tests (DST-free but still test month boundaries, skipped dates, "until"
|
||||
vs "count").
|
||||
- Scan endpoint takes an optional `sessionId`; defaults to the current session.
|
||||
- Help content, audit log entries (session created/moved/cancelled),
|
||||
CHANGELOG, FEATURES.md, SETTINGS.md.
|
||||
|
||||
---
|
||||
|
||||
## 2. Payment providers: PayFast, SnapScan, Zapper
|
||||
|
||||
Today Yoco is the only online provider. Add **PayFast**, **SnapScan** and
|
||||
**Zapper**, and let the admin **enable one or more** in Site settings. At
|
||||
checkout the user picks from the enabled ones.
|
||||
|
||||
**Who:** Admins configure providers. Users choose at payment. Supervisors
|
||||
reconcile.
|
||||
|
||||
### Site settings → Payments
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Providers | Yoco · PayFast · SnapScan · Zapper — each with an **Enabled** toggle |
|
||||
| Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (`utils/encryption.js`, same as WhatsApp) |
|
||||
| Mode | Live / Sandbox per provider (where supported) |
|
||||
| Display order / default | Which one is shown first. Default order: Yoco · PayFast · SnapScan · Zapper |
|
||||
| Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) |
|
||||
|
||||
**Fees are not handled automatically.** Payments are recorded at the full
|
||||
amount paid; provider fees are not deducted, estimated or added to the payer's
|
||||
total. Fees are dealt with by people during **cashup**, as today. There is no
|
||||
"pass fee to payer" option.
|
||||
|
||||
"Test connection" button per provider, like the WhatsApp setup.
|
||||
|
||||
### How it works
|
||||
|
||||
- A single **PaymentProvider interface** in the backend:
|
||||
`createCheckout`, `handleWebhook` (verify signature → mark paid),
|
||||
`getTransaction`, `refund` (if the provider supports it via API),
|
||||
`listTransactions` (for reconciliation).
|
||||
- Yoco is moved behind the same interface first (no behaviour change), then
|
||||
PayFast, SnapScan and Zapper are added one release each.
|
||||
- SnapScan and Zapper are QR/app-based: on desktop show the QR, on mobile
|
||||
deep-link into the app; the page polls/waits for the webhook and moves on
|
||||
automatically when payment lands.
|
||||
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
|
||||
`ProviderTransaction` (`provider` column) so the existing reconciliation page
|
||||
works for all providers.
|
||||
- Refunds: if the provider has no refund API, the supervisor refunds in the
|
||||
provider's portal and records it here (existing manual refund flow).
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Provider disabled while a checkout is in progress → webhook still accepted
|
||||
and processed.
|
||||
- Duplicate / late webhooks → idempotent on the provider's transaction id.
|
||||
- Webhook never arrives → "Check payment status" button and a periodic
|
||||
reconcile job.
|
||||
- Amount mismatch (user edits amount in app) → payment held as "needs review".
|
||||
- Only enabled providers appear in kiosk / self-service.
|
||||
- Amount below R15 → online payment options hidden, with a message to pay at
|
||||
the door / contact the organiser.
|
||||
- **Before building each one:** get the documentation links from Joshua and
|
||||
confirm current API docs for webhooks, sandbox, refunds and QR deep-link
|
||||
support.
|
||||
|
||||
---
|
||||
|
||||
## 3. POPIA toolkit
|
||||
|
||||
Tools that make the platform **POPIA-ready** out of the box. This is the main
|
||||
selling point for other churches and organisations: "your attendee data is
|
||||
handled properly, and you can prove it."
|
||||
|
||||
> This is tooling to support compliance, not legal advice. Wording and
|
||||
> retention periods should be checked by whoever is the customer's
|
||||
> Information Officer.
|
||||
|
||||
**Who:** Admins (and the appointed Information Officer) manage it. Users get
|
||||
self-service privacy tools.
|
||||
|
||||
### For users (data subjects)
|
||||
|
||||
- **Privacy centre** in My account:
|
||||
- **Download my data** — JSON + readable PDF of profile, registrations,
|
||||
payments, tickets, form answers, consents, messages sent.
|
||||
- **Correct my data** — edit profile; request correction of locked records.
|
||||
- **Delete my account** — creates a deletion request (see below).
|
||||
- **Communication preferences** — email / WhatsApp / marketing opt-in with
|
||||
timestamps (extends the existing notification preferences).
|
||||
- **Unsubscribe link** in every bulk email and an opt-out keyword for bulk
|
||||
WhatsApp. Opted-out users are skipped automatically in broadcasts.
|
||||
- **Privacy notice acceptance** — versioned; users accept the current version
|
||||
at registration, and are asked again when it changes.
|
||||
|
||||
### For admins
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| Information Officer | Name, contact details, shown on privacy notice and contact page |
|
||||
| Privacy notice editor | Versioned text, published version history |
|
||||
| Consent log | Who consented to what, when, which notice version, how (web, kiosk, WhatsApp) |
|
||||
| Data requests inbox | Access, correction and deletion requests with status, due dates and audit trail |
|
||||
| Deletion workflow | Admin approves → personal info is **anonymised**; financial records kept (amounts, dates) but unlinked from the person, as tax law requires them to be kept |
|
||||
| Retention policies | Per data category (inactive accounts, form answers, medical details, analytics, audit log, backups): keep for N months, then anonymise/delete via nightly job. Sensible defaults shipped |
|
||||
| Operator register | List of third parties that process data (Yoco/PayFast/…, WhatsApp provider, email provider, Sentry, hosting) with purpose; feeds the privacy notice |
|
||||
| Access log | Who exported or viewed personal data in bulk (exports, attendee lists, medical details) |
|
||||
| Breach register | Record a security compromise, affected users, actions taken; templates for notifying users and the Information Regulator |
|
||||
| Children's data | Already covered by parental consent; flagged as special/children's info in reports |
|
||||
| POPIA readiness page | Checklist with score: officer set, notice published, retention set, operators listed, unsubscribe working… |
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Deletion of a user with an open registration / upcoming event → request
|
||||
waits until after the event, or the admin cancels and refunds first.
|
||||
- Anonymised users still count in historic reports ("Deleted user").
|
||||
- Backups contain deleted data until they age out — stated in the notice;
|
||||
backup retention is one of the retention policies.
|
||||
- Impersonation and exports must be logged in the access log.
|
||||
|
||||
---
|
||||
|
||||
## 4. Kids check-in with security labels
|
||||
|
||||
A secure check-in/check-out flow for children's ministry and youth events.
|
||||
**Builds on sessions (§1), attendees, parental consent and allocation groups.**
|
||||
|
||||
**Who:** Parents check in children (self-service kiosk or staff desk).
|
||||
Kids-ministry staff run rooms. Supervisors set up rooms.
|
||||
|
||||
### How it works
|
||||
|
||||
- Parent finds their family (phone number / QR from My account) and ticks the
|
||||
children being dropped off. New children can be added on the spot.
|
||||
- The system prints **two labels**:
|
||||
- **Child label:** name, room, allergy/medical icons, short pickup code.
|
||||
- **Parent label:** the same pickup code.
|
||||
- **Check-out** requires scanning or typing the matching code. A mismatch
|
||||
alerts the room leader.
|
||||
- Rooms come from allocation groups (age/grade rules), with **ratios** (e.g.
|
||||
1 leader per 5 toddlers) and live room counts.
|
||||
- **Page a parent:** room leader taps "Call parent" → WhatsApp/SMS to the
|
||||
parent ("Please come to the Toddlers room").
|
||||
- Attendance per child per session feeds the attendance register.
|
||||
|
||||
### Plumbing / edge cases
|
||||
|
||||
- Label printing via browser print with label-size CSS (Dymo / Brother label
|
||||
printers, 62 mm and 89×36 mm presets); PDF fallback.
|
||||
- Allergies and medical info come from the consent/medical fields; respect
|
||||
the medical-info deletion rules.
|
||||
- Custody notes ("Do not release to …") visible to room leaders only.
|
||||
- Works on the existing offline-scanning foundations for flaky Wi-Fi.
|
||||
|
||||
---
|
||||
|
||||
## 5. Volunteer / serving rosters
|
||||
|
||||
Plan who serves where, for an event or each session.
|
||||
|
||||
- **Serving roles** per event/session: Welcome, Parking, Kids, Sound, Kitchen…
|
||||
with the number of people needed.
|
||||
- Volunteers **sign up** for open slots, or supervisors **assign** people.
|
||||
- Reminders via email/WhatsApp before their slot; "can't make it" → swap
|
||||
request to others in the role.
|
||||
- **Volunteer check-in** on the day (separate from attendee check-in).
|
||||
- Volunteers can get a free/discounted ticket automatically (uses coupons/comp).
|
||||
- Reports: roster sheet per session, fill rate, hours served per person.
|
||||
|
||||
---
|
||||
|
||||
## 6. Event series and cloning
|
||||
|
||||
- **Clone event:** copy an event with its options, variants, early-bird tiers,
|
||||
forms, consent text, feedback form, cost template, allocation groups and
|
||||
sessions (dates shifted to the new start). No registrations.
|
||||
- **Series:** group separate events (Camp 2025, Camp 2026; monthly Men's
|
||||
Breakfast) on one series page, with "Next in this series" and a series
|
||||
follow/notify option.
|
||||
- **Year-on-year reports:** registrations, income and attendance compared
|
||||
across events in a series.
|
||||
- **Season pass:** a bundle across events in a series (extends event bundles).
|
||||
|
||||
---
|
||||
|
||||
## 7. Apple and Google Wallet tickets
|
||||
|
||||
- "Add to Apple Wallet" / "Add to Google Wallet" buttons next to the PDF
|
||||
ticket, in email and in My Events.
|
||||
- Pass shows event, date (next session for multi-day), location, holder name,
|
||||
QR code.
|
||||
- Passes **update** when the event/session changes (time, venue, cancelled).
|
||||
- Requires an Apple Developer account (pass signing certificate) and a Google
|
||||
Wallet issuer account; both configured in Site settings and **optional** —
|
||||
hidden when not configured.
|
||||
|
||||
---
|
||||
|
||||
## 8. Outbound webhooks
|
||||
|
||||
Let other systems react to what happens in Hope Events.
|
||||
|
||||
- Admin adds endpoints and picks events: `registration.created`,
|
||||
`registration.paid`, `registration.cancelled`, `payment.refunded`,
|
||||
`ticket.checked_in`, `user.created`, `event.published`…
|
||||
- Payloads signed with HMAC (secret per endpoint), retries with back-off,
|
||||
delivery log with "Resend".
|
||||
- Enables integrations (ChurchSuite, Planning Center, Google Sheets via
|
||||
Zapier/Make) without building each one. Native integrations can follow later.
|
||||
|
||||
---
|
||||
|
||||
## 9. Admin dashboard, command palette, bulk actions
|
||||
|
||||
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
|
||||
far, money in (today/week), pending items (unreconciled payments, consent
|
||||
outstanding, waitlist offers, data requests), alerts.
|
||||
- **Command palette (Ctrl+K):** jump to any event, user, registration,
|
||||
ticket or setting; quick actions ("New event", "Find payment R450").
|
||||
- **Bulk actions** on registration/attendee lists: message, export, mark
|
||||
paid, move option, cancel, allocate.
|
||||
- **Saved filters** per user on list pages.
|
||||
|
||||
---
|
||||
|
||||
## 10. Languages (Afrikaans first)
|
||||
|
||||
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
|
||||
- User language preference; emails, WhatsApp messages and PDFs sent in the
|
||||
user's language, with per-language templates.
|
||||
- Event content stays as typed by the supervisor (optional translated title /
|
||||
description fields later).
|
||||
|
||||
---
|
||||
|
||||
## 11. Accessibility pass (WCAG 2.2 AA)
|
||||
|
||||
- Audit public pages, registration, payment, My Events and the kiosk.
|
||||
- Keyboard navigation, focus states, labels, colour contrast (light and
|
||||
dark), screen-reader names for icon buttons, error messages linked to
|
||||
fields.
|
||||
- Automated `axe` checks in the Playwright suite so it doesn't regress.
|
||||
|
||||
---
|
||||
|
||||
## 12. Multi-campus (one install per church)
|
||||
|
||||
**Decision: one install per church.** Each customer gets their own install
|
||||
and database (the `/setup` wizard already makes this possible). This gives
|
||||
full data separation — which also helps the POPIA story — and lets each
|
||||
church run on a different server. **True multi-tenant is out of scope.**
|
||||
|
||||
**Multi-campus** is in scope: one church with several campuses inside one
|
||||
install.
|
||||
|
||||
### Setup
|
||||
|
||||
- Site settings → **Campuses**: off by default. When on, admins add campuses
|
||||
(name, address, contact, optional logo/colour accent, default location for
|
||||
events).
|
||||
- Single-campus churches never see campus fields anywhere.
|
||||
|
||||
### How it works
|
||||
|
||||
- **Events** belong to one campus, or to **All campuses** (church-wide
|
||||
conferences, camps).
|
||||
- **Public site:** campus filter on the events list, remembered per visitor;
|
||||
campus shown on event cards and tickets. Optional campus landing pages
|
||||
(`/campus/<slug>`).
|
||||
- **Users** can set a home campus (used as the default filter and for
|
||||
broadcasts). Accounts stay shared across campuses — one login for the
|
||||
church.
|
||||
- **Campus-scoped roles:** a supervisor or staff member can be limited to
|
||||
one or more campuses (sees and manages only those events, registrations,
|
||||
scanning, cashups). Admins and unscoped supervisors see everything.
|
||||
- **Reports, cashup, dashboard, funnel stats:** filter by campus plus a
|
||||
church-wide total.
|
||||
- **Broadcasts:** target "everyone at campus X".
|
||||
- **Kids check-in rooms** and **volunteer roles** are per campus.
|
||||
- **Notification recipients** per campus (e.g. the campus pastor gets that
|
||||
campus's daily summary).
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Turning campuses off with campus data present → hidden, not deleted.
|
||||
- Deleting a campus with events → blocked; archive instead.
|
||||
- Moving an event to another campus → scoped supervisors of the old campus
|
||||
lose access; logged in the audit log.
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
model Campus {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
address String?
|
||||
isActive Boolean @default(true)
|
||||
order Int @default(0)
|
||||
events Event[]
|
||||
}
|
||||
// Event.campusId String? (null = all campuses)
|
||||
// User.homeCampusId String?
|
||||
// UserCampusScope { userId, campusId } — empty = unscoped
|
||||
```
|
||||
|
||||
### Running many installs
|
||||
|
||||
Because every church is a separate install, a few tools make that manageable:
|
||||
|
||||
- **Install script:** fresh server → Node, Postgres, PM2, reverse proxy,
|
||||
`.env`, migrations, then `/setup` in the browser.
|
||||
- **Update script:** pull a tagged version, back up the database, run
|
||||
migrations, build, restart; roll back on failure.
|
||||
- **Version and health:** current version shown in admin and on a `/health`
|
||||
endpoint (version, DB, jobs, last backup), reported to the
|
||||
[Hub](#13-central-management-platform-hope-events-hub).
|
||||
|
||||
---
|
||||
|
||||
## 13. Central management platform (Hope Events Hub)
|
||||
|
||||
A separate app, run by Joshua, to see and manage **every Hope Events
|
||||
install** from one place: who the customers are, which version each runs,
|
||||
whether they're healthy, basic usage stats, and their **licence** status.
|
||||
|
||||
**Who:** Joshua (and any future support staff). Customers never log in to it.
|
||||
|
||||
### Separate app, separate repo, separate server
|
||||
|
||||
- **Its own git repository:** `joshua/hope-events-hub` on the same Gitea
|
||||
server (c-git.crosscode.co.za), with its own semver tags, CHANGELOG and
|
||||
deploys. It is never shipped to churches, so none of its code (customers,
|
||||
licence signing key, other churches' data) ends up on a customer's server.
|
||||
- Same stack (Next.js + Express + Prisma + Postgres) so it's familiar.
|
||||
- **What lives where:**
|
||||
- Hub repo: dashboard, customers, licences, licence **signing** (private
|
||||
key — only ever on the Hub server), `/api/installs/register` and
|
||||
`/api/installs/heartbeat` endpoints, alerts.
|
||||
- Hope Events repo: the Hub **client** — licence step in `/setup`,
|
||||
"Connect to Hub" in Site settings, heartbeat job, licence verification
|
||||
with the Hub's **public** key, read-only mode, banners.
|
||||
- **The contract between them** (register / heartbeat payloads, licence token
|
||||
format) is versioned with a `protocolVersion` field and documented in both
|
||||
repos (`docs/hub-protocol.md`). The Hub accepts the current and previous
|
||||
protocol version, so churches on older versions keep reporting while they
|
||||
wait to update.
|
||||
- Runs on its own server/domain (e.g. `hub.<your-domain>`). If the Hub is
|
||||
down, **no church install is affected**.
|
||||
- Hope Family Church's own site is the first registered install.
|
||||
|
||||
### Registration (setup → Hub)
|
||||
|
||||
1. The `/setup` wizard gets a **Licence** step: the customer enters the
|
||||
**licence key** Joshua issued (created in the Hub first).
|
||||
2. When setup completes, the install sends `POST /api/installs/register`
|
||||
with: licence key, site name, URL, admin contact, version, server info
|
||||
(Node/Postgres version, OS), timezone, enabled features.
|
||||
3. The Hub checks the key, links the install to the customer, and returns an
|
||||
**install id + install secret** (stored encrypted in AppSettings) and a
|
||||
**signed licence token**.
|
||||
4. Existing installs (already past setup) get a **"Connect to Hub"** button
|
||||
in Site settings → Licence.
|
||||
|
||||
All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
|
||||
### Heartbeat (install → Hub)
|
||||
|
||||
- Every 15 minutes (job queue) the install POSTs a heartbeat:
|
||||
- **Health:** version, uptime, DB OK, job queue OK, last successful
|
||||
backup, disk free, last error count (from Sentry or local logs), pending
|
||||
migrations.
|
||||
- **Basic stats (aggregate counts only):** users, active/upcoming events,
|
||||
registrations and tickets (30 days / all time), check-ins, WhatsApp/email
|
||||
sent, enabled payment providers, campuses on/off.
|
||||
- **Optional (off by default, customer chooses):** payment totals.
|
||||
- **Install → Hub only.** The install pushes; the Hub never needs to reach
|
||||
into the install, so it works behind firewalls and the Hub has no access
|
||||
to church data.
|
||||
- **No personal information is ever sent** (no names, emails, phones,
|
||||
attendee data). Listed in the install's POPIA operator register and
|
||||
privacy notice as "aggregate usage statistics to the software provider".
|
||||
|
||||
### Hub dashboard
|
||||
|
||||
- **Installs list:** customer, URL, version (flag if behind the latest tag),
|
||||
licence status, last heartbeat (red if > 1 h ago), health status.
|
||||
- **Install detail:** health history, stats charts, version history,
|
||||
licence history, notes, contacts.
|
||||
- **Alerts** (email/WhatsApp to Joshua): install offline > 1 h, backup
|
||||
failed or older than 48 h, disk low, errors spiking, licence expiring.
|
||||
- **Releases:** latest version and changelog; installs see "Update
|
||||
available" in their admin.
|
||||
- **Customers:** contact details, plan, licence(s), billing notes.
|
||||
|
||||
### Licences
|
||||
|
||||
| State | What the install does |
|
||||
|-------|----------------------|
|
||||
| **Active** | Everything works |
|
||||
| **Grace** (expired or Hub unreachable) | Everything works; admins see a banner "Licence needs renewing — X days left". Default 14 days |
|
||||
| **Suspended** | **Read-only:** no new events, no new registrations or payments. Existing tickets **still scan**, and admins can still view and **export all data** |
|
||||
| **Revoked** | Same as suspended, with a permanent banner |
|
||||
|
||||
- Joshua turns a licence on/off, sets expiry dates and plan in the Hub; the
|
||||
install picks up the change on its next heartbeat.
|
||||
- The licence is a **signed token** (Ed25519: the Hub signs with its private
|
||||
key, installs only hold the public key). Installs verify it **offline**, so
|
||||
a Hub outage never switches a church off, and a token can't be faked by
|
||||
editing the database.
|
||||
- **Never a hard kill:** a church must always be able to scan tickets for
|
||||
events people already paid for, and get its own data out (POPIA access
|
||||
rights). Suspension blocks *new* activity only.
|
||||
- Optional plan limits later (e.g. number of campuses, WhatsApp) carried in
|
||||
the token.
|
||||
|
||||
### Things to be aware of
|
||||
|
||||
- Code runs on the customer's server, so a determined developer could patch
|
||||
out the licence check. Real protection is the **contract** plus updates and
|
||||
support only being available to valid licences — the licence check keeps
|
||||
honest customers honest.
|
||||
- The Hub should not offer remote commands (run updates, impersonate,
|
||||
database access) in v1: it's the most valuable target an attacker could
|
||||
hit, so keeping it read-only toward installs keeps every church safe if it
|
||||
is ever compromised. Remote "trigger update" can be revisited later.
|
||||
- The Hub itself needs 2FA, backups and the same audit log approach.
|
||||
|
||||
### Data model (Hub, sketch)
|
||||
|
||||
```prisma
|
||||
model Customer { id, name, contactName, email, phone, notes, licences, installs }
|
||||
model Licence { id, customerId, key, plan, status, expiresAt, graceDays, issuedAt }
|
||||
model Install { id, customerId, licenceId, name, url, secretHash, version,
|
||||
lastHeartbeatAt, health Json, registeredAt }
|
||||
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
|
||||
// rolled up to daily after 30 days
|
||||
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Foundations (platform work)
|
||||
|
||||
Not visible to users, but this is what makes 2.0 a safe product to sell and
|
||||
keep adding to.
|
||||
|
||||
| Item | What |
|
||||
|------|------|
|
||||
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
|
||||
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
|
||||
| Job queue | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin |
|
||||
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite |
|
||||
| TypeScript backend | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend |
|
||||
| API v2 | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition |
|
||||
| Dependencies | Express 5, native `fetch` instead of `node-fetch`, password hashing upgrade with rehash-on-login |
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Version | Features | Why |
|
||||
|-------|---------|----------|-----|
|
||||
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
|
||||
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 – 1.39.0 | Payment provider interface (Yoco moved behind it) → PayFast → SnapScan → Zapper, one release each | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.40.0 – 1.41.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.42.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.44.0 – 1.45.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.46.0 – 1.48.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
|
||||
and SETTINGS.md — same as the previous round. Branch → merge → tag → push per
|
||||
feature.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (2026-10-22)
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|----------|----------|
|
||||
| 1 | Pick sessions in the first release? | No — whole-course first (1.33.0), pick sessions as a separate release (1.35.0) |
|
||||
| 2 | Late joining and late price | Set **per event**: late joining on/off (until session N), and the late price (full / pro-rata / fixed) |
|
||||
| 3 | Check-in window | Per event, **whole day** by default (any time on the session's date); "session time only" as the alternative |
|
||||
| 4 | Certificates | Yes — four templates (Classic, Modern, Church, Kids); the event creator chooses whether to issue certificates and which template |
|
||||
| 5 | Session reminders | Off by default, configurable per event |
|
||||
| 6 | Selling model | **One install per church** — data separation, any server. No multi-tenant. **Multi-campus** inside an install is in scope |
|
||||
| — | Pro-rata rounding | Nearest R10 |
|
||||
| — | Multi-campus | In scope (§12), inside one install |
|
||||
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
|
||||
| 7 | Payment provider order | Yoco · PayFast · SnapScan · Zapper. Joshua can supply documentation links |
|
||||
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
|
||||
| — | Minimum online payment | R15 for all providers |
|
||||
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
|
||||
| — | Early-bird ribbon | Added before everything else; shipped as 1.32.0, all later versions moved up by one |
|
||||
|
||||
## Still open
|
||||
|
||||
Hub (needed before phase 6, not before 1.33.0):
|
||||
|
||||
1. Hub domain and server, and where licence keys come from (created by hand
|
||||
in the Hub per customer?).
|
||||
2. Grace period length (proposed 14 days) and suspended = read-only (tickets
|
||||
still scan, data export still works) — agreed?
|
||||
3. Should payment totals be in the heartbeat at all (opt-in per customer), or
|
||||
counts only?
|
||||
4. Licence plans/tiers (e.g. limits on campuses or WhatsApp), or one plan for
|
||||
now?
|
||||
+1
-1
@@ -5,7 +5,7 @@ import path from "path";
|
||||
export type Role = "admin" | "supervisor" | "staff" | "user";
|
||||
export type Seed = {
|
||||
users: Record<Role, { id: string; name: string; email: string; token: string }>;
|
||||
events: { free: string; paid: string; course: string };
|
||||
events: { free: string; paid: string; course: string; dropIn: string };
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
type Registration = { id: string; eventId: string; registrationOptions: { sessionId?: string | null; priceSnapshot: number }[] };
|
||||
|
||||
test.describe("pick sessions", () => {
|
||||
test("book two classes of a drop-in event; a full class can't be ticked", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.dropIn}`);
|
||||
await expect(page.getByText("R50.00 per session").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await expect(picker.getByText("Full")).toBeVisible();
|
||||
await expect(picker.getByRole("checkbox").first()).toBeDisabled();
|
||||
await picker.getByRole("checkbox").nth(1).check();
|
||||
await picker.getByRole("checkbox").nth(2).check();
|
||||
await expect(page.getByText("Total: R100.00")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await page.waitForURL(/\/registration\/success/);
|
||||
|
||||
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
|
||||
const reg = regs.find((r) => r.eventId === seed().events.dropIn);
|
||||
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,29 @@ async function seed(url) {
|
||||
});
|
||||
await prisma.event.update({ where: { id: course.id }, data: { lateJoinUntilSessionId: course.sessions[2].id } });
|
||||
|
||||
// A drop-in class: three upcoming classes, booked per class (the first is full).
|
||||
const classes = [1, 8, 15].map((days, i) => {
|
||||
const startsAt = at(days, 16, 0);
|
||||
return { label: `Class ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 60 * 60 * 1000), order: i, capacity: i === 0 ? 1 : null };
|
||||
});
|
||||
const dropIn = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Drop-in Class', description: 'Come to the classes you like.', startDate: classes[0].startsAt, endDate: classes[2].endsAt,
|
||||
price: 50, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
scheduleType: 'sessions', attendanceMode: 'pick',
|
||||
sessions: { create: classes },
|
||||
eventOptions: { create: [{ name: 'Drop-in', price: 50, isMainTicket: true, sessionMode: 'pick' }] },
|
||||
},
|
||||
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
|
||||
});
|
||||
// Someone else already has the only place in class 1.
|
||||
await prisma.registration.create({
|
||||
data: {
|
||||
userId: users.staff.id, eventId: dropIn.id, status: 'paid',
|
||||
registrationOptions: { create: [{ eventOptionId: dropIn.eventOptions[0].id, quantity: 1, priceSnapshot: 50, sessionId: dropIn.sessions[0].id, tranches: { create: [{ quantity: 1, priceSnapshot: 50 }] } }] },
|
||||
},
|
||||
});
|
||||
|
||||
// Thandi is booked on the course and was checked in for week 1.
|
||||
const reg = await prisma.registration.create({
|
||||
data: {
|
||||
@@ -115,7 +138,7 @@ async function seed(url) {
|
||||
|
||||
const out = {
|
||||
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
|
||||
events: { free: free.id, paid: paid.id, course: course.id },
|
||||
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id },
|
||||
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
|
||||
};
|
||||
fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true });
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -15,7 +16,7 @@ import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type Lu
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint";
|
||||
import { todaysSession } from "@/lib/sessions";
|
||||
import { isPickOption, todaysSession } from "@/lib/sessions";
|
||||
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
@@ -96,6 +97,7 @@ export default function AtTheDoorPage() {
|
||||
const [eventId, setEventId] = useState("");
|
||||
|
||||
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
|
||||
const [doorHasPickOptions, setDoorHasPickOptions] = useState(false);
|
||||
const [showOptionsModal, setShowOptionsModal] = useState(false);
|
||||
const [pendingUser, setPendingUser] = useState<NewAttendee | null>(null);
|
||||
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
|
||||
@@ -158,7 +160,11 @@ export default function AtTheDoorPage() {
|
||||
authToken: token
|
||||
});
|
||||
|
||||
setEventOptions(ev.options || ev.eventOptions || []);
|
||||
// Per-session tickets ("pick sessions") are booked from Manual registration,
|
||||
// which has the session picker.
|
||||
const all = ev.options || ev.eventOptions || [];
|
||||
setEventOptions(all.filter(o => !isPickOption(ev, o)));
|
||||
setDoorHasPickOptions(all.some(o => isPickOption(ev, o)));
|
||||
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
|
||||
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
|
||||
setDoorCollectGender(!!ev.collectGender);
|
||||
@@ -493,6 +499,11 @@ export default function AtTheDoorPage() {
|
||||
{mode === "refund" && (
|
||||
<DoorRefundPanel token={token} eventId={eventId} setInfo={setInfo} />
|
||||
)}
|
||||
{doorHasPickOptions && (
|
||||
<div className="mt-4 p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm">
|
||||
This event has per-session tickets. Book those from <Link className="underline font-medium" href="/dashboard/supervisor/manual">Manual registration</Link>, which lets you choose the sessions.
|
||||
</div>
|
||||
)}
|
||||
<OptionsModal
|
||||
open={showOptionsModal}
|
||||
onClose={() => { setShowOptionsModal(false); setPendingEditReg(null); }}
|
||||
|
||||
@@ -91,7 +91,9 @@ function AttendancePageInner() {
|
||||
const key = `${row.ticketId}:${sessionId}`;
|
||||
setPending(key);
|
||||
try {
|
||||
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId: row.ticketId, sessionId, present } });
|
||||
// Picked sessions have a ticket per session.
|
||||
const ticketId = row.sessionTickets?.[sessionId] || row.ticketId;
|
||||
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId, sessionId, present } });
|
||||
const col = reg?.columns.find(c => c.id === sessionId);
|
||||
setInfo(`${row.name || "Attendee"} marked ${present ? "present" : "absent"}${multi && col ? ` for ${col.label}` : ""}.`);
|
||||
await load();
|
||||
@@ -229,6 +231,9 @@ function AttendancePageInner() {
|
||||
{columns.map(c => {
|
||||
const qty = r.checkIns[c.id] || 0;
|
||||
const key = `${r.ticketId}:${c.id}`;
|
||||
if (r.bookedSessions && !r.bookedSessions.includes(c.id)) {
|
||||
return <td key={c.id} className="px-2 py-2 text-center text-gray-300" title="Not booked for this session">—</td>;
|
||||
}
|
||||
const disabled = !canManage || c.cancelled || pending !== null || reg.event.cashupStatus === "closed";
|
||||
return (
|
||||
<td key={c.id} className="px-2 py-2 text-center">
|
||||
|
||||
@@ -95,6 +95,7 @@ type OptionDraft = {
|
||||
staffPrice?: string; // empty → no staff price
|
||||
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
|
||||
variants: Variant[];
|
||||
sessionMode?: "all" | "pick"; // "pick sessions" events only
|
||||
};
|
||||
|
||||
type SectionDraft = {
|
||||
@@ -209,7 +210,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
|
||||
// ─── Items & Pricing substep editors ────────────────────────────────────────
|
||||
|
||||
/** Substep 0 — bare option rows */
|
||||
function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean }) {
|
||||
function OptionsEditor({ options, onChange, required, pickMode }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean; pickMode?: boolean }) {
|
||||
const upd = (i: number, patch: Partial<OptionDraft>) => { const c = options.slice(); c[i] = { ...c[i], ...patch }; onChange(c); };
|
||||
const rem = (i: number) => { const c = options.slice(); c.splice(i, 1); onChange(c); };
|
||||
const add = () => {
|
||||
@@ -245,6 +246,15 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
|
||||
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24" placeholder="(none)"
|
||||
value={opt.staffPrice ?? ""} onChange={e => upd(i, { staffPrice: e.target.value })} />
|
||||
</div>
|
||||
{pickMode && (
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-500 mb-0.5" title="Pick sessions: bought per session — the price and stock are per session">Sessions</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={opt.sessionMode || "all"} onChange={e => upd(i, { sessionMode: e.target.value as "all" | "pick" })}>
|
||||
<option value="all">All sessions</option>
|
||||
<option value="pick">Pick sessions (price per session)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 pb-1">
|
||||
<label className="text-xs flex items-center gap-1 cursor-pointer whitespace-nowrap">
|
||||
<input type="checkbox" checked={opt.isMainTicket} onChange={e => upd(i, { isMainTicket: e.target.checked })} /> Main ticket
|
||||
@@ -826,6 +836,7 @@ interface EventDraft {
|
||||
allowTicketTransfer: boolean; collectGender: boolean;
|
||||
allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean;
|
||||
scheduleType: "single" | "sessions";
|
||||
attendanceMode: "whole" | "pick";
|
||||
}
|
||||
|
||||
const blankDraft = (location = ""): EventDraft => ({
|
||||
@@ -840,6 +851,7 @@ const blankDraft = (location = ""): EventDraft => ({
|
||||
allowTicketTransfer: true, collectGender: false,
|
||||
allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true,
|
||||
scheduleType: "single",
|
||||
attendanceMode: "whole",
|
||||
});
|
||||
|
||||
// "YYYY-MM-DDTHH:MM" (local) → ISO
|
||||
@@ -886,6 +898,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
location: ev.location || "",
|
||||
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
|
||||
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
|
||||
} : blankDraft(settings.org_address || ""));
|
||||
|
||||
// ── multi-day schedule + attendance settings ──
|
||||
@@ -904,6 +917,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
}));
|
||||
const updAttendance = (patch: Partial<AttendanceDraft>) => setAttendance(a => ({ ...a, ...patch }));
|
||||
const multi = draft.scheduleType === "sessions";
|
||||
const pickMode = multi && draft.attendanceMode === "pick";
|
||||
// A multi-session event's dates come from its sessions.
|
||||
const scheduleBounds = useMemo(() => {
|
||||
const active = sortSessions(sessions).filter(s => !s.cancelled && s.startsAt && s.endsAt);
|
||||
@@ -926,6 +940,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
isMainTicket: !!o.isMainTicket,
|
||||
stockLimit: String(o.stockLimit ?? "0"),
|
||||
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
|
||||
sessionMode: o.sessionMode || "all",
|
||||
// Option-level tiers only (no variantId)
|
||||
earlyBirdTiers: allTiers
|
||||
.filter((t) => !t.variantId)
|
||||
@@ -1075,7 +1090,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// Update option + variants first to get/confirm variant IDs
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload }
|
||||
});
|
||||
|
||||
// Build variant tiers with confirmed IDs from response
|
||||
@@ -1107,7 +1122,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// New option added during edit
|
||||
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
|
||||
});
|
||||
if (created?.id) {
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
|
||||
@@ -1154,13 +1169,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// Update the auto-created main ticket
|
||||
await apiFetch(`/api/events/options/${existingMain.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
|
||||
});
|
||||
optId = existingMain.id;
|
||||
} else {
|
||||
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
|
||||
});
|
||||
optId = created?.id;
|
||||
}
|
||||
@@ -1234,7 +1249,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
endDate: multi && scheduleBounds ? isoOf(scheduleBounds.end) : new Date(draft.endDate).toISOString(),
|
||||
scheduleType: draft.scheduleType,
|
||||
...(multi ? {
|
||||
sessions: sortedSessions.map(s => ({ id: s.id, label: s.label.trim() || null, startsAt: isoOf(s.startsAt), endsAt: isoOf(s.endsAt), location: s.location.trim() || null, cancelled: s.cancelled })),
|
||||
attendanceMode: draft.attendanceMode,
|
||||
sessions: sortedSessions.map(s => ({ id: s.id, label: s.label.trim() || null, startsAt: isoOf(s.startsAt), endsAt: isoOf(s.endsAt), location: s.location.trim() || null, cancelled: s.cancelled, capacity: pickMode && s.capacity.trim() ? parseInt(s.capacity, 10) : null })),
|
||||
lateJoinEnabled: attendance.lateJoinEnabled,
|
||||
lateJoinUntilSessionIndex: attendance.lateJoinEnabled ? sortedSessions.findIndex(s => s.key === attendance.lateJoinUntilKey) : -1,
|
||||
latePricing: attendance.latePricing,
|
||||
@@ -1437,7 +1453,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
{multi && <p className="text-[10px] text-gray-400 mt-1">One registration and one ticket for every session, e.g. every Wednesday for 5 weeks. Check-in is per session.</p>}
|
||||
</div>
|
||||
{multi ? (
|
||||
<SessionsEditor sessions={sessions} onChange={setSessions} defaultLocation={draft.location} />
|
||||
<>
|
||||
<SessionsEditor sessions={sessions} onChange={setSessions} defaultLocation={draft.location} showCapacity={pickMode} />
|
||||
<div>
|
||||
<div className="block text-xs text-gray-600 mb-1">How people book</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-start gap-2 text-sm"><input type="radio" className="mt-1" name="attendance-mode" checked={draft.attendanceMode === "whole"} onChange={() => upd({ attendanceMode: "whole" })} /><span><span className="font-medium">The whole course</span><span className="block text-xs text-gray-500">One ticket for every session.</span></span></label>
|
||||
<label className="flex items-start gap-2 text-sm"><input type="radio" className="mt-1" name="attendance-mode" disabled={draft.requiresParentalConsent} checked={draft.attendanceMode === "pick"} onChange={() => upd({ attendanceMode: "pick" })} /><span><span className="font-medium">Pick sessions</span><span className="block text-xs text-gray-500">People choose which sessions to come to (e.g. day passes or a drop-in class). Set which tickets are per session in Items & Pricing, and an optional capacity per session above.{draft.requiresParentalConsent ? " Not available on youth events." : ""}</span></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<DTInput label="Start" required value={draft.startDate} onChange={v => upd({ startDate: v })} />
|
||||
@@ -1506,7 +1531,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
{step === 1 && pricingSubstep === 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-3">Define your ticket types or purchasable items. Variants and early-bird pricing are configured in the next two steps.</p>
|
||||
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} />
|
||||
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} pickMode={pickMode} />
|
||||
</div>
|
||||
)}
|
||||
{step === 1 && pricingSubstep === 1 && (
|
||||
|
||||
@@ -12,6 +12,8 @@ import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload,
|
||||
import { UserPlus, CreditCard } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import { isPickOption } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
@@ -268,29 +270,45 @@ export default function ManualRegistrationPage() {
|
||||
}
|
||||
}, [selectedEventId, events]);
|
||||
|
||||
// The events list doesn't carry bundle deals; the single-event response does.
|
||||
// The events list doesn't carry bundle deals or places per session; the single-event
|
||||
// response does.
|
||||
const [fullEvent, setFullEvent] = useState<Event | null>(null);
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) { setBundles([]); return; }
|
||||
if (!selectedEventId) { setBundles([]); setFullEvent(null); return; }
|
||||
let cancelled = false;
|
||||
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
|
||||
.then(ev => { if (!cancelled) setBundles(ev?.bundles || []); })
|
||||
.catch(() => { if (!cancelled) setBundles([]); });
|
||||
.then(ev => { if (!cancelled) { setBundles(ev?.bundles || []); setFullEvent(ev || null); } })
|
||||
.catch(() => { if (!cancelled) { setBundles([]); setFullEvent(null); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedEventId, token]);
|
||||
|
||||
// "Pick sessions" options: the sessions chosen per quantity key.
|
||||
const [picks, setPicks] = useState<Record<string, string[]>>({});
|
||||
useEffect(() => { setPicks({}); }, [selectedEventId]);
|
||||
const pickOpt = (optionId: string) => {
|
||||
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
|
||||
return fullEvent && o && isPickOption(fullEvent, o) ? o : null;
|
||||
};
|
||||
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
|
||||
const picker = (key: string, optionId: string) => {
|
||||
const o = pickOpt(optionId);
|
||||
if (!o || !fullEvent || !(quantities[key] > 0)) return null;
|
||||
return <SessionPicker event={fullEvent} option={o} staff quantity={quantities[key]} selected={picks[key] || []} onChange={ids => setPicks(p => ({ ...p, [key]: ids }))} />;
|
||||
};
|
||||
|
||||
const totalDue = useMemo(() => {
|
||||
return options.reduce((sum, o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
|
||||
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * variantUnitNow(o, v), 0);
|
||||
}
|
||||
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
|
||||
return sum + (quantities[o.id] || 0) * times(o.id) * optionUnitNow(o);
|
||||
}, 0);
|
||||
}, [options, quantities]);
|
||||
}, [options, quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const basket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
}), [quantities]);
|
||||
return { eventOptionId, quantity, variantId: variantId || null, ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
|
||||
}), [quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const bundleQuote = useBundleQuote({ eventId: selectedEventId, basket, token, forUserId: pickedUserId });
|
||||
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : totalDue;
|
||||
|
||||
@@ -304,9 +322,11 @@ export default function ManualRegistrationPage() {
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
|
||||
});
|
||||
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
|
||||
const noSessions = opts.find(o => "sessionIds" in o && o.sessionIds && o.sessionIds.length === 0);
|
||||
if (noSessions) { setError(`Choose the sessions for "${options.find(o => o.id === noSessions.eventOptionId)?.name || "the ticket"}".`); return; }
|
||||
if (selectedIsYouth) {
|
||||
const missing = consentDetailsMissing(options, manualQtyFor, attendeeEntries, selectedEv.startDate);
|
||||
if (missing) { setError(missing); return; }
|
||||
@@ -564,7 +584,8 @@ export default function ManualRegistrationPage() {
|
||||
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const key = `${opt.id}::${v.id}`;
|
||||
return (
|
||||
<div key={v.id} className="flex items-center justify-between px-3 py-2 border-b last:border-b-0">
|
||||
<div key={v.id} className="border-b last:border-b-0">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm">{v.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
@@ -582,6 +603,8 @@ export default function ManualRegistrationPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{picker(key, opt.id) && <div className="px-3 pb-2">{picker(key, opt.id)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -605,6 +628,7 @@ export default function ManualRegistrationPage() {
|
||||
onChange={e => setQuantities(q => ({ ...q, [opt.id]: Math.max(0, parseInt(e.target.value || '0')) }))}
|
||||
/>
|
||||
</div>
|
||||
{picker(opt.id, opt.id)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import { BundleSavings } from "@/components/shared/BundleDeals";
|
||||
import { MyCertificates } from "@/components/events/MyCertificates";
|
||||
import { isPickOption } from "@/lib/sessions";
|
||||
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
|
||||
|
||||
// Helper formatters
|
||||
@@ -484,8 +485,13 @@ export default function UserDashboardPage() {
|
||||
return !isEventOver(activeReg.event);
|
||||
}, [activeReg]);
|
||||
|
||||
// Picked-session lines can't be edited here (book more by registering), but still count.
|
||||
const pickedLinesTotal = useMemo(() => (activeReg?.registrationOptions || [])
|
||||
.filter(ro => ro.sessionId)
|
||||
.reduce((s, ro) => s + (ro.lineTotal ?? (ro.quantity || 0) * (ro.priceSnapshot || 0)), 0), [activeReg]);
|
||||
|
||||
const editTotal = useMemo(() => {
|
||||
return editEventOptions.reduce((sum, eo) => {
|
||||
return pickedLinesTotal + editEventOptions.reduce((sum, eo) => {
|
||||
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
|
||||
return sum + eo.variants.reduce((vsum, v) => {
|
||||
const qty = editQuantities[`${eo.id}:${v.id}`] || 0;
|
||||
@@ -494,7 +500,7 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
return sum + (editQuantities[eo.id] || 0) * effectiveEventOptionUnitPrice(eo, null);
|
||||
}, 0);
|
||||
}, [editEventOptions, editQuantities]);
|
||||
}, [editEventOptions, editQuantities, pickedLinesTotal]);
|
||||
|
||||
const beginEdit = async () => {
|
||||
if (!activeReg || !token) return;
|
||||
@@ -503,13 +509,13 @@ export default function UserDashboardPage() {
|
||||
try {
|
||||
// Load event options (with variants) for this event
|
||||
const eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
|
||||
const eventOptions = eventData?.eventOptions || [];
|
||||
const eventOptions = (eventData?.eventOptions || []).filter((eo) => !isPickOption(eventData, eo));
|
||||
|
||||
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
|
||||
|
||||
// Prefill quantities from the current registration
|
||||
const qtyMap: Record<string, number> = {};
|
||||
for (const ro of (activeReg.registrationOptions || [])) {
|
||||
for (const ro of (activeReg.registrationOptions || []).filter(r => !r.sessionId)) {
|
||||
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
|
||||
qtyMap[key] = (qtyMap[key] || 0) + (ro.quantity || 0);
|
||||
}
|
||||
@@ -519,7 +525,7 @@ export default function UserDashboardPage() {
|
||||
const minQtyMap: Record<string, number> = {};
|
||||
for (const t of tickets) {
|
||||
const ro = t.registrationOptionId ? regOptById.get(t.registrationOptionId) : undefined;
|
||||
if (!ro) continue;
|
||||
if (!ro || ro.sessionId) continue;
|
||||
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
|
||||
minQtyMap[key] = (minQtyMap[key] || 0) + (t.quantity || 0);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type EventOption = {
|
||||
stockLimit?: number;
|
||||
availableCount?: number;
|
||||
isMainTicket?: boolean;
|
||||
sessionMode?: "all" | "pick";
|
||||
earlyBirdTiers?: { id: string; deadline: string; price: number; stockLimit?: number }[];
|
||||
variants?: OptionVariant[];
|
||||
};
|
||||
@@ -48,6 +49,7 @@ type Event = {
|
||||
allowSponsorships?: boolean;
|
||||
bundles?: PublicBundle[];
|
||||
scheduleType?: "single" | "sessions";
|
||||
attendanceMode?: "whole" | "pick";
|
||||
sessions?: EventSession[];
|
||||
registrationClosesAt?: string;
|
||||
lateJoin?: LateJoinInfo | null;
|
||||
@@ -58,7 +60,7 @@ import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import { BundleOffers } from "@/components/shared/BundleDeals";
|
||||
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
|
||||
import { latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
|
||||
import { isPickOption, latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
|
||||
import { SessionSchedule } from "@/components/events/SessionSchedule";
|
||||
import { appUrl, appName } from "@/lib/siteConfig";
|
||||
import { getServerSettings } from "@/lib/serverSettings";
|
||||
@@ -292,7 +294,8 @@ function TicketList({ event }: { event: Event }) {
|
||||
}
|
||||
// Late joiners on a multi-day event pay a lower main-ticket price.
|
||||
const late = event.lateJoin?.active ? event.lateJoin : null;
|
||||
const forLate = (opt: EventOption, price: number) => (late && opt.isMainTicket ? latePrice(price, late) : price);
|
||||
const forLate = (opt: EventOption, price: number) => (late && opt.isMainTicket && opt.sessionMode !== "pick" ? latePrice(price, late) : price);
|
||||
const perSession = (opt: EventOption) => (isPickOption(event, opt) ? " per session" : "");
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<BundleOffers bundles={event.bundles} />
|
||||
@@ -316,7 +319,7 @@ function TicketList({ event }: { event: Event }) {
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
|
||||
<span className="font-medium text-sm">{opt.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">{priceLabel}</span>
|
||||
<span className="text-sm text-gray-600">{priceLabel}{perSession(opt)}</span>
|
||||
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
|
||||
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
|
||||
</div>
|
||||
@@ -360,7 +363,7 @@ function TicketList({ event }: { event: Event }) {
|
||||
<span className="text-xs text-brand-600">Early bird</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
|
||||
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}${perSession(opt)}`}</span>
|
||||
)}
|
||||
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
|
||||
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { apiFetch } from "@/lib/api";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
|
||||
import { latePrice, registrationClosesAt } from "@/lib/sessions";
|
||||
import { isPickOption, latePrice, registrationClosesAt } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
import { analyticsSessionId } from "@/lib/analytics";
|
||||
@@ -33,6 +34,8 @@ type EventOption = {
|
||||
isMainTicket?: boolean;
|
||||
earlyBirdTiers?: EarlyBirdTier[];
|
||||
variants?: OptionVariant[];
|
||||
sessionMode?: "all" | "pick";
|
||||
sessionAvailability?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type RegisterEvent = {
|
||||
@@ -54,8 +57,10 @@ export type RegisterEvent = {
|
||||
waitlistEnabled?: boolean;
|
||||
bundles?: PublicBundle[];
|
||||
scheduleType?: "single" | "sessions";
|
||||
attendanceMode?: "whole" | "pick";
|
||||
sessions?: EventSession[];
|
||||
registrationClosesAt?: string;
|
||||
wholeCourseClosesAt?: string | null;
|
||||
lateJoin?: LateJoinInfo | null;
|
||||
};
|
||||
|
||||
@@ -66,7 +71,7 @@ function withLatePrices(event: RegisterEvent): RegisterEvent {
|
||||
if (!late) return event;
|
||||
return {
|
||||
...event,
|
||||
eventOptions: (event.eventOptions || []).map(o => (!o.isMainTicket ? o : {
|
||||
eventOptions: (event.eventOptions || []).map(o => (!o.isMainTicket || o.sessionMode === "pick" ? o : {
|
||||
...o,
|
||||
price: latePrice(o.price, late),
|
||||
earlyBirdTiers: (o.earlyBirdTiers || []).map(t => ({ ...t, price: latePrice(t.price, late) })),
|
||||
@@ -156,18 +161,27 @@ function QtyControl({
|
||||
);
|
||||
}
|
||||
|
||||
// A "pick sessions" option: the sessions chosen per quantity key, and the event's schedule.
|
||||
type PickProps = { event: RegisterEvent; picks: Record<string, string[]>; onPick: (key: string, ids: string[]) => void };
|
||||
|
||||
function OptionCard({
|
||||
opt,
|
||||
quantities,
|
||||
onQtyChange,
|
||||
waitlist,
|
||||
pick,
|
||||
}: {
|
||||
opt: EventOption;
|
||||
quantities: QtyMap;
|
||||
onQtyChange: (key: string, val: number) => void;
|
||||
waitlist?: { eventId: string; guestsAllowed: boolean } | null;
|
||||
pick?: PickProps | null;
|
||||
}) {
|
||||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||||
const perSession = pick ? " per session" : "";
|
||||
const picker = (key: string, qty: number, unit: number) => (pick && qty > 0 ? (
|
||||
<SessionPicker event={pick.event} option={opt} selected={pick.picks[key] || []} onChange={(ids) => pick.onPick(key, ids)} quantity={qty} unitPrice={unit} />
|
||||
) : null);
|
||||
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
|
||||
|
||||
if (hasVariants) {
|
||||
@@ -177,7 +191,7 @@ function OptionCard({
|
||||
const variantPrices = variants.map((v) => effectiveUnitForVariant(opt, v));
|
||||
const minPrice = Math.min(...variantPrices);
|
||||
const maxPrice = Math.max(...variantPrices);
|
||||
const priceLabel = minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
|
||||
const priceLabel = (minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`) + perSession;
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden bg-white shadow-sm">
|
||||
@@ -214,10 +228,11 @@ function OptionCard({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={variant.id} className="flex items-center justify-between px-4 py-2.5">
|
||||
<div key={variant.id} className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm">{variant.name}</span>
|
||||
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}</span>
|
||||
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}{perSession}</span>
|
||||
<StockBadge stockLimit={variant.stockLimit} availableCount={variant.availableCount} />
|
||||
{waitlist && variantSoldOut && !isSoldOut && (
|
||||
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
|
||||
@@ -231,6 +246,8 @@ function OptionCard({
|
||||
max={maxQty}
|
||||
/>
|
||||
</div>
|
||||
{picker(key, qty, unitPrice)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -248,7 +265,8 @@ function OptionCard({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border rounded-lg p-3 bg-white shadow-sm">
|
||||
<div className="border rounded-lg p-3 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<div className="font-medium text-sm">{opt.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
@@ -260,7 +278,7 @@ function OptionCard({
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>R{price.toFixed(2)}</span>
|
||||
<span>R{price.toFixed(2)}{perSession}</span>
|
||||
)}
|
||||
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
|
||||
{waitlist && isSoldOut && (
|
||||
@@ -275,6 +293,8 @@ function OptionCard({
|
||||
max={maxQty}
|
||||
/>
|
||||
</div>
|
||||
{picker(key, qty, price)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -432,18 +452,26 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
setQuantities((q) => ({ ...q, [key]: val }));
|
||||
};
|
||||
|
||||
// "Pick sessions" options: the sessions chosen for each quantity key. Whole-course
|
||||
// tickets on such an event stop selling when the course starts.
|
||||
const [picks, setPicks] = useState<Record<string, string[]>>({});
|
||||
const wholeCourseClosed = event.attendanceMode === "pick" && !!event.wholeCourseClosesAt && Date.now() >= new Date(event.wholeCourseClosesAt).getTime();
|
||||
const pickFor = (opt: EventOption) => isPickOption(event, opt);
|
||||
const sessionsFor = (key: string, opt: EventOption) => (pickFor(opt) ? (picks[key] || []).length : 1);
|
||||
|
||||
const total = useMemo(() => {
|
||||
return (event.eventOptions || []).reduce((sum, opt) => {
|
||||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||||
if (hasVariants) {
|
||||
return sum + opt.variants!.reduce((vsum, v) => {
|
||||
const qty = quantities[`${opt.id}:${v.id}`] || 0;
|
||||
return vsum + qty * effectiveUnitForVariant(opt, v);
|
||||
const key = `${opt.id}:${v.id}`;
|
||||
const qty = quantities[key] || 0;
|
||||
return vsum + qty * sessionsFor(key, opt) * effectiveUnitForVariant(opt, v);
|
||||
}, 0);
|
||||
}
|
||||
return sum + (quantities[opt.id] || 0) * effectiveUnitForOption(opt);
|
||||
return sum + (quantities[opt.id] || 0) * sessionsFor(opt.id, opt) * effectiveUnitForOption(opt);
|
||||
}, 0);
|
||||
}, [event, quantities]);
|
||||
}, [event, quantities, picks]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const isGuestEligible = !!(event.requiresAuth === false);
|
||||
const isLoggedIn = !!user || !!token;
|
||||
@@ -454,7 +482,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
return limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
|
||||
}, [event]);
|
||||
|
||||
const basket = useMemo(() => buildItems(), [quantities]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const basket = useMemo(() => buildItems(), [quantities, picks]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const bundleQuote = useBundleQuote({ eventId, basket, token, enabled: (event.bundles || []).length > 0 });
|
||||
// The server prices the basket (staff price, tiers, bundle deals) when quoting or checking
|
||||
// a code, so its total wins.
|
||||
@@ -472,15 +500,13 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
: 0;
|
||||
|
||||
function buildItems() {
|
||||
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
|
||||
const items: { eventOptionId: string; quantity: number; variantId?: string; sessionIds?: string[] }[] = [];
|
||||
Object.entries(quantities).forEach(([key, qty]) => {
|
||||
if (qty <= 0) return;
|
||||
if (key.includes(":")) {
|
||||
const [eventOptionId, variantId] = key.split(":");
|
||||
items.push({ eventOptionId, quantity: qty, variantId });
|
||||
} else {
|
||||
items.push({ eventOptionId: key, quantity: qty });
|
||||
}
|
||||
const [eventOptionId, variantId] = key.includes(":") ? key.split(":") : [key, undefined];
|
||||
const opt = (event.eventOptions || []).find(o => o.id === eventOptionId);
|
||||
const sessionIds = opt && pickFor(opt) ? picks[key] || [] : undefined;
|
||||
items.push({ eventOptionId, quantity: qty, ...(variantId ? { variantId } : {}), ...(sessionIds ? { sessionIds } : {}) });
|
||||
});
|
||||
return items;
|
||||
}
|
||||
@@ -491,6 +517,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
setError("Select at least one ticket.");
|
||||
return;
|
||||
}
|
||||
const noSessions = items.find(i => i.sessionIds && i.sessionIds.length === 0);
|
||||
if (noSessions) {
|
||||
setError(`Choose the sessions for "${(event.eventOptions || []).find(o => o.id === noSessions.eventOptionId)?.name || "your ticket"}".`);
|
||||
return;
|
||||
}
|
||||
if (isYouthEvent) {
|
||||
const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate);
|
||||
if (missing) { setError(missing); return; }
|
||||
@@ -660,14 +691,18 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
|
||||
{!eventSoldOut && <BundleOffers bundles={event.bundles} className="mb-3" />}
|
||||
|
||||
{wholeCourseClosed && (
|
||||
<p className="mb-3 text-sm text-gray-600 bg-gray-50 border rounded-lg px-3 py-2">The course has started, so whole-course tickets are no longer on sale — choose the sessions you'd like to come to.</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{(event.eventOptions || []).map((opt) => (
|
||||
{(event.eventOptions || []).filter(opt => !(wholeCourseClosed && !pickFor(opt))).map((opt) => (
|
||||
<OptionCard
|
||||
key={opt.id}
|
||||
opt={opt}
|
||||
quantities={quantities}
|
||||
onQtyChange={handleQtyChange}
|
||||
waitlist={event.waitlistEnabled ? { eventId, guestsAllowed: event.requiresAuth === false } : null}
|
||||
pick={pickFor(opt) ? { event, picks, onPick: (key, ids) => setPicks(p => ({ ...p, [key]: ids })) } : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,9 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { PublicBundle, Registration } from "@/types";
|
||||
import type { Event, PublicBundle, Registration } from "@/types";
|
||||
import { isPickOption } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -209,25 +211,41 @@ export default function SelfServicePage() {
|
||||
[events, selectedEventId]
|
||||
);
|
||||
|
||||
// The events list doesn't carry bundle deals or places per session; the single-event
|
||||
// response does.
|
||||
const [kioskFull, setKioskFull] = useState<Event | null>(null);
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) { setKioskBundles([]); setKioskFull(null); return; }
|
||||
let cancelled = false;
|
||||
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
|
||||
.then(ev => { if (!cancelled) { setKioskBundles(ev?.bundles || []); setKioskFull(ev || null); } })
|
||||
.catch(() => { if (!cancelled) { setKioskBundles([]); setKioskFull(null); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedEventId, supervisorToken]);
|
||||
|
||||
// "Pick sessions" options: the sessions chosen per quantity key.
|
||||
const [picks, setPicks] = useState<Record<string, string[]>>({});
|
||||
useEffect(() => { setPicks({}); }, [selectedEventId]);
|
||||
const pickOpt = (optionId: string) => {
|
||||
const o = (kioskFull?.eventOptions || []).find(x => x.id === optionId);
|
||||
return kioskFull && o && isPickOption(kioskFull, o) ? o : null;
|
||||
};
|
||||
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
|
||||
const picker = (key: string, optionId: string) => {
|
||||
const o = pickOpt(optionId);
|
||||
if (!o || !kioskFull || !(quantities[key] > 0)) return null;
|
||||
return <SessionPicker event={kioskFull} option={o} quantity={quantities[key]} selected={picks[key] || []} onChange={ids => setPicks(p => ({ ...p, [key]: ids }))} />;
|
||||
};
|
||||
|
||||
const total = useMemo(() => {
|
||||
if (!selectedEvent) return 0;
|
||||
return (selectedEvent.eventOptions || []).reduce((sum, o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
|
||||
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * effectiveVariantUnit(o, v), 0);
|
||||
}
|
||||
return sum + (quantities[o.id] || 0) * effectiveUnit(o);
|
||||
return sum + (quantities[o.id] || 0) * times(o.id) * effectiveUnit(o);
|
||||
}, 0);
|
||||
}, [selectedEvent, quantities]);
|
||||
|
||||
// The events list doesn't carry bundle deals; the single-event response does.
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) { setKioskBundles([]); return; }
|
||||
let cancelled = false;
|
||||
apiFetch<{ bundles?: PublicBundle[] }>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
|
||||
.then(ev => { if (!cancelled) setKioskBundles(ev?.bundles || []); })
|
||||
.catch(() => { if (!cancelled) setKioskBundles([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedEventId, supervisorToken]);
|
||||
}, [selectedEvent, quantities, picks, kioskFull]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const kioskBasket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
@@ -438,7 +456,7 @@ export default function SelfServicePage() {
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
|
||||
}),
|
||||
user,
|
||||
// If the visitor declined to create an account, don't push an unsolicited
|
||||
@@ -973,7 +991,8 @@ export default function SelfServicePage() {
|
||||
const baseVariantPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const key = `${opt.id}::${v.id}`;
|
||||
return (
|
||||
<div key={v.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div key={v.id}>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-gray-800">{v.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
@@ -999,6 +1018,8 @@ export default function SelfServicePage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{picker(key, opt.id) && <div className="px-4 pb-3">{picker(key, opt.id)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1007,7 +1028,8 @@ export default function SelfServicePage() {
|
||||
}
|
||||
const unit = effectiveUnit(opt);
|
||||
return (
|
||||
<div key={opt.id} className="flex items-center justify-between border border-gray-200 rounded-xl px-4 py-3.5">
|
||||
<div key={opt.id} className="border border-gray-200 rounded-xl px-4 py-3.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{opt.name}</p>
|
||||
<p className="text-sm text-gray-500">{fmtCurrency(unit)}{unit !== opt.price && opt.price > 0 ? ` (was ${fmtCurrency(opt.price)})` : ""}</p>
|
||||
@@ -1030,6 +1052,8 @@ export default function SelfServicePage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{picker(opt.id, opt.id)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ function PatternBuilder({ onGenerate }: { onGenerate: (rows: SessionDraft[], mod
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessions: KeyedSession[]; onChange: (s: KeyedSession[]) => void; defaultLocation?: string }) {
|
||||
export function SessionsEditor({ sessions, onChange, defaultLocation, showCapacity }: { sessions: KeyedSession[]; onChange: (s: KeyedSession[]) => void; defaultLocation?: string; showCapacity?: boolean }) {
|
||||
const [showPattern, setShowPattern] = useState(sessions.length === 0);
|
||||
const sorted = sortSessions(sessions);
|
||||
const activeCount = sessions.filter(s => !s.cancelled).length;
|
||||
@@ -137,7 +137,7 @@ export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessio
|
||||
const start = new Date(base.getTime() + (last ? 7 * 24 * 60 * 60 * 1000 : 0));
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
const fmt = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
onChange([...sessions, keyed({ label: "", startsAt: fmt(start), endsAt: fmt(new Date(start.getTime() + dur)), location: "", cancelled: false })]);
|
||||
onChange([...sessions, keyed({ label: "", startsAt: fmt(start), endsAt: fmt(new Date(start.getTime() + dur)), location: "", cancelled: false, capacity: last?.capacity || "" })]);
|
||||
};
|
||||
|
||||
let position = 0;
|
||||
@@ -180,7 +180,7 @@ export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessio
|
||||
)}
|
||||
</div>
|
||||
{!s.cancelled && (
|
||||
<div className="grid sm:grid-cols-3 gap-2 pl-8">
|
||||
<div className={`grid gap-2 pl-8 ${showCapacity ? "sm:grid-cols-4" : "sm:grid-cols-3"}`}>
|
||||
<label className="text-[11px] text-gray-500">Starts
|
||||
<input type="datetime-local" className="mt-0.5 w-full border rounded px-2 py-1 text-sm" value={s.startsAt} onChange={e => {
|
||||
const v = e.target.value;
|
||||
@@ -197,6 +197,11 @@ export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessio
|
||||
<label className="text-[11px] text-gray-500">Location (optional)
|
||||
<input className="mt-0.5 w-full border rounded px-2 py-1 text-sm" placeholder={defaultLocation || "Event location"} value={s.location} onChange={e => update(s.key, { location: e.target.value })} />
|
||||
</label>
|
||||
{showCapacity && (
|
||||
<label className="text-[11px] text-gray-500" title="Most people at this session (picked places plus whole-course tickets). Blank = no limit.">Capacity
|
||||
<input type="number" min={1} className="mt-0.5 w-full border rounded px-2 py-1 text-sm" placeholder="No limit" value={s.capacity} onChange={e => update(s.key, { capacity: e.target.value })} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{s.hasCheckIns && <p className="pl-8 text-[10px] text-gray-400">Has check-ins — it can be cancelled but not removed.</p>}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Event } from "@/types";
|
||||
import { bookableSessions, formatSessionWhen, sessionLabel } from "@/lib/sessions";
|
||||
|
||||
// Tick boxes for the sessions of a "pick sessions" ticket. Full sessions (or ones with
|
||||
// fewer places than `quantity`) can't be ticked.
|
||||
export function SessionPicker({ event, option, selected, onChange, quantity = 1, staff = false, unitPrice }: {
|
||||
event: Pick<Event, "sessions">;
|
||||
option: { id: string; sessionAvailability?: Record<string, number> };
|
||||
selected: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
quantity?: number;
|
||||
staff?: boolean;
|
||||
unitPrice?: number;
|
||||
}) {
|
||||
const sessions = bookableSessions(event, option, staff);
|
||||
if (sessions.length === 0) return <p className="text-xs text-gray-500 mt-2">No sessions left to book.</p>;
|
||||
const toggle = (id: string) => onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
|
||||
const allOpen = sessions.filter(s => s.left === null || s.left >= Math.max(1, quantity)).map(s => s.id);
|
||||
return (
|
||||
<div className="mt-2 space-y-1" role="group" aria-label="Choose sessions">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Choose your sessions{unitPrice !== undefined ? ` — R${unitPrice.toFixed(2)} each` : ""}</span>
|
||||
<button type="button" className="text-brand-700 hover:underline" onClick={() => onChange(selected.length === allOpen.length ? [] : allOpen)}>
|
||||
{selected.length === allOpen.length && allOpen.length > 0 ? "Clear" : "Select all"}
|
||||
</button>
|
||||
</div>
|
||||
{sessions.map(s => {
|
||||
const full = s.left !== null && s.left < Math.max(1, quantity);
|
||||
return (
|
||||
<label key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-400" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
|
||||
<input type="checkbox" disabled={full && !selected.includes(s.id)} checked={selected.includes(s.id)} onChange={() => toggle(s.id)} />
|
||||
<span className="font-medium">{sessionLabel(s, event.sessions)}</span>
|
||||
<span className="text-gray-500">{formatSessionWhen(s)}</span>
|
||||
<span className="ml-auto text-xs">{s.left === null ? "" : s.left <= 0 ? "Full" : `${s.left} left`}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,9 @@ export const supervisorEventsHelpContent: HelpContent = {
|
||||
<GuideItem icon={CalendarDays} title="Multiple sessions" tone="brand">
|
||||
Choose "Multiple sessions" for one event on several dates — e.g. every Wednesday 18:30–20:30 for 5 weeks. Use the repeat pattern to create the dates, then edit any of them, add a label ("Week 1 — …"), a different location, or an extra date. People register and pay once and get one ticket for every session. Cancel a session (e.g. a public holiday) rather than deleting it once people have been checked in. Registration closes when the first session starts unless you turn on late joining.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CalendarDays} title="Pick sessions" tone="violet">
|
||||
For day passes or a drop-in class, set "How people book" to "Pick sessions", give sessions an optional capacity, and in Items & Pricing set a ticket to "Pick sessions (price per session)". People tick the sessions they want; each one is its own ticket. Whole-course tickets can still be sold too, until the course starts.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ListChecks} title="Items & pricing" tone="green">
|
||||
Ticket options and stock, variants of each option, and early-bird pricing tiers. An optional staff price is charged automatically to Staff and above when it's lower than the normal price.
|
||||
</GuideItem>
|
||||
|
||||
@@ -22,6 +22,7 @@ export type SessionDraft = {
|
||||
endsAt: string;
|
||||
location: string;
|
||||
cancelled: boolean;
|
||||
capacity: string; // "pick sessions" events; blank = no limit
|
||||
hasCheckIns?: boolean;
|
||||
};
|
||||
|
||||
@@ -54,7 +55,7 @@ export function generateSessions(p: SessionPattern): SessionDraft[] {
|
||||
const start = atTime(day, p.startTime);
|
||||
let end = atTime(day, p.endTime);
|
||||
if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
|
||||
out.push({ label: "", startsAt: localDT(start), endsAt: localDT(end), location: "", cancelled: false });
|
||||
out.push({ label: "", startsAt: localDT(start), endsAt: localDT(end), location: "", cancelled: false, capacity: "" });
|
||||
};
|
||||
|
||||
if (p.frequency === "daily") {
|
||||
@@ -83,7 +84,7 @@ export function generateSessions(p: SessionPattern): SessionDraft[] {
|
||||
}
|
||||
|
||||
export function toSessionDraft(s: EventSession & { hasCheckIns?: boolean }): SessionDraft {
|
||||
return { id: s.id, label: s.label || "", startsAt: localDT(new Date(s.startsAt)), endsAt: localDT(new Date(s.endsAt)), location: s.location || "", cancelled: !!(s.cancelled || s.cancelledAt), hasCheckIns: s.hasCheckIns };
|
||||
return { id: s.id, label: s.label || "", startsAt: localDT(new Date(s.startsAt)), endsAt: localDT(new Date(s.endsAt)), location: s.location || "", cancelled: !!(s.cancelled || s.cancelledAt), capacity: s.capacity ? String(s.capacity) : "", hasCheckIns: s.hasCheckIns };
|
||||
}
|
||||
|
||||
/** Sessions that aren't cancelled, earliest first. */
|
||||
@@ -172,3 +173,22 @@ export function todaysSession(event: Pick<Event, "scheduleType" | "sessions"> |
|
||||
const list = activeSessions(event.sessions).filter(x => new Date(x.startsAt).toDateString() === today);
|
||||
return list.find(s => now <= new Date(s.endsAt).getTime()) || list[list.length - 1] || null;
|
||||
}
|
||||
|
||||
/** Whether an option is bought per session (only in "pick sessions" events). */
|
||||
export function isPickOption(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined, option: { sessionMode?: "all" | "pick" } | null | undefined): boolean {
|
||||
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick" && option?.sessionMode === "pick";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions a pick option can still be booked for, with places left (null = no limit).
|
||||
* Public: before the session starts; staff: until it ends.
|
||||
*/
|
||||
export function bookableSessions(event: Pick<Event, "sessions">, option: { sessionAvailability?: Record<string, number> }, staff = false): (EventSession & { left: number | null })[] {
|
||||
const now = Date.now();
|
||||
return activeSessions(event.sessions)
|
||||
.filter(s => new Date(staff ? s.endsAt : s.startsAt).getTime() > now)
|
||||
.map(s => {
|
||||
const limits = [s.placesLeft, option.sessionAvailability?.[s.id]].filter((n): n is number => typeof n === "number");
|
||||
return { ...s, left: limits.length ? Math.min(...limits) : null };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Ticket as TicketRecord } from "@/types";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { scheduleSummary } from "@/lib/sessions";
|
||||
import { formatSessionWhen, scheduleSummary } from "@/lib/sessions";
|
||||
|
||||
// One ticket design for every printed ticket (user dashboard, staff event tickets, at the
|
||||
// door). The emailed/WhatsApp PDF in backend/src/controllers/ticketController.js mirrors it.
|
||||
@@ -18,8 +18,12 @@ export function ticketTypeLabel(t: TicketRecord): string {
|
||||
/** A ticket card. `buyerName` adds a "Purchased by" line. */
|
||||
export function ticketCardHtml(t: TicketRecord, buyerName?: string | null): string {
|
||||
const eventTitle = t.event?.title || t.eventId || "Event";
|
||||
// Multi-day events: "5 sessions · Wed 4 Nov – Wed 2 Dec" — one ticket for every session.
|
||||
const dateStr = (t.event && scheduleSummary(t.event)) || (t.event?.startDate ? formatDate(t.event.startDate) : "");
|
||||
// Multi-day events: "5 sessions · Wed 4 Nov – Wed 2 Dec" — one ticket for every session;
|
||||
// a picked-session ticket shows its own session.
|
||||
const session = t.registrationOption?.session;
|
||||
const dateStr = session
|
||||
? `${session.label || "Session"} · ${formatSessionWhen(session)}`
|
||||
: (t.event && scheduleSummary(t.event)) || (t.event?.startDate ? formatDate(t.event.startDate) : "");
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(t.qrCode || t.id)}`;
|
||||
return `
|
||||
<div class="ticket">
|
||||
|
||||
@@ -73,6 +73,10 @@ export type EventOption = {
|
||||
earlyBirdTiers?: EarlyBirdTier[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
// "Pick sessions" events: `pick` options are bought per session (price per session).
|
||||
sessionMode?: "all" | "pick";
|
||||
// Pick options with a stock limit: places left per session id.
|
||||
sessionAvailability?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type FormFieldType =
|
||||
@@ -158,6 +162,8 @@ export type Event = {
|
||||
// When public registration closes: the deadline, the first session (or session N with
|
||||
// late joining), or the end date — whichever applies.
|
||||
registrationClosesAt?: string;
|
||||
// "Pick sessions" events: when whole-course tickets stop selling.
|
||||
wholeCourseClosesAt?: string | null;
|
||||
lateJoinEnabled?: boolean;
|
||||
lateJoinUntilSessionId?: string | null;
|
||||
latePricing?: "full" | "prorata" | "fixed";
|
||||
@@ -182,6 +188,11 @@ export type EventSession = {
|
||||
cancelledAt?: string | null;
|
||||
cancelled?: boolean;
|
||||
order?: number;
|
||||
// "Pick sessions" events: optional limit on people, and (single-event responses) how
|
||||
// many are booked and how many places are left (null = no limit).
|
||||
capacity?: number | null;
|
||||
booked?: number;
|
||||
placesLeft?: number | null;
|
||||
};
|
||||
|
||||
export type LateJoinInfo = {
|
||||
@@ -208,6 +219,9 @@ export type CertificateConfig = {
|
||||
export type AttendanceColumn = { id: string; label: string; startsAt: string; endsAt: string; cancelled: boolean };
|
||||
export type AttendanceRow = {
|
||||
ticketId: string;
|
||||
// Picked-session bookings: the sessions booked (null = all) and the ticket for each.
|
||||
bookedSessions?: string[] | null;
|
||||
sessionTickets?: Record<string, string> | null;
|
||||
name: string;
|
||||
bookedBy: string;
|
||||
email: string;
|
||||
@@ -458,6 +472,9 @@ export type RegistrationOption = {
|
||||
appliedTierId?: string | null;
|
||||
eventOption?: EventOption;
|
||||
variant?: OptionVariant | null;
|
||||
// Picked-session lines: the one session this line (and its ticket) is for.
|
||||
sessionId?: string | null;
|
||||
session?: EventSession | null;
|
||||
// One entry per price the line was bought at (e.g. some before an early-bird tier expired).
|
||||
tranches?: { id: string; quantity: number; priceSnapshot: number; appliedTierId?: string | null }[];
|
||||
tickets?: { id: string; quantity?: number }[];
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
@@ -36,7 +36,7 @@ export default defineConfig({
|
||||
cwd: "backend",
|
||||
url: `http://localhost:${API_PORT}/api/settings/needs-setup`,
|
||||
timeout: 180_000,
|
||||
reuseExistingServer: false,
|
||||
reuseExistingServer: !ci,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
@@ -56,7 +56,7 @@ export default defineConfig({
|
||||
cwd: "frontend",
|
||||
url: `http://localhost:${WEB_PORT}`,
|
||||
timeout: 600_000,
|
||||
reuseExistingServer: false,
|
||||
reuseExistingServer: !ci,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
|
||||
Reference in New Issue
Block a user