feat:simplify dx and improve payment functions
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user