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