From f6c2bad9dfb7c978e524b6e5675f22dba79be8f0 Mon Sep 17 00:00:00 2001 From: zias Date: Tue, 3 Mar 2026 10:36:08 +0100 Subject: [PATCH] feat:multiple bezoekers --- apps/web/src/components/CookieConsent.tsx | 2 +- .../homepage/EventRegistrationForm.tsx | 1595 ++++++++++++----- apps/web/src/components/homepage/Info.tsx | 5 - apps/web/src/routes/admin.tsx | 222 ++- apps/web/src/routes/manage.$token.tsx | 433 ++++- packages/api/src/routers/index.ts | 160 +- packages/db/drizzle.config.ts | 2 +- .../0002_registration_type_redesign.sql | 20 + .../db/src/migrations/0003_add_guests.sql | 2 + packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/registrations.ts | 15 +- packages/infra/alchemy.run.ts | 2 +- 12 files changed, 1891 insertions(+), 574 deletions(-) create mode 100644 packages/db/src/migrations/0002_registration_type_redesign.sql create mode 100644 packages/db/src/migrations/0003_add_guests.sql diff --git a/apps/web/src/components/CookieConsent.tsx b/apps/web/src/components/CookieConsent.tsx index cd458bb..2efa08f 100644 --- a/apps/web/src/components/CookieConsent.tsx +++ b/apps/web/src/components/CookieConsent.tsx @@ -40,7 +40,7 @@ export function CookieConsent() { We gebruiken cookies

- We gebruiken analytische cookies om onze website te verbeteren. + We gebruiken analytische cookies om onze website te verbeteren.{" "} Meer informatie diff --git a/apps/web/src/components/homepage/EventRegistrationForm.tsx b/apps/web/src/components/homepage/EventRegistrationForm.tsx index cf120ff..d461f17 100644 --- a/apps/web/src/components/homepage/EventRegistrationForm.tsx +++ b/apps/web/src/components/homepage/EventRegistrationForm.tsx @@ -1,90 +1,108 @@ "use client"; import { useMutation } from "@tanstack/react-query"; - import { useCallback, useState } from "react"; import { toast } from "sonner"; import { orpc } from "@/utils/orpc"; -interface FormErrors { +type RegistrationType = "performer" | "watcher" | null; + +interface GuestEntry { + firstName: string; + lastName: string; + email: string; + phone: string; +} + +interface GuestErrors { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; +} + +interface PerformerErrors { firstName?: string; lastName?: string; email?: string; phone?: string; artForm?: string; - experience?: string; + isOver16?: string; +} + +interface WatcherErrors { + firstName?: string; + lastName?: string; + email?: string; + phone?: string; } export default function EventRegistrationForm() { - const [formData, setFormData] = useState({ + const [selectedType, setSelectedType] = useState(null); + const [successToken, setSuccessToken] = useState(null); + + // Performer form state + const [performerData, setPerformerData] = useState({ firstName: "", lastName: "", email: "", phone: "", - wantsToPerform: false, artForm: "", experience: "", + isOver16: false, extraQuestions: "", }); - const [errors, setErrors] = useState({}); - const [touched, setTouched] = useState>({}); - const [successToken, setSuccessToken] = useState(null); + const [performerErrors, setPerformerErrors] = useState({}); + const [performerTouched, setPerformerTouched] = useState< + Record + >({}); - const validateField = useCallback( - ( - name: string, - value: string, - wantsToPerform?: boolean, - ): string | undefined => { - switch (name) { - case "firstName": - if (!value.trim()) return "Voornaam is verplicht"; - if (value.length < 2) - return "Voornaam moet minimaal 2 tekens bevatten"; - break; - case "lastName": - if (!value.trim()) return "Achternaam is verplicht"; - if (value.length < 2) - return "Achternaam moet minimaal 2 tekens bevatten"; - break; - case "email": - if (!value.trim()) return "E-mail is verplicht"; - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { - return "Voer een geldig e-mailadres in"; - } - break; - case "phone": - if (value && !/^[\d\s\-+()]{10,}$/.test(value.replace(/\s/g, ""))) { - return "Voer een geldig telefoonnummer in"; - } - break; - case "artForm": - if (wantsToPerform && !value.trim()) return "Kunstvorm is verplicht"; - break; - } - return undefined; - }, - [], + // Watcher form state + const [watcherData, setWatcherData] = useState({ + firstName: "", + lastName: "", + email: "", + phone: "", + extraQuestions: "", + }); + const [watcherErrors, setWatcherErrors] = useState({}); + const [watcherTouched, setWatcherTouched] = useState>( + {}, ); + // Guests state (bezoeker only) + const [guests, setGuests] = useState([]); + const [guestErrors, setGuestErrors] = useState([]); + const submitMutation = useMutation({ ...orpc.submitRegistration.mutationOptions(), onSuccess: (data) => { if (data.managementToken) { setSuccessToken(data.managementToken); } - setFormData({ + setPerformerData({ firstName: "", lastName: "", email: "", phone: "", - wantsToPerform: false, artForm: "", experience: "", + isOver16: false, extraQuestions: "", }); - setErrors({}); - setTouched({}); + setWatcherData({ + firstName: "", + lastName: "", + email: "", + phone: "", + extraQuestions: "", + }); + setGuests([]); + setGuestErrors([]); + setPerformerErrors({}); + setWatcherErrors({}); + setPerformerTouched({}); + setWatcherTouched({}); }, onError: (error) => { toast.error(`Er is iets misgegaan: ${error.message}`); @@ -93,109 +111,252 @@ export default function EventRegistrationForm() { const handleReset = useCallback(() => { setSuccessToken(null); + setSelectedType(null); }, []); - const handleChange = useCallback( - (e: React.ChangeEvent) => { - const { name, value, type } = e.target; - const checked = (e.target as HTMLInputElement).checked; - const newValue = type === "checkbox" ? checked : value; + const 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; + }; - setFormData((prev) => { - const updated = { ...prev, [name]: newValue }; - // Clear performer fields when unchecking wantsToPerform - if (name === "wantsToPerform" && !checked) { - updated.artForm = ""; - updated.experience = ""; - } - return updated; - }); + const 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; + }; - if (type !== "checkbox" && touched[name]) { - const error = validateField( - name, - value, - name === "artForm" ? formData.wantsToPerform : undefined, - ); - setErrors((prev) => ({ ...prev, [name]: error })); - } - }, - [touched, validateField, formData.wantsToPerform], - ); + const validatePhone = (value: string): string | undefined => { + if (value && !/^[\d\s\-+()]{10,}$/.test(value.replace(/\s/g, ""))) { + return "Voer een geldig telefoonnummer in"; + } + return undefined; + }; - const handleBlur = useCallback( - (e: React.FocusEvent) => { - const { name, value } = e.target; - setTouched((prev) => ({ ...prev, [name]: true })); - const error = validateField( - name, - value, - name === "artForm" ? formData.wantsToPerform : undefined, - ); - setErrors((prev) => ({ ...prev, [name]: error })); - }, - [validateField, formData.wantsToPerform], - ); - - const validateForm = useCallback((): boolean => { - const newErrors: FormErrors = {}; - newErrors.firstName = validateField("firstName", formData.firstName); - newErrors.lastName = validateField("lastName", formData.lastName); - newErrors.email = validateField("email", formData.email); - newErrors.phone = validateField("phone", formData.phone); - newErrors.artForm = validateField( - "artForm", - formData.artForm, - formData.wantsToPerform, - ); - - setErrors(newErrors); - setTouched({ + const validatePerformerForm = useCallback((): boolean => { + const errs: PerformerErrors = { + firstName: validateTextField(performerData.firstName, true, "Voornaam"), + lastName: validateTextField(performerData.lastName, true, "Achternaam"), + email: validateEmail(performerData.email), + phone: validatePhone(performerData.phone), + artForm: !performerData.artForm.trim() + ? "Kunstvorm is verplicht" + : undefined, + isOver16: !performerData.isOver16 + ? "Je moet 16 jaar of ouder zijn om op te treden" + : undefined, + }; + setPerformerErrors(errs); + setPerformerTouched({ firstName: true, lastName: true, email: true, phone: true, artForm: true, - experience: true, + isOver16: true, }); + return !Object.values(errs).some(Boolean); + }, [performerData]); - return !Object.values(newErrors).some(Boolean); - }, [formData, validateField]); + const validateWatcherForm = useCallback((): boolean => { + const errs: WatcherErrors = { + firstName: validateTextField(watcherData.firstName, true, "Voornaam"), + lastName: validateTextField(watcherData.lastName, true, "Achternaam"), + email: validateEmail(watcherData.email), + phone: validatePhone(watcherData.phone), + }; + setWatcherErrors(errs); + setWatcherTouched({ + firstName: true, + lastName: true, + email: true, + phone: true, + }); + return !Object.values(errs).some(Boolean); + }, [watcherData]); - const handleSubmit = useCallback( + const handlePerformerChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value, type } = e.target; + const checked = (e.target as HTMLInputElement).checked; + const newValue = type === "checkbox" ? checked : value; + setPerformerData((prev) => ({ ...prev, [name]: newValue })); + if (type !== "checkbox" && performerTouched[name]) { + const fieldErrs: Record = { + firstName: validateTextField( + name === "firstName" ? value : performerData.firstName, + true, + "Voornaam", + ), + lastName: validateTextField( + name === "lastName" ? value : performerData.lastName, + true, + "Achternaam", + ), + email: + name === "email" + ? validateEmail(value) + : validateEmail(performerData.email), + phone: name === "phone" ? validatePhone(value) : undefined, + artForm: + name === "artForm" && !value.trim() + ? "Kunstvorm is verplicht" + : undefined, + }; + setPerformerErrors((prev) => ({ ...prev, [name]: fieldErrs[name] })); + } + }, + [performerTouched, performerData], + ); + + const handlePerformerBlur = useCallback( + (e: React.FocusEvent) => { + const { name, value } = e.target; + setPerformerTouched((prev) => ({ ...prev, [name]: true })); + const errMap: Record = { + firstName: validateTextField(value, true, "Voornaam"), + lastName: validateTextField(value, true, "Achternaam"), + email: validateEmail(value), + phone: validatePhone(value), + artForm: !value.trim() ? "Kunstvorm is verplicht" : undefined, + }; + setPerformerErrors((prev) => ({ ...prev, [name]: errMap[name] })); + }, + [], + ); + + const handleWatcherChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value } = e.target; + setWatcherData((prev) => ({ ...prev, [name]: value })); + if (watcherTouched[name]) { + const errMap: Record = { + firstName: validateTextField(value, true, "Voornaam"), + lastName: validateTextField(value, true, "Achternaam"), + email: validateEmail(value), + phone: validatePhone(value), + }; + setWatcherErrors((prev) => ({ ...prev, [name]: errMap[name] })); + } + }, + [watcherTouched], + ); + + const handleWatcherBlur = useCallback( + (e: React.FocusEvent) => { + const { name, value } = e.target; + setWatcherTouched((prev) => ({ ...prev, [name]: true })); + const errMap: Record = { + firstName: validateTextField(value, true, "Voornaam"), + lastName: validateTextField(value, true, "Achternaam"), + email: validateEmail(value), + phone: validatePhone(value), + }; + setWatcherErrors((prev) => ({ ...prev, [name]: errMap[name] })); + }, + [], + ); + + const handlePerformerSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); - if (!validateForm()) { + if (!validatePerformerForm()) { toast.error("Controleer je invoer"); return; } submitMutation.mutate({ - firstName: formData.firstName.trim(), - lastName: formData.lastName.trim(), - email: formData.email.trim(), - phone: formData.phone.trim() || undefined, - wantsToPerform: formData.wantsToPerform, - artForm: formData.wantsToPerform - ? formData.artForm.trim() || undefined - : undefined, - experience: formData.wantsToPerform - ? formData.experience.trim() || undefined - : undefined, - extraQuestions: formData.extraQuestions.trim() || undefined, + firstName: performerData.firstName.trim(), + lastName: performerData.lastName.trim(), + email: performerData.email.trim(), + phone: performerData.phone.trim() || undefined, + registrationType: "performer", + artForm: performerData.artForm.trim() || undefined, + experience: performerData.experience.trim() || undefined, + isOver16: performerData.isOver16, + extraQuestions: performerData.extraQuestions.trim() || undefined, }); }, - [formData, submitMutation, validateForm], + [performerData, submitMutation, validatePerformerForm], ); - const getInputClasses = (fieldName: keyof FormErrors) => { - const baseClasses = - "border-b bg-transparent pb-2 text-lg text-white placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-white/50 focus:ring-offset-2 focus:ring-offset-[#214e51] transition-all"; - const errorClasses = - touched[fieldName] && errors[fieldName] - ? "border-red-400" - : "border-white/30 focus:border-white"; - return `${baseClasses} ${errorClasses}`; - }; + const validateGuests = useCallback((): boolean => { + const errs: 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, + })); + setGuestErrors(errs); + return !errs.some((e) => Object.values(e).some(Boolean)); + }, [guests]); + + const handleAddGuest = useCallback(() => { + if (guests.length >= 9) return; + setGuests((prev) => [ + ...prev, + { firstName: "", lastName: "", email: "", phone: "" }, + ]); + setGuestErrors((prev) => [...prev, {}]); + }, [guests.length]); + + const handleRemoveGuest = useCallback((index: number) => { + setGuests((prev) => prev.filter((_, i) => i !== index)); + setGuestErrors((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const handleGuestChange = useCallback( + (index: number, field: keyof GuestEntry, value: string) => { + setGuests((prev) => { + const next = [...prev]; + next[index] = { ...next[index], [field]: value } as GuestEntry; + return next; + }); + }, + [], + ); + + const handleWatcherSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const watcherValid = validateWatcherForm(); + const guestsValid = validateGuests(); + if (!watcherValid || !guestsValid) { + toast.error("Controleer je invoer"); + return; + } + submitMutation.mutate({ + firstName: watcherData.firstName.trim(), + lastName: watcherData.lastName.trim(), + email: watcherData.email.trim(), + phone: watcherData.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: watcherData.extraQuestions.trim() || undefined, + }); + }, + [watcherData, guests, submitMutation, validateWatcherForm, validateGuests], + ); + + const inputClasses = (hasError: boolean) => + `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"}`; const manageUrl = successToken ? `${typeof window !== "undefined" ? window.location.origin : ""}/manage/${successToken}` @@ -207,7 +368,7 @@ export default function EventRegistrationForm() { id="registration" className="relative z-30 flex w-full items-center justify-center bg-[#214e51]/96 px-6 py-16 md:px-12" > -

+
Gelukt! -

+

Je inschrijving is bevestigd. We sturen je zo dadelijk een - bevestigingsmail met alle details. + bevestigingsmail.

-

- Geen mail ontvangen? Controleer je spam-map of gebruik deze - link: + Geen mail ontvangen? Gebruik deze link:

-
-
+

Schrijf je nu in!

- Doet dit jouw creatieve geest borrelen? Vul nog even dit formulier in + Doe je mee of kom je kijken? Kies je rol en vul het formulier in.

-
-
- {/* Row 1: First Name + Last Name */} -
-
- - - {touched.firstName && errors.firstName && ( - - {errors.firstName} - - )} -
- -
- - - {touched.lastName && errors.lastName && ( - - {errors.lastName} - - )} -
-
- - {/* Row 2: Email + Phone */} -
-
- - - {touched.email && errors.email && ( - - {errors.email} - - )} -
- -
- - - {touched.phone && errors.phone && ( - - {errors.phone} - - )} -
-
- - {/* Row 3: Wants to perform checkbox */} -
+ )} + + {/* Performer form */} + {selectedType === "performer" && ( +
+
+ +
+
+
+ * + + +
+ + Ik wil optreden + +
+
+ + +
+
+ - - - {touched.artForm && errors.artForm && ( - - {errors.artForm} + {performerTouched.firstName && performerErrors.firstName && ( + + {performerErrors.firstName} )}
- - {/* Experience */}
-
- )} - {/* Extra questions / remarks */} -
- -