feat:multiple bezoekers

This commit is contained in:
2026-03-03 10:36:08 +01:00
parent 1210b2e13e
commit f6c2bad9df
12 changed files with 1891 additions and 574 deletions

View File

@@ -8,6 +8,31 @@ export const Route = createFileRoute("/manage/$token")({
component: ManageRegistrationPage,
});
interface GuestEntry {
firstName: string;
lastName: string;
email: string;
phone: string;
}
function parseGuests(raw: string | null | undefined): GuestEntry[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((g) => ({
firstName: g.firstName ?? "",
lastName: g.lastName ?? "",
email: g.email ?? "",
phone: g.phone ?? "",
}));
}
} catch {
// ignore
}
return [];
}
function ManageRegistrationPage() {
const { token } = useParams({ from: "/manage/$token" });
const queryClient = useQueryClient();
@@ -18,11 +43,13 @@ function ManageRegistrationPage() {
lastName: "",
email: "",
phone: "",
wantsToPerform: false,
registrationType: "watcher" as "performer" | "watcher",
artForm: "",
experience: "",
isOver16: false,
extraQuestions: "",
});
const [formGuests, setFormGuests] = useState<GuestEntry[]>([]);
const { data, isLoading, error } = useQuery({
...orpc.getRegistrationByToken.queryOptions({ input: { token } }),
@@ -58,30 +85,41 @@ function ManageRegistrationPage() {
lastName: data.lastName,
email: data.email,
phone: data.phone || "",
wantsToPerform: data.wantsToPerform ?? false,
registrationType:
(data.registrationType as "performer" | "watcher") ?? "watcher",
artForm: data.artForm || "",
experience: data.experience || "",
isOver16: data.isOver16 ?? false,
extraQuestions: data.extraQuestions || "",
});
setFormGuests(parseGuests(data.guests));
setIsEditing(true);
}
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
const isPerformer = formData.registrationType === "performer";
updateMutation.mutate({
token,
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
email: formData.email.trim(),
phone: formData.phone.trim() || undefined,
wantsToPerform: formData.wantsToPerform,
artForm: formData.wantsToPerform
? formData.artForm.trim() || undefined
: undefined,
experience: formData.wantsToPerform
registrationType: formData.registrationType,
artForm: isPerformer ? formData.artForm.trim() || undefined : undefined,
experience: isPerformer
? formData.experience.trim() || undefined
: undefined,
isOver16: isPerformer ? formData.isOver16 : false,
guests: isPerformer
? []
: formGuests.map((g) => ({
firstName: g.firstName.trim(),
lastName: g.lastName.trim(),
email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined,
})),
extraQuestions: formData.extraQuestions.trim() || undefined,
});
};
@@ -96,6 +134,33 @@ function ManageRegistrationPage() {
}
};
const handleAddGuest = () => {
if (formGuests.length >= 9) return;
setFormGuests((prev) => [
...prev,
{ firstName: "", lastName: "", email: "", phone: "" },
]);
};
const handleRemoveGuest = (index: number) => {
setFormGuests((prev) => prev.filter((_, i) => i !== index));
};
const handleGuestChange = (
index: number,
field: keyof GuestEntry,
value: string,
) => {
setFormGuests((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const inputClasses =
"w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none";
if (isLoading) {
return (
<div className="min-h-screen bg-[#214e51]">
@@ -133,7 +198,13 @@ function ManageRegistrationPage() {
);
}
const isPerformer = data.registrationType === "performer";
const viewGuests = parseGuests(data.guests);
if (isEditing) {
const isEditingWatcher = formData.registrationType === "watcher";
const totalDrinkCard = isEditingWatcher ? 5 + formGuests.length * 2 : 0;
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
@@ -161,7 +232,7 @@ function ManageRegistrationPage() {
onChange={(e) =>
setFormData((p) => ({ ...p, firstName: e.target.value }))
}
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
className={inputClasses}
/>
</div>
<div>
@@ -176,7 +247,7 @@ function ManageRegistrationPage() {
onChange={(e) =>
setFormData((p) => ({ ...p, lastName: e.target.value }))
}
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
className={inputClasses}
/>
</div>
</div>
@@ -193,7 +264,7 @@ function ManageRegistrationPage() {
onChange={(e) =>
setFormData((p) => ({ ...p, email: e.target.value }))
}
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
className={inputClasses}
/>
</div>
@@ -208,46 +279,57 @@ function ManageRegistrationPage() {
onChange={(e) =>
setFormData((p) => ({ ...p, phone: e.target.value }))
}
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
className={inputClasses}
/>
</div>
<label
htmlFor="wantsToPerform"
className="flex cursor-pointer items-center gap-4"
>
<div className="relative flex shrink-0">
<input
id="wantsToPerform"
type="checkbox"
checked={formData.wantsToPerform}
onChange={(e) =>
setFormData((p) => ({
...p,
wantsToPerform: e.target.checked,
artForm: e.target.checked ? p.artForm : "",
experience: e.target.checked ? p.experience : "",
}))
}
className="peer sr-only"
/>
<div className="h-6 w-6 border-2 border-white/50 bg-transparent transition-colors peer-checked:border-white peer-checked:bg-white" />
<svg
className="pointer-events-none absolute top-0 left-0 h-6 w-6 scale-0 text-[#214e51] transition-transform peer-checked:scale-100"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
aria-label="Geselecteerd"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/* Registration type */}
<div>
<p className="mb-3 text-white">Type inschrijving</p>
<div className="flex gap-4">
<label className="flex cursor-pointer items-center gap-3">
<div className="relative flex shrink-0">
<input
type="radio"
name="registrationType"
value="performer"
checked={formData.registrationType === "performer"}
onChange={() =>
setFormData((p) => ({
...p,
registrationType: "performer",
}))
}
className="peer sr-only"
/>
<div className="h-5 w-5 rounded-full border-2 border-white/50 bg-transparent transition-colors peer-checked:border-amber-400 peer-checked:bg-amber-400" />
</div>
<span className="text-white">Optreden</span>
</label>
<label className="flex cursor-pointer items-center gap-3">
<div className="relative flex shrink-0">
<input
type="radio"
name="registrationType"
value="watcher"
checked={formData.registrationType === "watcher"}
onChange={() =>
setFormData((p) => ({
...p,
registrationType: "watcher",
}))
}
className="peer sr-only"
/>
<div className="h-5 w-5 rounded-full border-2 border-white/50 bg-transparent transition-colors peer-checked:border-teal-400 peer-checked:bg-teal-400" />
</div>
<span className="text-white">Kijken</span>
</label>
</div>
<span className="text-white text-xl">Ik wil optreden</span>
</label>
</div>
{formData.wantsToPerform && (
<div className="space-y-6 border border-white/20 p-6">
{formData.registrationType === "performer" && (
<div className="space-y-6 border border-amber-400/20 bg-amber-400/5 p-6">
<div>
<label htmlFor="artForm" className="mb-2 block text-white">
Kunstvorm *
@@ -255,16 +337,13 @@ function ManageRegistrationPage() {
<input
id="artForm"
type="text"
required={formData.wantsToPerform}
required={formData.registrationType === "performer"}
value={formData.artForm}
onChange={(e) =>
setFormData((p) => ({
...p,
artForm: e.target.value,
}))
setFormData((p) => ({ ...p, artForm: e.target.value }))
}
list="artFormSuggestions"
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
className={inputClasses}
/>
<datalist id="artFormSuggestions">
<option value="Muziek" />
@@ -290,7 +369,7 @@ function ManageRegistrationPage() {
}))
}
list="experienceSuggestions"
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
className={inputClasses}
/>
<datalist id="experienceSuggestions">
<option value="Beginner" />
@@ -298,6 +377,187 @@ function ManageRegistrationPage() {
<option value="Professional" />
</datalist>
</div>
<label className="flex cursor-pointer items-center gap-3">
<div className="relative flex shrink-0">
<input
type="checkbox"
checked={formData.isOver16}
onChange={(e) =>
setFormData((p) => ({
...p,
isOver16: e.target.checked,
}))
}
className="peer sr-only"
/>
<div className="h-6 w-6 border-2 border-white/50 bg-transparent transition-colors peer-checked:border-amber-400 peer-checked:bg-amber-400" />
<svg
className="pointer-events-none absolute top-0 left-0 h-6 w-6 scale-0 text-[#214e51] transition-transform peer-checked:scale-100"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
aria-label="Geselecteerd"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<span className="text-white">Ik ben 16 jaar of ouder</span>
</label>
</div>
)}
{/* Guests section for watchers */}
{isEditingWatcher && (
<div className="border border-teal-400/20 bg-teal-400/5 p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-sm text-teal-300/80 uppercase tracking-wider">
Medebezoekers{" "}
<span className="text-white/50 normal-case">
({formGuests.length}/9)
</span>
</p>
<p className="mt-1 text-sm text-white/60">
Drinkkaart totaal: {totalDrinkCard} voor{" "}
{1 + formGuests.length} personen
</p>
</div>
{formGuests.length < 9 && (
<button
type="button"
onClick={handleAddGuest}
className="flex items-center gap-1.5 rounded border border-teal-400/40 bg-teal-400/10 px-3 py-1.5 text-sm text-teal-300 transition-colors hover:bg-teal-400/20"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
Toevoegen
</button>
)}
</div>
{formGuests.length === 0 && (
<p className="text-sm text-white/40">
Geen medebezoekers toegevoegd.
</p>
)}
<div className="flex flex-col gap-4">
{formGuests.map((guest, idx) => (
<div
key={`edit-guest-${
// biome-ignore lint/suspicious/noArrayIndexKey: stable index
idx
}`}
className="border border-teal-400/20 p-4"
>
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-sm text-teal-300">
Medebezoeker {idx + 1}
</span>
<button
type="button"
onClick={() => handleRemoveGuest(idx)}
className="text-red-400/70 text-sm transition-colors hover:text-red-300"
>
Verwijderen
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label
htmlFor={`edit-guest-${idx}-firstName`}
className="mb-1 block text-sm text-white/70"
>
Voornaam *
</label>
<input
id={`edit-guest-${idx}-firstName`}
type="text"
required
value={guest.firstName}
onChange={(e) =>
handleGuestChange(
idx,
"firstName",
e.target.value,
)
}
placeholder="Voornaam"
className={inputClasses}
/>
</div>
<div>
<label
htmlFor={`edit-guest-${idx}-lastName`}
className="mb-1 block text-sm text-white/70"
>
Achternaam *
</label>
<input
id={`edit-guest-${idx}-lastName`}
type="text"
required
value={guest.lastName}
onChange={(e) =>
handleGuestChange(idx, "lastName", e.target.value)
}
placeholder="Achternaam"
className={inputClasses}
/>
</div>
<div>
<label
htmlFor={`edit-guest-${idx}-email`}
className="mb-1 block text-sm text-white/70"
>
E-mail
</label>
<input
id={`edit-guest-${idx}-email`}
type="email"
value={guest.email}
onChange={(e) =>
handleGuestChange(idx, "email", e.target.value)
}
placeholder="optioneel@email.be"
className={inputClasses}
/>
</div>
<div>
<label
htmlFor={`edit-guest-${idx}-phone`}
className="mb-1 block text-sm text-white/70"
>
Telefoon
</label>
<input
id={`edit-guest-${idx}-phone`}
type="tel"
value={guest.phone}
onChange={(e) =>
handleGuestChange(idx, "phone", e.target.value)
}
placeholder="06-12345678"
className={inputClasses}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
@@ -358,6 +618,19 @@ function ManageRegistrationPage() {
Open Mic Night vrijdag 18 april 2026
</p>
{/* Type badge */}
<div
className={`mb-6 inline-flex items-center gap-2 rounded-full border px-4 py-1.5 font-semibold text-sm ${isPerformer ? "border-amber-400/40 bg-amber-400/10 text-amber-300" : "border-teal-400/40 bg-teal-400/10 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
{!isPerformer && (
<span className="ml-1 opacity-80">
Drinkkaart {data.drinkCardValue ?? 5}
{viewGuests.length > 0 && ` (${1 + viewGuests.length} personen)`}
</span>
)}
</div>
<div className="space-y-6 rounded-lg border border-white/20 bg-white/5 p-6 md:p-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
@@ -378,17 +651,51 @@ function ManageRegistrationPage() {
</div>
</div>
<div className="border-white/10 border-t pt-6">
<p className="mb-2 text-sm text-white/50">Rol</p>
<p className="text-lg text-white">
{data.wantsToPerform
? `Optreden${data.artForm ? `${data.artForm}` : ""}`
: "Toeschouwer"}
</p>
{data.wantsToPerform && data.experience && (
<p className="mt-1 text-white/60">{data.experience}</p>
)}
</div>
{isPerformer && (
<div className="border-white/10 border-t pt-6">
<p className="mb-2 text-sm text-white/50">Optreden</p>
<p className="text-lg text-white">
{data.artForm || ""}
{data.experience && (
<span className="ml-2 text-white/60">
({data.experience})
</span>
)}
</p>
<p className="mt-1 text-sm text-white/60">
{data.isOver16 ? "16+ bevestigd" : "Leeftijd niet bevestigd"}
</p>
</div>
)}
{!isPerformer && viewGuests.length > 0 && (
<div className="border-white/10 border-t pt-6">
<p className="mb-3 text-sm text-white/50">
Medebezoekers ({viewGuests.length})
</p>
<div className="flex flex-col gap-3">
{viewGuests.map((g, idx) => (
<div
key={`view-guest-${
// biome-ignore lint/suspicious/noArrayIndexKey: stable index
idx
}`}
className="rounded border border-teal-400/20 bg-teal-400/5 p-3"
>
<p className="text-white">
{g.firstName} {g.lastName}
</p>
{g.email && (
<p className="text-sm text-white/60">{g.email}</p>
)}
{g.phone && (
<p className="text-sm text-white/60">{g.phone}</p>
)}
</div>
))}
</div>
</div>
)}
{data.extraQuestions && (
<div className="border-white/10 border-t pt-6">