Files
hope-events/frontend/src/components/shared/AttendeeNamesEditor.tsx
T
joshuaandClaude Opus 5.5 1516e104df Add rooms & groups allocation
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>
2026-10-19 14:44:58 +02:00

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&apos;s coming?
</div>
<p className="text-xs text-gray-500 mt-1">This is a youth event: add each person&apos;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&apos;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>
);
}