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",

File diff suppressed because it is too large Load Diff

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"),
message: "This is private",
user: context.session?.user, privateData: protectedProcedure.handler(({ context }) => ({
}; message: "This is private",
}), 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,49 +385,48 @@ 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") { await db
// Allow re-requesting if previously rejected .update(adminRequest)
await db .set({
.update(adminRequest) status: "pending",
.set({ requestedAt: new Date(),
status: "pending", reviewedAt: null,
requestedAt: new Date(), reviewedBy: null,
reviewedAt: null, })
reviewedBy: null, .where(eq(adminRequest.userId, userId));
}) return { success: true, message: "Nieuwe aanvraag ingediend" };
.where(eq(adminRequest.userId, userId));
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 };
}), }),