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