- Waitlist per session: entries for pick-sessions tickets name a session; offers use that session's free places, never run past its start, and claim links tick it. Held offers count towards session capacity. - At the door: the Select items pop-up has the session picker, with today's session preselected for new attendees. - On pick-sessions events every ticket shows the session list straight away. "All sessions" tickets record the ticked sessions (RegistrationOption.sessionIds) at one price; scanning, attendance, certificates, reminders and capacity follow them. - Fix: per-session stock limits weren't enforced (takenCount filtered WaitlistEntry by a sessionId column that didn't exist, and the error was swallowed), and the public event page lost its stock info. - Fix: editing a door registration with picked sessions couldn't be saved. - Fix: the offline-scanning service worker served /_next/static cache-first forever, so dev browsers that had opened the scanner ran stale code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
9.6 KiB
TypeScript
203 lines
9.6 KiB
TypeScript
import type { Event, EventSession, LateJoinInfo } from "@/types";
|
||
|
||
// Multi-day events — browser-side helpers. The rules live in backend utils/sessions.js;
|
||
// latePrice below mirrors it so prices shown match what the server charges.
|
||
|
||
export type SessionPattern = {
|
||
frequency: "daily" | "weekly";
|
||
interval: number; // every N days/weeks
|
||
weekdays: number[]; // weekly only: 0 = Sunday … 6 = Saturday
|
||
startDate: string; // YYYY-MM-DD (local)
|
||
startTime: string; // HH:MM
|
||
endTime: string; // HH:MM (an end before the start runs past midnight)
|
||
endsBy: "count" | "until";
|
||
count: number;
|
||
until: string; // YYYY-MM-DD (local)
|
||
};
|
||
|
||
export type SessionDraft = {
|
||
id?: string;
|
||
label: string;
|
||
startsAt: string; // datetime-local value (YYYY-MM-DDTHH:MM, local)
|
||
endsAt: string;
|
||
location: string;
|
||
cancelled: boolean;
|
||
capacity: string; // "pick sessions" events; blank = no limit
|
||
hasCheckIns?: boolean;
|
||
};
|
||
|
||
const MAX_GENERATED = 200;
|
||
|
||
function localDT(d: Date): string {
|
||
const p = (n: number) => String(n).padStart(2, "0");
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
}
|
||
|
||
function atTime(day: Date, hhmm: string): Date {
|
||
const [h, m] = hhmm.split(":").map(Number);
|
||
const d = new Date(day);
|
||
d.setHours(h || 0, m || 0, 0, 0);
|
||
return d;
|
||
}
|
||
|
||
/** Turns a repeat pattern into session rows (local time). */
|
||
export function generateSessions(p: SessionPattern): SessionDraft[] {
|
||
const [y, mo, d] = p.startDate.split("-").map(Number);
|
||
if (!y || !mo || !d || !p.startTime || !p.endTime) return [];
|
||
const first = new Date(y, mo - 1, d);
|
||
const until = p.endsBy === "until" && p.until ? (() => { const [uy, um, ud] = p.until.split("-").map(Number); return new Date(uy, um - 1, ud, 23, 59); })() : null;
|
||
const limit = p.endsBy === "count" ? Math.min(Math.max(1, p.count || 1), MAX_GENERATED) : MAX_GENERATED;
|
||
const interval = Math.max(1, p.interval || 1);
|
||
const weekdays = p.frequency === "weekly" ? (p.weekdays.length ? [...p.weekdays].sort() : [first.getDay()]) : [];
|
||
|
||
const out: SessionDraft[] = [];
|
||
const push = (day: Date) => {
|
||
const start = atTime(day, p.startTime);
|
||
let end = atTime(day, p.endTime);
|
||
if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
|
||
out.push({ label: "", startsAt: localDT(start), endsAt: localDT(end), location: "", cancelled: false, capacity: "" });
|
||
};
|
||
|
||
if (p.frequency === "daily") {
|
||
for (let i = 0, day = new Date(first); out.length < limit; i++, day.setDate(day.getDate() + interval)) {
|
||
if (until && day > until) break;
|
||
push(day);
|
||
if (i > 5000) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Weekly: walk week by week from the week containing the start date (weeks start Sunday).
|
||
const weekStart = new Date(first);
|
||
weekStart.setDate(first.getDate() - first.getDay());
|
||
for (let w = 0; out.length < limit && w < 1000; w += interval) {
|
||
for (const wd of weekdays) {
|
||
const day = new Date(weekStart);
|
||
day.setDate(weekStart.getDate() + w * 7 + wd);
|
||
if (day < first) continue;
|
||
if (until && day > until) return out;
|
||
if (out.length >= limit) return out;
|
||
push(day);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function toSessionDraft(s: EventSession & { hasCheckIns?: boolean }): SessionDraft {
|
||
return { id: s.id, label: s.label || "", startsAt: localDT(new Date(s.startsAt)), endsAt: localDT(new Date(s.endsAt)), location: s.location || "", cancelled: !!(s.cancelled || s.cancelledAt), capacity: s.capacity ? String(s.capacity) : "", hasCheckIns: s.hasCheckIns };
|
||
}
|
||
|
||
/** Sessions that aren't cancelled, earliest first. */
|
||
export function activeSessions<T extends { startsAt: string; cancelled?: boolean; cancelledAt?: string | null }>(sessions: T[] | undefined | null): T[] {
|
||
return (sessions || []).filter(s => !(s.cancelled || s.cancelledAt)).sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime());
|
||
}
|
||
|
||
/** "Week 1" — the session's label, else its position among active sessions. */
|
||
export function sessionLabel(session: EventSession, sessions: EventSession[] | undefined | null): string {
|
||
if (session.label) return session.label;
|
||
const idx = activeSessions(sessions).findIndex(s => s.id === session.id);
|
||
return `Session ${idx >= 0 ? idx + 1 : ""}`.trim();
|
||
}
|
||
|
||
const dayFmt = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" });
|
||
const timeFmt = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
|
||
/** "Wed 4 Nov, 18:30–20:30" */
|
||
export function formatSessionWhen(s: { startsAt: string; endsAt: string }): string {
|
||
const start = new Date(s.startsAt);
|
||
const end = new Date(s.endsAt);
|
||
const sameDay = start.toDateString() === end.toDateString();
|
||
return sameDay
|
||
? `${dayFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`
|
||
: `${dayFmt.format(start)} ${timeFmt.format(start)} – ${dayFmt.format(end)} ${timeFmt.format(end)}`;
|
||
}
|
||
|
||
/** "5 sessions · Wed 4 Nov – Wed 2 Dec" for event cards; null for single-date events. */
|
||
export function scheduleSummary(event: Pick<Event, "scheduleType" | "sessions">): string | null {
|
||
if (event.scheduleType !== "sessions") return null;
|
||
const active = activeSessions(event.sessions);
|
||
if (active.length === 0) return null;
|
||
const first = dayFmt.format(new Date(active[0].startsAt));
|
||
const last = dayFmt.format(new Date(active[active.length - 1].startsAt));
|
||
return active.length === 1 ? `1 session · ${first}` : `${active.length} sessions · ${first} – ${last}`;
|
||
}
|
||
|
||
/** When public registration closes (falls back for older API responses). */
|
||
export function registrationClosesAt(event: Pick<Event, "registrationClosesAt" | "registrationDeadline" | "endDate">): Date {
|
||
if (event.registrationClosesAt) return new Date(event.registrationClosesAt);
|
||
const end = new Date(event.endDate);
|
||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||
return deadline && deadline < end ? deadline : end;
|
||
}
|
||
|
||
/** A main ticket's price for a late joiner — same rule as the backend. */
|
||
export function latePrice(price: number, info: LateJoinInfo | null | undefined): number {
|
||
if (!info || !info.active || !(price > 0)) return price;
|
||
if (info.mode === "fixed") return Math.min(price, Math.max(0, Number(info.fixedPrice)));
|
||
if (info.mode === "prorata") {
|
||
if (!info.totalSessions) return price;
|
||
const rounded = Math.round((price * info.sessionsLeft / info.totalSessions) / 10) * 10;
|
||
return Math.min(price, Math.max(10, rounded));
|
||
}
|
||
return price;
|
||
}
|
||
|
||
export const REMINDER_OPTIONS = [
|
||
{ minutes: 60, label: "1 hour before" },
|
||
{ minutes: 180, label: "3 hours before" },
|
||
{ minutes: 1440, label: "1 day before" },
|
||
] as const;
|
||
|
||
export const CERTIFICATE_TEMPLATES = [
|
||
{ id: "classic", name: "Classic", blurb: "Double border, serif type" },
|
||
{ id: "modern", name: "Modern", blurb: "Clean, with a colour bar" },
|
||
{ id: "church", name: "Church", blurb: "Logo-led and soft" },
|
||
{ id: "kids", name: "Kids", blurb: "Bright and playful" },
|
||
] as const;
|
||
|
||
export const DEFAULT_CERTIFICATE_BODY = "This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.";
|
||
|
||
/** Label of the session open for check-in today (whole-day rule, browser time), or null. */
|
||
export function currentSessionLabel(event: Pick<Event, "scheduleType" | "sessions"> | null | undefined): string | null {
|
||
if (!event || event.scheduleType !== "sessions") return null;
|
||
const today = new Date().toDateString();
|
||
const s = activeSessions(event.sessions).find(x => new Date(x.startsAt).toDateString() === today);
|
||
return s ? sessionLabel(s, event.sessions) : null;
|
||
}
|
||
|
||
/** The session of a multi-day event open for check-in today (browser's local day), or null. */
|
||
export function todaysSession(event: Pick<Event, "scheduleType" | "sessions"> | null | undefined): EventSession | null {
|
||
if (!event || event.scheduleType !== "sessions") return null;
|
||
const today = new Date().toDateString();
|
||
const now = Date.now();
|
||
const list = activeSessions(event.sessions).filter(x => new Date(x.startsAt).toDateString() === today);
|
||
return list.find(s => now <= new Date(s.endsAt).getTime()) || list[list.length - 1] || null;
|
||
}
|
||
|
||
/**
|
||
* Whether people tick sessions when booking this event ("pick sessions" attendance).
|
||
* Every ticket shows the sessions; only per-session tickets are charged per session.
|
||
*/
|
||
export function choosesSessions(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined): boolean {
|
||
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick";
|
||
}
|
||
|
||
/** Whether an option is bought per session (only in "pick sessions" events). */
|
||
export function isPickOption(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined, option: { sessionMode?: "all" | "pick" } | null | undefined): boolean {
|
||
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick" && option?.sessionMode === "pick";
|
||
}
|
||
|
||
/**
|
||
* Sessions a pick option can still be booked for, with places left (null = no limit).
|
||
* Public: before the session starts; staff: until it ends.
|
||
*/
|
||
export function bookableSessions(event: Pick<Event, "sessions">, option: { sessionAvailability?: Record<string, number> }, staff = false): (EventSession & { left: number | null })[] {
|
||
const now = Date.now();
|
||
return activeSessions(event.sessions)
|
||
.filter(s => new Date(staff ? s.endsAt : s.startsAt).getTime() > now)
|
||
.map(s => {
|
||
const limits = [s.placesLeft, option.sessionAvailability?.[s.id]].filter((n): n is number => typeof n === "number");
|
||
return { ...s, left: limits.length ? Math.min(...limits) : null };
|
||
});
|
||
}
|