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">
16+
</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">
Datum
</th>
@@ -486,7 +492,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? (
<tr>
<td
colSpan={9}
colSpan={10}
className="px-6 py-8 text-center text-white/60"
>
Laden...
@@ -495,7 +501,7 @@ function AdminPage() {
) : registrations.length === 0 ? (
<tr>
<td
colSpan={9}
colSpan={10}
className="px-6 py-8 text-center text-white/60"
>
Geen registraties gevonden
@@ -564,6 +570,47 @@ function AdminPage() {
"-"
)}
</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">
{new Date(reg.createdAt).toLocaleDateString(
"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 { useState } from "react";
import { toast } from "sonner";
import { GuestList } from "@/components/registration/GuestList";
import {
calculateDrinkCard,
type GuestEntry,
inputCls,
parseGuests,
} from "@/lib/registration";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/manage/$token")({
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;
lastName: 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[] {
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 EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
// Narrow the string from DB to the valid union; fall back to "watcher"
const initialType: "performer" | "watcher" =
initialData.registrationType === "performer" ? "performer" : "watcher";
function ManageRegistrationPage() {
const { token } = useParams({ from: "/manage/$token" });
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
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 } }),
firstName: initialData.firstName,
lastName: initialData.lastName,
email: initialData.email,
phone: initialData.phone ?? "",
registrationType: initialType,
artForm: initialData.artForm ?? "",
experience: initialData.experience ?? "",
isOver16: initialData.isOver16,
extraQuestions: initialData.extraQuestions ?? "",
});
const [formGuests, setFormGuests] = useState<GuestEntry[]>(
parseGuests(initialData.guests),
);
const updateMutation = useMutation({
...orpc.updateRegistration.mutationOptions(),
onSuccess: () => {
toast.success("Inschrijving bijgewerkt!");
queryClient.invalidateQueries({ queryKey: ["getRegistrationByToken"] });
setIsEditing(false);
onSaved();
},
onError: (err) => {
toast.error(`Opslaan mislukt: ${err.message}`);
},
});
const cancelMutation = useMutation({
...orpc.cancelRegistration.mutationOptions(),
onSuccess: () => {
toast.success("Inschrijving geannuleerd");
queryClient.invalidateQueries({ queryKey: ["getRegistrationByToken"] });
},
onError: (err) => {
toast.error(`Annuleren mislukt: ${err.message}`);
},
});
const isWatcher = formData.registrationType === "watcher";
const totalDrinkCard = isWatcher ? calculateDrinkCard(formGuests.length) : 0;
const originalGuestCount = parseGuests(initialData.guests).length;
const isPaid = initialData.paymentStatus === "paid";
const extraGuests = formGuests.length - originalGuestCount;
const checkoutMutation = useMutation({
...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) => {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const isPerformer = formData.registrationType === "performer";
const performer = formData.registrationType === "performer";
updateMutation.mutate({
token,
firstName: formData.firstName.trim(),
@@ -123,12 +152,12 @@ function ManageRegistrationPage() {
email: formData.email.trim(),
phone: formData.phone.trim() || undefined,
registrationType: formData.registrationType,
artForm: isPerformer ? formData.artForm.trim() || undefined : undefined,
experience: isPerformer
artForm: performer ? formData.artForm.trim() || undefined : undefined,
experience: performer
? formData.experience.trim() || undefined
: undefined,
isOver16: isPerformer ? formData.isOver16 : false,
guests: isPerformer
isOver16: performer ? formData.isOver16 : false,
guests: performer
? []
: formGuests.map((g) => ({
firstName: g.firstName.trim(),
@@ -138,103 +167,17 @@ function ManageRegistrationPage() {
})),
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 (
<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-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>
<>
<BackLink />
<h1 className="mb-8 font-['Intro',sans-serif] text-4xl text-white">
Bewerk inschrijving
</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>
<label htmlFor="firstName" className="mb-2 block text-white">
@@ -248,7 +191,7 @@ function ManageRegistrationPage() {
onChange={(e) =>
setFormData((p) => ({ ...p, firstName: e.target.value }))
}
className={inputClasses}
className={inputCls(false)}
/>
</div>
<div>
@@ -263,11 +206,12 @@ function ManageRegistrationPage() {
onChange={(e) =>
setFormData((p) => ({ ...p, lastName: e.target.value }))
}
className={inputClasses}
className={inputCls(false)}
/>
</div>
</div>
{/* Contact */}
<div>
<label htmlFor="email" className="mb-2 block text-white">
E-mail *
@@ -280,10 +224,9 @@ function ManageRegistrationPage() {
onChange={(e) =>
setFormData((p) => ({ ...p, email: e.target.value }))
}
className={inputClasses}
className={inputCls(false)}
/>
</div>
<div>
<label htmlFor="phone" className="mb-2 block text-white">
Telefoon
@@ -295,55 +238,43 @@ function ManageRegistrationPage() {
onChange={(e) =>
setFormData((p) => ({ ...p, phone: e.target.value }))
}
className={inputClasses}
className={inputCls(false)}
/>
</div>
{/* Registration type */}
{/* Registration type toggle */}
<div>
<p className="mb-3 text-white">Type inschrijving</p>
<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">
<input
type="radio"
name="registrationType"
value="performer"
checked={formData.registrationType === "performer"}
value={type}
checked={formData.registrationType === type}
onChange={() =>
setFormData((p) => ({
...p,
registrationType: "performer",
}))
setFormData((p) => ({ ...p, registrationType: type }))
}
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 ${type === "performer" ? "peer-checked:border-amber-400 peer-checked:bg-amber-400" : "peer-checked:border-teal-400 peer-checked:bg-teal-400"}`}
/>
<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>
<span className="text-white">
{type === "performer" ? "Optreden" : "Kijken"}
</span>
</label>
))}
</div>
</div>
{/* Performer fields */}
{formData.registrationType === "performer" && (
<div className="space-y-6 border border-amber-400/20 bg-amber-400/5 p-6">
<div>
@@ -353,13 +284,13 @@ function ManageRegistrationPage() {
<input
id="artForm"
type="text"
required={formData.registrationType === "performer"}
required
value={formData.artForm}
onChange={(e) =>
setFormData((p) => ({ ...p, artForm: e.target.value }))
}
list="artFormSuggestions"
className={inputClasses}
className={inputCls(false)}
/>
<datalist id="artFormSuggestions">
<option value="Muziek" />
@@ -379,13 +310,10 @@ function ManageRegistrationPage() {
type="text"
value={formData.experience}
onChange={(e) =>
setFormData((p) => ({
...p,
experience: e.target.value,
}))
setFormData((p) => ({ ...p, experience: e.target.value }))
}
list="experienceSuggestions"
className={inputClasses}
className={inputCls(false)}
/>
<datalist id="experienceSuggestions">
<option value="Beginner" />
@@ -399,10 +327,7 @@ function ManageRegistrationPage() {
type="checkbox"
checked={formData.isOver16}
onChange={(e) =>
setFormData((p) => ({
...p,
isOver16: e.target.checked,
}))
setFormData((p) => ({ ...p, isOver16: e.target.checked }))
}
className="peer sr-only"
/>
@@ -423,160 +348,44 @@ function ManageRegistrationPage() {
</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>
{/* Guests for watchers */}
{isWatcher && (
<GuestList
guests={formGuests}
errors={[]}
onChange={(idx, field, value) => {
setFormGuests((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value } as GuestEntry;
return next;
});
}}
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">
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}
{isPaid && extraGuests > 0 && (
<span className="ml-2 text-yellow-400">
Let op: {extraGuests * 2} extra voor {extraGuests} nieuwe
gast(en)
</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,
)
)}
</p>
}
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>
<label htmlFor="extraQuestions" className="mb-2 block text-white">
Vragen of opmerkingen
@@ -586,10 +395,7 @@ function ManageRegistrationPage() {
rows={4}
value={formData.extraQuestions}
onChange={(e) =>
setFormData((p) => ({
...p,
extraQuestions: e.target.value,
}))
setFormData((p) => ({ ...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"
/>
@@ -605,27 +411,117 @@ function ManageRegistrationPage() {
</button>
<button
type="button"
onClick={() => setIsEditing(false)}
onClick={onCancel}
className="text-white/60 underline underline-offset-2 transition-colors hover:text-white"
>
Annuleren
</button>
</div>
</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 (
<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"
<PageShell>
<div className="animate-pulse text-white/60">Laden...</div>
</PageShell>
);
}
// Error / not found / cancelled
if (error || !data || data.cancelledAt) {
return (
<PageShell>
<BackLink />
<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"
>
Terug naar home
</Link>
Nieuwe inschrijving
</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">
Jouw inschrijving
@@ -647,47 +543,14 @@ function ManageRegistrationPage() {
)}
</div>
{/* Payment status badge for watchers */}
{/* Payment status */}
{!isPerformer && (
<div className="mb-6">
{data.paymentStatus === "paid" ? (
<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>
)}
{data.paymentStatus === "paid" ? <PaidBadge /> : <PendingBadge />}
</div>
)}
{/* Registration details */}
<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>
@@ -714,9 +577,7 @@ function ManageRegistrationPage() {
<p className="text-lg text-white">
{data.artForm || "—"}
{data.experience && (
<span className="ml-2 text-white/60">
({data.experience})
</span>
<span className="ml-2 text-white/60">({data.experience})</span>
)}
</p>
<p className="mt-1 text-sm text-white/60">
@@ -756,20 +617,18 @@ function ManageRegistrationPage() {
{data.extraQuestions && (
<div className="border-white/10 border-t pt-6">
<p className="mb-2 text-sm text-white/50">
Vragen of opmerkingen
</p>
<p className="mb-2 text-sm text-white/50">Vragen of opmerkingen</p>
<p className="text-white">{data.extraQuestions}</p>
</div>
)}
</div>
{/* Actions */}
<div className="mt-8 flex flex-wrap items-center gap-4">
{/* Pay button for watchers who haven't paid */}
{!isPerformer && data.paymentStatus !== "paid" && (
<button
type="button"
onClick={handlePay}
onClick={() => checkoutMutation.mutate({ token })}
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"
>
@@ -778,7 +637,7 @@ function ManageRegistrationPage() {
)}
<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"
>
Bewerken
@@ -789,9 +648,7 @@ function ManageRegistrationPage() {
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"
>
{cancelMutation.isPending
? "Annuleren..."
: "Inschrijving annuleren"}
{cancelMutation.isPending ? "Annuleren..." : "Inschrijving annuleren"}
</button>
</div>
@@ -799,7 +656,6 @@ function ManageRegistrationPage() {
Deze link is uniek voor jouw inschrijving. Bewaar deze pagina of de
bevestigingsmail om je gegevens later aan te passen.
</p>
</div>
</div>
</PageShell>
);
}

View File

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