Supervisors can split an event's named attendees into allocation sets (rooms, small groups, buses) with capacity, gender and leader per group, using a drag-and-drop board or auto-allocate (keep bookings together or split them). Adds printable group and master lists (medical view audited), the group on tickets and in My Events, messaging by group, a "My groups" page for leaders, and an optional per-event gender question for named attendees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
11 KiB
TypeScript
208 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { Users } from "lucide-react";
|
|
|
|
export type AttendeeOption = {
|
|
id: string;
|
|
name: string;
|
|
isMainTicket?: boolean;
|
|
variants?: { id: string; name: string }[] | null;
|
|
};
|
|
|
|
export type AttendeeEntry = {
|
|
name: string; email: string; phone: string; gender?: string;
|
|
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
|
|
};
|
|
export type AttendeeEntries = Record<string, AttendeeEntry>;
|
|
export type TicketDelivery = "payer" | "each";
|
|
|
|
export type AttendeePayload = {
|
|
eventOptionId: string; variantId?: string | null; name: string; email?: string; phone?: string; gender?: string;
|
|
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
|
|
};
|
|
|
|
/** Under 18 on the event's start date (mirrors backend utils/consent.js isMinor). */
|
|
export function isMinorOn(dateOfBirth: string | undefined, eventStart: string | undefined): boolean {
|
|
if (!dateOfBirth || !eventStart) return false;
|
|
const dob = new Date(dateOfBirth);
|
|
const on = new Date(eventStart);
|
|
if (isNaN(dob.getTime()) || isNaN(on.getTime())) return false;
|
|
let age = on.getUTCFullYear() - dob.getUTCFullYear();
|
|
if (on.getUTCMonth() < dob.getUTCMonth() || (on.getUTCMonth() === dob.getUTCMonth() && on.getUTCDate() < dob.getUTCDate())) age -= 1;
|
|
return age < 18;
|
|
}
|
|
|
|
type Slot = { key: string; eventOptionId: string; variantId: string | null; label: string };
|
|
|
|
/**
|
|
* One slot per main-ticket place in the basket. `qtyFor(optionId, variantId)` reads the
|
|
* caller's quantities (each page keys its quantity map differently).
|
|
*/
|
|
function slotsFor(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number): Slot[] {
|
|
const slots: Slot[] = [];
|
|
for (const o of options) {
|
|
if (!o.isMainTicket) continue;
|
|
const variants = o.variants || [];
|
|
const lines = variants.length > 0 ? variants.map(v => ({ variantId: v.id, label: `${o.name} (${v.name})` })) : [{ variantId: null, label: o.name }];
|
|
for (const line of lines) {
|
|
const qty = qtyFor(o.id, line.variantId);
|
|
for (let i = 0; i < qty; i++) {
|
|
slots.push({ key: `${o.id}::${line.variantId || ""}#${i}`, eventOptionId: o.id, variantId: line.variantId, label: `${line.label} ${i + 1}` });
|
|
}
|
|
}
|
|
}
|
|
return slots;
|
|
}
|
|
|
|
/** The named places as the API expects them (unnamed places are left out). */
|
|
export function attendeesPayload(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number, entries: AttendeeEntries): AttendeePayload[] {
|
|
return slotsFor(options, qtyFor)
|
|
.map(s => ({ s, e: entries[s.key] }))
|
|
.filter(({ e }) => e && e.name.trim())
|
|
.map(({ s, e }) => ({
|
|
eventOptionId: s.eventOptionId,
|
|
...(s.variantId ? { variantId: s.variantId } : {}),
|
|
name: e.name.trim(),
|
|
...(e.email.trim() ? { email: e.email.trim() } : {}),
|
|
...(e.phone.trim() ? { phone: e.phone.trim() } : {}),
|
|
...(e.dateOfBirth ? { dateOfBirth: e.dateOfBirth } : {}),
|
|
...(e.gender ? { gender: e.gender } : {}),
|
|
...(e.parentName?.trim() ? { parentName: e.parentName.trim() } : {}),
|
|
...(e.parentEmail?.trim() ? { parentEmail: e.parentEmail.trim() } : {}),
|
|
...(e.parentPhone?.trim() ? { parentPhone: e.parentPhone.trim() } : {}),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Youth events: what's still missing before registering (every place named with a date of
|
|
* birth; parent details for minors). Empty when everything's in.
|
|
*/
|
|
export function consentDetailsMissing(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number, entries: AttendeeEntries, eventStart: string): string | null {
|
|
for (const s of slotsFor(options, qtyFor)) {
|
|
const e = entries[s.key];
|
|
if (!e?.name.trim()) return `Add a name for ${s.label}.`;
|
|
if (!e.dateOfBirth) return `Add a date of birth for ${e.name.trim()}.`;
|
|
if (isMinorOn(e.dateOfBirth, eventStart) && (!e.parentName?.trim() || !e.parentEmail?.trim() || !e.parentPhone?.trim())) {
|
|
return `${e.name.trim()} is under 18 — add a parent or guardian's name, email and phone.`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* "Registering for other people?" — a name (and optional email/phone) for each main
|
|
* ticket place, plus who gets the tickets. Named places get their own ticket; any left
|
|
* blank share one ticket as usual.
|
|
*/
|
|
export function AttendeeNamesEditor({
|
|
options,
|
|
qtyFor,
|
|
enabled,
|
|
onEnabledChange,
|
|
entries,
|
|
onEntriesChange,
|
|
delivery,
|
|
onDeliveryChange,
|
|
payerLabel = "me",
|
|
consentEventStart,
|
|
collectGender = false,
|
|
}: {
|
|
options: AttendeeOption[];
|
|
qtyFor: (optionId: string, variantId: string | null) => number;
|
|
enabled: boolean;
|
|
onEnabledChange: (v: boolean) => void;
|
|
entries: AttendeeEntries;
|
|
onEntriesChange: (v: AttendeeEntries) => void;
|
|
delivery: TicketDelivery;
|
|
onDeliveryChange: (v: TicketDelivery) => void;
|
|
payerLabel?: string;
|
|
/** Set for youth events: names are required, with a date of birth, and parent details for minors. */
|
|
consentEventStart?: string;
|
|
/** Ask each person's gender (for gender-split rooms). */
|
|
collectGender?: boolean;
|
|
}) {
|
|
const consent = !!consentEventStart;
|
|
const slots = slotsFor(options, qtyFor);
|
|
const set = (key: string, field: keyof AttendeeEntry, value: string) => {
|
|
const prev: AttendeeEntry = entries[key] || { name: "", email: "", phone: "" };
|
|
onEntriesChange({ ...entries, [key]: { ...prev, [field]: value } });
|
|
};
|
|
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
|
|
|
return (
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
{consent ? (
|
|
<div>
|
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-800">
|
|
<Users className="w-4 h-4 text-brand-600" /> Who's coming?
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-1">This is a youth event: add each person's name and date of birth. For anyone under 18, a parent or guardian will be asked to give consent by email and WhatsApp before they can be checked in.</p>
|
|
</div>
|
|
) : (
|
|
<label className="flex items-center gap-2 text-sm font-medium text-gray-800 cursor-pointer">
|
|
<input type="checkbox" checked={enabled} onChange={e => onEnabledChange(e.target.checked)} />
|
|
<Users className="w-4 h-4 text-brand-600" />
|
|
Registering for other people?
|
|
</label>
|
|
)}
|
|
{(enabled || consent) && (
|
|
<div className="mt-3 space-y-3">
|
|
{slots.length === 0 ? (
|
|
<p className="text-sm text-gray-500">Choose how many tickets first, then add a name for each person.</p>
|
|
) : (
|
|
<>
|
|
{!consent && <p className="text-xs text-gray-500">Each named person gets their own ticket and their own attendee form. Leave a name blank to keep that ticket with the others.</p>}
|
|
{slots.map(s => {
|
|
const e = entries[s.key] || { name: "", email: "", phone: "" };
|
|
return (
|
|
<div key={s.key} className="border rounded-lg p-3">
|
|
<div className="text-xs font-semibold text-gray-600 mb-2">{s.label}</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<input className={inputCls} placeholder="Full name" value={e.name} onChange={ev => set(s.key, "name", ev.target.value)} aria-label={`${s.label} name`} />
|
|
<input type="email" className={inputCls} placeholder="Email (optional)" value={e.email} onChange={ev => set(s.key, "email", ev.target.value)} aria-label={`${s.label} email`} />
|
|
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={e.phone} onChange={ev => set(s.key, "phone", ev.target.value)} aria-label={`${s.label} phone`} />
|
|
</div>
|
|
{collectGender && (
|
|
<select className={`${inputCls} mt-2 sm:w-48`} value={e.gender || ""} onChange={ev => set(s.key, "gender", ev.target.value)} aria-label={`${s.label} gender`}>
|
|
<option value="">Gender…</option>
|
|
<option value="male">Male</option>
|
|
<option value="female">Female</option>
|
|
</select>
|
|
)}
|
|
{consent && (
|
|
<div className="mt-2">
|
|
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
|
<input type="date" className={`${inputCls} sm:w-48`} value={e.dateOfBirth || ""} max={new Date().toISOString().slice(0, 10)} onChange={ev => set(s.key, "dateOfBirth", ev.target.value)} aria-label={`${s.label} date of birth`} />
|
|
{isMinorOn(e.dateOfBirth, consentEventStart) && (
|
|
<div className="mt-2 rounded-lg bg-amber-50 border border-amber-200 p-2">
|
|
<div className="text-xs font-medium text-amber-900 mb-1">Parent or guardian (they'll be asked for consent)</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<input className={inputCls} placeholder="Parent/guardian name" value={e.parentName || ""} onChange={ev => set(s.key, "parentName", ev.target.value)} aria-label={`${s.label} parent name`} />
|
|
<input type="email" className={inputCls} placeholder="Parent email" value={e.parentEmail || ""} onChange={ev => set(s.key, "parentEmail", ev.target.value)} aria-label={`${s.label} parent email`} />
|
|
<input type="tel" className={inputCls} placeholder="Parent phone" value={e.parentPhone || ""} onChange={ev => set(s.key, "parentPhone", ev.target.value)} aria-label={`${s.label} parent phone`} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<fieldset className="pt-1">
|
|
<legend className="text-sm font-medium text-gray-700 mb-1">Send the tickets</legend>
|
|
<label className="flex items-center gap-2 text-sm text-gray-700">
|
|
<input type="radio" name="ticket-delivery" checked={delivery === "payer"} onChange={() => onDeliveryChange("payer")} /> All to {payerLabel}
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-gray-700">
|
|
<input type="radio" name="ticket-delivery" checked={delivery === "each"} onChange={() => onDeliveryChange("each")} /> Each person their own (by their email or WhatsApp; anyone without contact details goes to {payerLabel})
|
|
</label>
|
|
</fieldset>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|