426 lines
13 KiB
TypeScript
426 lines
13 KiB
TypeScript
import { useMutation } from "@tanstack/react-query";
|
|
import { useEffect, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { authClient } from "@/lib/auth-client";
|
|
import {
|
|
calculateDrinkCard,
|
|
type GuestEntry,
|
|
type GuestErrors,
|
|
inputCls,
|
|
validateEmail,
|
|
validateGuests,
|
|
validatePhone,
|
|
validateTextField,
|
|
} from "@/lib/registration";
|
|
import { orpc } from "@/utils/orpc";
|
|
import { GiftSelector } from "./GiftSelector";
|
|
import { GuestList } from "./GuestList";
|
|
|
|
interface WatcherErrors {
|
|
firstName?: string;
|
|
lastName?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
}
|
|
|
|
interface Props {
|
|
onBack: () => void;
|
|
onSuccess: (token: string, email: string, name: string) => void;
|
|
}
|
|
|
|
// ── Main watcher form ──────────────────────────────────────────────────────
|
|
|
|
export function WatcherForm({ onBack, onSuccess }: Props) {
|
|
const { data: session } = authClient.useSession();
|
|
const sessionEmail = session?.user?.email ?? "";
|
|
|
|
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 [giftAmount, setGiftAmount] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (sessionEmail) {
|
|
setData((prev) => ({ ...prev, email: sessionEmail }));
|
|
}
|
|
}, [sessionEmail]);
|
|
|
|
const submitMutation = useMutation({
|
|
...orpc.submitRegistration.mutationOptions(),
|
|
onSuccess: (result) => {
|
|
if (result.managementToken) {
|
|
onSuccess(
|
|
result.managementToken,
|
|
data.email.trim(),
|
|
`${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
|
|
);
|
|
}
|
|
},
|
|
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,
|
|
giftAmount,
|
|
});
|
|
}
|
|
|
|
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 registratie
|
|
<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={sessionEmail ? undefined : handleChange}
|
|
onBlur={sessionEmail ? undefined : handleBlur}
|
|
readOnly={!!sessionEmail}
|
|
placeholder="jouw@email.be"
|
|
autoComplete="email"
|
|
inputMode="email"
|
|
aria-required="true"
|
|
aria-invalid={touched.email && !!errors.email}
|
|
className={`${inputCls(!!touched.email && !!errors.email)}${sessionEmail ? "cursor-not-allowed opacity-75" : ""}`}
|
|
/>
|
|
{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>
|
|
|
|
{/* Gift selector */}
|
|
<div className="border border-white/10 p-6">
|
|
<h3 className="mb-4 font-['Intro',sans-serif] text-white text-xl">
|
|
Vrijwillige Gift
|
|
</h3>
|
|
<GiftSelector value={giftAmount} onChange={setGiftAmount} />
|
|
</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"
|
|
)}
|
|
</button>
|
|
<a
|
|
href="/contact"
|
|
className="text-sm text-white/60 transition-colors hover:text-white"
|
|
>
|
|
Nog vragen? Neem contact op
|
|
</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|