Cashup, users, profile, consent, allocations, event options, forms, event tickets, ticket scanning and manual registration now use real types. Data loaders are wrapped in useCallback and listed as effect dependencies. Also adds shared Attendee, Section and form response types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
12 KiB
TypeScript
231 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo, useState } from "react";
|
|
import { Users, Send } from "lucide-react";
|
|
import { apiFetch } from "@/lib/api";
|
|
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
|
import { errorMessage } from "@/lib/utils";
|
|
import type { Attendee } from "@/types";
|
|
|
|
const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
|
pending: { text: "consent pending", cls: "text-amber-700" },
|
|
given: { text: "consent given", cls: "text-green-700" },
|
|
declined: { text: "consent declined", cls: "text-red-700" },
|
|
};
|
|
|
|
|
|
type Row = {
|
|
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
|
|
dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string;
|
|
};
|
|
|
|
// The parts of a registration (with its attendees, options and event) this panel reads.
|
|
export type AttendeesRegistration = {
|
|
id: string;
|
|
ticketDelivery?: "payer" | "each";
|
|
attendees?: Attendee[];
|
|
event?: {
|
|
requiresParentalConsent?: boolean;
|
|
collectGender?: boolean;
|
|
allowNamedAttendees?: boolean;
|
|
startDate?: string;
|
|
} | null;
|
|
registrationOptions?: {
|
|
eventOptionId: string;
|
|
variantId?: string | null;
|
|
quantity: number;
|
|
eventOption?: { name?: string; isMainTicket?: boolean } | null;
|
|
variant?: { name?: string } | null;
|
|
}[];
|
|
formResponses?: { attendeeId?: string | null }[];
|
|
};
|
|
|
|
/**
|
|
* Named attendees on a registration (group tickets): shows who each main-ticket place
|
|
* is for, and lets the payer (or staff) name places, change details, choose where the
|
|
* tickets go, and send an attendee the link to their own form.
|
|
*/
|
|
export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: {
|
|
registration: AttendeesRegistration;
|
|
token: string;
|
|
canEdit: boolean;
|
|
hasForm: boolean;
|
|
onSaved: () => void;
|
|
onMessage: (msg: string) => void;
|
|
}) {
|
|
const attendees = useMemo<Attendee[]>(() => registration.attendees || [], [registration.attendees]);
|
|
const youth = !!registration.event?.requiresParentalConsent;
|
|
const collectGender = !!registration.event?.collectGender;
|
|
const eventStart: string | undefined = registration.event?.startDate;
|
|
const [editing, setEditing] = useState(false);
|
|
const [rows, setRows] = useState<Row[]>([]);
|
|
const [delivery, setDelivery] = useState<"payer" | "each">(registration.ticketDelivery || "payer");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// One row per main-ticket place: named ones first (in order), then blanks.
|
|
const buildRows = useMemo(() => () => {
|
|
const out: Row[] = [];
|
|
for (const ro of registration.registrationOptions || []) {
|
|
if (!ro.eventOption?.isMainTicket) continue;
|
|
const label = `${ro.eventOption?.name || "Ticket"}${ro.variant?.name ? ` (${ro.variant.name})` : ""}`;
|
|
const named = attendees.filter(a => a.eventOptionId === ro.eventOptionId && (a.variantId || null) === (ro.variantId || null));
|
|
for (let i = 0; i < (ro.quantity || 0); i++) {
|
|
const a = named[i];
|
|
out.push({
|
|
id: a?.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId || null, label: `${label} ${i + 1}`,
|
|
name: a?.name || "", email: a?.email || "", phone: a?.phone || "",
|
|
dateOfBirth: a?.dateOfBirth ? String(a.dateOfBirth).slice(0, 10) : "",
|
|
gender: a?.gender || "",
|
|
parentName: a?.parentName || "", parentEmail: a?.parentEmail || "", parentPhone: a?.parentPhone || "",
|
|
locked: !!a?.ticket?.isUsed,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}, [registration, attendees]);
|
|
|
|
if (!registration.event?.allowNamedAttendees && !youth && attendees.length === 0) return null;
|
|
|
|
const startEdit = () => { setRows(buildRows()); setDelivery(registration.ticketDelivery || "payer"); setError(null); setEditing(true); };
|
|
const update = (i: number, field: "name" | "email" | "phone" | "dateOfBirth" | "gender" | "parentName" | "parentEmail" | "parentPhone", value: string) =>
|
|
setRows(prev => prev.map((r, j) => (j === i ? { ...r, [field]: value } : r)));
|
|
|
|
const save = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/attendees`, {
|
|
method: "PUT",
|
|
authToken: token,
|
|
body: {
|
|
ticketDelivery: delivery,
|
|
attendees: rows.filter(r => r.name.trim()).map(r => ({
|
|
...(r.id ? { id: r.id } : {}),
|
|
eventOptionId: r.eventOptionId,
|
|
...(r.variantId ? { variantId: r.variantId } : {}),
|
|
name: r.name.trim(),
|
|
email: r.email.trim() || undefined,
|
|
phone: r.phone.trim() || undefined,
|
|
...(r.dateOfBirth ? { dateOfBirth: r.dateOfBirth } : {}),
|
|
...(r.gender ? { gender: r.gender } : {}),
|
|
...(r.parentName.trim() ? { parentName: r.parentName.trim() } : {}),
|
|
...(r.parentEmail.trim() ? { parentEmail: r.parentEmail.trim() } : {}),
|
|
...(r.parentPhone.trim() ? { parentPhone: r.parentPhone.trim() } : {}),
|
|
})),
|
|
},
|
|
});
|
|
setEditing(false);
|
|
onMessage("Attendees saved.");
|
|
onSaved();
|
|
} catch (e) {
|
|
setError(errorMessage(e) || "Couldn't save attendees");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const sendLink = async (a: Attendee) => {
|
|
try {
|
|
const res = await apiFetch<{ message: string }>(`/api/registrations/${encodeURIComponent(registration.id)}/attendees/${encodeURIComponent(a.id)}/send-form-link`, { method: "POST", authToken: token });
|
|
onMessage(res.message);
|
|
} catch (e) {
|
|
setError(errorMessage(e) || "Couldn't send the form link");
|
|
}
|
|
};
|
|
|
|
const answered = new Set((registration.formResponses || []).map((r) => r.attendeeId).filter(Boolean));
|
|
|
|
const resendConsent = async (requestId: string) => {
|
|
try {
|
|
const res = await apiFetch<{ message: string }>(`/api/consent/requests/${encodeURIComponent(requestId)}/resend`, { method: "POST", authToken: token });
|
|
onMessage(res.message);
|
|
} catch (e) {
|
|
setError(errorMessage(e) || "Couldn't re-send the consent request");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="font-medium mb-1 flex items-center justify-between">
|
|
<span className="flex items-center gap-1.5"><Users className="w-4 h-4 text-brand-600" /> People</span>
|
|
{canEdit && !editing && (
|
|
<button onClick={startEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700">{attendees.length ? "Edit names" : "Add names"}</button>
|
|
)}
|
|
</div>
|
|
|
|
{!editing ? (
|
|
attendees.length === 0 ? (
|
|
<p className="text-sm text-gray-500">No names yet — add a name for each person to give them their own ticket.</p>
|
|
) : (
|
|
<>
|
|
<ul className="text-sm space-y-1">
|
|
{attendees.map(a => (
|
|
<li key={a.id} className="flex items-center justify-between gap-2">
|
|
<span>
|
|
{a.name}
|
|
{(a.email || a.phone) && <span className="text-xs text-gray-500"> · {[a.email, a.phone].filter(Boolean).join(" · ")}</span>}
|
|
{hasForm && <span className={`ml-2 text-xs ${answered.has(a.id) ? "text-green-700" : "text-amber-700"}`}>{answered.has(a.id) ? "form done" : "form to do"}</span>}
|
|
{youth && a.consentRequests?.[0] && (
|
|
<span className={`ml-2 text-xs ${CONSENT_LABEL[a.consentRequests[0].status]?.cls}`}>{CONSENT_LABEL[a.consentRequests[0].status]?.text}</span>
|
|
)}
|
|
</span>
|
|
{canEdit && youth && a.consentRequests?.[0]?.status === "pending" && (
|
|
<button onClick={() => resendConsent(a.consentRequests![0].id)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Resend consent</button>
|
|
)}
|
|
{canEdit && hasForm && !answered.has(a.id) && (a.email || a.phone) && (
|
|
<button onClick={() => sendLink(a)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Send form link</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p className="text-xs text-gray-500 mt-1">Tickets go {registration.ticketDelivery === "each" ? "to each person (or you, if they have no contact details)" : "to you"}.</p>
|
|
</>
|
|
)
|
|
) : (
|
|
<div className="border rounded p-3 space-y-2 bg-gray-50">
|
|
{rows.map((r, i) => (
|
|
<div key={i}>
|
|
<div className="text-xs font-semibold text-gray-600 mb-1">{r.label}{r.locked && <span className="font-normal text-gray-500"> — checked in, can't be changed</span>}</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Full name" value={r.name} disabled={r.locked} onChange={e => update(i, "name", e.target.value)} aria-label={`${r.label} name`} />
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Email (optional)" value={r.email} disabled={r.locked} onChange={e => update(i, "email", e.target.value)} aria-label={`${r.label} email`} />
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Phone (optional)" value={r.phone} disabled={r.locked} onChange={e => update(i, "phone", e.target.value)} aria-label={`${r.label} phone`} />
|
|
</div>
|
|
{collectGender && (
|
|
<select className="mt-1 border rounded px-2 py-1 text-sm" value={r.gender} disabled={r.locked} onChange={e => update(i, "gender", e.target.value)} aria-label={`${r.label} gender`}>
|
|
<option value="">Gender…</option>
|
|
<option value="male">Male</option>
|
|
<option value="female">Female</option>
|
|
</select>
|
|
)}
|
|
{youth && (
|
|
<div className="mt-1 space-y-1">
|
|
<input type="date" className="border rounded px-2 py-1 text-sm" value={r.dateOfBirth} disabled={r.locked} onChange={e => update(i, "dateOfBirth", e.target.value)} aria-label={`${r.label} date of birth`} />
|
|
{isMinorOn(r.dateOfBirth, eventStart) && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent/guardian name" value={r.parentName} disabled={r.locked} onChange={e => update(i, "parentName", e.target.value)} aria-label={`${r.label} parent name`} />
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent email" value={r.parentEmail} disabled={r.locked} onChange={e => update(i, "parentEmail", e.target.value)} aria-label={`${r.label} parent email`} />
|
|
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent phone" value={r.parentPhone} disabled={r.locked} onChange={e => update(i, "parentPhone", e.target.value)} aria-label={`${r.label} parent phone`} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="text-sm pt-1">
|
|
<label className="flex items-center gap-2"><input type="radio" checked={delivery === "payer"} onChange={() => setDelivery("payer")} /> Send all tickets to me</label>
|
|
<label className="flex items-center gap-2"><input type="radio" checked={delivery === "each"} onChange={() => setDelivery("each")} /> Send each person their own</label>
|
|
</div>
|
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
|
<div className="flex gap-2">
|
|
<button onClick={() => setEditing(false)} className="px-3 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" disabled={busy}>Cancel</button>
|
|
<button onClick={save} disabled={busy} className="px-3 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">{busy ? "Saving…" : "Save"}</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!editing && error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|