Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66968768f | ||
|
|
2166f94247 |
@@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.16.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Waitlist for sold-out tickets. A new event setting (event wizard → Visibility) turns it on, with how long offered places are held (24 hours by default) and whether someone can be offered fewer places than they asked for.
|
||||
- Sold-out tickets show "Join waitlist" on the event page, the register page and the events list. Guests can join on events that allow guest registration.
|
||||
- When places free up, the next person is emailed/WhatsApped a claim link, and the places are held for them so nobody else can buy them. Places free up through a cancellation (by the person or staff), a smaller booking, or a raised stock limit.
|
||||
- The claim link opens the register page with the places pre-selected, and works without logging in.
|
||||
- Unclaimed offers expire (checked every 10 minutes) and move on to the next person.
|
||||
- People can leave a waitlist from My Events, which now lists their waitlists and any places held for them.
|
||||
- Supervisor → Waitlist page: the queue per event, with offer now (even over the stock limit), move up/down and remove.
|
||||
- The daily summary email shows each event's waitlist count.
|
||||
- New `/api/waitlist` endpoints. `POST /api/registrations` accepts `waitlistToken`.
|
||||
- Requires the `add_waitlist` database migration.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Manual registrations (Manual registration, At the door, the kiosk) and adding places to an existing booking never checked stock, so either could take an option over its stock limit. Both are now checked like a normal registration.
|
||||
- Stock is now counted in one place (`utils/stock.js`) for registration checks, `availableCount` and `isSoldOut`, including places held for waitlist offers.
|
||||
|
||||
## [1.15.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
+12
-1
@@ -134,6 +134,13 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
added or changed later from My Events, and attendees can be sent a link to fill in
|
||||
their own form. Available on the registration page, manual registration, At the
|
||||
door and the kiosk.
|
||||
- **Waitlist** — on events with a waitlist, sold-out tickets show "Join waitlist"
|
||||
(on the event page, the register page and the event list). When places free up — a
|
||||
cancellation, a smaller booking or a higher stock limit — the next person is
|
||||
emailed/WhatsApped a claim link and the places are held for them (24 hours by
|
||||
default) so nobody else can buy them. Unclaimed offers expire and move on; people
|
||||
who asked for more than came free can be offered what's free or skipped. Claiming
|
||||
works without logging in. People can leave from My Events.
|
||||
- **Parental consent (youth events)** — on events that require it, every attendee
|
||||
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
|
||||
a WhatsApp with a consent link. They read the event's consent text, optionally give
|
||||
@@ -292,6 +299,8 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
Reconcile (Yoco transactions), and Payment Link.
|
||||
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
|
||||
discount codes, and see who used each one.
|
||||
- **Waitlist** (`/dashboard/supervisor/waitlist`) — the queue per event: offer a
|
||||
place now, move people up or down, or remove them.
|
||||
- **Parental consent** (`/dashboard/supervisor/consent`) — consent status per
|
||||
attendee on youth events, resend (one or all pending), re-request after the
|
||||
consent text changes, and a printable medical & emergency list (audited).
|
||||
@@ -420,6 +429,7 @@ falling back to a general guide.
|
||||
|-----|----------|
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
|
||||
| Daily event summary email | 07:00 daily |
|
||||
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_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) |
|
||||
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
|
||||
@@ -454,9 +464,10 @@ falling back to a general guide.
|
||||
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
|
||||
| `/setup` | First run | Setup wizard |
|
||||
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
|
||||
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -54,6 +54,9 @@ SCHEDULED_EMAILS_ENABLED=true
|
||||
SCHEDULED_EMAILS_INTERVAL_MS=30000
|
||||
# Daily 09:00 parental-consent job: reminders, and deleting medical details after events
|
||||
CONSENT_JOBS_ENABLED=true
|
||||
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
|
||||
WAITLIST_JOB_ENABLED=true
|
||||
# WAITLIST_JOB_INTERVAL_MS=600000
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
@@ -20,6 +20,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Registrations](#registrations)
|
||||
- [Coupons](#coupons)
|
||||
- [Parental consent](#parental-consent)
|
||||
- [Waitlist](#waitlist)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -277,6 +278,28 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## Waitlist
|
||||
|
||||
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity |
|
||||
| GET | `/api/waitlist/mine` | user+ | My active entries |
|
||||
| DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) |
|
||||
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers |
|
||||
| GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event |
|
||||
| POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit |
|
||||
| POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` |
|
||||
| POST | `/api/waitlist/:id/remove` | supervisor+ | Take someone off |
|
||||
|
||||
How offers work:
|
||||
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks.
|
||||
- Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
|
||||
- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers.
|
||||
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
|
||||
- Manual registration and adding places to an existing booking are now checked against stock too.
|
||||
|
||||
## Parental consent
|
||||
|
||||
Youth events (`requiresParentalConsent`, plus `consentText`, `consentCollectMedical`, `consentCollectEmergency` and `consentCollectPhoto` on the event) need every main place named with a `dateOfBirth`. Attendees under 18 on the start date also need `parentName`, `parentEmail` and `parentPhone`. Changing `consentText` bumps `consentTextVersion`.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WaitlistStatus" AS ENUM ('waiting', 'offered', 'claimed', 'expired', 'removed', 'left');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "waitlistEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "waitlistOfferHours" INTEGER NOT NULL DEFAULT 24,
|
||||
ADD COLUMN "waitlistPartialOffers" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WaitlistEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"status" "WaitlistStatus" NOT NULL DEFAULT 'waiting',
|
||||
"offeredQuantity" INTEGER,
|
||||
"offerTokenHash" TEXT,
|
||||
"offeredAt" TIMESTAMP(3),
|
||||
"offerExpiresAt" TIMESTAMP(3),
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"registrationId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WaitlistEntry_offerTokenHash_key" ON "WaitlistEntry"("offerTokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_eventId_status_position_idx" ON "WaitlistEntry"("eventId", "status", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_eventOptionId_status_idx" ON "WaitlistEntry"("eventOptionId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_variantId_idx" ON "WaitlistEntry"("variantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_userId_idx" ON "WaitlistEntry"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_registrationId_idx" ON "WaitlistEntry"("registrationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -103,7 +103,8 @@ model User {
|
||||
couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy")
|
||||
|
||||
attendeeProfiles Attendee[]
|
||||
heldTickets Ticket[] @relation("TicketHolder")
|
||||
heldTickets Ticket[] @relation("TicketHolder")
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
twoFactorChallenges TwoFactorChallenge[]
|
||||
trustedDevices TrustedDevice[]
|
||||
@@ -133,6 +134,12 @@ model Event {
|
||||
consentCollectMedical Boolean @default(false)
|
||||
consentCollectEmergency Boolean @default(false)
|
||||
consentCollectPhoto Boolean @default(false)
|
||||
// Waitlist for sold-out options: when places free up, the next person is offered
|
||||
// them for waitlistOfferHours. waitlistPartialOffers: offer what's free even if it's
|
||||
// fewer places than asked for (otherwise skip to the next person who fits).
|
||||
waitlistEnabled Boolean @default(false)
|
||||
waitlistOfferHours Int @default(24)
|
||||
waitlistPartialOffers Boolean @default(true)
|
||||
contactName String?
|
||||
contactPhone String?
|
||||
contactEmail String?
|
||||
@@ -167,6 +174,7 @@ model Event {
|
||||
notifyRecipients User[] @relation("EventNotifyRecipients")
|
||||
coupons CouponEvent[]
|
||||
consentRequests ConsentRequest[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@ -189,6 +197,7 @@ model EventOption {
|
||||
variants OptionVariant[]
|
||||
costs EventCost[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -223,6 +232,7 @@ model OptionVariant {
|
||||
registrationOptions RegistrationOption[]
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -249,6 +259,7 @@ model Registration {
|
||||
formDrafts FormDraft[]
|
||||
couponRedemptions CouponRedemption[]
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([eventId])
|
||||
@@ -1033,3 +1044,44 @@ model ConsentRequest {
|
||||
@@index([attendeeId, createdAt])
|
||||
@@index([eventId, status])
|
||||
}
|
||||
|
||||
// ─── Waitlist ─────────────────────────────────────────────────────────────────
|
||||
|
||||
enum WaitlistStatus {
|
||||
waiting
|
||||
offered // places held for them until offerExpiresAt
|
||||
claimed // they registered with the offer
|
||||
expired // the offer ran out
|
||||
removed // taken off by staff
|
||||
left // they left the waitlist
|
||||
}
|
||||
|
||||
model WaitlistEntry {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
userId String
|
||||
quantity Int
|
||||
position Int
|
||||
status WaitlistStatus @default(waiting)
|
||||
offeredQuantity Int?
|
||||
offerTokenHash String? @unique
|
||||
offeredAt DateTime?
|
||||
offerExpiresAt DateTime?
|
||||
claimedAt DateTime?
|
||||
registrationId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([eventId, status, position])
|
||||
@@index([eventOptionId, status])
|
||||
@@index([variantId])
|
||||
@@index([userId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) {
|
||||
// @access Private/Admin
|
||||
const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -69,6 +69,9 @@ const createEvent = async (req, res) => {
|
||||
consentCollectMedical: consentCollectMedical === true,
|
||||
consentCollectEmergency: consentCollectEmergency === true,
|
||||
consentCollectPhoto: consentCollectPhoto === true,
|
||||
waitlistEnabled: waitlistEnabled === true,
|
||||
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
|
||||
waitlistPartialOffers: waitlistPartialOffers !== false,
|
||||
contactName: contactName || null,
|
||||
contactPhone: contactPhone || null,
|
||||
contactEmail: contactEmail || null,
|
||||
@@ -219,12 +222,8 @@ const getEvents = async (req, res) => {
|
||||
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
|
||||
let isSoldOut = false;
|
||||
if (limitedOpts.length > 0) {
|
||||
const soldCounts = await Promise.all(limitedOpts.map(opt =>
|
||||
prisma.registrationOption.aggregate({
|
||||
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true },
|
||||
}).then(r => r._sum?.quantity || 0)
|
||||
));
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
|
||||
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
|
||||
}
|
||||
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
|
||||
@@ -425,22 +424,15 @@ const getEventById = async (req, res) => {
|
||||
try {
|
||||
if (canIncludeVariants) {
|
||||
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
|
||||
// Total sold quantity for this option (all non-cancelled)
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const soldCount = soldAgg._sum?.quantity || 0;
|
||||
// Taken = non-cancelled registrations + places held for live waitlist offers
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const soldCount = await takenCount({ eventOptionId: opt.id });
|
||||
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
|
||||
|
||||
// Per-variant stock
|
||||
const variantsWithStock = await Promise.all((opt.variants || []).map(async v => {
|
||||
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
|
||||
const vAgg = await prisma.registrationOption.aggregate({
|
||||
where: { variantId: v.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const vSold = vAgg._sum?.quantity || 0;
|
||||
const vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
|
||||
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
|
||||
}));
|
||||
|
||||
@@ -544,7 +536,7 @@ const updateEvent = async (req, res) => {
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
@@ -566,6 +558,9 @@ const updateEvent = async (req, res) => {
|
||||
consentCollectMedical: consentCollectMedical !== undefined ? consentCollectMedical === true : event.consentCollectMedical,
|
||||
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
|
||||
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
|
||||
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
|
||||
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
|
||||
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
|
||||
// Editing the consent text starts a new version; parents who already agreed keep
|
||||
// their record of the old one (supervisors can re-request consent).
|
||||
...(consentText !== undefined && String(consentText || '') !== String(event.consentText || '')
|
||||
@@ -899,6 +894,10 @@ const updateEventOption = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (stockLimit !== undefined || variants !== undefined) {
|
||||
require('../utils/waitlist').queueWaitlist(eventOption.eventId);
|
||||
}
|
||||
|
||||
const refreshed = await prisma.eventOption.findUnique({
|
||||
where: { id: updatedEventOption.id },
|
||||
include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } }
|
||||
|
||||
@@ -11,6 +11,8 @@ const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateR
|
||||
const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { ensureConsentRequests } = require('../utils/consent');
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
@@ -84,33 +86,22 @@ async function priceRequestedOption(eventOption, variantId, qty, buyer) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check overall stock availability for an EventOption.
|
||||
* Check overall stock availability for an EventOption. Places held for live waitlist
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
* Returns { available: boolean, remaining: number|null }
|
||||
*/
|
||||
async function checkOptionStock(option, requestedQty) {
|
||||
async function checkOptionStock(option, requestedQty, excludeEntryId = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
|
||||
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: { eventOptionId: option.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const sold = soldAgg._sum?.quantity || 0;
|
||||
const remaining = option.stockLimit - sold;
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check stock availability for a specific variant.
|
||||
* Check stock availability for a specific variant (same rules as checkOptionStock).
|
||||
*/
|
||||
async function checkVariantStock(variant, requestedQty) {
|
||||
async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
|
||||
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: { variantId: variant.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const sold = soldAgg._sum?.quantity || 0;
|
||||
const remaining = variant.stockLimit - sold;
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
@@ -119,7 +110,7 @@ async function checkVariantStock(variant, requestedQty) {
|
||||
// @access Private
|
||||
const createRegistration = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery } = req.body;
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken } = req.body;
|
||||
|
||||
// Check if event exists
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
@@ -147,15 +138,26 @@ const createRegistration = async (req, res) => {
|
||||
throw new Error('This event is closed and no longer accepting registrations.');
|
||||
}
|
||||
|
||||
// A waitlist claim link: its token identifies the person and releases the places
|
||||
// held for them. It works without logging in, the same way a consent link does.
|
||||
let waitlistOffer = null;
|
||||
if (waitlistToken) {
|
||||
waitlistOffer = await findOfferByToken(waitlistToken);
|
||||
if (!waitlistOffer || waitlistOffer.eventId !== eventId) { res.status(400); throw new Error('This waitlist link is not valid.'); }
|
||||
if (waitlistOffer.status !== 'offered') { res.status(400); throw new Error(`This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`); }
|
||||
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) { res.status(400); throw new Error('This waitlist offer has expired.'); }
|
||||
if (req.user && req.user.id !== waitlistOffer.userId) { res.status(403); throw new Error('This waitlist offer belongs to someone else.'); }
|
||||
}
|
||||
|
||||
// Enforce auth unless event explicitly opts out
|
||||
const eventRequiresAuth = event.requiresAuth !== false;
|
||||
if (eventRequiresAuth && !req.user) {
|
||||
if (eventRequiresAuth && !req.user && !waitlistOffer) {
|
||||
res.status(401);
|
||||
throw new Error('Authentication required for this event');
|
||||
}
|
||||
|
||||
// Unauthenticated guest registration is only permitted for free events
|
||||
if (!req.user) {
|
||||
if (!req.user && !waitlistOffer) {
|
||||
const totalDueCheck = (event.eventOptions || []).reduce((sum, eo) => sum + (eo.price || 0), 0);
|
||||
// Also check against the requested options specifically
|
||||
const requestedTotal = (options || []).reduce((sum, opt) => {
|
||||
@@ -172,6 +174,8 @@ const createRegistration = async (req, res) => {
|
||||
let userId;
|
||||
if (req.user) {
|
||||
userId = req.user.id;
|
||||
} else if (waitlistOffer) {
|
||||
userId = waitlistOffer.userId;
|
||||
} else {
|
||||
// Guest registration: find existing user by email or create inactive placeholder
|
||||
if (!guestEmail || !guestName) {
|
||||
@@ -234,7 +238,7 @@ const createRegistration = async (req, res) => {
|
||||
|
||||
// Check overall option stock
|
||||
try {
|
||||
const stockCheck = await checkOptionStock(eventOption, qty);
|
||||
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id);
|
||||
if (!stockCheck.available) {
|
||||
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
@@ -254,7 +258,7 @@ const createRegistration = async (req, res) => {
|
||||
throw new Error(`Variant not found for option "${eventOption.name}"`);
|
||||
}
|
||||
try {
|
||||
const vStock = await checkVariantStock(variant, qty);
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id);
|
||||
if (!vStock.available) {
|
||||
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
@@ -271,7 +275,8 @@ const createRegistration = async (req, res) => {
|
||||
let priceSnapshot = null;
|
||||
let appliedTierId = null;
|
||||
if (canIncludeTiers) {
|
||||
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, req.user));
|
||||
const buyer = req.user || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
|
||||
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, buyer));
|
||||
} else {
|
||||
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
|
||||
}
|
||||
@@ -414,6 +419,12 @@ const createRegistration = async (req, res) => {
|
||||
await createAttendees(registration.id, cleanAttendees);
|
||||
queueConsentRequests(registration.id);
|
||||
}
|
||||
if (waitlistOffer) {
|
||||
await prisma.waitlistEntry.update({
|
||||
where: { id: waitlistOffer.id },
|
||||
data: { status: 'claimed', claimedAt: new Date(), registrationId: registration.id, offerTokenHash: null },
|
||||
});
|
||||
}
|
||||
if (ticketDelivery) {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
||||
}
|
||||
@@ -738,6 +749,7 @@ const updateRegistrationStatus = async (req, res) => {
|
||||
// paths land under the one 'registration_cancelled' filter in the audit log.
|
||||
if (status === 'cancelled' && registration.status !== 'cancelled') {
|
||||
await releaseRegistrationCoupons(req.params.id);
|
||||
queueWaitlist(registration.eventId);
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
@@ -823,6 +835,7 @@ const cancelRegistration = async (req, res) => {
|
||||
}
|
||||
});
|
||||
await releaseRegistrationCoupons(req.params.id);
|
||||
queueWaitlist(registration.eventId);
|
||||
|
||||
// Only log when staff cancels on someone else's behalf — a routine self-service
|
||||
// cancellation isn't an admin action worth cluttering the audit trail with.
|
||||
@@ -932,7 +945,8 @@ const createManualRegistration = async (req, res) => {
|
||||
throw new Error('At least one option must be selected');
|
||||
}
|
||||
|
||||
// Validate each option up front, before any account is created or changed.
|
||||
// Validate each option up front, before any account is created or changed —
|
||||
// including stock, which staff registrations previously skipped.
|
||||
for (const option of options) {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption) {
|
||||
@@ -943,6 +957,14 @@ const createManualRegistration = async (req, res) => {
|
||||
res.status(400);
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
const variant = option.variantId ? (eventOption.variants || []).find(v => v.id === option.variantId) : null;
|
||||
const oStock = await checkOptionStock(eventOption, option.quantity);
|
||||
const vStock = variant ? await checkVariantStock(variant, option.quantity) : { available: true };
|
||||
if (!oStock.available || !vStock.available) {
|
||||
res.status(400);
|
||||
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
|
||||
throw new Error(`"${eventOption.name}${variant ? ` (${variant.name})` : ''}" is sold out or does not have enough stock (${left} remaining). Raise the stock limit to add more.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
|
||||
@@ -1508,6 +1530,15 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
let removalPlan = null;
|
||||
|
||||
if (delta > 0) {
|
||||
// Adding places needs stock, like a new registration does.
|
||||
const oStock = await checkOptionStock(eventOption, delta);
|
||||
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
|
||||
const vStock = variant ? await checkVariantStock(variant, delta) : { available: true };
|
||||
if (!oStock.available || !vStock.available) {
|
||||
res.status(400);
|
||||
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
|
||||
throw new Error(`Only ${left} more "${eventOption.name}${variant ? ` (${variant.name})` : ''}" available.`);
|
||||
}
|
||||
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, delta, registration.user));
|
||||
newTotalDue += existingTotal + delta * (priceSnapshot || 0);
|
||||
} else if (delta < 0) {
|
||||
@@ -1686,6 +1717,7 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
|
||||
const { sendRegistrationUpdatedEmails } = require('../utils/notifications');
|
||||
sendRegistrationUpdatedEmails(registrationId).catch(e => console.error('Failed to send registration updated emails:', e));
|
||||
if (resolvedUpdateOptions.some(o => o.delta < 0) || oldByKey.size > newKeys.size) queueWaitlist(registration.eventId);
|
||||
|
||||
// If registration is now paid, regenerate tickets and send them
|
||||
if (updated.status === 'paid') {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { remainingFor } = require('../utils/stock');
|
||||
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken } = require('../utils/waitlist');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const ENTRY_INCLUDE = {
|
||||
event: { select: { id: true, title: true, startDate: true, picture: true } },
|
||||
eventOption: { select: { id: true, name: true } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
};
|
||||
|
||||
// @desc Join the waitlist for a sold-out option
|
||||
// @route POST /api/waitlist
|
||||
// @access Public (login needed when the event requires it)
|
||||
// Body: { eventId, eventOptionId, variantId?, quantity, guestName?, guestEmail?, guestPhone? }
|
||||
const joinWaitlist = async (req, res) => {
|
||||
try {
|
||||
const { eventId, eventOptionId, variantId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
|
||||
const qty = parseInt(quantity, 10);
|
||||
if (!eventId || !eventOptionId || !(qty >= 1)) { res.status(400); throw new Error('eventId, eventOptionId and a quantity of at least 1 are required'); }
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } } } });
|
||||
if (!event || !event.isActive) { res.status(404); throw new Error('Event not found'); }
|
||||
if (!event.waitlistEnabled) { res.status(400); throw new Error('This event does not have a waitlist.'); }
|
||||
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) { res.status(400); throw new Error('This event is no longer taking registrations.'); }
|
||||
const option = event.eventOptions.find(o => o.id === eventOptionId);
|
||||
if (!option) { res.status(400); throw new Error('Unknown ticket option for this event'); }
|
||||
const variant = variantId ? (option.variants || []).find(v => v.id === variantId) : null;
|
||||
if (variantId && !variant) { res.status(400); throw new Error('Unknown variant for this option'); }
|
||||
|
||||
const free = await remainingFor(option, variant);
|
||||
if (free === null || free >= qty) { res.status(400); throw new Error('There are places available — you can register now.'); }
|
||||
|
||||
// Who's joining
|
||||
let userId = req.user?.id;
|
||||
if (!userId) {
|
||||
if (event.requiresAuth !== false) { res.status(401); throw new Error('Log in to join the waitlist for this event.'); }
|
||||
if (!guestName || !guestEmail) { res.status(400); throw new Error('Name and email are required to join the waitlist'); }
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) { res.status(409); throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.'); }
|
||||
userId = match.user?.id || (await prisma.user.create({
|
||||
data: { id: uuidv4(), name: String(guestName).trim(), email: String(guestEmail).trim(), password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
|
||||
})).id;
|
||||
}
|
||||
|
||||
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, status: { in: ACTIVE } } });
|
||||
if (existing) { res.status(409); throw new Error('You\'re already on the waitlist for this.'); }
|
||||
|
||||
const last = await prisma.waitlistEntry.aggregate({ where: { eventId }, _max: { position: true } });
|
||||
const entry = await prisma.waitlistEntry.create({
|
||||
data: { eventId, eventOptionId, variantId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
|
||||
include: ENTRY_INCLUDE,
|
||||
});
|
||||
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, status: 'waiting', position: { lt: entry.position } } });
|
||||
res.status(201).json({ ...entry, ahead, message: `You're on the waitlist${ahead ? ` — ${ahead} ahead of you` : ''}. We'll email or WhatsApp you if a place opens up.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The caller's waitlist entries
|
||||
// @route GET /api/waitlist/mine
|
||||
// @access Private
|
||||
const myWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entries = await prisma.waitlistEntry.findMany({
|
||||
where: { userId: req.user.id, status: { in: ACTIVE }, event: { endDate: { gte: new Date() } } },
|
||||
include: ENTRY_INCLUDE,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Leave the waitlist (an open offer is passed on)
|
||||
// @route DELETE /api/waitlist/:id
|
||||
// @access Private (owner)
|
||||
const leaveWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry || entry.userId !== req.user.id) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('You\'re no longer on this waitlist.'); }
|
||||
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'left', offerTokenHash: null } });
|
||||
if (entry.status === 'offered') queueWaitlist(entry.eventId);
|
||||
res.json({ message: 'You\'ve left the waitlist.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc What a claim link offers
|
||||
// @route GET /api/waitlist/claim/:token
|
||||
// @access Public (the token is the credential)
|
||||
const claimInfo = async (req, res) => {
|
||||
try {
|
||||
const entry = await findOfferByToken(req.params.token);
|
||||
if (!entry) { res.status(404); throw new Error('This waitlist link is not valid or has already been used.'); }
|
||||
const expired = entry.status !== 'offered' || new Date(entry.offerExpiresAt) < new Date();
|
||||
res.json({
|
||||
status: expired ? 'expired' : 'offered',
|
||||
eventId: entry.eventId,
|
||||
eventTitle: entry.event.title,
|
||||
eventStart: entry.event.startDate,
|
||||
eventOptionId: entry.eventOptionId,
|
||||
optionName: entry.eventOption.name,
|
||||
variantId: entry.variantId,
|
||||
variantName: entry.variant?.name || null,
|
||||
quantity: entry.offeredQuantity,
|
||||
requested: entry.quantity,
|
||||
expiresAt: entry.offerExpiresAt,
|
||||
name: entry.user?.name,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The waitlist for an event
|
||||
// @route GET /api/waitlist/events/:eventId
|
||||
// @access Private/Staff
|
||||
const eventWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entries = await prisma.waitlistEntry.findMany({
|
||||
where: { eventId: req.params.eventId, ...(req.query.all === 'true' ? {} : { status: { in: [...ACTIVE, 'claimed', 'expired'] } }) },
|
||||
include: { ...ENTRY_INCLUDE, user: { select: { id: true, name: true, email: true, phoneNumber: true } } },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Offer a place to someone on the waitlist now (even without free stock)
|
||||
// @route POST /api/waitlist/:id/offer
|
||||
// @access Private/Supervisor
|
||||
const offerManually = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!['waiting', 'expired'].includes(entry.status)) { res.status(400); throw new Error(`This person is ${entry.status === 'offered' ? 'already holding an offer' : `no longer waiting (${entry.status})`}.`); }
|
||||
const qty = Math.max(1, Math.min(entry.quantity, parseInt(req.body?.quantity, 10) || entry.quantity));
|
||||
const updated = await makeOffer(entry.id, qty);
|
||||
res.json({ ...updated, message: `Offered ${qty} place(s); the claim link has been sent.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Move someone up or down the queue
|
||||
// @route POST /api/waitlist/:id/move { direction: 'up' | 'down' }
|
||||
// @access Private/Supervisor
|
||||
const moveEntry = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
const up = req.body?.direction === 'up';
|
||||
const neighbour = await prisma.waitlistEntry.findFirst({
|
||||
where: { eventId: entry.eventId, status: 'waiting', position: up ? { lt: entry.position } : { gt: entry.position } },
|
||||
orderBy: { position: up ? 'desc' : 'asc' },
|
||||
});
|
||||
if (!neighbour) return res.json({ message: 'Already at the ' + (up ? 'front.' : 'back.') });
|
||||
await prisma.$transaction([
|
||||
prisma.waitlistEntry.update({ where: { id: entry.id }, data: { position: neighbour.position } }),
|
||||
prisma.waitlistEntry.update({ where: { id: neighbour.id }, data: { position: entry.position } }),
|
||||
]);
|
||||
res.json({ message: 'Moved.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Take someone off the waitlist (an open offer is passed on)
|
||||
// @route POST /api/waitlist/:id/remove
|
||||
// @access Private/Supervisor
|
||||
const removeEntry = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('This person is not on the waitlist any more.'); }
|
||||
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'removed', offerTokenHash: null } });
|
||||
if (entry.status === 'offered') queueWaitlist(entry.eventId);
|
||||
res.json({ message: 'Removed from the waitlist.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry };
|
||||
@@ -177,6 +177,7 @@ app.use('/api/cashups', cashupRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||
app.use('/api/consent', require('./routes/consentRoutes'));
|
||||
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
|
||||
app.use('/api/backups', backupRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
@@ -633,6 +634,18 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
]},
|
||||
|
||||
{ title: 'Waitlist', base: '/api/waitlist', endpoints: [
|
||||
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
|
||||
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
|
||||
responses:[{ status:201, desc:'Joined', body:{ id:'wl-uuid-...', status:'waiting', ahead:3 }}, { status:400, desc:'Places available', body:{ message:'There are places available — you can register now.' }}]},
|
||||
{ method:'GET', path:'/api/waitlist/mine', auth:'user+', desc:'My active waitlist entries' },
|
||||
{ method:'DELETE', path:'/api/waitlist/:id', auth:'user+', desc:'Leave the waitlist' },
|
||||
{ method:'GET', path:'/api/waitlist/claim/:token', auth:'public', desc:'What a claim link offers (rate-limited)' },
|
||||
{ method:'GET', path:'/api/waitlist/events/:eventId', auth:'staff+', desc:'The queue for an event' },
|
||||
{ method:'POST', path:'/api/waitlist/:id/offer', auth:'supervisor+', desc:'Offer places now, even over the stock limit' },
|
||||
{ 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' },
|
||||
]},
|
||||
{ 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)',
|
||||
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.' }}]},
|
||||
@@ -1308,6 +1321,28 @@ app.listen(PORT, () => {
|
||||
console.warn('[daily summaries] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Waitlist job — expire unclaimed offers and offer any free places to the next people.
|
||||
try {
|
||||
const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runWaitlistJob } = require('./utils/waitlist');
|
||||
const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runWaitlistJob();
|
||||
if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`);
|
||||
} catch (e) {
|
||||
console.warn('[waitlist] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[waitlist] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily parental-consent job — reminders for unanswered requests, and purging
|
||||
// medical/emergency details a set number of days after each event.
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
|
||||
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// Claim links are credentials, so keep guessing slow.
|
||||
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.post('/', optionalAuth, joinWaitlist);
|
||||
router.get('/mine', protect, myWaitlist);
|
||||
router.get('/claim/:token', claimLimiter, claimInfo);
|
||||
router.get('/events/:eventId', protect, staff, eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, offerManually);
|
||||
router.post('/:id/move', protect, supervisor, moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, removeEntry);
|
||||
router.delete('/:id', protect, leaveWaitlist);
|
||||
|
||||
module.exports = router;
|
||||
@@ -832,7 +832,7 @@ function buildRefundEmail(payment) {
|
||||
|
||||
// ─── Daily event summary ──────────────────────────────────────────────────────
|
||||
|
||||
function buildDailySummary(ev, registrations, payments, now) {
|
||||
function buildDailySummary(ev, registrations, payments, now, waitlistCount = 0) {
|
||||
const org = getOrg();
|
||||
const subject = `Daily summary: ${ev.title} — ${new Date(now).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`;
|
||||
|
||||
@@ -917,6 +917,7 @@ function buildDailySummary(ev, registrations, payments, now) {
|
||||
${statsRow('Total registrations', totalRegistrations)}
|
||||
${statsRow('Confirmed paid', paidCount, '#059669')}
|
||||
${statsRow('Awaiting payment', pendingCount, '#d97706')}
|
||||
${ev.waitlistEnabled ? statsRow('On the waitlist', waitlistCount, '#7c3aed') : ''}
|
||||
<td style="text-align:center;padding:16px">
|
||||
<div style="font-size:24px;font-weight:800;color:#2563eb;font-family:${ff}">${fmtAmount(totalRevenue)}</div>
|
||||
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">Total collected</div>
|
||||
@@ -939,7 +940,7 @@ function buildDailySummary(ev, registrations, payments, now) {
|
||||
`Daily Summary — ${ev.title}`,
|
||||
new Date(now).toLocaleDateString(),
|
||||
'',
|
||||
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount} | Revenue: ${fmtAmount(totalRevenue)}`,
|
||||
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount}${ev.waitlistEnabled ? ` | Waitlist: ${waitlistCount}` : ''} | Revenue: ${fmtAmount(totalRevenue)}`,
|
||||
'',
|
||||
'Registrations:',
|
||||
...(regRows.length ? regRows.map(r => ` ${r.name} <${r.email}> — ${r.status} — due: ${fmtAmount(r.totalDue)} paid: ${fmtAmount(r.totalPaid)} balance: ${fmtAmount(r.balance)}`) : [' (none)']),
|
||||
@@ -1457,7 +1458,10 @@ async function sendDailyEventSummaries(now = new Date()) {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now);
|
||||
const waitlistCount = ev.waitlistEnabled
|
||||
? await prisma.waitlistEntry.count({ where: { eventId: ev.id, status: { in: ['waiting', 'offered'] } } })
|
||||
: 0;
|
||||
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now, waitlistCount);
|
||||
const to = buildEventNotifyRecipientList(ev);
|
||||
if (to.length) await sendMail({ to: to.join(','), subject, html, text });
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Ticket stock — the one place that decides how many places are taken.
|
||||
*
|
||||
* Taken = places on registrations that aren't cancelled + places held for a live
|
||||
* waitlist offer (until it expires). An option's stock limit covers all its variants;
|
||||
* a variant can also have its own limit. 0 means unlimited.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
/**
|
||||
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
|
||||
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
|
||||
* waitlist offer's hold (the person claiming it)
|
||||
*/
|
||||
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
|
||||
const scope = variantId ? { variantId } : { eventOptionId };
|
||||
const [registered, held] = await Promise.all([
|
||||
db.registrationOption.aggregate({
|
||||
where: { ...scope, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true },
|
||||
}),
|
||||
db.waitlistEntry.aggregate({
|
||||
where: {
|
||||
...scope,
|
||||
status: 'offered',
|
||||
offerExpiresAt: { gt: new Date() },
|
||||
...(excludeEntryId ? { id: { not: excludeEntryId } } : {}),
|
||||
},
|
||||
_sum: { offeredQuantity: true },
|
||||
}),
|
||||
]);
|
||||
return (registered._sum?.quantity || 0) + (held._sum?.offeredQuantity || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Places still free on a line: the tighter of the option and variant limits.
|
||||
* @returns {Promise<number|null>} null when unlimited
|
||||
*/
|
||||
async function remainingFor(option, variant, opts = {}) {
|
||||
const limits = [];
|
||||
if (option?.stockLimit > 0) {
|
||||
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id }, opts));
|
||||
}
|
||||
if (variant?.stockLimit > 0) {
|
||||
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id }, opts));
|
||||
}
|
||||
if (limits.length === 0) return null;
|
||||
return Math.max(0, Math.min(...limits));
|
||||
}
|
||||
|
||||
module.exports = { takenCount, remainingFor };
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Waitlist for sold-out ticket options.
|
||||
*
|
||||
* Rules:
|
||||
* - Only on events with waitlistEnabled, and only for a line that's actually sold out.
|
||||
* - When places free up (a cancellation, a smaller booking, a raised stock limit, or an
|
||||
* offer expiring), processWaitlist offers them to the next people in line. An offer
|
||||
* holds the places for event.waitlistOfferHours — they count as taken (utils/stock.js)
|
||||
* so the public site can't sell them — and sends a claim link by email/WhatsApp.
|
||||
* - If fewer places are free than someone asked for: with waitlistPartialOffers they're
|
||||
* offered what's free; otherwise they're skipped for the next person who fits.
|
||||
* - Claiming = registering with the link (createRegistration's waitlistToken). Early-bird
|
||||
* and coupon rules apply as usual. An unclaimed offer expires and moves on.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { remainingFor } = require('./stock');
|
||||
|
||||
const ACTIVE = ['waiting', 'offered'];
|
||||
|
||||
function waitlistError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`waitlist:${token}`).digest('hex');
|
||||
|
||||
function claimLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/waitlist/claim/${token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who gets offered what, given the free places on a line. Pure.
|
||||
* @param {Array} waiting - entries in queue order [{ id, quantity }]
|
||||
* @param {number} free - places free
|
||||
* @param {boolean} partial - offer what's free when it's less than asked for
|
||||
* @returns {Array<{ entryId: string, quantity: number }>}
|
||||
*/
|
||||
function pickOffers(waiting, free, partial) {
|
||||
const offers = [];
|
||||
let left = free;
|
||||
for (const entry of waiting) {
|
||||
if (left <= 0) break;
|
||||
if (entry.quantity <= left) {
|
||||
offers.push({ entryId: entry.id, quantity: entry.quantity });
|
||||
left -= entry.quantity;
|
||||
} else if (partial) {
|
||||
// First in line gets what's free; nobody behind them jumps the queue.
|
||||
offers.push({ entryId: entry.id, quantity: left });
|
||||
left = 0;
|
||||
}
|
||||
// else: skip to the next person who fits
|
||||
}
|
||||
return offers;
|
||||
}
|
||||
|
||||
async function notifyOffer(entry, token) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const user = entry.user;
|
||||
const link = claimLink(token);
|
||||
const item = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
|
||||
const until = new Date(entry.offerExpiresAt).toLocaleString('en-GB', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
const partialNote = entry.offeredQuantity < entry.quantity
|
||||
? ` You asked for ${entry.quantity}; ${entry.offeredQuantity} came free, and you can take those now.` : '';
|
||||
const line = `${entry.offeredQuantity} × ${item} for ${entry.event.title} ${entry.offeredQuantity === 1 ? 'is' : 'are'} now available for you.${partialNote} We'll hold ${entry.offeredQuantity === 1 ? 'it' : 'them'} until ${until}.`;
|
||||
|
||||
const hasEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
const sends = [];
|
||||
if (hasEmail && shouldEmail(user)) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">A place has opened up!</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${line}</p>
|
||||
${ctaButton('Claim my place', link)}
|
||||
${fallbackLink(link)}
|
||||
`, { preheader: `Your waitlist place for ${entry.event.title}` });
|
||||
sends.push(sendMail({ to: user.email, subject: `A place is available: ${entry.event.title}`, html, text: `Hi ${user.name},\n\n${line}\n\nClaim it here: ${link}` }));
|
||||
}
|
||||
const wa = `🎟️ Hi ${user?.name || 'there'}, ${line} Claim it here: ${link}`;
|
||||
sends.push(hasEmail ? waText(user, wa) : waTextAny(user, wa));
|
||||
await Promise.allSettled(sends);
|
||||
}
|
||||
|
||||
const OFFER_INCLUDE = { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
|
||||
|
||||
/** Puts an entry on offer (inside the caller's transaction); returns the raw token. */
|
||||
async function writeOffer(db, entry, quantity, hours) {
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
await db.waitlistEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
status: 'offered',
|
||||
offeredQuantity: quantity,
|
||||
offerTokenHash: hashToken(token),
|
||||
offeredAt: new Date(),
|
||||
offerExpiresAt: new Date(Date.now() + (hours || 24) * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
async function notifyOffers(offers) {
|
||||
for (const { entryId, token } of offers) {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: OFFER_INCLUDE });
|
||||
if (entry) notifyOffer(entry, token).catch(e => console.warn('[waitlist] notify failed:', e?.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Offers `quantity` places to one entry (e.g. a supervisor's manual offer) and notifies. */
|
||||
async function makeOffer(entryId, quantity) {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true } });
|
||||
const token = await writeOffer(prisma, entry, quantity, entry.event.waitlistOfferHours);
|
||||
await notifyOffers([{ entryId, token }]);
|
||||
return prisma.waitlistEntry.findUnique({ where: { id: entryId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers any free places on an event to the people waiting for them. Safe to call any
|
||||
* time places might have freed up; runs one event at a time under an advisory lock.
|
||||
* @returns {Promise<number>} offers made
|
||||
*/
|
||||
async function processWaitlist(eventId) {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { variants: true } } },
|
||||
});
|
||||
if (!event || !event.waitlistEnabled || !event.isActive || event.cashupStatus === 'closed') return 0;
|
||||
if (new Date(event.endDate) < new Date()) return 0;
|
||||
|
||||
const made = [];
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'waitlist:' + eventId}))`;
|
||||
const waiting = await tx.waitlistEntry.findMany({ where: { eventId, status: 'waiting' }, orderBy: { position: 'asc' } });
|
||||
const lines = [...new Map(waiting.map(w => [`${w.eventOptionId}::${w.variantId || ''}`, w])).values()];
|
||||
for (const sample of lines) {
|
||||
const option = event.eventOptions.find(o => o.id === sample.eventOptionId);
|
||||
const variant = sample.variantId ? (option?.variants || []).find(v => v.id === sample.variantId) : null;
|
||||
if (!option) continue;
|
||||
const free = await remainingFor(option, variant, { db: tx });
|
||||
if (free === null || free <= 0) continue;
|
||||
const queue = waiting.filter(w => w.eventOptionId === sample.eventOptionId && (w.variantId || null) === (sample.variantId || null));
|
||||
for (const offer of pickOffers(queue, free, event.waitlistPartialOffers)) {
|
||||
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, event.waitlistOfferHours);
|
||||
made.push({ entryId: offer.entryId, token });
|
||||
}
|
||||
}
|
||||
}, { timeout: 20000 });
|
||||
await notifyOffers(made);
|
||||
return made.length;
|
||||
}
|
||||
|
||||
/** Marks lapsed offers as expired. @returns event ids that now have places to re-offer. */
|
||||
async function expireOffers(now = new Date()) {
|
||||
const lapsed = await prisma.waitlistEntry.findMany({ where: { status: 'offered', offerExpiresAt: { lte: now } }, select: { id: true, eventId: true } });
|
||||
if (lapsed.length === 0) return [];
|
||||
await prisma.waitlistEntry.updateMany({ where: { id: { in: lapsed.map(l => l.id) } }, data: { status: 'expired', offerTokenHash: null } });
|
||||
return [...new Set(lapsed.map(l => l.eventId))];
|
||||
}
|
||||
|
||||
/** Background job: expire lapsed offers, then re-offer free places everywhere waiting. */
|
||||
async function runWaitlistJob() {
|
||||
const expiredEvents = await expireOffers();
|
||||
const waitingEvents = await prisma.waitlistEntry.findMany({ where: { status: 'waiting' }, distinct: ['eventId'], select: { eventId: true } });
|
||||
const ids = new Set([...expiredEvents, ...waitingEvents.map(w => w.eventId)]);
|
||||
let offers = 0;
|
||||
for (const id of ids) offers += await processWaitlist(id).catch(() => 0);
|
||||
return { expired: expiredEvents.length, offers };
|
||||
}
|
||||
|
||||
/** Fire-and-forget re-offer after something freed places on an event. */
|
||||
function queueWaitlist(eventId) {
|
||||
if (!eventId) return;
|
||||
processWaitlist(eventId).catch(e => console.warn('[waitlist] process failed:', e?.message || e));
|
||||
}
|
||||
|
||||
/** A live offer from its claim token, or null. */
|
||||
async function findOfferByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 100) return null;
|
||||
return prisma.waitlistEntry.findUnique({
|
||||
where: { offerTokenHash: hashToken(token) },
|
||||
include: { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACTIVE,
|
||||
waitlistError,
|
||||
hashToken,
|
||||
pickOffers,
|
||||
makeOffer,
|
||||
processWaitlist,
|
||||
expireOffers,
|
||||
runWaitlistJob,
|
||||
queueWaitlist,
|
||||
findOfferByToken,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
const { pickOffers } = require('../src/utils/waitlist');
|
||||
|
||||
const q = (...qs) => qs.map((quantity, i) => ({ id: `e${i + 1}`, quantity }));
|
||||
|
||||
describe('pickOffers', () => {
|
||||
test('offers people in order while places last', () => {
|
||||
expect(pickOffers(q(1, 2, 1), 3, true)).toEqual([
|
||||
{ entryId: 'e1', quantity: 1 },
|
||||
{ entryId: 'e2', quantity: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('nothing free, no offers', () => {
|
||||
expect(pickOffers(q(1), 0, true)).toEqual([]);
|
||||
});
|
||||
|
||||
test('partial offers: the first in line gets what is free, nobody jumps ahead', () => {
|
||||
expect(pickOffers(q(3, 1), 2, true)).toEqual([{ entryId: 'e1', quantity: 2 }]);
|
||||
});
|
||||
|
||||
test('without partial offers, skips to the next person who fits', () => {
|
||||
expect(pickOffers(q(3, 1, 1), 2, false)).toEqual([
|
||||
{ entryId: 'e2', quantity: 1 },
|
||||
{ entryId: 'e3', quantity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('without partial offers, nobody fits', () => {
|
||||
expect(pickOffers(q(3, 4), 2, false)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -27,6 +27,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ 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/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
|
||||
@@ -772,6 +772,7 @@ interface EventDraft {
|
||||
location: string; allowNamedAttendees: boolean;
|
||||
requiresParentalConsent: boolean; consentText: string;
|
||||
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
|
||||
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
|
||||
}
|
||||
|
||||
const blankDraft = (location = ""): EventDraft => ({
|
||||
@@ -782,6 +783,7 @@ const blankDraft = (location = ""): EventDraft => ({
|
||||
location, allowNamedAttendees: false,
|
||||
requiresParentalConsent: false, consentText: "",
|
||||
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
|
||||
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
|
||||
});
|
||||
|
||||
const blankOptions = (): OptionDraft[] => [
|
||||
@@ -819,6 +821,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
allowNamedAttendees: !!ev.allowNamedAttendees,
|
||||
requiresParentalConsent: !!ev.requiresParentalConsent, consentText: ev.consentText || "",
|
||||
consentCollectMedical: !!ev.consentCollectMedical, consentCollectEmergency: !!ev.consentCollectEmergency, consentCollectPhoto: !!ev.consentCollectPhoto,
|
||||
waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== false,
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
location: ev.location || "",
|
||||
} : blankDraft(settings.org_address || ""));
|
||||
@@ -1098,6 +1101,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
consentCollectMedical: draft.consentCollectMedical,
|
||||
consentCollectEmergency: draft.consentCollectEmergency,
|
||||
consentCollectPhoto: draft.consentCollectPhoto,
|
||||
waitlistEnabled: draft.waitlistEnabled,
|
||||
waitlistOfferHours: parseInt(draft.waitlistOfferHours, 10) || 24,
|
||||
waitlistPartialOffers: draft.waitlistPartialOffers,
|
||||
contactName: draft.contactName || undefined,
|
||||
contactPhone: draft.contactPhone || undefined,
|
||||
contactEmail: draft.contactEmail || undefined,
|
||||
@@ -1364,6 +1370,27 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Waitlist</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.waitlistEnabled} onChange={e => upd({ waitlistEnabled: e.target.checked })} />
|
||||
<span><strong>Waitlist for sold-out tickets</strong> — people can join a queue; when places free up, the next person is offered them and has a set time to claim</span>
|
||||
</label>
|
||||
{draft.waitlistEnabled && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
Hold offered places for
|
||||
<input type="number" min="1" max="168" className="border rounded px-2 py-1 text-sm w-20" value={draft.waitlistOfferHours} onChange={e => upd({ waitlistOfferHours: e.target.value })} />
|
||||
hours
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.waitlistPartialOffers} onChange={e => upd({ waitlistPartialOffers: e.target.checked })} />
|
||||
<span>When fewer places free up than someone asked for, offer them what's free (otherwise skip to the next person who fits)</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Youth event</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -24,6 +24,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ 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/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
position: number;
|
||||
quantity: number;
|
||||
offeredQuantity: number | null;
|
||||
offerExpiresAt: string | null;
|
||||
status: "waiting" | "offered" | "claimed" | "expired" | "removed" | "left";
|
||||
createdAt: string;
|
||||
eventOption: { name: string };
|
||||
variant: { name: string } | null;
|
||||
user: { name: string; email: string; phoneNumber: string | null };
|
||||
};
|
||||
|
||||
const STATUS: Record<Entry["status"], { label: string; cls: string }> = {
|
||||
waiting: { label: "Waiting", cls: "bg-gray-100 text-gray-700" },
|
||||
offered: { label: "Offered", cls: "bg-amber-100 text-amber-800" },
|
||||
claimed: { label: "Claimed", cls: "bg-green-100 text-green-800" },
|
||||
expired: { label: "Offer expired", cls: "bg-red-100 text-red-800" },
|
||||
removed: { label: "Removed", cls: "bg-gray-100 text-gray-500" },
|
||||
left: { label: "Left", cls: "bg-gray-100 text-gray-500" },
|
||||
};
|
||||
|
||||
export default function WaitlistPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const canView = useMemo(() => ["admin", "supervisor", "staff"].includes(user?.role || ""), [user]);
|
||||
const canManage = user?.role === "admin" || user?.role === "supervisor";
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventId, setEventId] = useState(search.get("eventId") || "");
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all", { authToken: token })
|
||||
.then(evs => {
|
||||
const withList = (evs || []).filter(e => e.waitlistEnabled);
|
||||
setEvents(withList);
|
||||
if (!eventId && withList.length) setEventId(withList[0].id);
|
||||
})
|
||||
.catch(e => setError(e?.message || "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 {
|
||||
setEntries(await apiFetch<Entry[]>(`/api/waitlist/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load the waitlist");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const act = async (path: string, body?: any) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body });
|
||||
if (res?.message) setInfo(res.message);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const waitingCount = entries.filter(e => e.status === "waiting").length;
|
||||
const selected = events.find(e => e.id === eventId);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl 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">
|
||||
<ListChecks className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Waitlist</h1>
|
||||
<p className="text-sm text-gray-500">People waiting for sold-out tickets</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 the waitlist.</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">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<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]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No events with a waitlist</option>}
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{selected && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{waitingCount} waiting · offers are held for {selected.waitlistOfferHours} hours · {selected.waitlistPartialOffers ? "partial offers on" : "only whole requests offered"}.
|
||||
Turn the waitlist on or off in the event's Visibility step.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Person</TableHead>
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
{canManage && <TableHead className="text-right">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((e, i) => {
|
||||
const st = STATUS[e.status];
|
||||
const active = e.status === "waiting" || e.status === "offered";
|
||||
return (
|
||||
<TableRow key={e.id} className={active ? "" : "opacity-60"}>
|
||||
<TableCell className="text-sm text-gray-500">{i + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium">{e.user.name}</div>
|
||||
<div className="text-xs text-gray-500">{[e.user.email, e.user.phoneNumber].filter(Boolean).join(" · ")}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</TableCell>
|
||||
<TableCell className="text-sm">{e.quantity}{e.status === "offered" && e.offeredQuantity !== e.quantity ? ` (${e.offeredQuantity} offered)` : ""}</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">{formatDate(new Date(e.createdAt))}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${st.cls}`}>{st.label}</span>
|
||||
{e.status === "offered" && e.offerExpiresAt && <div className="text-xs text-gray-500 mt-1">until {new Date(e.offerExpiresAt).toLocaleString()}</div>}
|
||||
</TableCell>
|
||||
{canManage && (
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
{e.status === "waiting" && (
|
||||
<>
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "up" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move up"><ArrowUp className="w-3.5 h-3.5" /></button>
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "down" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move down"><ArrowDown className="w-3.5 h-3.5" /></button>
|
||||
</>
|
||||
)}
|
||||
{(e.status === "waiting" || e.status === "expired") && (
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/offer`)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 mr-1" title="Offer now, even if no place has freed up">Offer place</button>
|
||||
)}
|
||||
{active && (
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/remove`)} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Remove</button>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!busy && entries.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-sm text-gray-500 py-8">{eventId ? "Nobody is on the waitlist." : "Pick an event."}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">When places free up (a cancellation, a smaller booking, or a higher stock limit), the next people are offered them automatically. "Offer place" sends an offer straight away, even if it means going over the stock limit.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { QrImage } from "@/components/shared/QrImage";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
|
||||
import { MyWaitlists } from "@/components/shared/MyWaitlists";
|
||||
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
|
||||
|
||||
// Helper formatters
|
||||
@@ -871,6 +872,12 @@ export default function UserDashboardPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{token && (
|
||||
<div className="mb-6">
|
||||
<MyWaitlists token={token} onMessage={setInfo} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import ClientActions from "@/app/events/[id]/ClientActions";
|
||||
import { ContactButton } from "@/components/events/ContactButton";
|
||||
import { LocationMap } from "@/components/events/LocationMap";
|
||||
import { JoinWaitlistButton, soldOutLines } from "@/components/events/JoinWaitlistButton";
|
||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
@@ -40,6 +41,7 @@ type Event = {
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
location?: string | null;
|
||||
waitlistEnabled?: boolean;
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
@@ -93,6 +95,14 @@ function RegisterCta({ event }: { event: Event }) {
|
||||
);
|
||||
}
|
||||
if (eventSoldOut) {
|
||||
if (event.waitlistEnabled) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-center text-sm font-medium text-red-700">Sold out</div>
|
||||
<JoinWaitlistButton eventId={event.id} lines={soldOutLines(event.eventOptions || [])} guestsAllowed={event.requiresAuth === false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button disabled className="w-full bg-red-50 text-red-700 border border-red-200 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium">
|
||||
Sold Out
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
|
||||
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
|
||||
function lowStockThreshold(stockLimit: number): number {
|
||||
@@ -43,8 +44,11 @@ type Event = {
|
||||
requiresAuth?: boolean;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
waitlistEnabled?: boolean;
|
||||
};
|
||||
|
||||
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
|
||||
type QtyMap = Record<string, number>;
|
||||
|
||||
@@ -128,10 +132,12 @@ function OptionCard({
|
||||
opt,
|
||||
quantities,
|
||||
onQtyChange,
|
||||
waitlist,
|
||||
}: {
|
||||
opt: EventOption;
|
||||
quantities: QtyMap;
|
||||
onQtyChange: (key: string, val: number) => void;
|
||||
waitlist?: { eventId: string; guestsAllowed: boolean } | null;
|
||||
}) {
|
||||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||||
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
|
||||
@@ -155,7 +161,13 @@ function OptionCard({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSoldOut ? (
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>
|
||||
<>
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>
|
||||
{waitlist && (
|
||||
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
|
||||
lines={variants.map(v => ({ eventOptionId: opt.id, variantId: v.id, label: `${opt.name} (${v.name})` }))} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
|
||||
)}
|
||||
@@ -179,6 +191,10 @@ function OptionCard({
|
||||
<span className="text-sm">{variant.name}</span>
|
||||
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}</span>
|
||||
<StockBadge stockLimit={variant.stockLimit} availableCount={variant.availableCount} />
|
||||
{waitlist && variantSoldOut && !isSoldOut && (
|
||||
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
|
||||
lines={[{ eventOptionId: opt.id, variantId: variant.id, label: `${opt.name} (${variant.name})` }]} />
|
||||
)}
|
||||
</div>
|
||||
<QtyControl
|
||||
value={qty}
|
||||
@@ -219,6 +235,9 @@ function OptionCard({
|
||||
<span>R{price.toFixed(2)}</span>
|
||||
)}
|
||||
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
|
||||
{waitlist && isSoldOut && (
|
||||
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed} lines={[{ eventOptionId: opt.id, label: opt.name }]} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<QtyControl
|
||||
@@ -231,9 +250,41 @@ function OptionCard({
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterForm({ event }: { event: Event }) {
|
||||
export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
const { token, user } = useAuth();
|
||||
const eventId = event.id;
|
||||
const eventId = baseEvent.id;
|
||||
|
||||
// A waitlist claim link (?waitlist=<token>): the places held for this person are
|
||||
// added back to what's available, so they can choose them.
|
||||
const [waitlistToken, setWaitlistToken] = useState<string | null>(null);
|
||||
const [offer, setOffer] = useState<WaitlistOffer | null>(null);
|
||||
const [offerError, setOfferError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const t = new URLSearchParams(window.location.search).get("waitlist");
|
||||
if (!t) return;
|
||||
apiFetch<WaitlistOffer & { status: string }>(`/api/waitlist/claim/${encodeURIComponent(t)}`)
|
||||
.then(res => {
|
||||
if (res.status !== "offered") { setOfferError("This waitlist offer has expired."); return; }
|
||||
setWaitlistToken(t);
|
||||
setOffer(res);
|
||||
setQuantities(q => ({ ...q, [res.variantId ? `${res.eventOptionId}:${res.variantId}` : res.eventOptionId]: res.quantity }));
|
||||
})
|
||||
.catch((e: any) => setOfferError(e?.message || "This waitlist link is not valid."));
|
||||
}, []);
|
||||
const event = useMemo<Event>(() => {
|
||||
if (!offer) return baseEvent;
|
||||
return {
|
||||
...baseEvent,
|
||||
eventOptions: (baseEvent.eventOptions || []).map(o => {
|
||||
if (o.id !== offer.eventOptionId) return o;
|
||||
return {
|
||||
...o,
|
||||
availableCount: o.availableCount !== undefined ? o.availableCount + offer.quantity : o.availableCount,
|
||||
variants: (o.variants || []).map(v => v.id === offer.variantId && v.availableCount !== undefined ? { ...v, availableCount: v.availableCount + offer.quantity } : v),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [baseEvent, offer]);
|
||||
|
||||
const [quantities, setQuantities] = useState<QtyMap>(() => {
|
||||
const initial: QtyMap = {};
|
||||
@@ -334,6 +385,24 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
if (missing) { setError(missing); return; }
|
||||
}
|
||||
|
||||
// Claim link without logging in: the link identifies the person.
|
||||
if (waitlistToken && !isLoggedIn) {
|
||||
try {
|
||||
setError(null);
|
||||
setCreatingRegistration(true);
|
||||
const res = await apiFetch<{ id: string; totalDueComputed?: number }>("/api/registrations", {
|
||||
method: "POST",
|
||||
body: { eventId, options: items, waitlistToken, ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
|
||||
});
|
||||
if (res?.id) window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((res.totalDueComputed ?? payable).toFixed(2))}`;
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Registration failed");
|
||||
} finally {
|
||||
setCreatingRegistration(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
if (!token) {
|
||||
setError("Session expired — please log in again.");
|
||||
@@ -350,6 +419,7 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
options: items,
|
||||
...(coupon ? { couponCode: coupon.code } : {}),
|
||||
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
|
||||
...(waitlistToken ? { waitlistToken } : {}),
|
||||
},
|
||||
authToken: token,
|
||||
});
|
||||
@@ -436,13 +506,22 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold mb-4">Register for {event.title}</h1>
|
||||
|
||||
{eventSoldOut && (
|
||||
{offer && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-green-50 border-green-200 text-sm text-green-900">
|
||||
<strong>Your waitlist place:</strong> {offer.quantity} × {offer.optionName}{offer.variantName ? ` (${offer.variantName})` : ""} — held for you until {new Date(offer.expiresAt).toLocaleString()}.
|
||||
</div>
|
||||
)}
|
||||
{offerError && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-amber-50 border-amber-200 text-sm text-amber-900">{offerError}</div>
|
||||
)}
|
||||
|
||||
{eventSoldOut && !event.waitlistEnabled && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-red-50 border-red-200 text-sm text-red-800 font-medium">
|
||||
This event is sold out. Registration is no longer available.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoggedIn && !isGuestEligible && (
|
||||
{!isLoggedIn && !isGuestEligible && !waitlistToken && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-amber-50 border-amber-200 text-sm text-amber-800">
|
||||
You need to <a href={`/login?redirect=/register/${eventId}`} className="font-semibold underline">log in</a> or <a href={`/register?redirect=/register/${eventId}`} className="font-semibold underline">create an account</a> to register for this event.
|
||||
</div>
|
||||
@@ -489,6 +568,7 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
opt={opt}
|
||||
quantities={quantities}
|
||||
onQtyChange={handleQtyChange}
|
||||
waitlist={event.waitlistEnabled ? { eventId, guestsAllowed: event.requiresAuth === false } : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -551,7 +631,7 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
) : (
|
||||
<button
|
||||
onClick={submitRegistration}
|
||||
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible)}
|
||||
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible && !waitlistToken)}
|
||||
className="bg-brand-600 text-white px-4 py-2 rounded hover:bg-brand-700 disabled:opacity-60"
|
||||
>
|
||||
{creatingRegistration ? "Registering..." : "Register"}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { ListChecks } from "lucide-react";
|
||||
|
||||
type Claim = {
|
||||
status: "offered" | "expired";
|
||||
eventId: string;
|
||||
eventTitle: string;
|
||||
eventStart: string;
|
||||
optionName: string;
|
||||
variantName: string | null;
|
||||
quantity: number;
|
||||
requested: number;
|
||||
expiresAt: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/** Landing page for a waitlist offer link — shows what's held, then goes to registration. */
|
||||
export default function WaitlistClaimPage() {
|
||||
const params = useParams<{ token: string }>();
|
||||
const token = params?.token;
|
||||
const [claim, setClaim] = useState<Claim | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<Claim>(`/api/waitlist/claim/${encodeURIComponent(token)}`)
|
||||
.then(setClaim)
|
||||
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this offer."));
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 px-4 py-10 max-w-xl mx-auto w-full">
|
||||
<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">
|
||||
<ListChecks className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Your waitlist place</h1>
|
||||
</div>
|
||||
{error && <div className="border rounded-xl p-4 bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{!claim && !error && <div className="text-sm text-gray-600">Loading…</div>}
|
||||
{claim && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
|
||||
<div className="font-semibold text-gray-900">{claim.eventTitle}</div>
|
||||
<div className="text-sm text-gray-600">{formatDate(new Date(claim.eventStart))}</div>
|
||||
{claim.status === "offered" ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-700">
|
||||
{claim.name ? `Hi ${claim.name}, ` : ""}{claim.quantity} × {claim.optionName}{claim.variantName ? ` (${claim.variantName})` : ""} {claim.quantity === 1 ? "is" : "are"} being held for you until <strong>{new Date(claim.expiresAt).toLocaleString()}</strong>.
|
||||
{claim.quantity < claim.requested && ` You asked for ${claim.requested}; this is what came free.`}
|
||||
</p>
|
||||
<a href={`/register/${claim.eventId}?waitlist=${encodeURIComponent(token)}`} className="inline-block px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
|
||||
Claim and register
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-amber-900 bg-amber-50 border border-amber-200 rounded-lg p-3">This offer has expired, and the place has been offered to the next person on the waitlist.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ContactButton } from "@/components/events/ContactButton";
|
||||
price: number;
|
||||
picture?: string;
|
||||
isSoldOut?: boolean;
|
||||
waitlistEnabled?: boolean;
|
||||
requiresRegistration?: boolean;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
@@ -88,6 +89,14 @@ export const EventCard = ({ event }: { event: Event }) => {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (event.isSoldOut && event.waitlistEnabled) {
|
||||
// Sold out with a waitlist: send them to the event page to join it.
|
||||
return (
|
||||
<a href={`/events/${event.id}`} className="flex-1 text-center text-sm text-brand-700 bg-white border border-brand-300 rounded-lg py-2 hover:bg-brand-50">
|
||||
Sold out — join waitlist
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (event.isSoldOut) {
|
||||
return (
|
||||
<button disabled className="flex-1 text-center text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg py-2 cursor-not-allowed">
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ListPlus, X } from "lucide-react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
/**
|
||||
* "Join waitlist" for sold-out options. Opens a small form: which option, how many,
|
||||
* and (for guests on events that allow it) name and contact details.
|
||||
*/
|
||||
export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine, compact = false }: {
|
||||
eventId: string;
|
||||
lines: WaitlistLine[];
|
||||
guestsAllowed: boolean;
|
||||
initialLine?: WaitlistLine;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { token, user } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [lineKey, setLineKey] = useState(() => keyOf(initialLine || lines[0]));
|
||||
const [qty, setQty] = useState(1);
|
||||
const [guest, setGuest] = useState({ name: "", email: "", phone: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
const loggedIn = !!(token && user);
|
||||
const line = lines.find(l => keyOf(l) === lineKey) || lines[0];
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>("/api/waitlist", {
|
||||
method: "POST",
|
||||
authToken: token || undefined,
|
||||
body: {
|
||||
eventId,
|
||||
eventOptionId: line.eventOptionId,
|
||||
...(line.variantId ? { variantId: line.variantId } : {}),
|
||||
quantity: qty,
|
||||
...(!loggedIn ? { guestName: guest.name.trim(), guestEmail: guest.email.trim(), guestPhone: guest.phone.trim() || undefined } : {}),
|
||||
},
|
||||
});
|
||||
setDone(res.message);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't join the waitlist. Please try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(true); setDone(null); setError(null); }}
|
||||
className={compact
|
||||
? "inline-flex items-center gap-1 text-xs font-medium text-brand-700 border border-brand-200 bg-brand-50 rounded px-2 py-0.5 hover:bg-brand-100"
|
||||
: "w-full inline-flex items-center justify-center gap-2 bg-white text-brand-700 border border-brand-300 px-4 py-2.5 rounded-lg hover:bg-brand-50 font-medium"}
|
||||
>
|
||||
<ListPlus className={compact ? "w-3.5 h-3.5" : "w-4 h-4"} /> Join waitlist
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-xl w-full max-w-md p-5 space-y-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Join the waitlist</h2>
|
||||
<button type="button" onClick={() => setOpen(false)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
|
||||
{done ? (
|
||||
<>
|
||||
<p className="text-sm text-green-800 bg-green-50 border border-green-200 rounded-lg p-3">{done}</p>
|
||||
<button type="button" onClick={() => setOpen(false)} className="w-full px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">Done</button>
|
||||
</>
|
||||
) : !loggedIn && !guestsAllowed ? (
|
||||
<p className="text-sm text-gray-700">
|
||||
<a href={`/login?redirect=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "/events")}`} className="text-brand-600 font-medium underline">Log in</a> to join the waitlist for this event.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-600">If a place opens up, we'll email or WhatsApp you a link to claim it. We hold it for you for a limited time.</p>
|
||||
{lines.length > 1 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ticket</label>
|
||||
<select className={inputCls} value={lineKey} onChange={e => setLineKey(e.target.value)}>
|
||||
{lines.map(l => <option key={keyOf(l)} value={keyOf(l)}>{l.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{lines.length === 1 && <p className="text-sm"><span className="text-gray-500">Ticket:</span> {line.label}</p>}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">How many?</label>
|
||||
<input type="number" min={1} max={20} className={`${inputCls} w-24`} value={qty} onChange={e => setQty(Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 1)))} />
|
||||
</div>
|
||||
{!loggedIn && (
|
||||
<div className="space-y-2">
|
||||
<input className={inputCls} placeholder="Full name *" value={guest.name} onChange={e => setGuest({ ...guest, name: e.target.value })} required aria-label="Full name" />
|
||||
<input type="email" className={inputCls} placeholder="Email *" value={guest.email} onChange={e => setGuest({ ...guest, email: e.target.value })} required aria-label="Email" />
|
||||
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={guest.phone} onChange={e => setGuest({ ...guest, phone: e.target.value })} aria-label="Phone" />
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button type="submit" disabled={busy} className="w-full px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy ? "Joining…" : "Join waitlist"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function keyOf(l?: WaitlistLine) {
|
||||
return l ? `${l.eventOptionId}::${l.variantId || ""}` : "";
|
||||
}
|
||||
|
||||
/** The sold-out lines of an event, for the waitlist button. */
|
||||
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
|
||||
const out: WaitlistLine[] = [];
|
||||
for (const o of options || []) {
|
||||
const optOut = (o.stockLimit ?? 0) > 0 && o.availableCount !== undefined && o.availableCount !== null && o.availableCount <= 0;
|
||||
const variants = o.variants || [];
|
||||
if (variants.length > 0) {
|
||||
for (const v of variants) {
|
||||
const vOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount !== null && v.availableCount <= 0;
|
||||
if (optOut || vOut) out.push({ eventOptionId: o.id, variantId: v.id, label: `${o.name} (${v.name})` });
|
||||
}
|
||||
} else if (optOut) {
|
||||
out.push({ eventOptionId: o.id, label: o.name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
status: "waiting" | "offered";
|
||||
quantity: number;
|
||||
offeredQuantity: number | null;
|
||||
offerExpiresAt: string | null;
|
||||
event: { id: string; title: string; startDate: string };
|
||||
eventOption: { name: string };
|
||||
variant: { name: string } | null;
|
||||
};
|
||||
|
||||
/** My Events: the waitlists I'm on, with Leave (and a note when a place is on offer). */
|
||||
export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m: string) => void }) {
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const load = useCallback(() => {
|
||||
apiFetch<Entry[]>("/api/waitlist/mine", { authToken: token }).then(setEntries).catch(() => setEntries([]));
|
||||
}, [token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const leave = async (id: string) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/waitlist/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token });
|
||||
onMessage(res.message);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
onMessage(e?.message || "Couldn't leave the waitlist");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 font-semibold text-gray-900 mb-2">
|
||||
<ListChecks className="w-4 h-4 text-brand-600" /> Waitlists
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{entries.map(e => (
|
||||
<li key={e.id} className="py-2 flex items-center justify-between gap-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">{e.event.title}</div>
|
||||
<div className="text-xs text-gray-500">{e.quantity} × {e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</div>
|
||||
{e.status === "offered" ? (
|
||||
<div className="text-xs text-green-700 mt-0.5">
|
||||
{e.offeredQuantity} place(s) held for you until {e.offerExpiresAt ? new Date(e.offerExpiresAt).toLocaleString() : ""} — use the link we sent you to claim.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 mt-0.5">Waiting — we'll email or WhatsApp you if a place opens up.</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => leave(e.id)} className="px-2.5 py-1 text-xs rounded border hover:bg-gray-50 shrink-0">Leave</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const eventDetailHelpContent: HelpContent = {
|
||||
If a discounted price is shown with the original struck through, an early-bird window is active — the price rises automatically once it ends. Got a discount code? Enter it on the registration page under "Have a code?" (you need to be logged in).
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ticket} title="Availability badges" tone="brand">
|
||||
"N remaining" appears once stock runs low, and "Sold out" once an option is fully booked.
|
||||
"N remaining" appears once stock runs low, and "Sold out" once an option is fully booked. Some events have a waitlist: tap "Join waitlist" and you'll be emailed or WhatsApped a link to claim a place if one opens up (it's held for you for a limited time).
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="Registering for other people?" tone="violet">
|
||||
Some events let you name each person you're booking for. Each named person gets their own ticket and their own attendee form, and you can choose to have every ticket sent to you or each person their own. On youth events, you'll give each person's date of birth; for anyone under 18, their parent or guardian is asked to give consent by email and WhatsApp before they can be checked in.
|
||||
|
||||
@@ -23,6 +23,7 @@ import { supervisorPaymentsHelpContent } from "./supervisor-payments";
|
||||
import { supervisorFormsHelpContent } from "./supervisor-forms";
|
||||
import { supervisorCouponsHelpContent } from "./supervisor-coupons";
|
||||
import { supervisorConsentHelpContent } from "./supervisor-consent";
|
||||
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
|
||||
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
|
||||
import { supervisorWhatsappAttendeesHelpContent } from "./supervisor-whatsapp-attendees";
|
||||
import { staffEventTicketsHelpContent } from "./staff-event-tickets";
|
||||
@@ -149,6 +150,11 @@ const REGISTRY: HelpRegistryEntry[] = [
|
||||
matches: pathname => pathname === "/dashboard/supervisor/payments",
|
||||
content: supervisorPaymentsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-waitlist",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/waitlist",
|
||||
content: supervisorWaitlistHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-consent",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/consent",
|
||||
|
||||
@@ -44,7 +44,7 @@ export const supervisorEventsHelpContent: HelpContent = {
|
||||
Build a custom registration form — text, number, date, yes/no, statement, and heading fields, reorderable, optionally required before tickets are generated.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Eye} title="Visibility" tone="gray">
|
||||
Hide the event, allow guest (no-account) registration, turn on named attendees (group tickets — each named person gets their own ticket and form), make it a youth event that needs parental consent for under-18s, or toggle it active.
|
||||
Hide the event, allow guest (no-account) registration, turn on named attendees (group tickets — each named person gets their own ticket and form), make it a youth event that needs parental consent for under-18s, turn on a waitlist for sold-out tickets, or toggle it active.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Bell} title="Notifications" tone="blue">
|
||||
Choose which staff get registration, payment, and daily-summary emails for this event.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { ListChecks, Send, Clock, ArrowUpDown, UserMinus, Settings } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for /dashboard/supervisor/waitlist. */
|
||||
export const supervisorWaitlistHelpContent: HelpContent = {
|
||||
title: "Waitlist",
|
||||
subtitle: "People waiting for sold-out tickets.",
|
||||
tabs: [
|
||||
{
|
||||
key: "how",
|
||||
label: "How it works",
|
||||
icon: ListChecks,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Settings} title="Turning it on" tone="brand">
|
||||
In the event wizard's Visibility step, tick "Waitlist for sold-out tickets", set how long offered places are held (24 hours by default), and whether someone can be offered fewer places than they asked for.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ListChecks} title="Joining" tone="green">
|
||||
When a ticket is sold out, the event and registration pages show "Join waitlist". People pick the ticket and how many, and can leave again from My Events.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Send} title="Offers" tone="violet">
|
||||
When places free up — a cancellation, a smaller booking, or a higher stock limit — the next person in line is emailed or WhatsApped a claim link. The places are held for them, so nobody else can buy them in the meantime.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Clock} title="Expiry" tone="amber">
|
||||
If the offer isn't claimed in time, it expires and the places go to the next person. Claiming goes through normal registration and payment, with early-bird and coupon rules as usual.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "This page",
|
||||
icon: ArrowUpDown,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Send} title="Offer place" tone="brand">
|
||||
Send someone an offer straight away, even if no place has freed up (this can take the event over its stock limit).
|
||||
</GuideItem>
|
||||
<GuideItem icon={ArrowUpDown} title="Reorder" tone="green">
|
||||
Move someone up or down the queue.
|
||||
</GuideItem>
|
||||
<GuideItem icon={UserMinus} title="Remove" tone="rose">
|
||||
Take someone off the waitlist. If they were holding an offer, it goes to the next person.
|
||||
</GuideItem>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">The daily summary email shows how many people are on each event's waitlist.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -13,6 +13,7 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
|
||||
### For your congregation
|
||||
|
||||
- **Browse events** — a clean, mobile-friendly listing of upcoming events with photos, descriptions, dates, and location (with a map and directions). Fully booked events show a "Sold Out" badge, and popular tickets show how many are left.
|
||||
- **Waitlists** — when an event sells out, people can join a waitlist and are offered places automatically as they free up.
|
||||
- **Parental consent for youth events** — parents receive an email and WhatsApp to give consent online (with medical details and an emergency contact); children can't be checked in until they have. Reminders go out automatically.
|
||||
- **Group bookings** — a parent can register several children at once, naming each one so they get their own ticket and their own form, with the tickets sent to the parent or to each person.
|
||||
- **Discount codes** — enter a code for a staff, leader or promotional discount, shown on the invoice and receipt.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user