feat:simplify dx and improve payment functions
This commit is contained in:
File diff suppressed because it is too large
Load Diff
200
apps/web/src/components/registration/GuestList.tsx
Normal file
200
apps/web/src/components/registration/GuestList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
463
apps/web/src/components/registration/PerformerForm.tsx
Normal file
463
apps/web/src/components/registration/PerformerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/components/registration/SuccessScreen.tsx
Normal file
72
apps/web/src/components/registration/SuccessScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/registration/TypeSelector.tsx
Normal file
114
apps/web/src/components/registration/TypeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
403
apps/web/src/components/registration/WatcherForm.tsx
Normal file
403
apps/web/src/components/registration/WatcherForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
apps/web/src/lib/registration.ts
Normal file
117
apps/web/src/lib/registration.ts
Normal 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"}`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user