Files
hope-events/frontend/src/app/dashboard/supervisor/manual-registration/page.tsx
T
joshuaandClaude Sonnet 5 d6da2c8227 Send activation link immediately for walk-in and manual registration accounts
Accounts created by staff on someone's behalf now get their activation
link (email or WhatsApp) sent right away, instead of only on a first
failed login attempt, matching what the Terms of Use already promised.
This also fixed a real account with a real email being silently
activated with a fixed, undisclosed password (Hope123).

Also fixes the self-service kiosk's "Create an account" password field,
which never actually took effect server-side, and removes the "Guest
(no account)" checkboxes that no longer had any backend effect once
every walk-in account started behaving the same way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 13:29:38 +02:00

206 lines
9.7 KiB
TypeScript

"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { UserPlus } from "lucide-react";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
export default function ManualRegistrationPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [eventId, setEventId] = useState("");
const [optionId, setOptionId] = useState("");
const [quantity, setQuantity] = useState(1);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [createdReg, setCreatedReg] = useState<any | null>(null);
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!token) return;
setBusy(true);
setError(null);
try {
const res = await apiFetch<any>("/api/registrations/manual", {
method: "POST",
authToken: token,
body: {
eventId,
options: [{ eventOptionId: optionId, quantity }],
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
},
});
setCreatedReg(res);
// Load form definition for this event (if any)
try {
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
if (ev?.form) setForm(ev.form);
} catch {}
alert("Manual registration created");
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
} finally {
setBusy(false);
}
}
return (
<div className="max-w-xl">
<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">
<UserPlus className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900">Manual Registration</h1>
</div>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium">Event ID</label>
<input className="w-full border rounded px-3 py-2" value={eventId} onChange={(e) => setEventId(e.target.value)} required />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium">Option ID</label>
<input className="w-full border rounded px-3 py-2" value={optionId} onChange={(e) => setOptionId(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium">Quantity</label>
<input type="number" min={1} className="w-full border rounded px-3 py-2" value={quantity} onChange={(e) => setQuantity(parseInt(e.target.value || "1", 10))} required />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium">Name</label>
<input className="w-full border rounded px-3 py-2" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium">Email</label>
<input type="email" className="w-full border rounded px-3 py-2" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@example.com" />
</div>
</div>
<div>
<label className="block text-sm font-medium">Cell Number</label>
<input type="tel" className="w-full border rounded px-3 py-2" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+27…" />
</div>
<p className="text-xs text-gray-500">At least one of email or cell number is required. The account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so the attendee can set their own password.</p>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
{busy ? "Submitting..." : "Create"}
</button>
</form>
{createdReg && form && Array.isArray(form.fields) && (
<AttendeeFormsSection registration={createdReg} form={form} formsData={formsData} setFormsData={setFormsData} />
)}
</div>
);
}
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
const { token } = useAuth();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
const count = Math.max(0, mainTickets);
const canSubmit = React.useMemo(() => {
if (!form || count <= 0) return false;
const reqFields = (form.fields || [])
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
.map(f => f.id);
for (let i = 0; i < count; i++) {
const data = formsData[i] || {};
for (const fid of reqFields) {
const v = data[fid];
if (v === undefined || v === null || String(v).trim() === '') {
return false;
}
}
}
return true;
}, [form, formsData, count]);
const update = (idx: number, fieldId: string, value: string) => {
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
};
const submit = async () => {
if (!token) return;
try {
setSubmitting(true);
setError(null); setInfo(null);
const payload = [] as any[];
for (let i = 0; i < count; i++) payload.push({ answers: formsData[i] || {} });
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/forms/responses`, {
method: 'POST', authToken: token, body: { responses: payload }
});
setInfo('Attendee forms submitted successfully.');
} catch (e: any) {
setError(e?.message || 'Failed to submit forms');
} finally {
setSubmitting(false);
}
};
if (!form || !Array.isArray(form.fields) || count === 0) return null;
return (
<div className="mt-6 border rounded p-3 bg-gray-50">
<div className="text-sm font-medium mb-2">Attendee forms for this registration</div>
{error && <div className="text-xs text-red-600 mb-2">{error}</div>}
{info && <div className="text-xs text-emerald-700 mb-2">{info}</div>}
<div className="space-y-4">
{Array.from({ length: count }, (_, idx) => (
<div key={idx} className="bg-white border rounded p-3">
<div className="font-medium mb-2">Attendee {idx + 1}</div>
{form.fields.map((f) => (
<div key={f.id} className="mb-2">
{f.type === 'statement' ? (
<div className="text-sm text-gray-700 whitespace-pre-line">{f.label}</div>
) : f.type === 'paragraph' ? (
<div className="text-sm text-gray-700">
{f.label && <div className="font-medium mb-1 whitespace-pre-line">{f.label}</div>}
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
</div>
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
<option value="">Select</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
) : f.type === 'date' ? (
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
) : f.type === 'numeric' ? (
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
) : (
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
)}
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
</>
)}
</div>
))}
</div>
))}
</div>
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
</div>
);
}