Files
kunstenkamp/apps/web/src/components/registration/WatcherForm.tsx
2026-03-03 16:34:13 +01:00

416 lines
12 KiB
TypeScript

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 { GiftSelector } from "./GiftSelector";
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 [giftAmount, setGiftAmount] = useState(0);
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,
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={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>
{/* 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 & 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>
);
}