feat:simplify dx and improve payment functions

This commit is contained in:
2026-03-03 14:02:29 +01:00
parent b9e5c44588
commit 0a1d1db9ec
10 changed files with 2167 additions and 2224 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
import {
type GuestEntry,
type GuestErrors,
inputCls,
MAX_GUESTS,
} from "@/lib/registration";
interface Props {
guests: GuestEntry[];
errors: GuestErrors[];
onChange: (index: number, field: keyof GuestEntry, value: string) => void;
onAdd: () => void;
onRemove: (index: number) => void;
/** Optional suffix rendered after the guest count header (e.g. price warning) */
headerNote?: React.ReactNode;
}
export function GuestList({
guests,
errors,
onChange,
onAdd,
onRemove,
headerNote,
}: Props) {
return (
<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">
({guests.length}/{MAX_GUESTS})
</span>
</p>
{headerNote}
</div>
{guests.length < MAX_GUESTS && (
<button
type="button"
onClick={onAdd}
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>
{guests.length === 0 ? "Medebezoeker toevoegen" : "Toevoegen"}
</button>
)}
</div>
{guests.length === 0 && (
<p className="text-sm text-white/40">
Kom je met iemand mee? Voeg je medebezoekers toe. Elke extra persoon
kost 2 op de drinkkaart.
</p>
)}
<div className="flex flex-col gap-4">
{guests.map((guest, idx) => (
<div
key={`guest-${
// biome-ignore lint/suspicious/noArrayIndexKey: stable positional 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={() => onRemove(idx)}
className="flex items-center gap-1 text-red-400/70 text-sm transition-colors hover:text-red-300"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
Verwijderen
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label
htmlFor={`guest-${idx}-firstName`}
className="text-white/80"
>
Voornaam <span className="text-red-300">*</span>
</label>
<input
type="text"
id={`guest-${idx}-firstName`}
value={guest.firstName}
onChange={(e) => onChange(idx, "firstName", e.target.value)}
placeholder="Voornaam"
autoComplete="off"
className={inputCls(!!errors[idx]?.firstName)}
/>
{errors[idx]?.firstName && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].firstName}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label
htmlFor={`guest-${idx}-lastName`}
className="text-white/80"
>
Achternaam <span className="text-red-300">*</span>
</label>
<input
type="text"
id={`guest-${idx}-lastName`}
value={guest.lastName}
onChange={(e) => onChange(idx, "lastName", e.target.value)}
placeholder="Achternaam"
autoComplete="off"
className={inputCls(!!errors[idx]?.lastName)}
/>
{errors[idx]?.lastName && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].lastName}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`guest-${idx}-email`} className="text-white/80">
E-mail
</label>
<input
type="email"
id={`guest-${idx}-email`}
value={guest.email}
onChange={(e) => onChange(idx, "email", e.target.value)}
placeholder="optioneel@email.be"
autoComplete="off"
inputMode="email"
className={inputCls(!!errors[idx]?.email)}
/>
{errors[idx]?.email && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].email}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`guest-${idx}-phone`} className="text-white/80">
Telefoon
</label>
<input
type="tel"
id={`guest-${idx}-phone`}
value={guest.phone}
onChange={(e) => onChange(idx, "phone", e.target.value)}
placeholder="06-12345678"
autoComplete="off"
inputMode="tel"
className={inputCls(!!errors[idx]?.phone)}
/>
{errors[idx]?.phone && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].phone}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,463 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import {
inputCls,
validateEmail,
validatePhone,
validateTextField,
} from "@/lib/registration";
import { orpc } from "@/utils/orpc";
interface PerformerErrors {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
artForm?: string;
isOver16?: string;
}
interface Props {
onBack: () => void;
onSuccess: (token: string) => void;
}
export function PerformerForm({ onBack, onSuccess }: Props) {
const [data, setData] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
artForm: "",
experience: "",
isOver16: false,
extraQuestions: "",
});
const [errors, setErrors] = useState<PerformerErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(),
onSuccess: (result) => {
if (result.managementToken) onSuccess(result.managementToken);
},
onError: (error) => {
toast.error(`Er is iets misgegaan: ${error.message}`);
},
});
function validate(): boolean {
const errs: PerformerErrors = {
firstName: validateTextField(data.firstName, true, "Voornaam"),
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined,
isOver16: !data.isOver16
? "Je moet 16 jaar of ouder zijn om op te treden"
: undefined,
};
setErrors(errs);
setTouched({
firstName: true,
lastName: true,
email: true,
phone: true,
artForm: true,
isOver16: true,
});
return !Object.values(errs).some(Boolean);
}
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
const { name, value, type } = e.target;
const newValue =
type === "checkbox" ? (e.target as HTMLInputElement).checked : value;
setData((prev) => ({ ...prev, [name]: newValue }));
if (type !== "checkbox" && touched[name]) {
const fieldError: Record<string, string | undefined> = {
firstName: validateTextField(
name === "firstName" ? value : data.firstName,
true,
"Voornaam",
),
lastName: validateTextField(
name === "lastName" ? value : data.lastName,
true,
"Achternaam",
),
email: validateEmail(name === "email" ? value : data.email),
phone: validatePhone(name === "phone" ? value : data.phone),
artForm:
name === "artForm" && !value.trim()
? "Kunstvorm is verplicht"
: undefined,
};
setErrors((prev) => ({ ...prev, [name]: fieldError[name] }));
}
}
function handleBlur(
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
const { name, value } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
const errMap: Record<string, string | undefined> = {
firstName: validateTextField(value, true, "Voornaam"),
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
artForm: !value.trim() ? "Kunstvorm is verplicht" : undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) {
toast.error("Controleer je invoer");
return;
}
submitMutation.mutate({
firstName: data.firstName.trim(),
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
registrationType: "performer",
artForm: data.artForm.trim() || undefined,
experience: data.experience.trim() || undefined,
isOver16: data.isOver16,
extraQuestions: data.extraQuestions.trim() || undefined,
});
}
return (
<div>
{/* Back + type header */}
<div className="mb-8 flex items-center gap-4">
<button
type="button"
onClick={onBack}
className="flex items-center gap-2 text-white/60 transition-colors hover:text-white"
>
<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="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
Terug
</button>
<div className="h-px flex-1 bg-white/10" />
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-400/20 text-amber-300">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
/>
</svg>
</div>
<span className="font-['Intro',sans-serif] text-amber-300">
Ik wil optreden
</span>
</div>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
{/* Name row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="p-firstName" className="text-white text-xl">
Voornaam <span className="text-red-300">*</span>
</label>
<input
type="text"
id="p-firstName"
name="firstName"
value={data.firstName}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Jouw voornaam"
autoComplete="given-name"
aria-required="true"
aria-invalid={touched.firstName && !!errors.firstName}
className={inputCls(!!touched.firstName && !!errors.firstName)}
/>
{touched.firstName && errors.firstName && (
<span className="text-red-300 text-sm" role="alert">
{errors.firstName}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="p-lastName" className="text-white text-xl">
Achternaam <span className="text-red-300">*</span>
</label>
<input
type="text"
id="p-lastName"
name="lastName"
value={data.lastName}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Jouw achternaam"
autoComplete="family-name"
aria-required="true"
aria-invalid={touched.lastName && !!errors.lastName}
className={inputCls(!!touched.lastName && !!errors.lastName)}
/>
{touched.lastName && errors.lastName && (
<span className="text-red-300 text-sm" role="alert">
{errors.lastName}
</span>
)}
</div>
</div>
{/* Contact row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="p-email" className="text-white text-xl">
E-mail <span className="text-red-300">*</span>
</label>
<input
type="email"
id="p-email"
name="email"
value={data.email}
onChange={handleChange}
onBlur={handleBlur}
placeholder="jouw@email.be"
autoComplete="email"
inputMode="email"
aria-required="true"
aria-invalid={touched.email && !!errors.email}
className={inputCls(!!touched.email && !!errors.email)}
/>
{touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert">
{errors.email}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="p-phone" className="text-white text-xl">
Telefoon
</label>
<input
type="tel"
id="p-phone"
name="phone"
value={data.phone}
onChange={handleChange}
onBlur={handleBlur}
placeholder="06-12345678"
autoComplete="tel"
inputMode="tel"
aria-invalid={touched.phone && !!errors.phone}
className={inputCls(!!touched.phone && !!errors.phone)}
/>
{touched.phone && errors.phone && (
<span className="text-red-300 text-sm" role="alert">
{errors.phone}
</span>
)}
</div>
</div>
{/* Performer-specific fields */}
<div className="border border-amber-400/20 bg-amber-400/5 p-6">
<p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider">
Optreden details
</p>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<label htmlFor="p-artForm" className="text-white text-xl">
Kunstvorm <span className="text-red-300">*</span>
</label>
<input
type="text"
id="p-artForm"
name="artForm"
value={data.artForm}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Muziek, Theater, Dans, etc."
autoComplete="off"
list="artFormSuggestions"
aria-required="true"
aria-invalid={touched.artForm && !!errors.artForm}
className={inputCls(!!touched.artForm && !!errors.artForm)}
/>
<datalist id="artFormSuggestions">
<option value="Muziek" />
<option value="Theater" />
<option value="Dans" />
<option value="Beeldende Kunst" />
<option value="Woordkunst" />
<option value="Comedy" />
</datalist>
{touched.artForm && errors.artForm && (
<span className="text-red-300 text-sm" role="alert">
{errors.artForm}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="p-experience" className="text-white text-xl">
Ervaring
</label>
<input
type="text"
id="p-experience"
name="experience"
value={data.experience}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Beginner / Gevorderd / Professional"
autoComplete="off"
list="experienceSuggestions"
className={inputCls(false)}
/>
<datalist id="experienceSuggestions">
<option value="Beginner" />
<option value="Gevorderd" />
<option value="Professional" />
</datalist>
</div>
{/* Age confirmation */}
<div>
<label
htmlFor="p-isOver16"
className="flex cursor-pointer items-start gap-4"
>
<div className="relative mt-0.5 flex shrink-0">
<input
type="checkbox"
id="p-isOver16"
name="isOver16"
checked={data.isOver16}
onChange={handleChange}
className="peer sr-only"
/>
<div
className={`h-6 w-6 border-2 bg-transparent transition-colors peer-checked:bg-amber-400 peer-focus:ring-2 peer-focus:ring-amber-400/50 peer-focus:ring-offset-2 peer-focus:ring-offset-[#214e51] ${touched.isOver16 && errors.isOver16 ? "border-red-400" : "border-white/50 peer-checked:border-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-hidden="true"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div className="flex flex-col gap-1">
<span className="text-lg text-white">
Ik ben 16 jaar of ouder{" "}
<span className="text-red-300">*</span>
</span>
<span className="text-sm text-white/60">
Je moet minimaal 16 jaar oud zijn om op te treden.
</span>
</div>
</label>
{touched.isOver16 && errors.isOver16 && (
<span className="mt-2 block text-red-300 text-sm" role="alert">
{errors.isOver16}
</span>
)}
</div>
</div>
</div>
{/* Extra questions */}
<div className="flex flex-col gap-2 border border-white/10 p-6">
<label htmlFor="p-extraQuestions" className="text-white text-xl">
Vragen of opmerkingen
</label>
<textarea
id="p-extraQuestions"
name="extraQuestions"
value={data.extraQuestions}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Heb je nog vragen of iets dat we moeten weten?"
rows={3}
autoComplete="off"
className="resize-none bg-transparent text-lg text-white transition-all placeholder:text-white/40 focus:outline-none"
/>
</div>
<div className="flex flex-col items-center gap-4 pt-4">
<button
type="submit"
disabled={submitMutation.isPending}
aria-busy={submitMutation.isPending}
className="bg-amber-400 px-12 py-4 font-['Intro',sans-serif] text-[#1a3d40] text-xl transition-all hover:scale-105 hover:bg-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:ring-offset-2 focus:ring-offset-[#214e51] disabled:cursor-not-allowed disabled:opacity-50 md:text-2xl"
>
{submitMutation.isPending ? (
<span className="flex items-center gap-2">
<svg
className="h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-label="Laden"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Bezig...
</span>
) : (
"Bevestigen"
)}
</button>
<a
href="/contact"
className="text-sm text-white/60 transition-colors hover:text-white"
>
Nog vragen? Neem contact op
</a>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,72 @@
interface Props {
token: string;
onReset: () => void;
}
export function SuccessScreen({ token, onReset }: Props) {
const manageUrl =
typeof window !== "undefined"
? `${window.location.origin}/manage/${token}`
: `/manage/${token}`;
return (
<section
id="registration"
className="relative z-30 flex w-full items-center justify-center bg-[#214e51]/96 px-6 py-16 md:px-12"
>
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg border border-white/20 bg-white/5 p-8 md:p-12">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-500/20 text-green-400">
<svg
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-label="Succes"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="mb-4 font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Gelukt!
</h2>
<p className="mb-6 text-lg text-white/80">
Je inschrijving is bevestigd. We sturen je zo dadelijk een
bevestigingsmail.
</p>
<div className="mb-8 rounded-lg border border-white/10 bg-white/5 p-6">
<p className="mb-2 text-sm text-white/60">
Geen mail ontvangen? Gebruik deze link:
</p>
<a
href={manageUrl}
className="break-all text-sm text-white/80 underline underline-offset-2 hover:text-white"
>
{manageUrl}
</a>
</div>
<div className="flex flex-wrap items-center gap-4">
<a
href={manageUrl}
className="inline-flex items-center bg-white px-6 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100"
>
Bekijk mijn inschrijving
</a>
<button
type="button"
onClick={onReset}
className="text-white/60 underline underline-offset-2 transition-colors hover:text-white"
>
Nog een inschrijving
</button>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,114 @@
type RegistrationType = "performer" | "watcher";
interface Props {
onSelect: (type: RegistrationType) => void;
}
export function TypeSelector({ onSelect }: Props) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Performer card */}
<button
type="button"
onClick={() => onSelect("performer")}
className="group relative flex flex-col overflow-hidden border border-white/20 bg-white/5 p-8 text-left transition-all duration-300 hover:border-white/50 hover:bg-white/10 md:p-10"
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-amber-400 to-orange-500 opacity-80" />
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-amber-400/15 text-amber-300">
<svg
className="h-7 w-7"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
/>
</svg>
</div>
<h3 className="mb-3 font-['Intro',sans-serif] text-2xl text-white md:text-3xl">
Ik wil optreden
</h3>
<p className="mb-6 text-white/70">
Deel jouw talent met het publiek. Muziek, theater, dans, comedy
alles is welkom. Je moet 16 jaar of ouder zijn.
</p>
<div className="mt-auto flex items-center gap-2 font-medium text-amber-300 text-sm">
<span>Inschrijven als artiest</span>
<svg
className="h-4 w-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</div>
</button>
{/* Watcher card */}
<button
type="button"
onClick={() => onSelect("watcher")}
className="group relative flex flex-col overflow-hidden border border-white/20 bg-white/5 p-8 text-left transition-all duration-300 hover:border-white/50 hover:bg-white/10 md:p-10"
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-teal-400 to-cyan-400 opacity-80" />
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-teal-400/15 text-teal-300">
<svg
className="h-7 w-7"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z"
/>
</svg>
</div>
<h3 className="mb-3 font-['Intro',sans-serif] text-2xl text-white md:text-3xl">
Ik wil komen kijken
</h3>
<p className="mb-4 text-white/70">
Geniet van een avond vol talent en kunst. Je betaald 5EUR inkom dat je
mag gebruiken op je drinkkaart.
</p>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-teal-400/40 bg-teal-400/10 px-4 py-1.5">
<span className="font-semibold text-sm text-teal-300">
Drinkkaart 5 EUR inbegrepen bij inkom.
</span>
</div>
<div className="mt-auto flex items-center gap-2 font-medium text-sm text-teal-300">
<span>Inschrijven als bezoeker</span>
<svg
className="h-4 w-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</div>
</button>
</div>
);
}

View File

@@ -0,0 +1,403 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import {
calculateDrinkCard,
type GuestEntry,
type GuestErrors,
inputCls,
validateEmail,
validateGuests,
validatePhone,
validateTextField,
} from "@/lib/registration";
import { client, orpc } from "@/utils/orpc";
import { GuestList } from "./GuestList";
interface WatcherErrors {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}
interface Props {
onBack: () => void;
}
export function WatcherForm({ onBack }: Props) {
const [data, setData] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
extraQuestions: "",
});
const [errors, setErrors] = useState<WatcherErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [guests, setGuests] = useState<GuestEntry[]>([]);
const [guestErrors, setGuestErrors] = useState<GuestErrors[]>([]);
const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(),
onSuccess: async (result) => {
if (!result.managementToken) return;
// Redirect to Lemon Squeezy checkout immediately after registration
try {
const checkout = await client.getCheckoutUrl({
token: result.managementToken,
});
window.location.href = checkout.checkoutUrl;
} catch (error) {
console.error("Checkout error:", error);
toast.error("Er is iets misgegaan bij het aanmaken van de betaling");
}
},
onError: (error) => {
toast.error(`Er is iets misgegaan: ${error.message}`);
},
});
function validate(): boolean {
const fieldErrs: WatcherErrors = {
firstName: validateTextField(data.firstName, true, "Voornaam"),
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
};
setErrors(fieldErrs);
setTouched({
firstName: true,
lastName: true,
email: true,
phone: true,
});
const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs);
return !Object.values(fieldErrs).some(Boolean) && gValid;
}
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
const { name, value } = e.target;
setData((prev) => ({ ...prev, [name]: value }));
if (touched[name]) {
const errMap: Record<string, string | undefined> = {
firstName: validateTextField(value, true, "Voornaam"),
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
}
function handleBlur(
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
const { name, value } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
const errMap: Record<string, string | undefined> = {
firstName: validateTextField(value, true, "Voornaam"),
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
function handleGuestChange(
index: number,
field: keyof GuestEntry,
value: string,
) {
setGuests((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value } as GuestEntry;
return next;
});
}
function handleAddGuest() {
if (guests.length >= 9) return;
setGuests((prev) => [
...prev,
{ firstName: "", lastName: "", email: "", phone: "" },
]);
setGuestErrors((prev) => [...prev, {}]);
}
function handleRemoveGuest(index: number) {
setGuests((prev) => prev.filter((_, i) => i !== index));
setGuestErrors((prev) => prev.filter((_, i) => i !== index));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) {
toast.error("Controleer je invoer");
return;
}
submitMutation.mutate({
firstName: data.firstName.trim(),
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
registrationType: "watcher",
guests: guests.map((g) => ({
firstName: g.firstName.trim(),
lastName: g.lastName.trim(),
email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined,
})),
extraQuestions: data.extraQuestions.trim() || undefined,
});
}
const totalPrice = calculateDrinkCard(guests.length);
return (
<div>
{/* Back + type header */}
<div className="mb-8 flex items-center gap-4">
<button
type="button"
onClick={onBack}
className="flex items-center gap-2 text-white/60 transition-colors hover:text-white"
>
<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="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
Terug
</button>
<div className="h-px flex-1 bg-white/10" />
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-teal-400/20 text-teal-300">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z"
/>
</svg>
</div>
<span className="font-['Intro',sans-serif] text-teal-300">
Ik wil komen kijken
</span>
</div>
</div>
{/* Live price callout */}
<div className="mb-8 flex items-center gap-4 rounded-lg border border-teal-400/30 bg-teal-400/10 p-5">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-teal-400/20 font-bold text-teal-300 text-xl">
{totalPrice}
</div>
<div>
<p className="font-semibold text-white">Drinkkaart inbegrepen</p>
<p className="text-sm text-white/70">
Je betaald bij inkom <strong className="text-teal-300">5</strong>{" "}
(+ 2 per medebezoeker) dat gaat naar je drinkkaart.
{guests.length > 0 && (
<span className="ml-1 font-semibold text-teal-300">
Totaal: {totalPrice} voor {1 + guests.length} personen.
</span>
)}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
{/* Name row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="w-firstName" className="text-white text-xl">
Voornaam <span className="text-red-300">*</span>
</label>
<input
type="text"
id="w-firstName"
name="firstName"
value={data.firstName}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Jouw voornaam"
autoComplete="given-name"
aria-required="true"
aria-invalid={touched.firstName && !!errors.firstName}
className={inputCls(!!touched.firstName && !!errors.firstName)}
/>
{touched.firstName && errors.firstName && (
<span className="text-red-300 text-sm" role="alert">
{errors.firstName}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="w-lastName" className="text-white text-xl">
Achternaam <span className="text-red-300">*</span>
</label>
<input
type="text"
id="w-lastName"
name="lastName"
value={data.lastName}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Jouw achternaam"
autoComplete="family-name"
aria-required="true"
aria-invalid={touched.lastName && !!errors.lastName}
className={inputCls(!!touched.lastName && !!errors.lastName)}
/>
{touched.lastName && errors.lastName && (
<span className="text-red-300 text-sm" role="alert">
{errors.lastName}
</span>
)}
</div>
</div>
{/* Contact row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="w-email" className="text-white text-xl">
E-mail <span className="text-red-300">*</span>
</label>
<input
type="email"
id="w-email"
name="email"
value={data.email}
onChange={handleChange}
onBlur={handleBlur}
placeholder="jouw@email.be"
autoComplete="email"
inputMode="email"
aria-required="true"
aria-invalid={touched.email && !!errors.email}
className={inputCls(!!touched.email && !!errors.email)}
/>
{touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert">
{errors.email}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="w-phone" className="text-white text-xl">
Telefoon
</label>
<input
type="tel"
id="w-phone"
name="phone"
value={data.phone}
onChange={handleChange}
onBlur={handleBlur}
placeholder="06-12345678"
autoComplete="tel"
inputMode="tel"
aria-invalid={touched.phone && !!errors.phone}
className={inputCls(!!touched.phone && !!errors.phone)}
/>
{touched.phone && errors.phone && (
<span className="text-red-300 text-sm" role="alert">
{errors.phone}
</span>
)}
</div>
</div>
{/* Guests */}
<GuestList
guests={guests}
errors={guestErrors}
onChange={handleGuestChange}
onAdd={handleAddGuest}
onRemove={handleRemoveGuest}
/>
{/* Extra questions */}
<div className="flex flex-col gap-2 border border-white/10 p-6">
<label htmlFor="w-extraQuestions" className="text-white text-xl">
Vragen of opmerkingen
</label>
<textarea
id="w-extraQuestions"
name="extraQuestions"
value={data.extraQuestions}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Heb je nog vragen of iets dat we moeten weten?"
rows={3}
autoComplete="off"
className="resize-none bg-transparent text-lg text-white transition-all placeholder:text-white/40 focus:outline-none"
/>
</div>
<div className="flex flex-col items-center gap-4 pt-4">
<button
type="submit"
disabled={submitMutation.isPending}
aria-busy={submitMutation.isPending}
className="bg-white px-12 py-4 font-['Intro',sans-serif] text-[#214e51] text-xl transition-all hover:scale-105 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-white/50 focus:ring-offset-2 focus:ring-offset-[#214e51] disabled:cursor-not-allowed disabled:opacity-50 md:text-2xl"
>
{submitMutation.isPending ? (
<span className="flex items-center gap-2">
<svg
className="h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-label="Laden"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Bezig...
</span>
) : (
"Bevestigen & betalen"
)}
</button>
<a
href="/contact"
className="text-sm text-white/60 transition-colors hover:text-white"
>
Nog vragen? Neem contact op
</a>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,117 @@
// Shared types, validators, and helpers for the registration workflow.
// Single source of truth — used by EventRegistrationForm, manage page, and API router.
// ---------------------------------------------------------------------------
// Drink card pricing
// ---------------------------------------------------------------------------
export const DRINK_CARD_BASE = 5; // €5 for primary registrant
export const DRINK_CARD_PER_GUEST = 2; // €2 per additional guest
export const MAX_GUESTS = 9;
/** Returns drink card value in euros for a given number of extra guests. */
export function calculateDrinkCard(guestCount: number): number {
return DRINK_CARD_BASE + guestCount * DRINK_CARD_PER_GUEST;
}
/** Returns drink card value in cents for payment processing. */
export function calculateDrinkCardCents(guestCount: number): number {
return calculateDrinkCard(guestCount) * 100;
}
// ---------------------------------------------------------------------------
// Guest types
// ---------------------------------------------------------------------------
export interface GuestEntry {
firstName: string;
lastName: string;
email: string;
phone: string;
}
export interface GuestErrors {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}
/**
* Safely parses the JSON blob stored in the `guests` DB column.
* Returns an empty array on any parse failure.
*/
export function parseGuests(raw: string | null | undefined): GuestEntry[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.map((g) => ({
firstName: g.firstName ?? "",
lastName: g.lastName ?? "",
email: g.email ?? "",
phone: g.phone ?? "",
}));
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Module-level validators (pure functions — no hook deps)
// ---------------------------------------------------------------------------
export function validateTextField(
value: string,
required: boolean,
label: string,
minLen = 2,
): string | undefined {
if (required && !value.trim()) return `${label} is verplicht`;
if (value.trim() && value.length < minLen)
return `${label} moet minimaal ${minLen} tekens bevatten`;
return undefined;
}
export function validateEmail(value: string): string | undefined {
if (!value.trim()) return "E-mail is verplicht";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
return "Voer een geldig e-mailadres in";
return undefined;
}
export function validatePhone(value: string): string | undefined {
if (value && !/^[\d\s\-+()]{10,}$/.test(value.replace(/\s/g, "")))
return "Voer een geldig telefoonnummer in";
return undefined;
}
/** Validates all guests and returns errors array + overall validity flag. */
export function validateGuests(guests: GuestEntry[]): {
errors: GuestErrors[];
valid: boolean;
} {
const errors: GuestErrors[] = guests.map((g) => ({
firstName: !g.firstName.trim() ? "Voornaam is verplicht" : undefined,
lastName: !g.lastName.trim() ? "Achternaam is verplicht" : undefined,
email:
g.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(g.email.trim())
? "Voer een geldig e-mailadres in"
: undefined,
phone:
g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, ""))
? "Voer een geldig telefoonnummer in"
: undefined,
}));
const valid = !errors.some((e) => Object.values(e).some(Boolean));
return { errors, valid };
}
// ---------------------------------------------------------------------------
// Shared CSS helpers
// ---------------------------------------------------------------------------
/** Input class string with error-state variant. */
export function inputCls(hasError: boolean): string {
return `w-full border-b bg-transparent pb-2 text-lg text-white placeholder:text-white/40 focus:outline-none focus:ring-0 transition-colors ${hasError ? "border-red-400" : "border-white/30 focus:border-white"}`;
}

View File

@@ -477,6 +477,12 @@ function AdminPage() {
<th className="px-6 py-4 text-left font-medium text-sm text-white/60"> <th className="px-6 py-4 text-left font-medium text-sm text-white/60">
16+ 16+
</th> </th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Betaald
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Betaald
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60"> <th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Datum Datum
</th> </th>
@@ -486,7 +492,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? ( {registrationsQuery.isLoading ? (
<tr> <tr>
<td <td
colSpan={9} colSpan={10}
className="px-6 py-8 text-center text-white/60" className="px-6 py-8 text-center text-white/60"
> >
Laden... Laden...
@@ -495,7 +501,7 @@ function AdminPage() {
) : registrations.length === 0 ? ( ) : registrations.length === 0 ? (
<tr> <tr>
<td <td
colSpan={9} colSpan={10}
className="px-6 py-8 text-center text-white/60" className="px-6 py-8 text-center text-white/60"
> >
Geen registraties gevonden Geen registraties gevonden
@@ -564,6 +570,47 @@ function AdminPage() {
"-" "-"
)} )}
</td> </td>
<td className="px-6 py-4">
{isPerformer ? (
<span className="text-white/40">-</span>
) : reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 text-green-400 text-sm">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<title>Betaald icoon</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
Betaald
</span>
) : (
<span className="inline-flex items-center gap-1 text-sm text-yellow-400">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<title>In afwachting icoon</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Open
</span>
)}
</td>
<td className="px-6 py-4 text-white/60"> <td className="px-6 py-4 text-white/60">
{new Date(reg.createdAt).toLocaleDateString( {new Date(reg.createdAt).toLocaleDateString(
"nl-BE", "nl-BE",

View File

@@ -2,120 +2,149 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useParams } from "@tanstack/react-router"; import { createFileRoute, Link, useParams } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { GuestList } from "@/components/registration/GuestList";
import {
calculateDrinkCard,
type GuestEntry,
inputCls,
parseGuests,
} from "@/lib/registration";
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/manage/$token")({ export const Route = createFileRoute("/manage/$token")({
component: ManageRegistrationPage, component: ManageRegistrationPage,
}); });
interface GuestEntry { // ---------------------------------------------------------------------------
// Shared layout wrappers
// ---------------------------------------------------------------------------
function PageShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">{children}</div>
</div>
);
}
function BackLink() {
return (
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"
>
Terug naar home
</Link>
);
}
// ---------------------------------------------------------------------------
// Payment status badges
// ---------------------------------------------------------------------------
function PaidBadge() {
return (
<div className="inline-flex items-center gap-2 rounded-full border border-green-400/40 bg-green-400/10 px-4 py-1.5 font-semibold text-green-400 text-sm">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-label="Betaald"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Betaling ontvangen
</div>
);
}
function PendingBadge() {
return (
<div className="inline-flex items-center gap-2 rounded-full border border-yellow-400/40 bg-yellow-400/10 px-4 py-1.5 font-semibold text-sm text-yellow-400">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-label="In afwachting"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Betaling in afwachting
</div>
);
}
// ---------------------------------------------------------------------------
// Edit form (inline on the manage page)
// ---------------------------------------------------------------------------
interface EditFormProps {
token: string;
initialData: {
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; email: string;
phone: string; phone: string | null;
registrationType: string;
artForm: string | null;
experience: string | null;
isOver16: boolean;
extraQuestions: string | null;
guests: string | null;
paymentStatus: string | null;
};
onCancel: () => void;
onSaved: () => void;
} }
function parseGuests(raw: string | null | undefined): GuestEntry[] { function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
if (!raw) return []; // Narrow the string from DB to the valid union; fall back to "watcher"
try { const initialType: "performer" | "watcher" =
const parsed = JSON.parse(raw); initialData.registrationType === "performer" ? "performer" : "watcher";
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();
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
firstName: "", firstName: initialData.firstName,
lastName: "", lastName: initialData.lastName,
email: "", email: initialData.email,
phone: "", phone: initialData.phone ?? "",
registrationType: "watcher" as "performer" | "watcher", registrationType: initialType,
artForm: "", artForm: initialData.artForm ?? "",
experience: "", experience: initialData.experience ?? "",
isOver16: false, isOver16: initialData.isOver16,
extraQuestions: "", extraQuestions: initialData.extraQuestions ?? "",
});
const [formGuests, setFormGuests] = useState<GuestEntry[]>([]);
const { data, isLoading, error } = useQuery({
...orpc.getRegistrationByToken.queryOptions({ input: { token } }),
}); });
const [formGuests, setFormGuests] = useState<GuestEntry[]>(
parseGuests(initialData.guests),
);
const updateMutation = useMutation({ const updateMutation = useMutation({
...orpc.updateRegistration.mutationOptions(), ...orpc.updateRegistration.mutationOptions(),
onSuccess: () => { onSuccess: () => {
toast.success("Inschrijving bijgewerkt!"); toast.success("Inschrijving bijgewerkt!");
queryClient.invalidateQueries({ queryKey: ["getRegistrationByToken"] }); onSaved();
setIsEditing(false);
}, },
onError: (err) => { onError: (err) => {
toast.error(`Opslaan mislukt: ${err.message}`); toast.error(`Opslaan mislukt: ${err.message}`);
}, },
}); });
const cancelMutation = useMutation({ const isWatcher = formData.registrationType === "watcher";
...orpc.cancelRegistration.mutationOptions(), const totalDrinkCard = isWatcher ? calculateDrinkCard(formGuests.length) : 0;
onSuccess: () => { const originalGuestCount = parseGuests(initialData.guests).length;
toast.success("Inschrijving geannuleerd"); const isPaid = initialData.paymentStatus === "paid";
queryClient.invalidateQueries({ queryKey: ["getRegistrationByToken"] }); const extraGuests = formGuests.length - originalGuestCount;
},
onError: (err) => {
toast.error(`Annuleren mislukt: ${err.message}`);
},
});
const checkoutMutation = useMutation({ function handleSubmit(e: React.FormEvent) {
...orpc.createCheckout.mutationOptions(),
onSuccess: (data) => {
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl;
}
},
onError: (err) => {
toast.error(`Betaling starten mislukt: ${err.message}`);
},
});
const handlePay = () => {
checkoutMutation.mutate({ token });
};
const handleEdit = () => {
if (data) {
setFormData({
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: data.phone || "",
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(); e.preventDefault();
const isPerformer = formData.registrationType === "performer"; const performer = formData.registrationType === "performer";
updateMutation.mutate({ updateMutation.mutate({
token, token,
firstName: formData.firstName.trim(), firstName: formData.firstName.trim(),
@@ -123,12 +152,12 @@ function ManageRegistrationPage() {
email: formData.email.trim(), email: formData.email.trim(),
phone: formData.phone.trim() || undefined, phone: formData.phone.trim() || undefined,
registrationType: formData.registrationType, registrationType: formData.registrationType,
artForm: isPerformer ? formData.artForm.trim() || undefined : undefined, artForm: performer ? formData.artForm.trim() || undefined : undefined,
experience: isPerformer experience: performer
? formData.experience.trim() || undefined ? formData.experience.trim() || undefined
: undefined, : undefined,
isOver16: isPerformer ? formData.isOver16 : false, isOver16: performer ? formData.isOver16 : false,
guests: isPerformer guests: performer
? [] ? []
: formGuests.map((g) => ({ : formGuests.map((g) => ({
firstName: g.firstName.trim(), firstName: g.firstName.trim(),
@@ -138,103 +167,17 @@ function ManageRegistrationPage() {
})), })),
extraQuestions: formData.extraQuestions.trim() || undefined, extraQuestions: formData.extraQuestions.trim() || undefined,
}); });
};
const handleCancel = () => {
if (
confirm(
"Weet je zeker dat je je inschrijving wilt annuleren? Dit kan niet ongedaan worden gemaakt.",
)
) {
cancelMutation.mutate({ token });
}
};
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]">
<div className="mx-auto max-w-3xl px-6 py-16">
<div className="animate-pulse text-white/60">Laden...</div>
</div>
</div>
);
} }
if (error || !data || data.cancelledAt) {
return ( return (
<div className="min-h-screen bg-[#214e51]"> <>
<div className="mx-auto max-w-3xl px-6 py-16"> <BackLink />
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"
>
Terug naar home
</Link>
<h1 className="mb-4 font-['Intro',sans-serif] text-4xl text-white">
Inschrijving niet gevonden
</h1>
<p className="mb-6 text-white/80">
Deze link is ongeldig of de inschrijving is geannuleerd.
</p>
<a
href="/#registration"
className="inline-flex items-center bg-white px-6 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100"
>
Nieuwe inschrijving
</a>
</div>
</div>
);
}
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">
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"
>
Terug naar home
</Link>
<h1 className="mb-8 font-['Intro',sans-serif] text-4xl text-white"> <h1 className="mb-8 font-['Intro',sans-serif] text-4xl text-white">
Bewerk inschrijving Bewerk inschrijving
</h1> </h1>
<form onSubmit={handleSave} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Name */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
<label htmlFor="firstName" className="mb-2 block text-white"> <label htmlFor="firstName" className="mb-2 block text-white">
@@ -248,7 +191,7 @@ function ManageRegistrationPage() {
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ ...p, firstName: e.target.value })) setFormData((p) => ({ ...p, firstName: e.target.value }))
} }
className={inputClasses} className={inputCls(false)}
/> />
</div> </div>
<div> <div>
@@ -263,11 +206,12 @@ function ManageRegistrationPage() {
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ ...p, lastName: e.target.value })) setFormData((p) => ({ ...p, lastName: e.target.value }))
} }
className={inputClasses} className={inputCls(false)}
/> />
</div> </div>
</div> </div>
{/* Contact */}
<div> <div>
<label htmlFor="email" className="mb-2 block text-white"> <label htmlFor="email" className="mb-2 block text-white">
E-mail * E-mail *
@@ -280,10 +224,9 @@ function ManageRegistrationPage() {
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ ...p, email: e.target.value })) setFormData((p) => ({ ...p, email: e.target.value }))
} }
className={inputClasses} className={inputCls(false)}
/> />
</div> </div>
<div> <div>
<label htmlFor="phone" className="mb-2 block text-white"> <label htmlFor="phone" className="mb-2 block text-white">
Telefoon Telefoon
@@ -295,55 +238,43 @@ function ManageRegistrationPage() {
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ ...p, phone: e.target.value })) setFormData((p) => ({ ...p, phone: e.target.value }))
} }
className={inputClasses} className={inputCls(false)}
/> />
</div> </div>
{/* Registration type */} {/* Registration type toggle */}
<div> <div>
<p className="mb-3 text-white">Type inschrijving</p> <p className="mb-3 text-white">Type inschrijving</p>
<div className="flex gap-4"> <div className="flex gap-4">
<label className="flex cursor-pointer items-center gap-3"> {(["performer", "watcher"] as const).map((type) => (
<label
key={type}
className="flex cursor-pointer items-center gap-3"
>
<div className="relative flex shrink-0"> <div className="relative flex shrink-0">
<input <input
type="radio" type="radio"
name="registrationType" name="registrationType"
value="performer" value={type}
checked={formData.registrationType === "performer"} checked={formData.registrationType === type}
onChange={() => onChange={() =>
setFormData((p) => ({ setFormData((p) => ({ ...p, registrationType: type }))
...p,
registrationType: "performer",
}))
} }
className="peer sr-only" 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
</div> className={`h-5 w-5 rounded-full border-2 border-white/50 bg-transparent transition-colors ${type === "performer" ? "peer-checked:border-amber-400 peer-checked:bg-amber-400" : "peer-checked:border-teal-400 peer-checked:bg-teal-400"}`}
<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> </div>
<span className="text-white">Kijken</span> <span className="text-white">
{type === "performer" ? "Optreden" : "Kijken"}
</span>
</label> </label>
))}
</div> </div>
</div> </div>
{/* Performer fields */}
{formData.registrationType === "performer" && ( {formData.registrationType === "performer" && (
<div className="space-y-6 border border-amber-400/20 bg-amber-400/5 p-6"> <div className="space-y-6 border border-amber-400/20 bg-amber-400/5 p-6">
<div> <div>
@@ -353,13 +284,13 @@ function ManageRegistrationPage() {
<input <input
id="artForm" id="artForm"
type="text" type="text"
required={formData.registrationType === "performer"} required
value={formData.artForm} value={formData.artForm}
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ ...p, artForm: e.target.value })) setFormData((p) => ({ ...p, artForm: e.target.value }))
} }
list="artFormSuggestions" list="artFormSuggestions"
className={inputClasses} className={inputCls(false)}
/> />
<datalist id="artFormSuggestions"> <datalist id="artFormSuggestions">
<option value="Muziek" /> <option value="Muziek" />
@@ -379,13 +310,10 @@ function ManageRegistrationPage() {
type="text" type="text"
value={formData.experience} value={formData.experience}
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ setFormData((p) => ({ ...p, experience: e.target.value }))
...p,
experience: e.target.value,
}))
} }
list="experienceSuggestions" list="experienceSuggestions"
className={inputClasses} className={inputCls(false)}
/> />
<datalist id="experienceSuggestions"> <datalist id="experienceSuggestions">
<option value="Beginner" /> <option value="Beginner" />
@@ -399,10 +327,7 @@ function ManageRegistrationPage() {
type="checkbox" type="checkbox"
checked={formData.isOver16} checked={formData.isOver16}
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ setFormData((p) => ({ ...p, isOver16: e.target.checked }))
...p,
isOver16: e.target.checked,
}))
} }
className="peer sr-only" className="peer sr-only"
/> />
@@ -423,160 +348,44 @@ function ManageRegistrationPage() {
</div> </div>
)} )}
{/* Guests section for watchers */} {/* Guests for watchers */}
{isEditingWatcher && ( {isWatcher && (
<div className="border border-teal-400/20 bg-teal-400/5 p-6"> <GuestList
<div className="mb-4 flex items-center justify-between"> guests={formGuests}
<div> errors={[]}
<p className="text-sm text-teal-300/80 uppercase tracking-wider"> onChange={(idx, field, value) => {
Medebezoekers{" "} setFormGuests((prev) => {
<span className="text-white/50 normal-case"> const next = [...prev];
({formGuests.length}/9) next[idx] = { ...next[idx], [field]: value } as GuestEntry;
</span> return next;
</p> });
}}
onAdd={() => {
if (formGuests.length >= 9) return;
setFormGuests((prev) => [
...prev,
{ firstName: "", lastName: "", email: "", phone: "" },
]);
}}
onRemove={(idx) =>
setFormGuests((prev) => prev.filter((_, i) => i !== idx))
}
headerNote={
<p className="mt-1 text-sm text-white/60"> <p className="mt-1 text-sm text-white/60">
Drinkkaart totaal: {totalDrinkCard} voor{" "} Drinkkaart totaal: {totalDrinkCard} voor{" "}
{1 + formGuests.length} personen {1 + formGuests.length} personen
</p> {isPaid && extraGuests > 0 && (
</div> <span className="ml-2 text-yellow-400">
{formGuests.length < 9 && ( Let op: {extraGuests * 2} extra voor {extraGuests} nieuwe
<button gast(en)
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> </span>
<button )}
type="button" </p>
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>
)} )}
{/* Extra questions */}
<div> <div>
<label htmlFor="extraQuestions" className="mb-2 block text-white"> <label htmlFor="extraQuestions" className="mb-2 block text-white">
Vragen of opmerkingen Vragen of opmerkingen
@@ -586,10 +395,7 @@ function ManageRegistrationPage() {
rows={4} rows={4}
value={formData.extraQuestions} value={formData.extraQuestions}
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ setFormData((p) => ({ ...p, extraQuestions: e.target.value }))
...p,
extraQuestions: e.target.value,
}))
} }
className="w-full resize-none bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:outline-none" className="w-full resize-none bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:outline-none"
/> />
@@ -605,27 +411,117 @@ function ManageRegistrationPage() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => setIsEditing(false)} onClick={onCancel}
className="text-white/60 underline underline-offset-2 transition-colors hover:text-white" className="text-white/60 underline underline-offset-2 transition-colors hover:text-white"
> >
Annuleren Annuleren
</button> </button>
</div> </div>
</form> </form>
</div> </>
</div> );
}
// ---------------------------------------------------------------------------
// Main manage page
// ---------------------------------------------------------------------------
function ManageRegistrationPage() {
const { token } = useParams({ from: "/manage/$token" });
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const { data, isLoading, error } = useQuery({
...orpc.getRegistrationByToken.queryOptions({ input: { token } }),
});
const cancelMutation = useMutation({
...orpc.cancelRegistration.mutationOptions(),
onSuccess: () => {
toast.success("Inschrijving geannuleerd");
queryClient.invalidateQueries({ queryKey: ["getRegistrationByToken"] });
},
onError: (err) => {
toast.error(`Annuleren mislukt: ${err.message}`);
},
});
const checkoutMutation = useMutation({
...orpc.getCheckoutUrl.mutationOptions(),
onSuccess: (result) => {
window.location.href = result.checkoutUrl;
},
onError: (err) => {
toast.error(`Betaling starten mislukt: ${err.message}`);
},
});
function handleCancel() {
if (
confirm(
"Weet je zeker dat je je inschrijving wilt annuleren? Dit kan niet ongedaan worden gemaakt.",
)
) {
cancelMutation.mutate({ token });
}
}
function handleSaved() {
queryClient.invalidateQueries({ queryKey: ["getRegistrationByToken"] });
setIsEditing(false);
}
// Loading state
if (isLoading) {
return (
<PageShell>
<div className="animate-pulse text-white/60">Laden...</div>
</PageShell>
); );
} }
// Error / not found / cancelled
if (error || !data || data.cancelledAt) {
return ( return (
<div className="min-h-screen bg-[#214e51]"> <PageShell>
<div className="mx-auto max-w-3xl px-6 py-16"> <BackLink />
<Link <h1 className="mb-4 font-['Intro',sans-serif] text-4xl text-white">
to="/" Inschrijving niet gevonden
className="link-hover mb-8 inline-block text-white/80 hover:text-white" </h1>
<p className="mb-6 text-white/80">
Deze link is ongeldig of de inschrijving is geannuleerd.
</p>
<a
href="/#registration"
className="inline-flex items-center bg-white px-6 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100"
> >
Terug naar home Nieuwe inschrijving
</Link> </a>
</PageShell>
);
}
// Edit mode
if (isEditing) {
return (
<PageShell>
<EditForm
token={token}
initialData={data}
onCancel={() => setIsEditing(false)}
onSaved={handleSaved}
/>
</PageShell>
);
}
// View mode
const isPerformer = data.registrationType === "performer";
const viewGuests = parseGuests(data.guests);
return (
<PageShell>
<BackLink />
<h1 className="mb-2 font-['Intro',sans-serif] text-4xl text-white"> <h1 className="mb-2 font-['Intro',sans-serif] text-4xl text-white">
Jouw inschrijving Jouw inschrijving
@@ -647,47 +543,14 @@ function ManageRegistrationPage() {
)} )}
</div> </div>
{/* Payment status badge for watchers */} {/* Payment status */}
{!isPerformer && ( {!isPerformer && (
<div className="mb-6"> <div className="mb-6">
{data.paymentStatus === "paid" ? ( {data.paymentStatus === "paid" ? <PaidBadge /> : <PendingBadge />}
<div className="inline-flex items-center gap-2 rounded-full border border-green-400/40 bg-green-400/10 px-4 py-1.5 font-semibold text-green-400 text-sm">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
Betaling ontvangen
</div>
) : (
<div className="inline-flex items-center gap-2 rounded-full border border-yellow-400/40 bg-yellow-400/10 px-4 py-1.5 font-semibold text-sm text-yellow-400">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Betaling in afwachting
</div>
)}
</div> </div>
)} )}
{/* Registration details */}
<div className="space-y-6 rounded-lg border border-white/20 bg-white/5 p-6 md:p-8"> <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 className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
@@ -714,9 +577,7 @@ function ManageRegistrationPage() {
<p className="text-lg text-white"> <p className="text-lg text-white">
{data.artForm || "—"} {data.artForm || "—"}
{data.experience && ( {data.experience && (
<span className="ml-2 text-white/60"> <span className="ml-2 text-white/60">({data.experience})</span>
({data.experience})
</span>
)} )}
</p> </p>
<p className="mt-1 text-sm text-white/60"> <p className="mt-1 text-sm text-white/60">
@@ -756,20 +617,18 @@ function ManageRegistrationPage() {
{data.extraQuestions && ( {data.extraQuestions && (
<div className="border-white/10 border-t pt-6"> <div className="border-white/10 border-t pt-6">
<p className="mb-2 text-sm text-white/50"> <p className="mb-2 text-sm text-white/50">Vragen of opmerkingen</p>
Vragen of opmerkingen
</p>
<p className="text-white">{data.extraQuestions}</p> <p className="text-white">{data.extraQuestions}</p>
</div> </div>
)} )}
</div> </div>
{/* Actions */}
<div className="mt-8 flex flex-wrap items-center gap-4"> <div className="mt-8 flex flex-wrap items-center gap-4">
{/* Pay button for watchers who haven't paid */}
{!isPerformer && data.paymentStatus !== "paid" && ( {!isPerformer && data.paymentStatus !== "paid" && (
<button <button
type="button" type="button"
onClick={handlePay} onClick={() => checkoutMutation.mutate({ token })}
disabled={checkoutMutation.isPending} disabled={checkoutMutation.isPending}
className="bg-teal-400 px-8 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-teal-300 disabled:opacity-50" className="bg-teal-400 px-8 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-teal-300 disabled:opacity-50"
> >
@@ -778,7 +637,7 @@ function ManageRegistrationPage() {
)} )}
<button <button
type="button" type="button"
onClick={handleEdit} onClick={() => setIsEditing(true)}
className="bg-white px-8 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100" className="bg-white px-8 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100"
> >
Bewerken Bewerken
@@ -789,9 +648,7 @@ function ManageRegistrationPage() {
disabled={cancelMutation.isPending} disabled={cancelMutation.isPending}
className="border border-red-400/50 px-8 py-3 text-lg text-red-400 transition-all hover:bg-red-400/10 disabled:opacity-50" className="border border-red-400/50 px-8 py-3 text-lg text-red-400 transition-all hover:bg-red-400/10 disabled:opacity-50"
> >
{cancelMutation.isPending {cancelMutation.isPending ? "Annuleren..." : "Inschrijving annuleren"}
? "Annuleren..."
: "Inschrijving annuleren"}
</button> </button>
</div> </div>
@@ -799,7 +656,6 @@ function ManageRegistrationPage() {
Deze link is uniek voor jouw inschrijving. Bewaar deze pagina of de Deze link is uniek voor jouw inschrijving. Bewaar deze pagina of de
bevestigingsmail om je gegevens later aan te passen. bevestigingsmail om je gegevens later aan te passen.
</p> </p>
</div> </PageShell>
</div>
); );
} }

View File

@@ -13,6 +13,57 @@ import {
} from "../email"; } from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index"; import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/** Drink card price in euros: €5 base + €2 per extra guest. */
function drinkCardEuros(guestCount: number): number {
return 5 + guestCount * 2;
}
/** Drink card price in cents for payment processing. */
function drinkCardCents(guestCount: number): number {
return drinkCardEuros(guestCount) * 100;
}
/** Parses the stored guest JSON blob, returning an empty array on failure. */
function parseGuestsJson(raw: string | null): Array<{
firstName: string;
lastName: string;
email?: string;
phone?: string;
}> {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** Fetches a single registration by management token, throws if not found or cancelled. */
async function getActiveRegistration(token: string) {
const rows = await db
.select()
.from(registration)
.where(
and(
eq(registration.managementToken, token),
isNull(registration.cancelledAt),
),
)
.limit(1);
const row = rows[0];
if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd");
return row;
}
// ---------------------------------------------------------------------------
// Schemas
// ---------------------------------------------------------------------------
const registrationTypeSchema = z.enum(["performer", "watcher"]); const registrationTypeSchema = z.enum(["performer", "watcher"]);
const guestSchema = z.object({ const guestSchema = z.object({
@@ -22,20 +73,24 @@ const guestSchema = z.object({
phone: z.string().optional(), phone: z.string().optional(),
}); });
const submitRegistrationSchema = z.object({ const coreRegistrationFields = {
firstName: z.string().min(1), firstName: z.string().min(1),
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email(), email: z.string().email(),
phone: z.string().optional(), phone: z.string().optional(),
registrationType: registrationTypeSchema.default("watcher"), registrationType: registrationTypeSchema.default("watcher"),
// Performer-specific
artForm: z.string().optional(), artForm: z.string().optional(),
experience: z.string().optional(), experience: z.string().optional(),
isOver16: z.boolean().optional(), isOver16: z.boolean().optional(),
// Watcher-specific: drinkCardValue is computed server-side, guests are named
guests: z.array(guestSchema).max(9).optional(), guests: z.array(guestSchema).max(9).optional(),
// Shared
extraQuestions: z.string().optional(), extraQuestions: z.string().optional(),
};
const submitRegistrationSchema = z.object(coreRegistrationFields);
const updateRegistrationSchema = z.object({
token: z.string().uuid(),
...coreRegistrationFields,
}); });
const getRegistrationsSchema = z.object({ const getRegistrationsSchema = z.object({
@@ -48,17 +103,17 @@ const getRegistrationsSchema = z.object({
pageSize: z.number().int().min(1).max(100).default(50), pageSize: z.number().int().min(1).max(100).default(50),
}); });
export const appRouter = { // ---------------------------------------------------------------------------
healthCheck: publicProcedure.handler(() => { // Router
return "OK"; // ---------------------------------------------------------------------------
}),
privateData: protectedProcedure.handler(({ context }) => { export const appRouter = {
return { healthCheck: publicProcedure.handler(() => "OK"),
privateData: protectedProcedure.handler(({ context }) => ({
message: "This is private", message: "This is private",
user: context.session?.user, user: context.session?.user,
}; })),
}),
submitRegistration: publicProcedure submitRegistration: publicProcedure
.input(submitRegistrationSchema) .input(submitRegistrationSchema)
@@ -66,8 +121,7 @@ export const appRouter = {
const managementToken = randomUUID(); const managementToken = randomUUID();
const isPerformer = input.registrationType === "performer"; const isPerformer = input.registrationType === "performer";
const guests = isPerformer ? [] : (input.guests ?? []); const guests = isPerformer ? [] : (input.guests ?? []);
// €5 for primary registrant + €2 per extra guest
const drinkCardValue = isPerformer ? 0 : 5 + guests.length * 2;
await db.insert(registration).values({ await db.insert(registration).values({
id: randomUUID(), id: randomUUID(),
firstName: input.firstName, firstName: input.firstName,
@@ -78,7 +132,7 @@ export const appRouter = {
artForm: isPerformer ? input.artForm || null : null, artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null, experience: isPerformer ? input.experience || null : null,
isOver16: isPerformer ? (input.isOver16 ?? false) : false, isOver16: isPerformer ? (input.isOver16 ?? false) : false,
drinkCardValue, drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
guests: guests.length > 0 ? JSON.stringify(guests) : null, guests: guests.length > 0 ? JSON.stringify(guests) : null,
extraQuestions: input.extraQuestions || null, extraQuestions: input.extraQuestions || null,
managementToken, managementToken,
@@ -114,39 +168,15 @@ export const appRouter = {
}), }),
updateRegistration: publicProcedure updateRegistration: publicProcedure
.input( .input(updateRegistrationSchema)
z.object({
token: z.string().uuid(),
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(),
experience: z.string().optional(),
isOver16: z.boolean().optional(),
guests: z.array(guestSchema).max(9).optional(),
extraQuestions: z.string().optional(),
}),
)
.handler(async ({ input }) => { .handler(async ({ input }) => {
const rows = await db const row = await getActiveRegistration(input.token);
.select()
.from(registration)
.where(
and(
eq(registration.managementToken, input.token),
isNull(registration.cancelledAt),
),
)
.limit(1);
const row = rows[0];
if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd");
// If already paid and switching to a watcher with fewer guests, we
// still allow the update — payment adjustments are handled manually.
const isPerformer = input.registrationType === "performer"; const isPerformer = input.registrationType === "performer";
const guests = isPerformer ? [] : (input.guests ?? []); const guests = isPerformer ? [] : (input.guests ?? []);
const drinkCardValue = isPerformer ? 0 : 5 + guests.length * 2;
await db await db
.update(registration) .update(registration)
.set({ .set({
@@ -158,7 +188,7 @@ export const appRouter = {
artForm: isPerformer ? input.artForm || null : null, artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null, experience: isPerformer ? input.experience || null : null,
isOver16: isPerformer ? (input.isOver16 ?? false) : false, isOver16: isPerformer ? (input.isOver16 ?? false) : false,
drinkCardValue, drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
guests: guests.length > 0 ? JSON.stringify(guests) : null, guests: guests.length > 0 ? JSON.stringify(guests) : null,
extraQuestions: input.extraQuestions || null, extraQuestions: input.extraQuestions || null,
}) })
@@ -172,25 +202,16 @@ export const appRouter = {
artForm: input.artForm, artForm: input.artForm,
}).catch((err) => console.error("Failed to send update email:", err)); }).catch((err) => console.error("Failed to send update email:", err));
// Satisfy the linter — row is used to confirm the record existed.
void row;
return { success: true }; return { success: true };
}), }),
cancelRegistration: publicProcedure cancelRegistration: publicProcedure
.input(z.object({ token: z.string().uuid() })) .input(z.object({ token: z.string().uuid() }))
.handler(async ({ input }) => { .handler(async ({ input }) => {
const rows = await db const row = await getActiveRegistration(input.token);
.select()
.from(registration)
.where(
and(
eq(registration.managementToken, input.token),
isNull(registration.cancelledAt),
),
)
.limit(1);
const row = rows[0];
if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd");
await db await db
.update(registration) .update(registration)
@@ -213,46 +234,37 @@ export const appRouter = {
const conditions = []; const conditions = [];
if (input.search) { if (input.search) {
const searchTerm = `%${input.search}%`; const term = `%${input.search}%`;
conditions.push( conditions.push(
and( and(
like(registration.firstName, searchTerm), like(registration.firstName, term),
like(registration.lastName, searchTerm), like(registration.lastName, term),
like(registration.email, searchTerm), like(registration.email, term),
), ),
); );
} }
if (input.registrationType)
if (input.registrationType) {
conditions.push( conditions.push(
eq(registration.registrationType, input.registrationType), eq(registration.registrationType, input.registrationType),
); );
} if (input.artForm)
if (input.artForm) {
conditions.push(eq(registration.artForm, input.artForm)); conditions.push(eq(registration.artForm, input.artForm));
} if (input.fromDate)
if (input.fromDate) {
conditions.push(gte(registration.createdAt, new Date(input.fromDate))); conditions.push(gte(registration.createdAt, new Date(input.fromDate)));
} if (input.toDate)
if (input.toDate) {
conditions.push(lte(registration.createdAt, new Date(input.toDate))); conditions.push(lte(registration.createdAt, new Date(input.toDate)));
}
const whereClause = const where = conditions.length > 0 ? and(...conditions) : undefined;
conditions.length > 0 ? and(...conditions) : undefined;
const [data, countResult] = await Promise.all([ const [data, countResult] = await Promise.all([
db db
.select() .select()
.from(registration) .from(registration)
.where(whereClause) .where(where)
.orderBy(desc(registration.createdAt)) .orderBy(desc(registration.createdAt))
.limit(input.pageSize) .limit(input.pageSize)
.offset((input.page - 1) * input.pageSize), .offset((input.page - 1) * input.pageSize),
db.select({ count: count() }).from(registration).where(whereClause), db.select({ count: count() }).from(registration).where(where),
]); ]);
const total = countResult[0]?.count ?? 0; const total = countResult[0]?.count ?? 0;
@@ -280,10 +292,7 @@ export const appRouter = {
.from(registration) .from(registration)
.where(gte(registration.createdAt, today)), .where(gte(registration.createdAt, today)),
db db
.select({ .select({ artForm: registration.artForm, count: count() })
artForm: registration.artForm,
count: count(),
})
.from(registration) .from(registration)
.where(eq(registration.registrationType, "performer")) .where(eq(registration.registrationType, "performer"))
.groupBy(registration.artForm), .groupBy(registration.artForm),
@@ -329,16 +338,14 @@ export const appRouter = {
"Drink Card Value", "Drink Card Value",
"Guest Count", "Guest Count",
"Guests", "Guests",
"Payment Status",
"Paid At",
"Extra Questions", "Extra Questions",
"Created At", "Created At",
]; ];
const rows = data.map((r) => { const rows = data.map((r) => {
const guests: Array<{ const guests = parseGuestsJson(r.guests);
firstName: string;
lastName: string;
email?: string;
phone?: string;
}> = r.guests ? JSON.parse(r.guests) : [];
const guestSummary = guests const guestSummary = guests
.map( .map(
(g) => (g) =>
@@ -358,6 +365,8 @@ export const appRouter = {
String(r.drinkCardValue ?? 0), String(r.drinkCardValue ?? 0),
String(guests.length), String(guests.length),
guestSummary, guestSummary,
r.paymentStatus === "paid" ? "Paid" : "Pending",
r.paidAt ? r.paidAt.toISOString() : "",
r.extraQuestions || "", r.extraQuestions || "",
r.createdAt.toISOString(), r.createdAt.toISOString(),
]; ];
@@ -376,35 +385,36 @@ export const appRouter = {
}; };
}), }),
// Admin Request Procedures // ---------------------------------------------------------------------------
// Admin access requests
// ---------------------------------------------------------------------------
requestAdminAccess: protectedProcedure.handler(async ({ context }) => { requestAdminAccess: protectedProcedure.handler(async ({ context }) => {
const userId = context.session.user.id; const userId = context.session.user.id;
// Check if user is already an admin
if (context.session.user.role === "admin") { if (context.session.user.role === "admin") {
return { success: false, message: "Je bent al een admin" }; return { success: false, message: "Je bent al een admin" };
} }
// Check if request already exists const existing = await db
const existingRequest = await db
.select() .select()
.from(adminRequest) .from(adminRequest)
.where(eq(adminRequest.userId, userId)) .where(eq(adminRequest.userId, userId))
.limit(1); .limit(1)
.then((rows) => rows[0]);
if (existingRequest.length > 0) { if (existing) {
const existing = existingRequest[0]!; if (existing.status === "pending")
if (existing.status === "pending") { return {
return { success: false, message: "Je hebt al een aanvraag openstaan" }; success: false,
} message: "Je hebt al een aanvraag openstaan",
if (existing.status === "approved") { };
if (existing.status === "approved")
return { return {
success: false, success: false,
message: "Je aanvraag is al goedgekeurd, log opnieuw in", message: "Je aanvraag is al goedgekeurd, log opnieuw in",
}; };
} // Rejected — allow re-request
if (existing.status === "rejected") {
// Allow re-requesting if previously rejected
await db await db
.update(adminRequest) .update(adminRequest)
.set({ .set({
@@ -416,9 +426,7 @@ export const appRouter = {
.where(eq(adminRequest.userId, userId)); .where(eq(adminRequest.userId, userId));
return { success: true, message: "Nieuwe aanvraag ingediend" }; return { success: true, message: "Nieuwe aanvraag ingediend" };
} }
}
// Create new request
await db.insert(adminRequest).values({ await db.insert(adminRequest).values({
id: randomUUID(), id: randomUUID(),
userId, userId,
@@ -429,7 +437,7 @@ export const appRouter = {
}), }),
getAdminRequests: adminProcedure.handler(async () => { getAdminRequests: adminProcedure.handler(async () => {
const requests = await db return db
.select({ .select({
id: adminRequest.id, id: adminRequest.id,
userId: adminRequest.userId, userId: adminRequest.userId,
@@ -443,29 +451,22 @@ export const appRouter = {
.from(adminRequest) .from(adminRequest)
.leftJoin(user, eq(adminRequest.userId, user.id)) .leftJoin(user, eq(adminRequest.userId, user.id))
.orderBy(desc(adminRequest.requestedAt)); .orderBy(desc(adminRequest.requestedAt));
return requests;
}), }),
approveAdminRequest: adminProcedure approveAdminRequest: adminProcedure
.input(z.object({ requestId: z.string() })) .input(z.object({ requestId: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input, context }) => {
const request = await db const req = await db
.select() .select()
.from(adminRequest) .from(adminRequest)
.where(eq(adminRequest.id, input.requestId)) .where(eq(adminRequest.id, input.requestId))
.limit(1); .limit(1)
.then((rows) => rows[0]);
if (request.length === 0) { if (!req) throw new Error("Aanvraag niet gevonden");
throw new Error("Aanvraag niet gevonden"); if (req.status !== "pending")
}
const req = request[0]!;
if (req.status !== "pending") {
throw new Error("Deze aanvraag is al behandeld"); throw new Error("Deze aanvraag is al behandeld");
}
// Update request status
await db await db
.update(adminRequest) .update(adminRequest)
.set({ .set({
@@ -475,7 +476,6 @@ export const appRouter = {
}) })
.where(eq(adminRequest.id, input.requestId)); .where(eq(adminRequest.id, input.requestId));
// Update user role to admin
await db await db
.update(user) .update(user)
.set({ role: "admin" }) .set({ role: "admin" })
@@ -487,20 +487,16 @@ export const appRouter = {
rejectAdminRequest: adminProcedure rejectAdminRequest: adminProcedure
.input(z.object({ requestId: z.string() })) .input(z.object({ requestId: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input, context }) => {
const request = await db const req = await db
.select() .select()
.from(adminRequest) .from(adminRequest)
.where(eq(adminRequest.id, input.requestId)) .where(eq(adminRequest.id, input.requestId))
.limit(1); .limit(1)
.then((rows) => rows[0]);
if (request.length === 0) { if (!req) throw new Error("Aanvraag niet gevonden");
throw new Error("Aanvraag niet gevonden"); if (req.status !== "pending")
}
const req = request[0]!;
if (req.status !== "pending") {
throw new Error("Deze aanvraag is al behandeld"); throw new Error("Deze aanvraag is al behandeld");
}
await db await db
.update(adminRequest) .update(adminRequest)
@@ -514,8 +510,11 @@ export const appRouter = {
return { success: true, message: "Admin toegang geweigerd" }; return { success: true, message: "Admin toegang geweigerd" };
}), }),
// Lemon Squeezy Checkout Procedure // ---------------------------------------------------------------------------
createCheckout: publicProcedure // Checkout
// ---------------------------------------------------------------------------
getCheckoutUrl: publicProcedure
.input(z.object({ token: z.string().uuid() })) .input(z.object({ token: z.string().uuid() }))
.handler(async ({ input }) => { .handler(async ({ input }) => {
if ( if (
@@ -541,19 +540,12 @@ export const appRouter = {
if (!row) throw new Error("Inschrijving niet gevonden"); if (!row) throw new Error("Inschrijving niet gevonden");
if (row.paymentStatus === "paid") if (row.paymentStatus === "paid")
throw new Error("Betaling is al voltooid"); throw new Error("Betaling is al voltooid");
if (row.registrationType === "performer")
const isPerformer = row.registrationType === "performer";
if (isPerformer) {
throw new Error("Artiesten hoeven niet te betalen"); throw new Error("Artiesten hoeven niet te betalen");
}
// Calculate price: €5 + €2 per guest (in cents) const guests = parseGuestsJson(row.guests);
const guests: Array<{ firstName: string; lastName: string }> = row.guests const amountInCents = drinkCardCents(guests.length);
? JSON.parse(row.guests)
: [];
const amountInCents = 500 + guests.length * 200;
// Create checkout via Lemon Squeezy API
const response = await fetch( const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts", "https://api.lemonsqueezy.com/v1/checkouts",
{ {
@@ -576,9 +568,7 @@ export const appRouter = {
checkout_data: { checkout_data: {
email: row.email, email: row.email,
name: `${row.firstName} ${row.lastName}`, name: `${row.firstName} ${row.lastName}`,
custom: { custom: { registration_token: input.token },
registration_token: input.token,
},
}, },
checkout_options: { checkout_options: {
embed: false, embed: false,
@@ -587,16 +577,10 @@ export const appRouter = {
}, },
relationships: { relationships: {
store: { store: {
data: { data: { type: "stores", id: env.LEMON_SQUEEZY_STORE_ID },
type: "stores",
id: env.LEMON_SQUEEZY_STORE_ID,
},
}, },
variant: { variant: {
data: { data: { type: "variants", id: env.LEMON_SQUEEZY_VARIANT_ID },
type: "variants",
id: env.LEMON_SQUEEZY_VARIANT_ID,
},
}, },
}, },
}, },
@@ -611,17 +595,11 @@ export const appRouter = {
} }
const checkoutData = (await response.json()) as { const checkoutData = (await response.json()) as {
data?: { data?: { attributes?: { url?: string } };
attributes?: {
url?: string;
};
};
}; };
const checkoutUrl = checkoutData.data?.attributes?.url; const checkoutUrl = checkoutData.data?.attributes?.url;
if (!checkoutUrl) { if (!checkoutUrl) throw new Error("Geen checkout URL ontvangen");
throw new Error("Geen checkout URL ontvangen");
}
return { checkoutUrl }; return { checkoutUrl };
}), }),