Add multi-day events (frontend), attendance register and certificates

Event wizard: one date or multiple sessions with a repeat-pattern builder,
and a new Attendance step (late joining and pricing, check-in window,
session reminders, certificates with preview). Public pages show the
schedule and late prices; the scanner picks today's session; new
attendance register page, My Certificates, and a public verify page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 14:53:39 +02:00
co-authored by Claude Opus 5.5
parent c79b74bc67
commit 39558577f7
37 changed files with 1670 additions and 77 deletions
+16
View File
@@ -7,6 +7,22 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added
- **Multi-day events.** An event can run over several sessions, e.g. every Wednesday 18:30–20:30 for 5 weeks. In the event wizard choose "Multiple sessions", create the dates from a repeat pattern (daily or weekly, every N, on chosen weekdays, for a number of sessions or until a date), then edit any session's label, times or location, add one-off dates, or cancel a session (kept for history). People register and pay once and get one ticket for every session.
- The event page shows the schedule (past sessions greyed out, the next one highlighted, cancelled ones struck through). Event cards show "5 sessions · Wed 4 Nov – Wed 2 Dec", and multi-day events stay listed until their last session, sorted by their next one. Calendar files have one entry per session. Ticket PDFs, printed tickets and the ticket email give the session range.
- **Late joining**: a multi-day event closes registration when its first session starts, unless late joining is on (until session N, set per event). Late joiners pay full price, a pro-rata price for the sessions still to come (rounded to the nearest R10, at least R10), or a fixed late price — main tickets only, and never more than the normal price. The event and registration pages say when the late price applies.
- **Per-session check-in**: a multi-day ticket is checked in once per session. The scanner uses today's session (staff can pick another), shows the session on the confirm screen, and says "Already checked in for Week 3 at 18:42" on a second scan. Each event chooses whether check-in is open the whole day of a session (the default) or only from 2 hours before it until it ends. Offline mode downloads the list for today's session, and synced scans count for the session at their scan time.
- **Session reminders**: optional per event — 1 hour, 3 hours and/or 1 day before each session, by email and/or WhatsApp (each person's notification preference still applies). Off by default.
- **Attendance register** (Dashboard → Attendance): attendees × sessions with ticks, totals per session, completion, search and filters (completed, not completed, missed the last two sessions), and Excel/PDF export. Supervisors can tick someone present or absent, recorded in the audit log. Also works for single-date events (one "Checked in" column).
- **Completion certificates**: optional per event, in the wizard's new Attendance step. Four designs (Classic, Modern, Church, Kids) with the site logo and colour, your title and wording ({name}, {event}, {sessionsAttended}, {totalSessions}, {endDate}), an optional signature name, role and image, and who qualifies (every session, at least N sessions, or at least N%). "Preview certificate" shows a sample. Certificates are issued automatically when the event ends (and can be sent automatically), or from the attendance register: issue now, issue to one person anyway, send, download all as one PDF, or revoke. People download theirs under My Certificates on My Events. Each has a code and QR code that anyone can check on the public page `/certificates/verify/<code>`, which shows only the name, event and dates.
- Saving an event where sessions moved or were cancelled offers a "Message attendees" link, since registrants aren't told automatically.
- A background job every 5 minutes sends due session reminders and issues certificates (`SESSION_JOBS_ENABLED=false` to turn it off). The church's timezone for "today" defaults to Africa/Johannesburg (`APP_TIMEZONE` to change).
### Changed
- The event wizard has a ninth step, Attendance, for late joining, check-in window, session reminders and certificates.
## [1.32.0] - 2026-10-22 ## [1.32.0] - 2026-10-22
### Added ### Added
+34 -4
View File
@@ -79,14 +79,33 @@ Four roles, each including everything the roles below it can do:
## Events ## Events
Created and edited by supervisors through a 6-step wizard: **Basic Details → Created and edited by supervisors through a 9-step wizard: **Basic Details →
Items & Pricing → Sections → Form → Visibility → Attachments**. Items & Pricing → Sections → Form → Visibility → Notifications → Attachments →
Feedback → Attendance**.
- **Basic details** — title, description, start/end dates, registration - **Basic details** — title, description, start/end dates, registration
deadline, picture (upload or URL), location (defaults to the org's address), deadline, picture (upload or URL), location (defaults to the org's address),
contact person. contact person.
- **Contact-only events** — for things like baptisms: shown publicly with a - **Contact-only events** — for things like baptisms: shown publicly with a
"Contact us" popup instead of registration, with no tickets. "Contact us" popup instead of registration, with no tickets.
- **Multi-day events (sessions)** — one event on several dates, e.g. every
Wednesday 18:30–20:30 for 5 weeks. A repeat pattern (daily/weekly, every N,
chosen weekdays, a number of sessions or an end date) creates the sessions,
which can then be edited one by one (label, times, location), added to, or
cancelled (kept for history; one with check-ins can't be deleted). People
register and pay once and get one ticket. The event page shows the schedule
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.
- **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
R10), or a fixed late price — main tickets only.
- **Completion certificates** — optional per event: four designs (Classic,
Modern, Church, Kids), custom wording with placeholders, signature name/role/
image, and a completion rule (every session, at least N sessions, or N%).
Issued automatically when the event ends; each has a code and QR code checked
on a public verify page.
- **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner, - **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner,
T-shirt), each with a price and optional **stock limit**. One option is marked T-shirt), each with a price and optional **stock limit**. One option is marked
as the main ticket, which is what gets checked in at the door. as the main ticket, which is what gets checked in at the door.
@@ -242,6 +261,15 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
and remaining quantity. and remaining quantity.
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were - **Check-in confirmation** — holders get an email/WhatsApp saying how many were
checked in and how many remain. checked in and how many remain.
- **Per-session check-in** — multi-day tickets are checked in once per session.
The scanner uses today's session (or one picked from a list). Check-in is open
the whole day of a session, or only from 2 hours before it until it ends, per
event. Optional reminders (1 h / 3 h / 1 day before each session) by email
and/or WhatsApp.
- **Attendance register** — attendees × sessions with ticks, totals per session,
completion, filters (completed, missed the last two), Excel/PDF export.
Supervisors can tick someone present or absent (audit-logged), and issue,
send, download or revoke certificates.
- **Scan stats** — recent scans and per-event scan counts. - **Scan stats** — recent scans and per-event scan counts.
- **Offline scanning** — while online, a staff member locks an event section (or the - **Offline scanning** — while online, a staff member locks an event section (or the
whole event) to their device and downloads its tickets. They can then keep scanning whole event) to their device and downloads its tickets. They can then keep scanning
@@ -346,7 +374,7 @@ logout controls. Disconnected sessions are recovered automatically.
- **Dashboard** — KPIs (active events, revenue, donations, registrations, - **Dashboard** — KPIs (active events, revenue, donations, registrations,
tickets sold, each vs. the previous month), revenue trend chart, top events, tickets sold, each vs. the previous month), revenue trend chart, top events,
and recent scans. Tiles link to the matching report. and recent scans. Tiles link to the matching report.
- **Manage events** — the 6-step event wizard, ticket options, variants, - **Manage events** — the 9-step event wizard, ticket options, variants,
sections, form and attachments. sections, form and attachments.
- **At the door** — search by name/phone/ticket ID, walk-in registration, - **At the door** — search by name/phone/ticket ID, walk-in registration,
payments, a **Check-In** tab for redeeming main tickets by quantity, and payments, a **Check-In** tab for redeeming main tickets by quantity, and
@@ -508,6 +536,7 @@ falling back to a general guide.
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) | | Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) | | Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) | | Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) |
| Session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false` to disable) |
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) | | Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) | | Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
| Temp file cleanup (files older than 7 days in `/temp`) | Daily | | Temp file cleanup (files older than 7 days in `/temp`) | Daily |
@@ -543,9 +572,10 @@ falling back to a general guide.
| `/setup` | First run | Setup wizard | | `/setup` | First run | Setup wizard |
| `/consent/[token]` | Parent/guardian | Give or decline parental consent | | `/consent/[token]` | Parent/guardian | Give or decline parental consent |
| `/waitlist/claim/[token]` | Public | Claim a waitlist place | | `/waitlist/claim/[token]` | Public | Claim a waitlist place |
| `/certificates/verify/[code]` | Public | Check a completion certificate |
| `/self-service` | Supervisor-unlocked | Kiosk | | `/self-service` | Supervisor-unlocked | Kiosk |
| `/set-banner` | Supervisor | Site banner | | `/set-banner` | Supervisor | Site banner |
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events | | `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools | | `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools | | `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools | | `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
+48
View File
@@ -21,6 +21,7 @@ Tokens are issued by `POST /api/users/login`.
- [Coupons](#coupons) - [Coupons](#coupons)
- [Parental consent](#parental-consent) - [Parental consent](#parental-consent)
- [Waitlist](#waitlist) - [Waitlist](#waitlist)
- [Multi-day events & attendance](#multi-day-events--attendance)
- [Offline scanning](#offline-scanning) - [Offline scanning](#offline-scanning)
- [Ticket transfer](#ticket-transfer) - [Ticket transfer](#ticket-transfer)
- [Rooms & groups](#rooms--groups) - [Rooms & groups](#rooms--groups)
@@ -422,6 +423,53 @@ How syncing works:
- Check-in messages are sent after the sync. - Check-in messages are sent after the sync.
- Locks, releases, force-releases and syncs are written to the admin audit log. - Locks, releases, force-releases and syncs are written to the admin audit log.
## Multi-day events & attendance
An event with `scheduleType: "sessions"` runs on several dates (e.g. every Wednesday for
5 weeks). People register and pay once and get one ticket; check-in is per session.
`startDate`/`endDate` always follow the first and last active session.
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 |
| `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 }` |
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 }`.
Scanning: `GET /api/tickets/scan-preview/:qr?sessionId=` and `POST /api/tickets/scan/:qr`
(`{ sessionId? }`) default to today's session and return `session` plus `remaining` for it;
`sessionError` / 400 `NO_SESSION` when no session is open for check-in. Offline snapshots
are for the session running when downloaded; synced scans count for the session at their scan time.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/attendance/:eventId` | staff+ | Register: `columns` (sessions), `rows` (one per main ticket: `checkIns` per session, `attended`, `total`, `completed`, `certificate`), `totals` |
| GET | `/api/attendance/:eventId/export?format=xlsx\|pdf` | staff+ | Download the register |
| POST | `/api/attendance/:eventId/mark` | supervisor+ | `{ ticketId, sessionId, present }` — single-date events use `sessionId: "event"` |
| POST | `/api/attendance/:eventId/certificates` | supervisor+ | Issue now to everyone eligible, or `{ ticketId }` for one (even if not eligible) |
| POST | `/api/attendance/:eventId/certificates/send` | supervisor+ | Send unsent certificates, or `{ certificateId }` |
| GET | `/api/attendance/:eventId/certificates.pdf` | supervisor+ | All certificates in one PDF |
| POST | `/api/attendance/certificates/preview` | supervisor+ | Sample PDF: `{ title, config }` |
| POST | `/api/attendance/certificates/:id/revoke` | supervisor+ | Revoke (never re-issued automatically) |
| GET | `/api/attendance/certificates/mine` | user+ | My certificates |
| GET | `/api/attendance/certificates/:id/pdf` | user+ | Download one (its person, the booker, or a supervisor) |
| GET | `/api/attendance/certificates/verify/:code` | public | `{ valid, revoked?, name, event, completedOn, issuedAt }` |
A job every 5 minutes (`SESSION_JOBS_ENABLED`, `SESSION_JOBS_INTERVAL_MS`) sends due session
reminders (each once; when several are due at once only the closest is sent) and issues
certificates for events with certificates on that ended in the last 14 days.
---
## Waitlist ## Waitlist
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines. Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (e.g., Git) # It should be added in your version-control system (i.e. Git)
provider = "postgresql" provider = "postgresql"
@@ -176,13 +176,23 @@ const exportRegister = async (req, res) => {
const totalW = 50; const totalW = 50;
const usable = doc.page.width - 72 - nameW - totalW; const usable = doc.page.width - 72 - nameW - totalW;
const colW = Math.max(28, Math.min(70, usable / Math.max(1, cols.length))); const colW = Math.max(28, Math.min(70, usable / Math.max(1, cols.length)));
// Header height fits the longest session label.
const headText = (c) => `${c.label}\n${fmtColumnDate(c.startsAt)}`;
doc.font('Helvetica-Bold').fontSize(8);
const headH = Math.max(12, ...cols.map(c => doc.heightOfString(headText(c), { width: colW - 2 })));
const header = () => { const header = () => {
const y = doc.y; const y = doc.y;
doc.font('Helvetica-Bold').fontSize(8).fillColor('#1e293b').text('Name', 36, y, { width: nameW }); doc.font('Helvetica-Bold').fontSize(8).fillColor('#1e293b').text('Name', 36, y + headH - 10, { width: nameW });
cols.forEach((c, i) => doc.text(`${c.label}\n${fmtColumnDate(c.startsAt)}`, 36 + nameW + i * colW, y, { width: colW - 2, align: 'center' })); cols.forEach((c, i) => doc.text(headText(c), 36 + nameW + i * colW, y, { width: colW - 2, align: 'center' }));
doc.text('Total', 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center' }); doc.text('Total', 36 + nameW + cols.length * colW, y + headH - 10, { width: totalW, align: 'center' });
doc.y = y + 24; doc.y = y + headH + 6;
doc.moveTo(36, doc.y - 3).lineTo(doc.page.width - 36, doc.y - 3).lineWidth(0.5).strokeColor('#cbd5e1').stroke(); doc.moveTo(36, doc.y - 3).lineTo(doc.page.width - 36, doc.y - 3).lineWidth(0.5).strokeColor('#cbd5e1').stroke();
doc.y += 2;
};
// The built-in PDF fonts have no ✓, so the tick is drawn.
const tick = (cx, cy) => {
doc.save().lineWidth(1.4).strokeColor('#15803d')
.moveTo(cx - 3.5, cy).lineTo(cx - 1, cy + 2.8).lineTo(cx + 4, cy - 3).stroke().restore();
}; };
header(); header();
for (const r of reg.rows) { for (const r of reg.rows) {
@@ -191,7 +201,9 @@ const exportRegister = async (req, res) => {
doc.font('Helvetica').fontSize(8.5).fillColor('#1e293b').text(r.name + (r.quantity > 1 ? ` (×${r.quantity})` : ''), 36, y, { width: nameW, ellipsis: true, lineBreak: false }); doc.font('Helvetica').fontSize(8.5).fillColor('#1e293b').text(r.name + (r.quantity > 1 ? ` (×${r.quantity})` : ''), 36, y, { width: nameW, ellipsis: true, lineBreak: false });
cols.forEach((c, i) => { cols.forEach((c, i) => {
const qty = r.checkIns[c.id] || 0; const qty = r.checkIns[c.id] || 0;
doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? (r.quantity > 1 ? String(qty) : '✓') : '·', 36 + nameW + i * colW, y, { width: colW - 2, align: 'center', lineBreak: false }); const x = 36 + nameW + i * colW;
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 });
}); });
doc.fillColor('#1e293b').text(`${r.attended}/${r.total}`, 36 + nameW + cols.length * colW, y, { width: totalW, 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; doc.y = y + 14;
+3 -3
View File
@@ -9,12 +9,12 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils'); const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions'); const sessionsUtil = require('../utils/sessions');
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' } }; const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
// Adds the multi-day schedule to an event for API responses: sessions (cancelled ones // 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. // flagged), when registration closes, the next session, and late-joiner pricing.
function withSchedule(ev) { function withSchedule(ev) {
const sessions = (ev.sessions || []).map(s => ({ ...s, cancelled: !!s.cancelledAt })); const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({ ...s, cancelled: !!s.cancelledAt, hasCheckIns: (_count?.usages || 0) > 0 }));
const active = sessionsUtil.activeSessions(sessions); const active = sessionsUtil.activeSessions(sessions);
const now = new Date(); const now = new Date();
return { return {
@@ -594,7 +594,7 @@ const getEventIcs = async (req, res) => {
const eventId = req.params.id; const eventId = req.params.id;
const event = await prisma.event.findUnique({ const event = await prisma.event.findUnique({
where: { id: eventId }, where: { id: eventId },
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, sessions: SESSIONS_INCLUDE }, select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, sessions: { orderBy: { startsAt: 'asc' } } },
}); });
if (!event) { if (!event) {
+7 -6
View File
@@ -102,7 +102,7 @@ const getUserTickets = async (req, res) => {
}, },
}, },
user: { select: { id: true, name: true } }, user: { select: { id: true, name: true } },
event: true, event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } }, transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: { usages: {
include: { include: {
@@ -547,7 +547,7 @@ const getTicketsByEvent = async (req, res) => {
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } }, registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } },
attendee: { select: { id: true, name: true, email: true, phone: 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 } }, 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 } } } },
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } } usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@@ -914,7 +914,8 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
// Event title, with the date on the right of the same line // Event title, with the date on the right of the same line
let cy = y + pad; let cy = y + pad;
const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null; const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null;
const dateStr = eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : ''; const dateStr = require('../utils/sessions').ticketDateText(ticket.event)
|| (eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : '');
doc.font('Helvetica').fontSize(9); doc.font('Helvetica').fontSize(9);
const dateW = dateStr ? doc.widthOfString(dateStr) : 0; const dateW = dateStr ? doc.widthOfString(dateStr) : 0;
if (dateStr) doc.fillColor(gray500).text(dateStr, x + w - pad - dateW, cy + 1, { width: dateW, lineBreak: false }); if (dateStr) doc.fillColor(gray500).text(dateStr, x + w - pad - dateW, cy + 1, { width: dateW, lineBreak: false });
@@ -978,7 +979,7 @@ async function emailTicketsInternal(req, res, userOverride) {
} }
const ticketInclude = { const ticketInclude = {
event: true, 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, registration: { select: { ticketDelivery: true } } } },
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } }, attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
user: { select: { name: true } }, user: { select: { name: true } },
@@ -1087,9 +1088,9 @@ async function emailTicketsInternal(req, res, userOverride) {
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); }); await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
const eventTitle = tickets[0].event.title; const eventTitle = tickets[0].event.title;
const eventDate = tickets[0].event.startDate const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long') || (tickets[0].event.startDate
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) ? 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`; const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
const sentChannels = []; const sentChannels = [];
+15 -1
View File
@@ -547,7 +547,7 @@ app.get('/docs', async (req, res) => {
responses:[ responses:[
{ status:201, desc:'Created', body:{ id:'ev-uuid-new', title:'Camp 2025', createdAt:'2024-11-01T09:00:00.000Z' }}, { status:201, desc:'Created', body:{ id:'ev-uuid-new', title:'Camp 2025', createdAt:'2024-11-01T09:00:00.000Z' }},
]}, ]},
{ method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event', { method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event. Multi-day events send scheduleType:"sessions" with the full sessions list (rows left out are deleted; ones with check-ins must be cancelled instead), plus lateJoinEnabled, lateJoinUntilSessionIndex, latePricing, lateFixedPrice, checkInWindow, sessionReminders, certificatesEnabled and certificateConfig. The response includes sessionChanges.',
pathParams:{ ':id':'Event UUID' }, pathParams:{ ':id':'Event UUID' },
request:{ body:{ title:'Camp 2025 Updated', price:500 }}, request:{ body:{ title:'Camp 2025 Updated', price:500 }},
responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]}, responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]},
@@ -740,6 +740,20 @@ app.get('/docs', async (req, res) => {
{ method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} }, { method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} },
{ method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' }, { method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' },
]}, ]},
{ title: 'Attendance & certificates', base: '/api/attendance', endpoints: [
{ method:'GET', path:'/api/attendance/:eventId', auth:'staff+', desc:'Attendance register: sessions (columns), one row per main ticket with check-ins per session, completion and certificate',
responses:[{ status:200, desc:'Register', body:{ event:{ id:'ev-uuid-...', scheduleType:'sessions' }, columns:[{ id:'ses-uuid-...', label:'Week 1', startsAt:'2026-11-04T16:30:00.000Z', cancelled:false }], rows:[{ ticketId:'t-uuid-...', name:'Thandi', checkIns:{ 'ses-uuid-...':1 }, attended:1, total:5, completed:false, certificate:null }], totals:{ 'ses-uuid-...':12 } }}]},
{ method:'GET', path:'/api/attendance/:eventId/export?format=xlsx|pdf', auth:'staff+', desc:'Download the register as Excel or PDF' },
{ method:'POST', path:'/api/attendance/:eventId/mark', auth:'supervisor+', desc:'Mark a ticket present/absent for a session (single-date events use sessionId "event")', request:{ body:{ ticketId:'t-uuid-...', sessionId:'ses-uuid-...', present:true }} },
{ method:'POST', path:'/api/attendance/:eventId/certificates', auth:'supervisor+', desc:'Issue certificates now to everyone eligible, or to one ticket (even if not eligible)', request:{ body:{ ticketId:'t-uuid-... (optional)' }} },
{ method:'POST', path:'/api/attendance/:eventId/certificates/send', auth:'supervisor+', desc:'Email/WhatsApp unsent certificates, or one again', request:{ body:{ certificateId:'(optional)' }} },
{ method:'GET', path:'/api/attendance/:eventId/certificates.pdf', auth:'supervisor+', desc:'All of an event\'s certificates in one PDF' },
{ method:'POST', path:'/api/attendance/certificates/preview', auth:'supervisor+', desc:'Sample certificate PDF with unsaved settings', request:{ body:{ title:'Alpha Course', config:{ template:'classic' } }} },
{ method:'POST', path:'/api/attendance/certificates/:id/revoke', auth:'supervisor+', desc:'Revoke a certificate (never re-issued automatically)' },
{ method:'GET', path:'/api/attendance/certificates/mine', auth:'user+', desc:'My certificates' },
{ method:'GET', path:'/api/attendance/certificates/:id/pdf', auth:'user+', desc:'Download a certificate (its person, the booker, or a supervisor)' },
{ method:'GET', path:'/api/attendance/certificates/verify/:code', auth:'public', desc:'Check a certificate code — name, event and dates only', responses:[{ status:200, desc:'Result', body:{ valid:true, name:'Thandi Mokoena', event:'Alpha Course', completedOn:'2026-12-02T18:30:00.000Z' }}] },
]},
{ title: 'Parental consent', base: '/api/consent', endpoints: [ { title: 'Parental consent', base: '/api/consent', endpoints: [
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)', { method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]}, responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
+21 -1
View File
@@ -279,6 +279,26 @@ function scanSession(event, sessions, requestedId = null, now = new Date()) {
return { session, error: null }; return { session, error: null };
} }
/**
* How a ticket shows an event's date. Multi-day events: "5 sessions · 04 Nov – 02 Dec"
* (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') {
if (event?.scheduleType !== 'sessions') return null;
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)`;
}
const short = { day: '2-digit', month: 'short' };
return active.length === 1
? fmt(active[0].startsAt, { day: '2-digit', month: 'long', year: 'numeric' })
: `${active.length} sessions · ${fmt(active[0].startsAt, short)} – ${fmt(active[active.length - 1].startsAt, short)}`;
}
/** Places already checked in for one session, from a ticket's usages. */ /** Places already checked in for one session, from a ticket's usages. */
function redeemedForSession(usages, sessionId) { function redeemedForSession(usages, sessionId) {
return (usages || []).filter(u => u.sessionId === sessionId).reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0); return (usages || []).filter(u => u.sessionId === sessionId).reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
@@ -314,7 +334,7 @@ async function attendanceBySession(eventId) {
module.exports = { module.exports = {
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError, TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession, dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
sessionLabel, scanSession, redeemedForSession, sessionLabel, scanSession, redeemedForSession, ticketDateText,
registrationClosesAt, lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig, registrationClosesAt, lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession, syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
}; };
@@ -0,0 +1,61 @@
import { Metadata } from "next";
import { BadgeCheck, ShieldX } from "lucide-react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Verify a certificate",
robots: { index: false },
};
type Verification = { valid: boolean; revoked?: boolean; name?: string; event?: string; completedOn?: string; issuedAt?: string };
// Public check for the QR code / code printed on a completion certificate. Shows only the
// name, event and dates — nothing else about the person.
export default async function VerifyCertificatePage({ params }: { params: Promise<{ code: string }> }) {
const { code } = await params;
let result: Verification = { valid: false };
try {
result = await apiFetch<Verification>(`/api/attendance/certificates/verify/${encodeURIComponent(code)}`, { nextOptions: { cache: "no-store" } });
} catch {
result = { valid: false };
}
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-12 px-4 max-w-lg mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm text-center">
{result.valid ? (
<>
<BadgeCheck className="w-12 h-12 text-green-600 mx-auto mb-3" />
<h1 className="text-xl font-semibold text-gray-900">Valid certificate</h1>
<p className="text-gray-700 mt-3"><span className="font-semibold">{result.name}</span> completed</p>
<p className="text-lg font-medium text-brand-700 mt-1">{result.event}</p>
<p className="text-sm text-gray-500 mt-3">
{result.completedOn && <>Completed {formatDate(new Date(result.completedOn))} · </>}
{result.issuedAt && <>Issued {formatDate(new Date(result.issuedAt))}</>}
</p>
</>
) : (
<>
<ShieldX className="w-12 h-12 text-red-500 mx-auto mb-3" />
<h1 className="text-xl font-semibold text-gray-900">{result.revoked ? "This certificate was withdrawn" : "Certificate not found"}</h1>
<p className="text-sm text-gray-500 mt-3">
{result.revoked
? "The organiser has withdrawn this certificate, so it's no longer valid."
: "Check the code on the certificate and try again. Codes look like K7M2Q-9XRTB."}
</p>
</>
)}
<p className="text-xs text-gray-400 mt-6 font-mono">{decodeURIComponent(code).toUpperCase()}</p>
</div>
</main>
<Footer />
</div>
);
}
+2 -1
View File
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling"; import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList, Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
} from "lucide-react"; } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard"; import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile"; import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -30,6 +30,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet }, { href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble }, { href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff }, { href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks }, { href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck }, { href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent }, { href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
+2 -1
View File
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState"; import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling"; import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react"; import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard"; import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile"; import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import type { DashboardStats, RecentScan, ScanStats } from "@/types"; import type { DashboardStats, RecentScan, ScanStats } from "@/types";
@@ -15,6 +15,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode }, { href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket }, { href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users }, { href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session of an event", icon: ClipboardCheck },
] as const; ] as const;
export default function StaffDashboardPage() { export default function StaffDashboardPage() {
@@ -11,6 +11,7 @@ import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
import type { SnapshotTicket } from "@/lib/offlineScan"; import type { SnapshotTicket } from "@/lib/offlineScan";
import { errorMessage } from "@/lib/utils"; import { errorMessage } from "@/lib/utils";
import type { Event, RecentScan, ScanStats, Section } from "@/types"; import type { Event, RecentScan, ScanStats, Section } from "@/types";
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { BackLink } from "@/components/shared/BackLink"; import { BackLink } from "@/components/shared/BackLink";
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline // GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
@@ -30,6 +31,10 @@ type ScanTicket = {
attendee?: { name: string } | null; attendee?: { name: string } | null;
user?: { name?: string; email?: string } | null; user?: { name?: string; email?: string } | null;
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[]; usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
// Multi-day events: the session this scan counts for, and what's left for it.
remaining?: number;
session?: { id: string; label: string } | null;
sessionError?: string | null;
}; };
export default function TicketScanningPage() { export default function TicketScanningPage() {
@@ -73,6 +78,8 @@ export default function TicketScanningPage() {
const [sections, setSections] = useState<Section[]>([]); const [sections, setSections] = useState<Section[]>([]);
const [selectedSectionId, setSelectedSectionId] = useState<string>("all"); const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
const [loadingSections, setLoadingSections] = useState(false); const [loadingSections, setLoadingSections] = useState(false);
// Multi-day events: "auto" = today's session (the server picks it).
const [selectedSessionId, setSelectedSessionId] = useState<string>("auto");
// All events from API (including past); frontend filters to 12h window or all past // All events from API (including past); frontend filters to 12h window or all past
useEffect(() => { useEffect(() => {
@@ -100,6 +107,11 @@ export default function TicketScanningPage() {
}); });
}, [allEvents, includePastEvents]); }, [allEvents, includePastEvents]);
const selectedEvent = useMemo(() => allEvents.find(ev => ev.id === selectedEventId) || null, [allEvents, selectedEventId]);
const eventSessions = useMemo(() => selectedEvent?.scheduleType === "sessions" ? activeSessions(selectedEvent.sessions) : [], [selectedEvent]);
useEffect(() => { setSelectedSessionId("auto"); }, [selectedEventId]);
const sessionParam = selectedEventId !== "all" && selectedSessionId !== "auto" ? selectedSessionId : null;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!token || !selectedEventId || selectedEventId === "all") { if (!token || !selectedEventId || selectedEventId === "all") {
@@ -136,7 +148,7 @@ export default function TicketScanningPage() {
useEffect(() => { useEffect(() => {
if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; } if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; }
scannerRef.current?.stop(); scannerRef.current?.stop();
}, [selectedEventId, selectedSectionId]); }, [selectedEventId, selectedSectionId, selectedSessionId]);
async function handleResult(text: string) { async function handleResult(text: string) {
setError(null); setError(null);
@@ -177,7 +189,7 @@ export default function TicketScanningPage() {
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal) // Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
let preview: ScanTicket; let preview: ScanTicket;
try { try {
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token }); preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}${sessionParam ? `?sessionId=${encodeURIComponent(sessionParam)}` : ""}`, { authToken: token });
} catch (pe) { } catch (pe) {
setErrorModal(errorMessage(pe) || 'Failed to validate ticket'); setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
return; return;
@@ -208,13 +220,19 @@ export default function TicketScanningPage() {
} }
} }
// Compute remaining qty // Multi-day events: no session open for check-in now (wrong day, cancelled…).
if (preview?.sessionError) {
setErrorModal(preview.sessionError);
return;
}
// Compute remaining qty (the server counts it per session for multi-day events)
const ticketQty = preview?.quantity || 1; const ticketQty = preview?.quantity || 1;
const totalRedeemed = (preview?.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0); const totalRedeemed = (preview?.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = ticketQty - totalRedeemed; const remaining = typeof preview?.remaining === "number" ? preview.remaining : ticketQty - totalRedeemed;
if (remaining <= 0) { if (remaining <= 0) {
setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview }); setAlreadyUsedModal({ message: preview?.session ? `Already checked in for ${preview.session.label}` : 'Ticket has already been fully used', ticket: preview });
return; return;
} }
@@ -236,7 +254,7 @@ export default function TicketScanningPage() {
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket"; const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
const variantName = ticket?.registrationOption?.variant?.name; const variantName = ticket?.registrationOption?.variant?.name;
const optionName = variantName ? `${baseName} — ${variantName}` : baseName; const optionName = variantName ? `${baseName} — ${variantName}` : baseName;
const eventTitle = ticket?.event?.title || 'Event'; const eventTitle = `${ticket?.event?.title || 'Event'}${ticket?.session ? ` — ${ticket.session.label}` : ''}`;
const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : ''; const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : '';
const newRemaining = remaining - qty; const newRemaining = remaining - qty;
@@ -276,6 +294,7 @@ export default function TicketScanningPage() {
qty, qty,
deviceId: offline.deviceId(), deviceId: offline.deviceId(),
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}), ...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
...(ticket?.session ? { sessionId: ticket.session.id } : {}),
...(overrideConsent ? { overrideConsent: true } : {}), ...(overrideConsent ? { overrideConsent: true } : {}),
}, },
}); });
@@ -378,6 +397,23 @@ export default function TicketScanningPage() {
</div> </div>
)} )}
{eventSessions.length > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
<label className="text-sm text-gray-700 shrink-0">Session</label>
<select
className="border rounded px-3 py-2 w-full sm:w-72 max-w-full"
value={selectedSessionId}
onChange={(e) => setSelectedSessionId(e.target.value)}
disabled={!!offline.lock}
>
<option value="auto">Today&apos;s session{currentSessionLabel(selectedEvent) ? ` (${currentSessionLabel(selectedEvent)})` : ""}</option>
{eventSessions.map(s => (
<option key={s.id} value={s.id}>{sessionLabel(s, selectedEvent?.sessions)} — {formatSessionWhen(s)}</option>
))}
</select>
</div>
)}
<OfflineScanPanel <OfflineScanPanel
offline={offline} offline={offline}
token={token} token={token}
@@ -467,6 +503,7 @@ export default function TicketScanningPage() {
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm"> <div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
<div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}</div> <div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}</div>
<div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div> <div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div>
{confirmModal.ticket?.session && <div><span className="font-medium">Session:</span> {confirmModal.ticket.session.label}</div>}
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.attendee?.name || confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div> <div><span className="font-medium">Holder:</span> {confirmModal.ticket?.attendee?.name || confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
{confirmModal.ticket?.attendee?.name && confirmModal.ticket?.user?.name && ( {confirmModal.ticket?.attendee?.name && confirmModal.ticket?.user?.name && (
<div className="text-xs text-gray-500">Booked by {confirmModal.ticket.user.name}</div> <div className="text-xs text-gray-500">Booked by {confirmModal.ticket.user.name}</div>
@@ -0,0 +1,291 @@
"use client";
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ClipboardCheck, Check, Download, FileSpreadsheet, FileText, Send, Award, Search, Ban } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { formatDate } from "@/lib/date";
import { fetchApiFile, saveBlob } from "@/lib/export";
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
const colDate = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" });
function AttendancePageInner() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const canView = ["admin", "supervisor", "staff"].includes(user?.role || "");
const canManage = user?.role === "admin" || user?.role === "supervisor";
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState(search.get("eventId") || "");
const [reg, setReg] = useState<AttendanceRegister | null>(null);
const [busy, setBusy] = useState(false);
const [pending, setPending] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [showCancelled, setShowCancelled] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
.then(evs => {
// Multi-day events first (that's what the register is mostly for), newest first.
const list = (evs || []).filter(e => e.requiresRegistration !== false)
.sort((a, b) => Number(b.scheduleType === "sessions") - Number(a.scheduleType === "sessions") || new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
setEvents(list);
if (!eventId && list.length) setEventId(list[0].id);
})
.catch(e => setError(errorMessage(e) || "Failed to load events"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, canView]);
const load = useCallback(async () => {
if (!token || !eventId) return;
setBusy(true);
try {
setReg(await apiFetch<AttendanceRegister>(`/api/attendance/${encodeURIComponent(eventId)}`, { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Failed to load the register");
} finally {
setBusy(false);
}
}, [token, eventId, setError]);
useEffect(() => { load(); }, [load]);
const columns = useMemo(() => (reg?.columns || []).filter(c => showCancelled || !c.cancelled), [reg, showCancelled]);
const multi = reg?.event.scheduleType === "sessions";
// Sessions already held (for "missed the last two").
const heldIds = useMemo(() => (reg?.columns || []).filter(c => !c.cancelled && new Date(c.startsAt).getTime() < Date.now()).map(c => c.id), [reg]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return (reg?.rows || []).filter(r => {
if (q && ![r.name, r.bookedBy, r.email, r.phone].some(v => v?.toLowerCase().includes(q))) return false;
if (filter === "completed") return r.completed;
if (filter === "not_completed") return !r.completed;
if (filter === "missed_recent") {
const lastTwo = heldIds.slice(-2);
return lastTwo.length > 0 && lastTwo.every(id => !r.checkIns[id]);
}
return true;
});
}, [reg, query, filter, heldIds]);
const toggle = async (row: AttendanceRow, sessionId: string) => {
if (!canManage || !token) return;
const present = !row.checkIns[sessionId];
const key = `${row.ticketId}:${sessionId}`;
setPending(key);
try {
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId: row.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();
} catch (e) {
setError(errorMessage(e) || "Couldn't update attendance");
} finally {
setPending(null);
}
};
const post = async (path: string, body: object, done: (r: Record<string, number>) => string) => {
if (!token) return;
setBusy(true);
try {
const r = await apiFetch<Record<string, number>>(path, { method: "POST", authToken: token, body });
setInfo(done(r));
await load();
} catch (e) {
setError(errorMessage(e) || "Something went wrong");
} finally {
setBusy(false);
}
};
const download = async (path: string, filename: string) => {
if (!token) return;
try {
saveBlob(await fetchApiFile(API_BASE, token, path), filename);
} catch (e) {
setError(errorMessage(e) || "Download failed");
}
};
const safeTitle = (reg?.event.title || "event").replace(/[^a-z0-9 -]/gi, "").slice(0, 60);
const certsOn = !!reg?.event.certificatesEnabled;
const issued = (reg?.rows || []).filter(r => r.certificate && !r.certificate.revokedAt).length;
const unsent = (reg?.rows || []).filter(r => r.certificate && !r.certificate.revokedAt && !r.certificate.emailedAt).length;
const completed = (reg?.rows || []).filter(r => r.completed).length;
return (
<div className="max-w-7xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<ClipboardCheck className="w-5 h-5 text-brand-600" />
</div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Attendance</h1>
<p className="text-sm text-gray-500">Who came to each session, and completion certificates</p>
</div>
</div>
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need staff access to view attendance.</div>}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
{canView && (
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem] max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">No events</option>}
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))}{ev.scheduleType === "sessions" ? ` · ${(ev.sessions || []).filter(s => !s.cancelled).length} sessions` : ""})</option>
))}
</select>
</div>
<div className="relative">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input className="border rounded-lg pl-8 pr-3 py-2 text-sm w-56" placeholder="Search name, email, phone" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<select className="border rounded-lg px-3 py-2 text-sm" value={filter} onChange={e => setFilter(e.target.value as Filter)}>
<option value="all">Everyone</option>
<option value="completed">Completed</option>
<option value="not_completed">Not completed</option>
{multi && <option value="missed_recent">Missed the last two sessions</option>}
</select>
{multi && (reg?.columns || []).some(c => c.cancelled) && (
<label className="flex items-center gap-1.5 text-sm text-gray-600">
<input type="checkbox" checked={showCancelled} onChange={e => setShowCancelled(e.target.checked)} /> Show cancelled sessions
</label>
)}
<div className="ml-auto flex flex-wrap gap-2">
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=xlsx`, `Attendance - ${safeTitle}.xlsx`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileSpreadsheet className="w-4 h-4" /> Excel</button>
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=pdf`, `Attendance - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileText className="w-4 h-4" /> PDF</button>
</div>
</div>
{reg && (
<div className="flex flex-wrap gap-2 text-xs">
<span className="px-2 py-1 rounded bg-gray-100 text-gray-700">{reg.rows.length} attendee{reg.rows.length === 1 ? "" : "s"}</span>
<span className="px-2 py-1 rounded bg-green-50 text-green-800">{completed} completed</span>
{certsOn && <span className="px-2 py-1 rounded bg-brand-50 text-brand-800">{issued} certificate{issued === 1 ? "" : "s"} issued</span>}
{!multi && <span className="px-2 py-1 rounded bg-gray-50 text-gray-500">Single-date event — one check-in column</span>}
</div>
)}
{reg && certsOn && canManage && (
<div className="flex flex-wrap items-center gap-2 p-3 rounded-lg bg-brand-50/50 border border-brand-100">
<Award className="w-4 h-4 text-brand-600" />
<span className="text-sm text-gray-700 mr-auto">Certificates are issued automatically when the event ends. You can also:</span>
<button type="button" disabled={busy} onClick={() => post(`/api/attendance/${eventId}/certificates`, {}, r => `${r.issued} certificate${r.issued === 1 ? "" : "s"} issued.`)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Issue now</button>
<button type="button" disabled={busy || unsent === 0} onClick={() => post(`/api/attendance/${eventId}/certificates/send`, {}, r => `${r.sent} certificate${r.sent === 1 ? "" : "s"} sent${r.skipped ? `, ${r.skipped} couldn't be sent` : ""}.`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send unsent ({unsent})</button>
<button type="button" disabled={issued === 0} onClick={() => download(`/api/attendance/${eventId}/certificates.pdf`, `Certificates - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Download className="w-3.5 h-3.5" /> Download all</button>
</div>
)}
{busy && !reg && <p className="text-sm text-gray-500">Loading…</p>}
{reg && rows.length === 0 && <p className="text-sm text-gray-500 py-6 text-center">{reg.rows.length === 0 ? "No tickets for this event yet." : "Nobody matches."}</p>}
{reg && rows.length > 0 && (
<div className="overflow-x-auto border rounded-lg">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 text-xs text-gray-600">
<tr>
<th className="sticky left-0 z-10 bg-gray-50 text-left font-medium px-3 py-2 min-w-[12rem]">Name</th>
{columns.map(c => (
<th key={c.id} className={`font-medium px-2 py-2 text-center whitespace-nowrap ${c.cancelled ? "text-gray-400 line-through" : ""}`}>
<div>{c.label}</div>
<div className="text-[10px] font-normal text-gray-400">{multi ? colDate.format(new Date(c.startsAt)) : ""}</div>
</th>
))}
<th className="font-medium px-2 py-2 text-center">Total</th>
{certsOn && <th className="font-medium px-3 py-2 text-left">Certificate</th>}
</tr>
</thead>
<tbody className="divide-y">
{rows.map(r => (
<tr key={r.ticketId} className="hover:bg-gray-50/60">
<td className="sticky left-0 z-10 bg-white px-3 py-2">
<div className="font-medium text-gray-900">{r.name || "—"}{r.quantity > 1 && <span className="ml-1 text-xs text-gray-500">×{r.quantity}</span>}</div>
<div className="text-[11px] text-gray-500">{r.variant ? `${r.option} — ${r.variant}` : r.option}{r.bookedBy ? ` · booked by ${r.bookedBy}` : ""}</div>
</td>
{columns.map(c => {
const qty = r.checkIns[c.id] || 0;
const key = `${r.ticketId}:${c.id}`;
const disabled = !canManage || c.cancelled || pending !== null || reg.event.cashupStatus === "closed";
return (
<td key={c.id} className="px-2 py-2 text-center">
<button type="button" disabled={disabled} onClick={() => toggle(r, c.id)}
title={canManage ? (qty ? "Present — click to mark absent" : "Absent — click to mark present") : undefined}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border text-xs ${qty ? "bg-green-50 border-green-300 text-green-700" : "bg-white border-gray-200 text-gray-300"} ${disabled ? "cursor-default" : "hover:border-brand-400"} ${pending === key ? "opacity-50" : ""}`}>
{qty ? (r.quantity > 1 ? qty : <Check className="w-4 h-4" />) : "·"}
</button>
</td>
);
})}
<td className="px-2 py-2 text-center">
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${r.completed ? "bg-green-50 text-green-800" : "text-gray-600"}`}>{r.attended}/{r.total}</span>
</td>
{certsOn && (
<td className="px-3 py-2 whitespace-nowrap">
{r.certificate && !r.certificate.revokedAt ? (
<div className="flex items-center gap-2 text-xs">
<span className="font-mono text-gray-700">{r.certificate.code}</span>
<button type="button" className="text-brand-700 hover:underline" onClick={() => download(`/api/attendance/certificates/${r.certificate!.id}/pdf`, `Certificate - ${r.name}.pdf`)}>PDF</button>
{canManage && <button type="button" className="text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates/send`, { certificateId: r.certificate!.id }, x => (x.sent ? `Certificate sent to ${r.name}.` : `Couldn't send — ${r.name} has no email or WhatsApp on file.`))}>{r.certificate.emailedAt ? "Resend" : "Send"}</button>}
{canManage && <button type="button" className="text-red-600 hover:underline inline-flex items-center gap-0.5" onClick={() => post(`/api/attendance/certificates/${r.certificate!.id}/revoke`, {}, () => `Certificate for ${r.name} revoked.`)}><Ban className="w-3 h-3" />Revoke</button>}
</div>
) : canManage ? (
<button type="button" className="text-xs text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates`, { ticketId: r.ticketId }, () => `Certificate issued to ${r.name}.`)}>
{r.certificate?.revokedAt ? "Re-issue" : r.completed ? "Issue" : "Issue anyway"}
</button>
) : <span className="text-xs text-gray-400">—</span>}
</td>
)}
</tr>
))}
</tbody>
<tfoot className="bg-gray-50 text-xs text-gray-600">
<tr>
<td className="sticky left-0 bg-gray-50 px-3 py-2 font-medium">Present</td>
{columns.map(c => <td key={c.id} className="px-2 py-2 text-center font-medium">{c.cancelled ? "—" : reg.totals[c.id] || 0}</td>)}
<td />
{certsOn && <td />}
</tr>
</tfoot>
</table>
</div>
)}
{reg && canManage && (
<p className="text-[11px] text-gray-400">Click a box to mark someone present or absent (e.g. they were let in without a scan). Changes are recorded in the audit log.</p>
)}
</div>
)}
</div>
);
}
export default function AttendancePage() {
return (
<Suspense fallback={null}>
<AttendancePageInner />
</Suspense>
);
}
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch, API_BASE } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage"; import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState"; import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
@@ -13,6 +13,12 @@ import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils"; import { errorMessage } from "@/lib/utils";
import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types"; import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types";
import { BackLink } from "@/components/shared/BackLink"; import { BackLink } from "@/components/shared/BackLink";
import {
SessionsEditor, AttendanceSettings, keyed, sortSessions, sessionsProblem, defaultCertificate,
type KeyedSession, type AttendanceDraft,
} from "@/components/events/ScheduleEditor";
import { toSessionDraft, scheduleSummary } from "@/lib/sessions";
import { fetchApiFile, openBlob } from "@/lib/export";
// ─── helpers ──────────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────────
@@ -803,8 +809,8 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
// ─── steps ─────────────────────────────────────────────────────────────────── // ─── steps ───────────────────────────────────────────────────────────────────
const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments", "Feedback"] as const; const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments", "Feedback", "Attendance"] as const;
type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
const PRICING_SUBSTEPS = ["Options", "Variants", "Early Birds"] as const; const PRICING_SUBSTEPS = ["Options", "Variants", "Early Birds"] as const;
type PricingSubstep = 0 | 1 | 2; type PricingSubstep = 0 | 1 | 2;
@@ -819,6 +825,7 @@ interface EventDraft {
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean; waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
allowTicketTransfer: boolean; collectGender: boolean; allowTicketTransfer: boolean; collectGender: boolean;
allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean; allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean;
scheduleType: "single" | "sessions";
} }
const blankDraft = (location = ""): EventDraft => ({ const blankDraft = (location = ""): EventDraft => ({
@@ -832,8 +839,12 @@ const blankDraft = (location = ""): EventDraft => ({
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true, waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
allowTicketTransfer: true, collectGender: false, allowTicketTransfer: true, collectGender: false,
allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true, allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true,
scheduleType: "single",
}); });
// "YYYY-MM-DDTHH:MM" (local) → ISO
const isoOf = (local: string) => new Date(local).toISOString();
const blankOptions = (): OptionDraft[] => [ const blankOptions = (): OptionDraft[] => [
{ name: "Ticket", price: "", isMainTicket: true, stockLimit: "0", earlyBirdTiers: [], variants: [] } { name: "Ticket", price: "", isMainTicket: true, stockLimit: "0", earlyBirdTiers: [], variants: [] }
]; ];
@@ -874,8 +885,35 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false, allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "", contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "", location: ev.location || "",
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
} : blankDraft(settings.org_address || "")); } : blankDraft(settings.org_address || ""));
// ── multi-day schedule + attendance settings ──
const [sessions, setSessions] = useState<KeyedSession[]>(() => (ev?.sessions || []).map(s => keyed(toSessionDraft(s))));
const [attendance, setAttendance] = useState<AttendanceDraft>(() => ({
lateJoinEnabled: !!ev?.lateJoinEnabled,
lateJoinUntilKey: ev?.lateJoinUntilSessionId || "",
latePricing: ev?.latePricing || "full",
lateFixedPrice: ev?.lateFixedPrice !== null && ev?.lateFixedPrice !== undefined ? String(ev.lateFixedPrice) : "",
checkInWindow: ev?.checkInWindow || "whole_day",
remindersEnabled: !!ev?.sessionReminders,
reminderOffsets: ev?.sessionReminders?.offsets || [180],
reminderChannels: ev?.sessionReminders?.channels || ["email", "whatsapp"],
certificatesEnabled: !!ev?.certificatesEnabled,
certificate: { ...defaultCertificate(), ...(ev?.certificateConfig || {}) },
}));
const updAttendance = (patch: Partial<AttendanceDraft>) => setAttendance(a => ({ ...a, ...patch }));
const multi = draft.scheduleType === "sessions";
// 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);
if (!active.length) return null;
const end = active.reduce((m, s) => (s.endsAt > m ? s.endsAt : m), active[0].endsAt);
return { start: active[0].startsAt, end };
}, [sessions]);
// Shown after saving when sessions moved or were cancelled, to offer telling registrants.
const [changedNotice, setChangedNotice] = useState<{ eventId: string; moved: number; cancelled: number } | null>(null);
// ── options (with per-variant tiers) ── // ── options (with per-variant tiers) ──
const [options, setOptions] = useState<OptionDraft[]>(() => { const [options, setOptions] = useState<OptionDraft[]>(() => {
if (!ev?.eventOptions?.length) return blankOptions(); if (!ev?.eventOptions?.length) return blankOptions();
@@ -951,8 +989,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{ type: "text", label: "What could we do better?", isRequired: false, helpText: "" }, { type: "text", label: "What could we do better?", isRequired: false, helpText: "" },
] }); ] });
} }
if (on && !fbSettings.sendAt && draft.endDate) { const endForFeedback = multi ? scheduleBounds?.end : draft.endDate;
const next = new Date(draft.endDate); if (on && !fbSettings.sendAt && endForFeedback) {
const next = new Date(endForFeedback);
next.setDate(next.getDate() + 1); next.setDate(next.getDate() + 1);
next.setHours(9, 0, 0, 0); next.setHours(9, 0, 0, 0);
setFbSettings(s => ({ ...s, sendAt: toLocalDT(next) })); setFbSettings(s => ({ ...s, sendAt: toLocalDT(next) }));
@@ -1176,16 +1215,35 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const handleSave = async () => { const handleSave = async () => {
if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; } if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; }
if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; } if (multi) {
const problem = sessionsProblem(sessions);
if (problem) { setError(problem); setStep(0); return; }
if (attendance.lateJoinEnabled && !attendance.lateJoinUntilKey) { setError("Choose until which session late joining is allowed"); setStep(8); return; }
if (attendance.lateJoinEnabled && attendance.latePricing === "fixed" && attendance.lateFixedPrice.trim() === "") { setError("Enter the fixed late price"); setStep(8); return; }
} else if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; }
if (mode === "create" && draft.requiresRegistration) { if (mode === "create" && draft.requiresRegistration) {
if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; } if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; }
if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; } if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; }
} }
setSaving(true); setError(null); setSaving(true); setError(null);
try { try {
const sortedSessions = sortSessions(sessions);
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
title: draft.title.trim(), description: draft.description || undefined, title: draft.title.trim(), description: draft.description || undefined,
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(), startDate: multi && scheduleBounds ? isoOf(scheduleBounds.start) : new Date(draft.startDate).toISOString(),
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 })),
lateJoinEnabled: attendance.lateJoinEnabled,
lateJoinUntilSessionIndex: attendance.lateJoinEnabled ? sortedSessions.findIndex(s => s.key === attendance.lateJoinUntilKey) : -1,
latePricing: attendance.latePricing,
lateFixedPrice: attendance.latePricing === "fixed" ? attendance.lateFixedPrice : null,
checkInWindow: attendance.checkInWindow,
sessionReminders: attendance.remindersEnabled ? { offsets: attendance.reminderOffsets, channels: attendance.reminderChannels } : null,
} : {}),
certificatesEnabled: attendance.certificatesEnabled,
certificateConfig: attendance.certificate,
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined, registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined, goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined,
price: draft.requiresRegistration ? (draft.price ? parseFloat(draft.price) : 0) : 0, price: draft.requiresRegistration ? (draft.price ? parseFloat(draft.price) : 0) : 0,
@@ -1212,13 +1270,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
location: draft.location.trim(), location: draft.location.trim(),
}; };
let notice: { eventId: string; moved: number; cancelled: number } | null = null;
if (mode === "edit") { if (mode === "edit") {
body.isActive = draft.isActive; body.isActive = draft.isActive;
if (!ev) throw new Error("No event loaded to save"); if (!ev) throw new Error("No event loaded to save");
await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body }); const saved = await apiFetch<Event & { sessionChanges?: { moved: number; cancelled: number } | null }>(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
await saveOptions(ev.id); await saveOptions(ev.id);
// Form is saved inside saveOptions for edit mode; avoid double call // Form is saved inside saveOptions for edit mode; avoid double call
await saveFeedback(ev.id); await saveFeedback(ev.id);
const ch = saved?.sessionChanges;
if (ch && (ch.moved > 0 || ch.cancelled > 0)) notice = { eventId: ev.id, moved: ch.moved, cancelled: ch.cancelled };
} else { } else {
if (formDef.isRequired || formDef.fields.length > 0) { if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) }; body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
@@ -1247,7 +1308,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
} }
} }
onSuccess(); onClose(); onSuccess();
if (notice) setChangedNotice(notice);
else onClose();
} catch (e) { } catch (e) {
setError(errorMessage(e) || "Failed to save event"); setError(errorMessage(e) || "Failed to save event");
} finally { } finally {
@@ -1282,7 +1345,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const isOnLastSubstep = step !== 1 || pricingSubstep === 2; const isOnLastSubstep = step !== 1 || pricingSubstep === 2;
// Basic Details step requires title, start/end dates, and a base price before moving on // Basic Details step requires title, start/end dates, and a base price before moving on
// (mirrors handleSave's own checks). Base price is only compulsory when creating a new event. // (mirrors handleSave's own checks). Base price is only compulsory when creating a new event.
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price))); const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || (multi ? !!sessionsProblem(sessions) : (!draft.startDate || !draft.endDate)) || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price)));
// Items & Pricing: every option needs a valid price before leaving the step (checked across // Items & Pricing: every option needs a valid price before leaving the step (checked across
// all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate). // all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate).
// Not applicable to contact-only events, which have no ticket options at all. // Not applicable to contact-only events, which have no ticket options at all.
@@ -1339,6 +1402,18 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
</div> </div>
)} )}
{error && <div className="p-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>} {error && <div className="p-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{changedNotice && (
<div className="p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm space-y-2">
<p>
Saved. You {[changedNotice.moved ? `moved ${changedNotice.moved} session${changedNotice.moved === 1 ? "" : "s"}` : "", changedNotice.cancelled ? `cancelled ${changedNotice.cancelled}` : ""].filter(Boolean).join(" and ")}.
Tickets and calendar files show the new schedule, but people who registered aren&apos;t told automatically.
</p>
<div className="flex gap-2">
<a href={`/dashboard/supervisor/email-attendees?eventId=${changedNotice.eventId}`} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700">Message attendees</a>
<button type="button" onClick={onClose} className="px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50">Done</button>
</div>
</div>
)}
{/* ── Step 0: Basic Details ── */} {/* ── Step 0: Basic Details ── */}
{step === 0 && ( {step === 0 && (
@@ -1351,10 +1426,26 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<label className="block text-xs text-gray-600 mb-1">Description</label> <label className="block text-xs text-gray-600 mb-1">Description</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={3} value={draft.description} onChange={e => upd({ description: e.target.value })} /> <textarea className="w-full border rounded px-3 py-2 text-sm" rows={3} value={draft.description} onChange={e => upd({ description: e.target.value })} />
</div> </div>
<div>
<div className="block text-xs text-gray-600 mb-1">When <span className="text-red-500">*</span></div>
<div className="inline-flex rounded-lg border p-0.5 bg-gray-50">
{([["single", "One date"], ["sessions", "Multiple sessions"]] as const).map(([v, label]) => (
<button key={v} type="button" onClick={() => upd({ scheduleType: v })}
className={`px-3 py-1.5 text-xs rounded-md ${draft.scheduleType === v ? "bg-white shadow-sm text-brand-700 font-medium" : "text-gray-600 hover:text-gray-900"}`}>{label}</button>
))}
</div>
{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} />
) : (
<div className="grid sm:grid-cols-2 gap-3">
<DTInput label="Start" required value={draft.startDate} onChange={v => upd({ startDate: v })} />
<DTInput label="End" required value={draft.endDate} onChange={v => upd({ endDate: v })} />
</div>
)}
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
<DTInput label="Start" required value={draft.startDate} onChange={v => upd({ startDate: v })} /> <DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} hint={multi ? "Registration closes when the first session starts unless late joining is on (Attendance step)" : undefined} />
<DTInput label="End" required value={draft.endDate} onChange={v => upd({ endDate: v })} />
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" /> <DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div> </div>
<div> <div>
@@ -1431,6 +1522,22 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
</div> </div>
)} )}
{/* ── Step 8: Attendance (late joining, check-in, reminders, certificates) ── */}
{step === 8 && (
<AttendanceSettings
value={attendance}
onChange={updAttendance}
multi={multi}
sessions={sessions}
mainPrice={parseFloat(options.find(o => o.isMainTicket)?.price || draft.price || "0") || 0}
uploadButton={(onUploaded) => <UploadImageButton onUploaded={onUploaded} label="Upload" />}
onPreview={async () => {
if (!token) return;
openBlob(await fetchApiFile(API_BASE, token, "/api/attendance/certificates/preview", { method: "POST", body: { title: draft.title || "Your event", config: attendance.certificate } }));
}}
/>
)}
{/* ── Step 2: Sections ── */} {/* ── Step 2: Sections ── */}
{step === 2 && ( {step === 2 && (
<div> <div>
@@ -1714,7 +1821,7 @@ function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
{isClosed && <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700">Closed</span>} {isClosed && <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700">Closed</span>}
</div> </div>
<div className="text-xs text-gray-500 mt-0.5"> <div className="text-xs text-gray-500 mt-0.5">
{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""} {scheduleSummary(ev) || <>{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}</>}
{ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""} {ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""}
</div> </div>
</div> </div>
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling"; import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet, Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
} from "lucide-react"; } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard"; import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile"; import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -27,6 +27,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet }, { href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble }, { href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff }, { href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks }, { href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck }, { href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent }, { href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
+3
View File
@@ -16,6 +16,7 @@ import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
import { errorMessage } from "@/lib/utils"; import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing"; import { registrationOptionUnitPrice } from "@/lib/pricing";
import { BundleSavings } from "@/components/shared/BundleDeals"; import { BundleSavings } from "@/components/shared/BundleDeals";
import { MyCertificates } from "@/components/events/MyCertificates";
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types"; import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
// Helper formatters // Helper formatters
@@ -774,6 +775,8 @@ export default function UserDashboardPage() {
</div> </div>
)} )}
<MyCertificates token={token} />
<div className="grid lg:grid-cols-2 gap-6"> <div className="grid lg:grid-cols-2 gap-6">
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm"> <section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
+25 -8
View File
@@ -47,13 +47,19 @@ type Event = {
waitlistEnabled?: boolean; waitlistEnabled?: boolean;
allowSponsorships?: boolean; allowSponsorships?: boolean;
bundles?: PublicBundle[]; bundles?: PublicBundle[];
scheduleType?: "single" | "sessions";
sessions?: EventSession[];
registrationClosesAt?: string;
lateJoin?: LateJoinInfo | null;
}; };
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api"; import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage"; import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date"; import { formatDateTimeRange } from "@/lib/date";
import { BundleOffers } from "@/components/shared/BundleDeals"; import { BundleOffers } from "@/components/shared/BundleDeals";
import type { PublicBundle } from "@/types"; import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
import { latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
import { SessionSchedule } from "@/components/events/SessionSchedule";
import { appUrl, appName } from "@/lib/siteConfig"; import { appUrl, appName } from "@/lib/siteConfig";
import { getServerSettings } from "@/lib/serverSettings"; import { getServerSettings } from "@/lib/serverSettings";
import { eventJsonLd, jsonLdString } from "@/lib/eventSchema"; import { eventJsonLd, jsonLdString } from "@/lib/eventSchema";
@@ -82,11 +88,10 @@ function RegisterCta({ event }: { event: Event }) {
); );
} }
const now = new Date(); const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null; const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
const notYetOpen = goLive ? now < goLive : false; const notYetOpen = goLive ? now < goLive : false;
const closed = (deadline ? now >= deadline : false) || now >= end; // Multi-day events close when the first session starts (or later with late joining).
const closed = now >= registrationClosesAt(event);
const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0); const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0);
const eventSoldOut = limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0); const eventSoldOut = limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
@@ -203,7 +208,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<h1 className="text-3xl font-bold text-gray-900">{event.title}</h1> <h1 className="text-3xl font-bold text-gray-900">{event.title}</h1>
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-2"> <p className="text-sm text-gray-500 flex items-center gap-1.5 mt-2">
<Calendar className="w-4 h-4 shrink-0" /> <Calendar className="w-4 h-4 shrink-0" />
{formatDateTimeRange(event.startDate, event.endDate)} {scheduleSummary(event) || formatDateTimeRange(event.startDate, event.endDate)}
</p> </p>
<div className="mt-3"> <div className="mt-3">
<ClientActions event={event} /> <ClientActions event={event} />
@@ -212,6 +217,10 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<p className="text-gray-700 whitespace-pre-line">{event.description}</p> <p className="text-gray-700 whitespace-pre-line">{event.description}</p>
{event.scheduleType === "sessions" && (event.sessions || []).length > 0 && (
<SessionSchedule sessions={event.sessions || []} defaultLocation={event.location} />
)}
{event.location && ( {event.location && (
<div className="border rounded-xl p-5 bg-white shadow-sm"> <div className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-base font-semibold text-gray-900 mb-3">Location</h2> <h2 className="text-base font-semibold text-gray-900 mb-3">Location</h2>
@@ -281,16 +290,24 @@ function TicketList({ event }: { event: Event }) {
</div> </div>
); );
} }
// 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);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<BundleOffers bundles={event.bundles} /> <BundleOffers bundles={event.bundles} />
{late && (
<p className="text-xs text-brand-800 bg-brand-50 border border-brand-200 rounded-lg px-3 py-2">
Joining late? {late.sessionsLeft} of {late.totalSessions} sessions are still to come, so the ticket price below is {late.mode === "prorata" ? "for the sessions left" : "the late-joiner price"}.
</p>
)}
{options.map((opt) => { {options.map((opt) => {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0; const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0; const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0); const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0);
if (hasVariants) { if (hasVariants) {
const prices = opt.variants!.map((v) => v.price !== null && v.price !== undefined ? v.price : opt.price); const prices = opt.variants!.map((v) => forLate(opt, v.price !== null && v.price !== undefined ? v.price : opt.price));
const minPrice = Math.min(...prices); const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices); const maxPrice = Math.max(...prices);
const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free – R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`; const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free – R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
@@ -306,7 +323,7 @@ function TicketList({ event }: { event: Event }) {
</div> </div>
<ul className="divide-y"> <ul className="divide-y">
{opt.variants!.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((v) => { {opt.variants!.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((v) => {
const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price; const vPrice = forLate(opt, v.price !== null && v.price !== undefined ? v.price : opt.price);
const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0; const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0;
const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0); const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0);
return ( return (
@@ -330,7 +347,7 @@ function TicketList({ event }: { event: Event }) {
const activeTiers = (opt.earlyBirdTiers || []) const activeTiers = (opt.earlyBirdTiers || [])
.filter((t) => now < new Date(t.deadline)) .filter((t) => now < new Date(t.deadline))
.sort((a, b) => a.price - b.price); .sort((a, b) => a.price - b.price);
const displayPrice = activeTiers.length > 0 ? activeTiers[0].price : opt.price; const displayPrice = forLate(opt, activeTiers.length > 0 ? activeTiers[0].price : opt.price);
return ( return (
<div key={opt.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm text-gray-700"> <div key={opt.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm text-gray-700">
+3 -1
View File
@@ -20,6 +20,8 @@ type Event = {
startDate: string; startDate: string;
endDate: string; endDate: string;
registrationDeadline?: string | null; registrationDeadline?: string | null;
// Multi-day events: listed by their next session, not their first.
nextSession?: { startsAt: string } | null;
goLiveAt?: string; goLiveAt?: string;
price: number; price: number;
picture?: string; picture?: string;
@@ -35,7 +37,7 @@ export default async function EventsPage() {
const t = new Date(e.endDate || e.startDate).getTime(); const t = new Date(e.endDate || e.startDate).getTime();
return !isNaN(t) && t > now; return !isNaN(t) && t > now;
}); });
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()); const sorted = [...upcoming].sort((a, b) => new Date(a.nextSession?.startsAt || a.startDate).getTime() - new Date(b.nextSession?.startsAt || b.startDate).getTime());
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
+3 -1
View File
@@ -19,6 +19,8 @@ type Event = {
startDate: string; startDate: string;
endDate: string; endDate: string;
registrationDeadline?: string | null; registrationDeadline?: string | null;
// Multi-day events: listed by their next session, not their first.
nextSession?: { startsAt: string } | null;
goLiveAt?: string; goLiveAt?: string;
price: number; price: number;
picture?: string; picture?: string;
@@ -55,7 +57,7 @@ export default async function HomePage() {
const t = new Date(e.endDate || e.startDate).getTime(); const t = new Date(e.endDate || e.startDate).getTime();
return !isNaN(t) && t > now; return !isNaN(t) && t > now;
}); });
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()); const sorted = [...upcoming].sort((a, b) => new Date(a.nextSession?.startsAt || a.startDate).getTime() - new Date(b.nextSession?.startsAt || b.startDate).getTime());
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
@@ -5,7 +5,8 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField"; import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals"; import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import type { PublicBundle } from "@/types"; import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
import { latePrice, registrationClosesAt } from "@/lib/sessions";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor"; import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton"; import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
import { analyticsSessionId } from "@/lib/analytics"; import { analyticsSessionId } from "@/lib/analytics";
@@ -52,8 +53,28 @@ export type RegisterEvent = {
collectGender?: boolean; collectGender?: boolean;
waitlistEnabled?: boolean; waitlistEnabled?: boolean;
bundles?: PublicBundle[]; bundles?: PublicBundle[];
scheduleType?: "single" | "sessions";
sessions?: EventSession[];
registrationClosesAt?: string;
lateJoin?: LateJoinInfo | null;
}; };
// Late joiners on a multi-day event: show main tickets at the late price (the server
// charges the same — see backend utils/sessions.js).
function withLatePrices(event: RegisterEvent): RegisterEvent {
const late = event.lateJoin?.active ? event.lateJoin : null;
if (!late) return event;
return {
...event,
eventOptions: (event.eventOptions || []).map(o => (!o.isMainTicket ? o : {
...o,
price: latePrice(o.price, late),
earlyBirdTiers: (o.earlyBirdTiers || []).map(t => ({ ...t, price: latePrice(t.price, late) })),
variants: (o.variants || []).map(v => (v.price === null || v.price === undefined ? v : { ...v, price: latePrice(v.price, late) })),
})),
};
}
type WaitlistOffer = { eventOptionId: string; variantId: string | null; quantity: number; expiresAt: string; optionName: string; variantName: string | null }; type WaitlistOffer = { eventOptionId: string; variantId: string | null; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options // Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
@@ -350,10 +371,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
.catch((e: unknown) => setOfferError(errorMessage(e) || "This waitlist link is not valid.")); .catch((e: unknown) => setOfferError(errorMessage(e) || "This waitlist link is not valid."));
}, []); }, []);
const event = useMemo<RegisterEvent>(() => { const event = useMemo<RegisterEvent>(() => {
if (!offer) return baseEvent; const priced = withLatePrices(baseEvent);
if (!offer) return priced;
return { return {
...baseEvent, ...priced,
eventOptions: (baseEvent.eventOptions || []).map(o => { eventOptions: (priced.eventOptions || []).map(o => {
if (o.id !== offer.eventOptionId) return o; if (o.id !== offer.eventOptionId) return o;
return { return {
...o, ...o,
@@ -400,11 +422,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
const now = new Date(); const now = new Date();
const end = event.endDate ? new Date(event.endDate) : null; const end = event.endDate ? new Date(event.endDate) : null;
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
if ((deadline && now >= deadline) || (end && now >= end)) { if ((deadline && now >= deadline) || (end && now >= end) || now >= registrationClosesAt(event)) {
window.location.replace('/events'); window.location.replace('/events');
} }
} catch {} } catch {}
}, [event.endDate, event.registrationDeadline]); }, [event]);
const handleQtyChange = (key: string, val: number) => { const handleQtyChange = (key: string, val: number) => {
setQuantities((q) => ({ ...q, [key]: val })); setQuantities((q) => ({ ...q, [key]: val }));
@@ -672,6 +694,12 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
</div> </div>
)} )}
{event.lateJoin?.active && (
<div className="mt-4 p-3 rounded-lg bg-brand-50 border border-brand-200 text-sm text-brand-900">
<strong>Joining late:</strong> {event.lateJoin.sessionsLeft} of {event.lateJoin.totalSessions} sessions are still to come, so main tickets are at the {event.lateJoin.mode === "prorata" ? "price for the sessions left" : "late-joiner price"}.
</div>
)}
{(() => { {(() => {
// Collect all upcoming early-bird tiers across all options // Collect all upcoming early-bird tiers across all options
const now = new Date(); const now = new Date();
+10 -9
View File
@@ -21,20 +21,25 @@ import { ContactButton } from "@/components/events/ContactButton";
location?: string | null; location?: string | null;
// Set by GET /api/events while an early-bird tier is still running. // Set by GET /api/events while an early-bird tier is still running.
earlyBirdEndsAt?: string | null; earlyBirdEndsAt?: string | null;
scheduleType?: "single" | "sessions";
sessions?: EventSession[];
registrationClosesAt?: string;
}; };
import { formatDateTimeRange } from "@/lib/date"; import { formatDateTimeRange } from "@/lib/date";
import { registrationClosesAt, scheduleSummary } from "@/lib/sessions";
import type { EventSession } from "@/types";
export const EventCard = ({ event }: { event: Event }) => { export const EventCard = ({ event }: { event: Event }) => {
const dateRange = formatDateTimeRange(event.startDate, event.endDate); const dateRange = scheduleSummary(event) || formatDateTimeRange(event.startDate, event.endDate);
const now = new Date(); const now = new Date();
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null; const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
// Multi-day events close when the first session starts (or later with late joining).
const closesAt = registrationClosesAt(event);
const registrationOpen = event.requiresRegistration !== false const registrationOpen = event.requiresRegistration !== false
&& !(goLive && now < goLive) && !(goLive && now < goLive)
&& !(deadline && now >= deadline) && now < closesAt;
&& now < new Date(event.endDate);
const showEarlyBird = !!event.earlyBirdEndsAt && registrationOpen && !event.isSoldOut; const showEarlyBird = !!event.earlyBirdEndsAt && registrationOpen && !event.isSoldOut;
return ( return (
@@ -92,12 +97,8 @@ export const EventCard = ({ event }: { event: Event }) => {
/> />
); );
} }
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
const notYetOpen = goLive ? now < goLive : false; const notYetOpen = goLive ? now < goLive : false;
const closed = (deadline ? now >= deadline : false) || now >= end; const closed = now >= closesAt;
if (notYetOpen) { if (notYetOpen) {
return ( return (
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 rounded-lg py-2 cursor-not-allowed" title="Registration not yet open"> <button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 rounded-lg py-2 cursor-not-allowed" title="Registration not yet open">
@@ -0,0 +1,59 @@
"use client";
import React, { useEffect, useState } from "react";
import { Award, Download } from "lucide-react";
import { apiFetch, API_BASE } from "@/lib/api";
import { fetchApiFile, saveBlob } from "@/lib/export";
import { formatDate } from "@/lib/date";
import { errorMessage } from "@/lib/utils";
import type { MyCertificate } from "@/types";
// Completion certificates on My Events. Hidden until the person has one.
export function MyCertificates({ token }: { token: string | null }) {
const [certs, setCerts] = useState<MyCertificate[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<MyCertificate[]>("/api/attendance/certificates/mine", { authToken: token })
.then(list => setCerts(Array.isArray(list) ? list : []))
.catch(() => setCerts([]));
}, [token]);
if (certs.length === 0) return null;
const download = async (c: MyCertificate) => {
if (!token) return;
setError(null);
try {
saveBlob(await fetchApiFile(API_BASE, token, `/api/attendance/certificates/${c.id}/pdf`), `Certificate - ${c.event.title.replace(/[^a-z0-9 -]/gi, "")}.pdf`);
} catch (e) {
setError(errorMessage(e) || "Download failed");
}
};
return (
<section className="mb-6 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
<Award className="w-5 h-5 text-brand-600" />
<h2 className="text-lg font-semibold text-gray-900">My Certificates</h2>
</div>
{error && <div className="mb-3 p-2 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<ul className="divide-y">
{certs.map(c => (
<li key={c.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
<div className="min-w-0">
<div className="font-medium text-gray-900">{c.event.title}</div>
<div className="text-xs text-gray-500">
{c.name} · {c.totalSessions > 1 ? `${c.sessionsAttended} of ${c.totalSessions} sessions · ` : ""}issued {formatDate(new Date(c.issuedAt))}
</div>
</div>
<button type="button" onClick={() => download(c)} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100">
<Download className="w-4 h-4" /> Download
</button>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,425 @@
"use client";
import React, { useMemo, useState } from "react";
import { CalendarDays, Plus, RotateCcw, Trash2, Ban, FileText, Repeat } from "lucide-react";
import type { CertificateConfig, CertificateTemplate } from "@/types";
import {
generateSessions, formatSessionWhen, CERTIFICATE_TEMPLATES, REMINDER_OPTIONS, DEFAULT_CERTIFICATE_BODY,
type SessionDraft, type SessionPattern,
} from "@/lib/sessions";
// ─── Sessions (Basic Details step) ────────────────────────────────────────────
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const newKey = () => Math.random().toString(36).slice(2, 10);
export type KeyedSession = SessionDraft & { key: string };
export function keyed(s: SessionDraft): KeyedSession {
return { ...s, key: s.id || newKey() };
}
function todayISO() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** Sorts by start; rows still missing a date go last. */
export function sortSessions(list: KeyedSession[]): KeyedSession[] {
return [...list].sort((a, b) => (a.startsAt || "9999").localeCompare(b.startsAt || "9999"));
}
/** Problems that block saving, or null. */
export function sessionsProblem(list: KeyedSession[]): string | null {
if (!list.some(s => !s.cancelled)) return "Add at least one session";
for (const [i, s] of sortSessions(list).entries()) {
if (!s.startsAt || !s.endsAt) return `Session ${i + 1} needs a start and end`;
if (new Date(s.endsAt) <= new Date(s.startsAt)) return `Session ${i + 1} must end after it starts`;
}
return null;
}
function PatternBuilder({ onGenerate }: { onGenerate: (rows: SessionDraft[], mode: "replace" | "add") => void }) {
const [p, setP] = useState<SessionPattern>(() => ({
frequency: "weekly", interval: 1, weekdays: [new Date().getDay()], startDate: todayISO(),
startTime: "18:30", endTime: "20:30", endsBy: "count", count: 5, until: "",
}));
const set = (patch: Partial<SessionPattern>) => setP(x => ({ ...x, ...patch }));
const preview = useMemo(() => generateSessions(p), [p]);
const toggleDay = (d: number) => set({ weekdays: p.weekdays.includes(d) ? p.weekdays.filter(x => x !== d) : [...p.weekdays, d] });
return (
<div className="border rounded-lg p-3 bg-brand-50/40 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-gray-800"><Repeat className="w-4 h-4 text-brand-600" /> Repeat pattern</div>
<div className="grid sm:grid-cols-3 gap-2">
<label className="text-xs text-gray-600">Repeats
<select className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.frequency} onChange={e => set({ frequency: e.target.value as SessionPattern["frequency"] })}>
<option value="weekly">Weekly</option>
<option value="daily">Daily</option>
</select>
</label>
<label className="text-xs text-gray-600">Every
<div className="mt-1 flex items-center gap-1.5">
<input type="number" min={1} max={12} className="w-16 border rounded px-2 py-1.5 text-sm" value={p.interval} onChange={e => set({ interval: Math.max(1, parseInt(e.target.value) || 1) })} />
<span className="text-sm text-gray-600">{p.frequency === "weekly" ? (p.interval === 1 ? "week" : "weeks") : (p.interval === 1 ? "day" : "days")}</span>
</div>
</label>
<label className="text-xs text-gray-600">First date
<input type="date" className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.startDate} onChange={e => set({ startDate: e.target.value })} />
</label>
</div>
{p.frequency === "weekly" && (
<div>
<div className="text-xs text-gray-600 mb-1">On</div>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map((d, i) => (
<button key={d} type="button" onClick={() => toggleDay(i)}
className={`px-2.5 py-1 text-xs rounded-full border ${p.weekdays.includes(i) ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50"}`}>{d}</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<label className="text-xs text-gray-600">From
<input type="time" className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.startTime} onChange={e => set({ startTime: e.target.value })} />
</label>
<label className="text-xs text-gray-600">To
<input type="time" className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.endTime} onChange={e => set({ endTime: e.target.value })} />
</label>
<label className="text-xs text-gray-600">Ends
<select className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.endsBy} onChange={e => set({ endsBy: e.target.value as SessionPattern["endsBy"] })}>
<option value="count">After a number of sessions</option>
<option value="until">On a date</option>
</select>
</label>
{p.endsBy === "count" ? (
<label className="text-xs text-gray-600">Sessions
<input type="number" min={1} max={200} className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.count} onChange={e => set({ count: Math.max(1, Math.min(200, parseInt(e.target.value) || 1)) })} />
</label>
) : (
<label className="text-xs text-gray-600">Last date
<input type="date" className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={p.until} onChange={e => set({ until: e.target.value })} />
</label>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-gray-500">
{preview.length === 0 ? "Pick the dates and times to see the sessions." : `${preview.length} session${preview.length === 1 ? "" : "s"}: ${formatSessionWhen({ startsAt: preview[0].startsAt, endsAt: preview[0].endsAt })}${preview.length > 1 ? ` … ${formatSessionWhen({ startsAt: preview[preview.length - 1].startsAt, endsAt: preview[preview.length - 1].endsAt })}` : ""}`}
</p>
<div className="flex gap-2">
<button type="button" disabled={!preview.length} onClick={() => onGenerate(preview, "add")} className="px-3 py-1.5 text-xs rounded border border-gray-200 bg-white hover:bg-gray-50 disabled:opacity-50">Add to list</button>
<button type="button" disabled={!preview.length} onClick={() => onGenerate(preview, "replace")} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Create sessions</button>
</div>
</div>
</div>
);
}
export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessions: KeyedSession[]; onChange: (s: KeyedSession[]) => void; defaultLocation?: string }) {
const [showPattern, setShowPattern] = useState(sessions.length === 0);
const sorted = sortSessions(sessions);
const activeCount = sessions.filter(s => !s.cancelled).length;
const update = (key: string, patch: Partial<KeyedSession>) => onChange(sessions.map(s => (s.key === key ? { ...s, ...patch } : s)));
const remove = (key: string) => onChange(sessions.filter(s => s.key !== key));
const generate = (rows: SessionDraft[], mode: "replace" | "add") => {
const fresh = rows.map(keyed);
// Saved sessions with check-ins can't be removed, so "replace" keeps them.
const kept = mode === "replace" ? sessions.filter(s => s.hasCheckIns) : sessions;
onChange(sortSessions([...kept, ...fresh]));
setShowPattern(false);
};
const addOne = () => {
const last = sorted[sorted.length - 1];
const base = last?.startsAt ? new Date(last.startsAt) : new Date();
const dur = last?.startsAt && last?.endsAt ? new Date(last.endsAt).getTime() - new Date(last.startsAt).getTime() : 2 * 60 * 60 * 1000;
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 })]);
};
let position = 0;
return (
<div className="space-y-3">
{showPattern
? <PatternBuilder onGenerate={generate} />
: (
<button type="button" onClick={() => setShowPattern(true)} className="inline-flex items-center gap-1.5 text-xs text-brand-700 hover:underline">
<Repeat className="w-3.5 h-3.5" /> Create sessions from a repeat pattern
</button>
)}
{sorted.length > 0 && (
<div className="border rounded-lg divide-y">
<div className="px-3 py-2 text-xs text-gray-500 bg-gray-50 rounded-t-lg">
{activeCount} session{activeCount === 1 ? "" : "s"} — edit any date, add a label, or cancel one (e.g. a public holiday).
</div>
{sorted.map((s) => {
if (!s.cancelled) position += 1;
return (
<div key={s.key} className={`p-3 space-y-2 ${s.cancelled ? "bg-gray-50" : ""}`}>
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold w-6 shrink-0 ${s.cancelled ? "text-gray-400" : "text-brand-700"}`}>{s.cancelled ? "—" : position}</span>
<input className={`flex-1 min-w-0 border rounded px-2 py-1 text-sm ${s.cancelled ? "line-through text-gray-400" : ""}`} placeholder={s.cancelled ? "Cancelled" : `Session ${position} (optional label)`}
value={s.label} onChange={e => update(s.key, { label: e.target.value })} disabled={s.cancelled} />
{s.cancelled ? (
<button type="button" onClick={() => update(s.key, { cancelled: false })} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border bg-white hover:bg-gray-50" title="Restore this session">
<RotateCcw className="w-3.5 h-3.5" /> Restore
</button>
) : s.id ? (
<button type="button" onClick={() => update(s.key, { cancelled: true })} className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-amber-200 bg-amber-50 text-amber-800 hover:bg-amber-100" title="Cancel this session (kept for history)">
<Ban className="w-3.5 h-3.5" /> Cancel
</button>
) : null}
{!s.hasCheckIns && (
<button type="button" onClick={() => remove(s.key)} className="p-1.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50" title="Remove">
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
{!s.cancelled && (
<div className="grid sm:grid-cols-3 gap-2 pl-8">
<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;
// Keep the session's length when the start moves.
const len = s.startsAt && s.endsAt ? new Date(s.endsAt).getTime() - new Date(s.startsAt).getTime() : 0;
const p2 = (n: number) => String(n).padStart(2, "0");
const end = v && len > 0 ? new Date(new Date(v).getTime() + len) : null;
update(s.key, { startsAt: v, ...(end ? { endsAt: `${end.getFullYear()}-${p2(end.getMonth() + 1)}-${p2(end.getDate())}T${p2(end.getHours())}:${p2(end.getMinutes())}` } : {}) });
}} />
</label>
<label className="text-[11px] text-gray-500">Ends
<input type="datetime-local" className="mt-0.5 w-full border rounded px-2 py-1 text-sm" value={s.endsAt} onChange={e => update(s.key, { endsAt: e.target.value })} />
</label>
<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>
</div>
)}
{s.hasCheckIns && <p className="pl-8 text-[10px] text-gray-400">Has check-ins — it can be cancelled but not removed.</p>}
</div>
);
})}
</div>
)}
<button type="button" onClick={addOne} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded border border-gray-200 bg-white hover:bg-gray-50">
<Plus className="w-3.5 h-3.5" /> Add a date
</button>
</div>
);
}
// ─── Attendance step ──────────────────────────────────────────────────────────
export type AttendanceDraft = {
lateJoinEnabled: boolean;
lateJoinUntilKey: string;
latePricing: "full" | "prorata" | "fixed";
lateFixedPrice: string;
checkInWindow: "whole_day" | "session_only";
remindersEnabled: boolean;
reminderOffsets: number[];
reminderChannels: ("email" | "whatsapp")[];
certificatesEnabled: boolean;
certificate: CertificateConfig;
};
export function defaultCertificate(): CertificateConfig {
return { template: "classic", title: "Certificate of Completion", body: DEFAULT_CERTIFICATE_BODY, signatureName: "", signatureRole: "", signatureImage: "", rule: { type: "all", value: null }, sendOnIssue: false };
}
function Toggle({ id, checked, onChange, title, children }: { id: string; checked: boolean; onChange: (v: boolean) => void; title: string; children?: React.ReactNode }) {
return (
<div className="flex items-start gap-2">
<input id={id} type="checkbox" className="mt-0.5" checked={checked} onChange={e => onChange(e.target.checked)} />
<label htmlFor={id} className="text-sm text-gray-800">
<span className="font-medium">{title}</span>
{children && <span className="block text-xs text-gray-500 mt-0.5">{children}</span>}
</label>
</div>
);
}
export function AttendanceSettings({ value, onChange, multi, sessions, mainPrice, onPreview, uploadButton }: {
value: AttendanceDraft;
onChange: (patch: Partial<AttendanceDraft>) => void;
multi: boolean;
sessions: KeyedSession[];
mainPrice: number;
onPreview: () => Promise<void>;
uploadButton: (onUploaded: (url: string) => void) => React.ReactNode;
}) {
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const active = sortSessions(sessions).filter(s => !s.cancelled);
const cert = value.certificate;
const setCert = (patch: Partial<CertificateConfig>) => onChange({ certificate: { ...cert, ...patch } });
const total = active.length;
const examplePrice = mainPrice > 0 ? mainPrice : 300;
const example = total > 1
? (() => {
const left = total - 1;
const v = Math.min(examplePrice, Math.max(10, Math.round((examplePrice * left / total) / 10) * 10));
return `e.g. R${examplePrice} with ${left} of ${total} sessions left → R${v}`;
})()
: null;
return (
<div className="space-y-5">
{multi && (
<section className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-1.5"><CalendarDays className="w-4 h-4 text-brand-600" /> Late joining</h3>
<Toggle id="late-join" checked={value.lateJoinEnabled} onChange={v => onChange({ lateJoinEnabled: v })} title="Let people join after the first session">
Without this, registration closes when the first session starts.
</Toggle>
{value.lateJoinEnabled && (
<div className="pl-6 space-y-3">
<label className="block text-xs text-gray-600">Registration stays open until the start of
<select className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={value.lateJoinUntilKey} onChange={e => onChange({ lateJoinUntilKey: e.target.value })}>
<option value="">Choose a session…</option>
{active.slice(1).map((s, i) => (
<option key={s.key} value={s.key}>{s.label || `Session ${i + 2}`} — {s.startsAt ? formatSessionWhen(s) : "no date yet"}</option>
))}
</select>
</label>
<fieldset className="space-y-1.5">
<legend className="text-xs text-gray-600 mb-1">Price for late joiners (main tickets only)</legend>
{([
["full", "Full price"],
["prorata", "Pro-rata on the sessions left (rounded to the nearest R10)"],
["fixed", "A fixed late price"],
] as const).map(([v, label]) => (
<label key={v} className="flex items-center gap-2 text-sm">
<input type="radio" name="late-pricing" checked={value.latePricing === v} onChange={() => onChange({ latePricing: v })} /> {label}
</label>
))}
{value.latePricing === "prorata" && example && <p className="text-xs text-gray-500 pl-6">{example}</p>}
{value.latePricing === "fixed" && (
<div className="pl-6">
<input type="number" min={0} step="1" className="w-32 border rounded px-2 py-1.5 text-sm" placeholder="R" value={value.lateFixedPrice} onChange={e => onChange({ lateFixedPrice: e.target.value })} />
<p className="text-[10px] text-gray-400 mt-0.5">Only ever lowers the price — anyone whose normal price is already lower keeps it.</p>
</div>
)}
</fieldset>
</div>
)}
</section>
)}
{multi && (
<section className="space-y-2">
<h3 className="text-sm font-semibold text-gray-900">Check-in</h3>
{([
["whole_day", "Whole day", "Any time on the session's date — handy if someone was let in without being scanned."],
["session_only", "Session time only", "From 2 hours before the session starts until it ends."],
] as const).map(([v, title, hint]) => (
<label key={v} className="flex items-start gap-2 text-sm">
<input type="radio" name="checkin-window" className="mt-1" checked={value.checkInWindow === v} onChange={() => onChange({ checkInWindow: v })} />
<span><span className="font-medium">{title}</span><span className="block text-xs text-gray-500">{hint}</span></span>
</label>
))}
</section>
)}
{multi && (
<section className="space-y-2">
<h3 className="text-sm font-semibold text-gray-900">Session reminders</h3>
<Toggle id="reminders" checked={value.remindersEnabled} onChange={v => onChange({ remindersEnabled: v })} title="Remind ticket holders before each session" />
{value.remindersEnabled && (
<div className="pl-6 grid sm:grid-cols-2 gap-3">
<div className="space-y-1">
<div className="text-xs text-gray-600">When</div>
{REMINDER_OPTIONS.map(o => (
<label key={o.minutes} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={value.reminderOffsets.includes(o.minutes)} onChange={e => onChange({ reminderOffsets: e.target.checked ? [...value.reminderOffsets, o.minutes] : value.reminderOffsets.filter(x => x !== o.minutes) })} /> {o.label}
</label>
))}
</div>
<div className="space-y-1">
<div className="text-xs text-gray-600">How</div>
{(["email", "whatsapp"] as const).map(c => (
<label key={c} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={value.reminderChannels.includes(c)} onChange={e => onChange({ reminderChannels: e.target.checked ? [...value.reminderChannels, c] : value.reminderChannels.filter(x => x !== c) })} /> {c === "email" ? "Email" : "WhatsApp"}
</label>
))}
<p className="text-[10px] text-gray-400">Each person&apos;s notification preference still applies.</p>
</div>
</div>
)}
</section>
)}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-1.5"><FileText className="w-4 h-4 text-brand-600" /> Certificates</h3>
<Toggle id="certificates" checked={value.certificatesEnabled} onChange={v => onChange({ certificatesEnabled: v })} title="Give completion certificates">
Issued automatically when the event ends to everyone who meets the rule below. You can also issue them from the attendance register.
</Toggle>
{value.certificatesEnabled && (
<div className="pl-6 space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{CERTIFICATE_TEMPLATES.map(t => (
<button key={t.id} type="button" onClick={() => setCert({ template: t.id as CertificateTemplate })}
className={`text-left p-2 rounded-lg border ${cert.template === t.id ? "border-brand-500 ring-2 ring-brand-200 bg-brand-50" : "border-gray-200 hover:bg-gray-50"}`}>
<span className="block text-sm font-medium">{t.name}</span>
<span className="block text-[11px] text-gray-500">{t.blurb}</span>
</button>
))}
</div>
<label className="block text-xs text-gray-600">Title
<input className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={cert.title} onChange={e => setCert({ title: e.target.value })} />
</label>
<label className="block text-xs text-gray-600">Wording
<textarea rows={3} className="mt-1 w-full border rounded px-2 py-1.5 text-sm" value={cert.body} onChange={e => setCert({ body: e.target.value })} />
<span className="block text-[10px] text-gray-400 mt-0.5">Fills in: {"{name}"}, {"{event}"}, {"{sessionsAttended}"}, {"{totalSessions}"}, {"{endDate}"}</span>
</label>
<div className="grid sm:grid-cols-2 gap-2">
<label className="text-xs text-gray-600">Signed by (optional)
<input className="mt-1 w-full border rounded px-2 py-1.5 text-sm" placeholder="e.g. Pastor John" value={cert.signatureName || ""} onChange={e => setCert({ signatureName: e.target.value })} />
</label>
<label className="text-xs text-gray-600">Their role (optional)
<input className="mt-1 w-full border rounded px-2 py-1.5 text-sm" placeholder="e.g. Senior Pastor" value={cert.signatureRole || ""} onChange={e => setCert({ signatureRole: e.target.value })} />
</label>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-600">
Signature image (optional):
{uploadButton(url => setCert({ signatureImage: url }))}
{cert.signatureImage && <button type="button" className="text-red-600 hover:underline" onClick={() => setCert({ signatureImage: "" })}>Remove</button>}
</div>
{multi ? (
<fieldset className="space-y-1.5">
<legend className="text-xs text-gray-600 mb-1">Who gets one</legend>
<label className="flex items-center gap-2 text-sm"><input type="radio" name="cert-rule" checked={cert.rule.type === "all"} onChange={() => setCert({ rule: { type: "all", value: null } })} /> Attended every session</label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" name="cert-rule" checked={cert.rule.type === "count"} onChange={() => setCert({ rule: { type: "count", value: Math.max(1, total - 1) } })} /> Attended at least
<input type="number" min={1} className="w-16 border rounded px-2 py-0.5 text-sm" disabled={cert.rule.type !== "count"} value={cert.rule.type === "count" ? cert.rule.value ?? 1 : ""} onChange={e => setCert({ rule: { type: "count", value: Math.max(1, parseInt(e.target.value) || 1) } })} /> sessions
</label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" name="cert-rule" checked={cert.rule.type === "percent"} onChange={() => setCert({ rule: { type: "percent", value: 80 } })} /> Attended at least
<input type="number" min={1} max={100} className="w-16 border rounded px-2 py-0.5 text-sm" disabled={cert.rule.type !== "percent"} value={cert.rule.type === "percent" ? cert.rule.value ?? 80 : ""} onChange={e => setCert({ rule: { type: "percent", value: Math.min(100, Math.max(1, parseInt(e.target.value) || 1)) } })} /> % of sessions
</label>
</fieldset>
) : (
<p className="text-xs text-gray-500">Everyone who was checked in gets one.</p>
)}
<Toggle id="cert-send" checked={!!cert.sendOnIssue} onChange={v => setCert({ sendOnIssue: v })} title="Send certificates automatically">
Email/WhatsApp each certificate when it&apos;s issued. Otherwise people download theirs from My Events, and you can send them from the attendance register.
</Toggle>
<div>
<button type="button" disabled={previewing} onClick={async () => {
setPreviewing(true); setPreviewError(null);
try { await onPreview(); } catch (e) { setPreviewError(e instanceof Error ? e.message : "Couldn't make the preview"); } finally { setPreviewing(false); }
}} className="px-3 py-1.5 text-xs rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
{previewing ? "Making preview…" : "Preview certificate"}
</button>
{previewError && <p className="text-xs text-red-600 mt-1">{previewError}</p>}
</div>
</div>
)}
</section>
</div>
);
}
@@ -0,0 +1,39 @@
import { CalendarDays, MapPin } from "lucide-react";
import type { EventSession } from "@/types";
import { activeSessions, formatSessionWhen, sessionLabel } from "@/lib/sessions";
// The schedule of a multi-day event on its public page: past sessions greyed out,
// cancelled ones struck through, and the next one highlighted.
export function SessionSchedule({ sessions, defaultLocation }: { sessions: EventSession[]; defaultLocation?: string | null }) {
const now = Date.now();
const sorted = [...sessions].sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime());
const next = activeSessions(sessions).find(s => new Date(s.endsAt).getTime() > now);
const count = activeSessions(sessions).length;
return (
<div className="border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
<CalendarDays className="w-4 h-4 text-gray-500" />
<h2 className="text-base font-semibold text-gray-900">Schedule</h2>
<span className="text-xs text-gray-500">· {count} session{count === 1 ? "" : "s"}, one ticket for all</span>
</div>
<ol className="space-y-1.5">
{sorted.map((s) => {
const cancelled = !!(s.cancelled || s.cancelledAt);
const past = !cancelled && new Date(s.endsAt).getTime() <= now;
const isNext = next?.id === s.id;
const place = s.location && s.location !== defaultLocation ? s.location : null;
return (
<li key={s.id} className={`flex flex-wrap items-baseline gap-x-3 gap-y-0.5 rounded-lg px-3 py-2 text-sm ${isNext ? "bg-brand-50 border border-brand-200" : "border border-transparent"} ${past ? "text-gray-400" : "text-gray-700"}`}>
<span className={`font-medium ${cancelled ? "line-through text-gray-400" : ""}`}>{cancelled ? (s.label || "Session") : sessionLabel(s, sessions)}</span>
<span className={cancelled ? "line-through text-gray-400" : ""}>{formatSessionWhen(s)}</span>
{cancelled && <span className="text-xs font-medium text-amber-700">Cancelled</span>}
{isNext && <span className="text-xs font-medium text-brand-700">Next</span>}
{place && !cancelled && <span className="flex items-center gap-1 text-xs text-gray-500"><MapPin className="w-3 h-3" />{place}</span>}
</li>
);
})}
</ol>
</div>
);
}
@@ -73,7 +73,7 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
{lock ? ( {lock ? (
<> <>
<span className="text-gray-400">·</span> <span className="text-gray-400">·</span>
<span className="font-medium text-amber-900">Offline mode: {lock.eventTitle}{lock.sectionName ? ` — ${lock.sectionName}` : " — whole event"}</span> <span className="font-medium text-amber-900">Offline mode: {lock.eventTitle}{lock.sectionName ? ` — ${lock.sectionName}` : " — whole event"}{lock.sessionLabel ? ` · ${lock.sessionLabel}` : ""}</span>
<span className="text-xs text-gray-600">Ticket list downloaded at {new Date(lock.snapshotAt).toLocaleTimeString()}</span> <span className="text-xs text-gray-600">Ticket list downloaded at {new Date(lock.snapshotAt).toLocaleTimeString()}</span>
<span className="text-xs text-gray-600">· {pending} scan(s) waiting to sync</span> <span className="text-xs text-gray-600">· {pending} scan(s) waiting to sync</span>
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
@@ -88,8 +88,13 @@ export function useOfflineScanning(token: string | null) {
const created = await apiFetch<{ id: string }>("/api/scan-locks", { const created = await apiFetch<{ id: string }>("/api/scan-locks", {
method: "POST", authToken: token, body: { eventId, sectionId, deviceId: deviceId(), deviceName: deviceName() }, method: "POST", authToken: token, body: { eventId, sectionId, deviceId: deviceId(), deviceName: deviceName() },
}); });
const snap = await apiFetch<{ snapshotAt: string; tickets: SnapshotTicket[] }>(`/api/scan-locks/${created.id}/snapshot?deviceId=${encodeURIComponent(deviceId())}`, { authToken: token }); const snap = await apiFetch<{ snapshotAt: string; tickets: SnapshotTicket[]; session?: { id: string; label: string } | null; noSessionToday?: boolean }>(`/api/scan-locks/${created.id}/snapshot?deviceId=${encodeURIComponent(deviceId())}`, { authToken: token });
const next: OfflineLock = { lockId: created.id, eventId, eventTitle, sectionId, sectionName, snapshotAt: snap.snapshotAt }; // Multi-day events: the downloaded list is for today's session, so there must be one.
if (snap.noSessionToday) {
await apiFetch(`/api/scan-locks/${created.id}`, { method: "DELETE", authToken: token, body: { deviceId: deviceId() } }).catch(() => {});
throw new Error("There's no session of this event open for check-in today, so offline mode can't start.");
}
const next: OfflineLock = { lockId: created.id, eventId, eventTitle, sectionId, sectionName, snapshotAt: snap.snapshotAt, sessionLabel: snap.session?.label || null };
await startOffline(next, snap.tickets); await startOffline(next, snap.tickets);
setLock(next); setLock(next);
return snap.tickets.length; return snap.tickets.length;
+7 -1
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users, ArrowRightLeft } from "lucide-react"; import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users, ArrowRightLeft, Award, CalendarDays } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem"; import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types"; import type { HelpContent } from "./types";
@@ -60,6 +60,12 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={Printer} title="Print, email, or WhatsApp" tone="green"> <GuideItem icon={Printer} title="Print, email, or WhatsApp" tone="green">
Get your selected tickets onto your phone or paper before the event — as a printed page, an email, or a WhatsApp message. Get your selected tickets onto your phone or paper before the event — as a printed page, an email, or a WhatsApp message.
</GuideItem> </GuideItem>
<GuideItem icon={CalendarDays} title="Events with several sessions" tone="brand">
For a course or event that runs on several dates, your one ticket works for every session — bring the same QR code each time. The event page shows the full schedule.
</GuideItem>
<GuideItem icon={Award} title="Certificates" tone="amber">
If an event gives completion certificates, yours appears under My Certificates once it&apos;s issued — usually after the last session. Download it as a PDF; it has a code anyone can check.
</GuideItem>
<GuideItem icon={ArrowRightLeft} title="Transfer a ticket" tone="violet"> <GuideItem icon={ArrowRightLeft} title="Transfer a ticket" tone="violet">
Can&apos;t make it? Tap &quot;Transfer&quot; to offer your ticket to someone else before the event. Type their name and email and/or phone number — there&apos;s no searching for people — and we send them a link to accept it. Until they accept, the ticket is still yours (you&apos;ll see &quot;Waiting for … to accept&quot;, and can cancel). Once they accept, they get their own ticket with a new QR code, and the old code (and any screenshots of it) stops working. For a ticket covering several people, one place moves to them. You stay responsible for the booking and payment. A ticket can&apos;t be transferred once it has been scanned. Can&apos;t make it? Tap &quot;Transfer&quot; to offer your ticket to someone else before the event. Type their name and email and/or phone number — there&apos;s no searching for people — and we send them a link to accept it. Until they accept, the ticket is still yours (you&apos;ll see &quot;Waiting for … to accept&quot;, and can cancel). Once they accept, they get their own ticket with a new QR code, and the old code (and any screenshots of it) stops working. For a ticket covering several people, one place moves to them. You stay responsible for the booking and payment. A ticket can&apos;t be transferred once it has been scanned.
</GuideItem> </GuideItem>
+6
View File
@@ -25,6 +25,7 @@ import { supervisorCouponsHelpContent } from "./supervisor-coupons";
import { supervisorBundlesHelpContent } from "./supervisor-bundles"; import { supervisorBundlesHelpContent } from "./supervisor-bundles";
import { supervisorConsentHelpContent } from "./supervisor-consent"; import { supervisorConsentHelpContent } from "./supervisor-consent";
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist"; import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
import { supervisorAttendanceHelpContent } from "./supervisor-attendance";
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans"; import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
import { supervisorAllocationsHelpContent, staffMyGroupsHelpContent } from "./supervisor-allocations"; import { supervisorAllocationsHelpContent, staffMyGroupsHelpContent } from "./supervisor-allocations";
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees"; import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
@@ -173,6 +174,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/supervisor/waitlist", matches: pathname => pathname === "/dashboard/supervisor/waitlist",
content: supervisorWaitlistHelpContent, content: supervisorWaitlistHelpContent,
}, },
{
slug: "supervisor-attendance",
matches: pathname => pathname === "/dashboard/supervisor/attendance",
content: supervisorAttendanceHelpContent,
},
{ {
slug: "supervisor-consent", slug: "supervisor-consent",
matches: pathname => pathname === "/dashboard/supervisor/consent", matches: pathname => pathname === "/dashboard/supervisor/consent",
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { QrCode, Filter, CheckCircle2, ShieldAlert, WifiOff } from "lucide-react"; import { QrCode, Filter, CheckCircle2, ShieldAlert, WifiOff, CalendarDays } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem"; import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types"; import type { HelpContent } from "./types";
@@ -22,6 +22,9 @@ export const staffTicketScanningHelpContent: HelpContent = {
<GuideItem icon={WifiOff} title="Offline mode" tone="amber"> <GuideItem icon={WifiOff} title="Offline mode" tone="amber">
Worried about the venue Wi-Fi? While you&apos;re online, pick the event and section and tap &quot;Enable offline mode&quot;. The section is locked to your device and its tickets are downloaded, so you can keep scanning with no connection. Scans sync automatically when you&apos;re back online. Tickets bought after the download won&apos;t be on your list — send those people to the door desk. Tap &quot;Release section&quot; when you&apos;re done. Tip: add this page to your home screen so it opens offline. Worried about the venue Wi-Fi? While you&apos;re online, pick the event and section and tap &quot;Enable offline mode&quot;. The section is locked to your device and its tickets are downloaded, so you can keep scanning with no connection. Scans sync automatically when you&apos;re back online. Tickets bought after the download won&apos;t be on your list — send those people to the door desk. Tap &quot;Release section&quot; when you&apos;re done. Tip: add this page to your home screen so it opens offline.
</GuideItem> </GuideItem>
<GuideItem icon={CalendarDays} title="Multi-session events" tone="brand">
A ticket for an event with several sessions is checked in once per session. The scanner uses today&apos;s session automatically (pick another under &quot;Session&quot; if you need to). Scanning the same ticket again for the same session shows &quot;Already checked in for …&quot;. Depending on the event, check-in is open the whole day of a session or only around its start time. Offline mode downloads the list for today&apos;s session.
</GuideItem>
<GuideItem icon={ShieldAlert} title="Parental consent" tone="rose"> <GuideItem icon={ShieldAlert} title="Parental consent" tone="rose">
On youth events, the confirm screen warns when a parent hasn&apos;t given consent yet (or declined). That person can&apos;t be checked in; a supervisor can override, and the override is recorded in the audit log. On youth events, the confirm screen warns when a parent hasn&apos;t given consent yet (or declined). That person can&apos;t be checked in; a supervisor can override, and the override is recorded in the audit log.
</GuideItem> </GuideItem>
@@ -0,0 +1,61 @@
import React from "react";
import { ClipboardCheck, MousePointerClick, Filter, FileSpreadsheet, Award, Send, Ban, ShieldCheck } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/attendance. */
export const supervisorAttendanceHelpContent: HelpContent = {
title: "Attendance",
subtitle: "Who came to each session, and completion certificates.",
tabs: [
{
key: "register",
label: "The register",
icon: ClipboardCheck,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={ClipboardCheck} title="One row per person" tone="brand">
Every main ticket is a row, with a column for each session of a multi-day event (one &quot;Checked in&quot; column for a single-date event). A tick means they were scanned in for that session; a number is how many places of a group ticket were.
</GuideItem>
<GuideItem icon={MousePointerClick} title="Fixing a session" tone="green">
Supervisors can click a box to mark someone present (e.g. they were let in without a scan) or absent. Each change is recorded in the audit log. Closed events can&apos;t be changed.
</GuideItem>
<GuideItem icon={Filter} title="Finding people" tone="violet">
Search by name, email or phone. Filter to who completed, who didn&apos;t, or who missed the last two sessions — handy for a follow-up call.
</GuideItem>
<GuideItem icon={FileSpreadsheet} title="Export" tone="amber">
Download the register as Excel (with contact details) or a printable PDF. Cancelled sessions are left out.
</GuideItem>
</div>
</div>
),
},
{
key: "certificates",
label: "Certificates",
icon: Award,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={Award} title="Turning them on" tone="brand">
In the event wizard&apos;s Attendance step: pick one of four designs, the wording, who signs it, and who qualifies (every session, at least N sessions, or at least N%). They&apos;re issued automatically when the event ends.
</GuideItem>
<GuideItem icon={MousePointerClick} title="Issuing now" tone="green">
&quot;Issue now&quot; gives one to everyone who already qualifies. Use &quot;Issue anyway&quot; on a row to give one to someone who doesn&apos;t meet the rule.
</GuideItem>
<GuideItem icon={Send} title="Sending" tone="violet">
People can always download theirs from My Events. &quot;Send unsent&quot; emails or WhatsApps the ones not sent yet (as each person prefers); the event can also send them automatically.
</GuideItem>
<GuideItem icon={Ban} title="Revoking" tone="rose">
A revoked certificate stops showing as valid and is never issued again automatically. You can re-issue it by hand.
</GuideItem>
<GuideItem icon={ShieldCheck} title="Checking one" tone="amber">
Each certificate has a code and QR code. Anyone can check it at /certificates/verify/&lt;code&gt;, which shows only the name, event and dates.
</GuideItem>
</div>
</div>
),
},
],
};
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Calendar, ListChecks, Layers, FileText, Eye, Bell, Paperclip, Lock, MessageSquareHeart } from "lucide-react"; import { Calendar, ListChecks, Layers, FileText, Eye, Bell, Paperclip, Lock, MessageSquareHeart, CalendarDays, ClipboardCheck } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem"; import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types"; import type { HelpContent } from "./types";
@@ -34,6 +34,9 @@ export const supervisorEventsHelpContent: HelpContent = {
<GuideItem icon={Calendar} title="Basic details" tone="brand"> <GuideItem icon={Calendar} title="Basic details" tone="brand">
Title, description, start/end times, registration deadline, go-live time, base price, picture, and a custom URL alias. Title, description, start/end times, registration deadline, go-live time, base price, picture, and a custom URL alias.
</GuideItem> </GuideItem>
<GuideItem icon={CalendarDays} title="Multiple sessions" tone="brand">
Choose &quot;Multiple sessions&quot; 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 (&quot;Week 1 — …&quot;), 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={ListChecks} title="Items & pricing" tone="green"> <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&apos;s lower than the normal price. 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&apos;s lower than the normal price.
</GuideItem> </GuideItem>
@@ -55,6 +58,9 @@ export const supervisorEventsHelpContent: HelpContent = {
<GuideItem icon={MessageSquareHeart} title="Feedback" tone="violet"> <GuideItem icon={MessageSquareHeart} title="Feedback" tone="violet">
Send a feedback form automatically after the event: pick when (e.g. the morning after) and whether it goes to everyone who checked in or everyone registered. Questions can be star ratings, multiple choice, yes/no or text. Answers can be anonymous. Anyone who hasn&apos;t answered gets one reminder 3 days later. Results are in Reports → Feedback summary. Rating and multiple-choice questions can also be used on the attendee form. Send a feedback form automatically after the event: pick when (e.g. the morning after) and whether it goes to everyone who checked in or everyone registered. Questions can be star ratings, multiple choice, yes/no or text. Answers can be anonymous. Anyone who hasn&apos;t answered gets one reminder 3 days later. Results are in Reports → Feedback summary. Rating and multiple-choice questions can also be used on the attendee form.
</GuideItem> </GuideItem>
<GuideItem icon={ClipboardCheck} title="Attendance" tone="green">
For multi-session events: late joining (until which session, and a full, pro-rata or fixed late price for main tickets — pro-rata rounds to the nearest R10), the check-in window (the whole day of a session, or only from 2 hours before it until it ends), and reminders before each session. For any event: completion certificates — four designs, your wording and signature, and who qualifies. &quot;Preview certificate&quot; shows a sample.
</GuideItem>
</div> </div>
<p className="text-xs text-gray-500">When creating a new event, later steps unlock as you complete the ones before them; editing an existing event lets you jump to any step freely.</p> <p className="text-xs text-gray-500">When creating a new event, later steps unlock as you complete the ones before them; editing an existing event lets you jump to any step freely.</p>
</div> </div>
+34
View File
@@ -191,3 +191,37 @@ export function mailtoReport(subject: string, body: string) {
const url = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; const url = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = url; window.location.href = url;
} }
// Fetches a file (PDF, Excel…) from the API with the user's token.
export async function fetchApiFile(apiBase: string, authToken: string, path: string, init: { method?: string; body?: unknown } = {}): Promise<Blob> {
const res = await fetch(`${apiBase}${path}`, {
method: init.method || 'GET',
headers: {
'Authorization': `Bearer ${authToken}`,
...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
let message = text;
try { message = JSON.parse(text)?.message || text; } catch { /* not JSON */ }
throw new Error(message || `Download failed (${res.status})`);
}
return res.blob();
}
export function saveBlob(blob: Blob, filename: string) {
const dl = document.createElement('a');
const objUrl = URL.createObjectURL(blob);
dl.href = objUrl;
dl.download = filename;
dl.click();
setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
}
export function openBlob(blob: Blob) {
const objUrl = URL.createObjectURL(blob);
window.open(objUrl, '_blank');
setTimeout(() => URL.revokeObjectURL(objUrl), 60000);
}
+2
View File
@@ -29,6 +29,8 @@ export type OfflineLock = {
sectionId: string | null; sectionId: string | null;
sectionName: string | null; sectionName: string | null;
snapshotAt: string; snapshotAt: string;
// Multi-day events: the session the downloaded ticket list is for.
sessionLabel?: string | null;
}; };
const DB_NAME = "hope-events-offline-scan"; const DB_NAME = "hope-events-offline-scan";
+165
View File
@@ -0,0 +1,165 @@
import type { Event, EventSession, LateJoinInfo } from "@/types";
// Multi-day events — browser-side helpers. The rules live in backend utils/sessions.js;
// latePrice below mirrors it so prices shown match what the server charges.
export type SessionPattern = {
frequency: "daily" | "weekly";
interval: number; // every N days/weeks
weekdays: number[]; // weekly only: 0 = Sunday … 6 = Saturday
startDate: string; // YYYY-MM-DD (local)
startTime: string; // HH:MM
endTime: string; // HH:MM (an end before the start runs past midnight)
endsBy: "count" | "until";
count: number;
until: string; // YYYY-MM-DD (local)
};
export type SessionDraft = {
id?: string;
label: string;
startsAt: string; // datetime-local value (YYYY-MM-DDTHH:MM, local)
endsAt: string;
location: string;
cancelled: boolean;
hasCheckIns?: boolean;
};
const MAX_GENERATED = 200;
function localDT(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
function atTime(day: Date, hhmm: string): Date {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date(day);
d.setHours(h || 0, m || 0, 0, 0);
return d;
}
/** Turns a repeat pattern into session rows (local time). */
export function generateSessions(p: SessionPattern): SessionDraft[] {
const [y, mo, d] = p.startDate.split("-").map(Number);
if (!y || !mo || !d || !p.startTime || !p.endTime) return [];
const first = new Date(y, mo - 1, d);
const until = p.endsBy === "until" && p.until ? (() => { const [uy, um, ud] = p.until.split("-").map(Number); return new Date(uy, um - 1, ud, 23, 59); })() : null;
const limit = p.endsBy === "count" ? Math.min(Math.max(1, p.count || 1), MAX_GENERATED) : MAX_GENERATED;
const interval = Math.max(1, p.interval || 1);
const weekdays = p.frequency === "weekly" ? (p.weekdays.length ? [...p.weekdays].sort() : [first.getDay()]) : [];
const out: SessionDraft[] = [];
const push = (day: Date) => {
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 });
};
if (p.frequency === "daily") {
for (let i = 0, day = new Date(first); out.length < limit; i++, day.setDate(day.getDate() + interval)) {
if (until && day > until) break;
push(day);
if (i > 5000) break;
}
return out;
}
// Weekly: walk week by week from the week containing the start date (weeks start Sunday).
const weekStart = new Date(first);
weekStart.setDate(first.getDate() - first.getDay());
for (let w = 0; out.length < limit && w < 1000; w += interval) {
for (const wd of weekdays) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + w * 7 + wd);
if (day < first) continue;
if (until && day > until) return out;
if (out.length >= limit) return out;
push(day);
}
}
return out;
}
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 };
}
/** Sessions that aren't cancelled, earliest first. */
export function activeSessions<T extends { startsAt: string; cancelled?: boolean; cancelledAt?: string | null }>(sessions: T[] | undefined | null): T[] {
return (sessions || []).filter(s => !(s.cancelled || s.cancelledAt)).sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime());
}
/** "Week 1" — the session's label, else its position among active sessions. */
export function sessionLabel(session: EventSession, sessions: EventSession[] | undefined | null): string {
if (session.label) return session.label;
const idx = activeSessions(sessions).findIndex(s => s.id === session.id);
return `Session ${idx >= 0 ? idx + 1 : ""}`.trim();
}
const dayFmt = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" });
const timeFmt = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false });
/** "Wed 4 Nov, 18:30–20:30" */
export function formatSessionWhen(s: { startsAt: string; endsAt: string }): string {
const start = new Date(s.startsAt);
const end = new Date(s.endsAt);
const sameDay = start.toDateString() === end.toDateString();
return sameDay
? `${dayFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`
: `${dayFmt.format(start)} ${timeFmt.format(start)} – ${dayFmt.format(end)} ${timeFmt.format(end)}`;
}
/** "5 sessions · Wed 4 Nov – Wed 2 Dec" for event cards; null for single-date events. */
export function scheduleSummary(event: Pick<Event, "scheduleType" | "sessions">): string | null {
if (event.scheduleType !== "sessions") return null;
const active = activeSessions(event.sessions);
if (active.length === 0) return null;
const first = dayFmt.format(new Date(active[0].startsAt));
const last = dayFmt.format(new Date(active[active.length - 1].startsAt));
return active.length === 1 ? `1 session · ${first}` : `${active.length} sessions · ${first} – ${last}`;
}
/** When public registration closes (falls back for older API responses). */
export function registrationClosesAt(event: Pick<Event, "registrationClosesAt" | "registrationDeadline" | "endDate">): Date {
if (event.registrationClosesAt) return new Date(event.registrationClosesAt);
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
return deadline && deadline < end ? deadline : end;
}
/** A main ticket's price for a late joiner — same rule as the backend. */
export function latePrice(price: number, info: LateJoinInfo | null | undefined): number {
if (!info || !info.active || !(price > 0)) return price;
if (info.mode === "fixed") return Math.min(price, Math.max(0, Number(info.fixedPrice)));
if (info.mode === "prorata") {
if (!info.totalSessions) return price;
const rounded = Math.round((price * info.sessionsLeft / info.totalSessions) / 10) * 10;
return Math.min(price, Math.max(10, rounded));
}
return price;
}
export const REMINDER_OPTIONS = [
{ minutes: 60, label: "1 hour before" },
{ minutes: 180, label: "3 hours before" },
{ minutes: 1440, label: "1 day before" },
] as const;
export const CERTIFICATE_TEMPLATES = [
{ id: "classic", name: "Classic", blurb: "Double border, serif type" },
{ id: "modern", name: "Modern", blurb: "Clean, with a colour bar" },
{ id: "church", name: "Church", blurb: "Logo-led and soft" },
{ id: "kids", name: "Kids", blurb: "Bright and playful" },
] as const;
export const DEFAULT_CERTIFICATE_BODY = "This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.";
/** Label of the session open for check-in today (whole-day rule, browser time), or null. */
export function currentSessionLabel(event: Pick<Event, "scheduleType" | "sessions"> | null | undefined): string | null {
if (!event || event.scheduleType !== "sessions") return null;
const today = new Date().toDateString();
const s = activeSessions(event.sessions).find(x => new Date(x.startsAt).toDateString() === today);
return s ? sessionLabel(s, event.sessions) : null;
}
+3 -1
View File
@@ -1,5 +1,6 @@
import type { Ticket as TicketRecord } from "@/types"; import type { Ticket as TicketRecord } from "@/types";
import { formatDate } from "@/lib/date"; import { formatDate } from "@/lib/date";
import { scheduleSummary } from "@/lib/sessions";
// One ticket design for every printed ticket (user dashboard, staff event tickets, at the // 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. // door). The emailed/WhatsApp PDF in backend/src/controllers/ticketController.js mirrors it.
@@ -17,7 +18,8 @@ export function ticketTypeLabel(t: TicketRecord): string {
/** A ticket card. `buyerName` adds a "Purchased by" line. */ /** A ticket card. `buyerName` adds a "Purchased by" line. */
export function ticketCardHtml(t: TicketRecord, buyerName?: string | null): string { export function ticketCardHtml(t: TicketRecord, buyerName?: string | null): string {
const eventTitle = t.event?.title || t.eventId || "Event"; const eventTitle = t.event?.title || t.eventId || "Event";
const dateStr = t.event?.startDate ? formatDate(t.event.startDate) : ""; // 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) : "");
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(t.qrCode || t.id)}`; const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(t.qrCode || t.id)}`;
return ` return `
<div class="ticket"> <div class="ticket">
+87
View File
@@ -150,6 +150,93 @@ export type Event = {
closedById?: string | null; closedById?: string | null;
reopenedAt?: string | null; reopenedAt?: string | null;
reopenedById?: string | null; reopenedById?: string | null;
// Multi-day events (see backend utils/sessions.js). startDate/endDate follow the sessions.
scheduleType?: "single" | "sessions";
attendanceMode?: "whole" | "pick";
sessions?: EventSession[];
nextSession?: EventSession | null;
// When public registration closes: the deadline, the first session (or session N with
// late joining), or the end date — whichever applies.
registrationClosesAt?: string;
lateJoinEnabled?: boolean;
lateJoinUntilSessionId?: string | null;
latePricing?: "full" | "prorata" | "fixed";
lateFixedPrice?: number | null;
lateJoin?: LateJoinInfo | null;
checkInWindow?: "whole_day" | "session_only";
sessionReminders?: { offsets: number[]; channels: ("email" | "whatsapp")[] } | null;
certificatesEnabled?: boolean;
certificateConfig?: CertificateConfig | null;
// Set by GET /api/events while an early-bird tier is still running.
earlyBirdEndsAt?: string | null;
isSoldOut?: boolean;
};
export type EventSession = {
id: string;
eventId?: string;
label?: string | null;
startsAt: string;
endsAt: string;
location?: string | null;
cancelledAt?: string | null;
cancelled?: boolean;
order?: number;
};
export type LateJoinInfo = {
active: boolean;
mode: "full" | "prorata" | "fixed";
sessionsLeft: number;
totalSessions: number;
fixedPrice: number | null;
};
export type CertificateTemplate = "classic" | "modern" | "church" | "kids";
export type CertificateConfig = {
template: CertificateTemplate;
title: string;
body: string;
signatureName?: string;
signatureRole?: string;
signatureImage?: string;
rule: { type: "all" | "count" | "percent"; value: number | null };
sendOnIssue?: boolean;
};
// GET /api/attendance/:eventId
export type AttendanceColumn = { id: string; label: string; startsAt: string; endsAt: string; cancelled: boolean };
export type AttendanceRow = {
ticketId: string;
name: string;
bookedBy: string;
email: string;
phone: string;
option: string;
variant: string | null;
quantity: number;
checkIns: Record<string, number>;
attended: number;
total: number;
completed: boolean;
certificate: { id: string; code: string; issuedAt: string; revokedAt: string | null; manual: boolean; emailedAt: string | null } | null;
};
export type AttendanceRegister = {
event: Pick<Event, "id" | "title" | "scheduleType" | "startDate" | "endDate" | "cashupStatus" | "certificatesEnabled"> & { certificateConfig: CertificateConfig };
columns: AttendanceColumn[];
rows: AttendanceRow[];
totals: Record<string, number>;
};
// GET /api/attendance/certificates/mine
export type MyCertificate = {
id: string;
code: string;
name: string;
issuedAt: string;
sessionsAttended: number;
totalSessions: number;
event: { id: string; title: string; endDate: string };
}; };
export type BundleItem = { id?: string; eventOptionId: string; variantId?: string | null; quantity: number; eventOption?: { id: string; name: string; price?: number } | null; variant?: { id: string; name: string } | null }; export type BundleItem = { id?: string; eventOptionId: string; variantId?: string | null; quantity: number; eventOption?: { id: string; name: string; price?: number } | null; variant?: { id: string; name: string } | null };