From 0a1d1db9ec508fbafc2f313514ad20669320be30 Mon Sep 17 00:00:00 2001 From: zias Date: Tue, 3 Mar 2026 14:02:29 +0100 Subject: [PATCH] feat:simplify dx and improve payment functions --- .../homepage/EventRegistrationForm.tsx | 1351 +---------------- .../src/components/registration/GuestList.tsx | 200 +++ .../components/registration/PerformerForm.tsx | 463 ++++++ .../components/registration/SuccessScreen.tsx | 72 + .../components/registration/TypeSelector.tsx | 114 ++ .../components/registration/WatcherForm.tsx | 403 +++++ apps/web/src/lib/registration.ts | 117 ++ apps/web/src/routes/admin.tsx | 51 +- apps/web/src/routes/manage.$token.tsx | 1302 +++++++--------- packages/api/src/routers/index.ts | 318 ++-- 10 files changed, 2167 insertions(+), 2224 deletions(-) create mode 100644 apps/web/src/components/registration/GuestList.tsx create mode 100644 apps/web/src/components/registration/PerformerForm.tsx create mode 100644 apps/web/src/components/registration/SuccessScreen.tsx create mode 100644 apps/web/src/components/registration/TypeSelector.tsx create mode 100644 apps/web/src/components/registration/WatcherForm.tsx create mode 100644 apps/web/src/lib/registration.ts diff --git a/apps/web/src/components/homepage/EventRegistrationForm.tsx b/apps/web/src/components/homepage/EventRegistrationForm.tsx index 47da6a5..c4f73e8 100644 --- a/apps/web/src/components/homepage/EventRegistrationForm.tsx +++ b/apps/web/src/components/homepage/EventRegistrationForm.tsx @@ -1,424 +1,26 @@ -"use client"; +import { useState } from "react"; +import { PerformerForm } from "@/components/registration/PerformerForm"; +import { SuccessScreen } from "@/components/registration/SuccessScreen"; +import { TypeSelector } from "@/components/registration/TypeSelector"; +import { WatcherForm } from "@/components/registration/WatcherForm"; -import { useMutation } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; -import { toast } from "sonner"; -import { orpc } from "@/utils/orpc"; - -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; - isOver16?: string; -} - -interface WatcherErrors { - firstName?: string; - lastName?: string; - email?: string; - phone?: string; -} +type RegistrationType = "performer" | "watcher"; export default function EventRegistrationForm() { - const [selectedType, setSelectedType] = useState(null); + const [selectedType, setSelectedType] = useState( + null, + ); const [successToken, setSuccessToken] = useState(null); - // Performer form state - const [performerData, setPerformerData] = useState({ - firstName: "", - lastName: "", - email: "", - phone: "", - artForm: "", - experience: "", - isOver16: false, - extraQuestions: "", - }); - const [performerErrors, setPerformerErrors] = useState({}); - const [performerTouched, setPerformerTouched] = useState< - Record - >({}); - - // 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: async (data, variables) => { - if (!data.managementToken) return; - - // If it's a performer, show success immediately - if (variables.registrationType === "performer") { - setSuccessToken(data.managementToken); - return; - } - - // For watchers, create a checkout and redirect - try { - const checkoutResult = await orpc.createCheckout.call({ - token: data.managementToken, - }); - - if (checkoutResult.checkoutUrl) { - window.location.href = checkoutResult.checkoutUrl; - } else { - toast.error("Kon betalingspagina niet laden"); - } - } 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}`); - }, - }); - - const handleReset = useCallback(() => { - setSuccessToken(null); - setSelectedType(null); - }, []); - - 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; - }; - - 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; - }; - - 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 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, - isOver16: true, - }); - return !Object.values(errs).some(Boolean); - }, [performerData]); - - 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 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 (!validatePerformerForm()) { - toast.error("Controleer je invoer"); - return; - } - submitMutation.mutate({ - 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, - }); - }, - [performerData, submitMutation, validatePerformerForm], - ); - - 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}` - : ""; - if (successToken) { return ( -
-
-
-
- - - -
-

- Gelukt! -

-

- Je inschrijving is bevestigd. We sturen je zo dadelijk een - bevestigingsmail. -

-
-

- Geen mail ontvangen? Gebruik deze link: -

- - {manageUrl} - -
-
- - Bekijk mijn inschrijving - - -
-
-
-
+ { + setSuccessToken(null); + setSelectedType(null); + }} + /> ); } @@ -435,926 +37,17 @@ export default function EventRegistrationForm() { Doe je mee of kom je kijken? Kies je rol en vul het formulier in.

- {/* Two-column choice cards */} - {!selectedType && ( -
- {/* Performer card */} - - - {/* Watcher card */} - -
- )} - - {/* Performer form */} {selectedType === "performer" && ( -
-
- -
-
-
- -
- - Ik wil optreden - -
-
- -
-
-
- - - {performerTouched.firstName && performerErrors.firstName && ( - - {performerErrors.firstName} - - )} -
-
- - - {performerTouched.lastName && performerErrors.lastName && ( - - {performerErrors.lastName} - - )} -
-
- -
-
- - - {performerTouched.email && performerErrors.email && ( - - {performerErrors.email} - - )} -
-
- - - {performerTouched.phone && performerErrors.phone && ( - - {performerErrors.phone} - - )} -
-
- - {/* Performer-specific fields */} -
-

- Optreden details -

-
-
- - - - - {performerTouched.artForm && performerErrors.artForm && ( - - {performerErrors.artForm} - - )} -
- -
- - - - -
- - {/* Age confirmation */} -
-
-
- -
- -