feat:accessibility, new routes, cookie consent, and UI improvements

- Add contact, privacy, and terms pages
- Add CookieConsent component with accept/decline and localStorage
- Add self-hosted DM Sans font with @font-face definitions
- Improve registration form with field validation, blur handlers, and
performer toggle
- Redesign Info section with 'Ongedesemd Brood' hero and FAQ layout
- Remove scroll-snap behavior from all sections
- Add reduced motion support and selection color theming
- Add SVG favicon and SEO meta tags in root layout
- Improve accessibility: aria attributes, semantic HTML, focus styles
- Add link-hover underline animation utility
This commit is contained in:
2026-03-02 20:45:17 +01:00
parent b343314931
commit 37d9a415eb
23 changed files with 1804 additions and 184 deletions

View File

@@ -1,82 +1,223 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { orpc } from "@/utils/orpc";
interface FormErrors {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
artForm?: string;
experience?: string;
}
export default function EventRegistrationForm() {
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
wantsToPerform: false,
artForm: "",
experience: "",
extraQuestions: "",
});
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
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;
},
[],
);
const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(),
onSuccess: () => {
toast.success("Registratie succesvol!");
toast.success(
"Registratie succesvol! We nemen binnenkort contact met je op.",
);
setFormData({
firstName: "",
lastName: "",
email: "",
phone: "",
wantsToPerform: false,
artForm: "",
experience: "",
extraQuestions: "",
});
setErrors({});
setTouched({});
},
onError: (error) => {
toast.error(`Er is iets misgegaan: ${error.message}`);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
submitMutation.mutate({
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
phone: formData.phone || undefined,
artForm: formData.artForm,
experience: formData.experience || undefined,
});
};
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
const checked = (e.target as HTMLInputElement).checked;
const newValue = type === "checkbox" ? checked : value;
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
setFormData((prev) => {
const updated = { ...prev, [name]: newValue };
// Clear performer fields when unchecking wantsToPerform
if (name === "wantsToPerform" && !checked) {
updated.artForm = "";
updated.experience = "";
}
return updated;
});
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 handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
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({
firstName: true,
lastName: true,
email: true,
phone: true,
artForm: true,
experience: true,
});
return !Object.values(newErrors).some(Boolean);
}, [formData, validateField]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
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,
});
},
[formData, submitMutation, validateForm],
);
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}`;
};
return (
<section
id="registration"
className="snap-section relative z-30 min-h-screen w-full bg-[#214e51]/96 px-12 py-16"
className="relative z-30 flex w-full items-center justify-center bg-[#214e51]/96 px-6 py-16 md:px-12"
>
<div className="mx-auto flex h-full max-w-6xl flex-col">
<h2 className="mb-2 font-['Intro',sans-serif] text-4xl text-white">
<div className="mx-auto flex w-full max-w-6xl flex-col">
<h2 className="mb-2 font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Schrijf je nu in!
</h2>
<p className="mb-12 max-w-3xl font-['Intro',sans-serif] text-white/80 text-xl">
Doet dit jouw creatieve geest borellen? Vul nog even dit formulier in
<p className="mb-12 max-w-3xl text-lg text-white/80 md:text-xl">
Doet dit jouw creatieve geest borrelen? Vul nog even dit formulier in
</p>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
<div className="flex flex-col gap-8">
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col"
noValidate
>
<div className="flex flex-col gap-6 md:gap-8">
{/* Row 1: First Name + Last Name */}
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-8">
<div className="flex flex-col gap-2">
<label
htmlFor="firstName"
className="font-['Intro',sans-serif] text-2xl text-white"
className="text-white text-xl md:text-2xl"
>
Voornaam
Voornaam <span className="text-red-300">*</span>
</label>
<input
type="text"
@@ -84,18 +225,33 @@ export default function EventRegistrationForm() {
name="firstName"
value={formData.firstName}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Jouw voornaam"
required
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
autoComplete="given-name"
aria-required="true"
aria-invalid={touched.firstName && !!errors.firstName}
aria-describedby={
errors.firstName ? "firstName-error" : undefined
}
className={getInputClasses("firstName")}
/>
{touched.firstName && errors.firstName && (
<span
id="firstName-error"
className="text-red-300 text-sm"
role="alert"
>
{errors.firstName}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="lastName"
className="font-['Intro',sans-serif] text-2xl text-white"
className="text-white text-xl md:text-2xl"
>
Achternaam
Achternaam <span className="text-red-300">*</span>
</label>
<input
type="text"
@@ -103,21 +259,36 @@ export default function EventRegistrationForm() {
name="lastName"
value={formData.lastName}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Jouw achternaam"
required
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
autoComplete="family-name"
aria-required="true"
aria-invalid={touched.lastName && !!errors.lastName}
aria-describedby={
errors.lastName ? "lastName-error" : undefined
}
className={getInputClasses("lastName")}
/>
{touched.lastName && errors.lastName && (
<span
id="lastName-error"
className="text-red-300 text-sm"
role="alert"
>
{errors.lastName}
</span>
)}
</div>
</div>
{/* Row 2: Email + Phone */}
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-8">
<div className="flex flex-col gap-2">
<label
htmlFor="email"
className="font-['Intro',sans-serif] text-2xl text-white"
className="text-white text-xl md:text-2xl"
>
E-mail
E-mail <span className="text-red-300">*</span>
</label>
<input
type="email"
@@ -125,16 +296,30 @@ export default function EventRegistrationForm() {
name="email"
value={formData.email}
onChange={handleChange}
placeholder="jouw@email.nl"
required
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
onBlur={handleBlur}
placeholder="jouw@email.be"
autoComplete="email"
inputMode="email"
aria-required="true"
aria-invalid={touched.email && !!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
className={getInputClasses("email")}
/>
{touched.email && errors.email && (
<span
id="email-error"
className="text-red-300 text-sm"
role="alert"
>
{errors.email}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="phone"
className="font-['Intro',sans-serif] text-2xl text-white"
className="text-white text-xl md:text-2xl"
>
Telefoon
</label>
@@ -144,51 +329,158 @@ export default function EventRegistrationForm() {
name="phone"
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
placeholder="06-12345678"
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
autoComplete="tel"
inputMode="tel"
aria-invalid={touched.phone && !!errors.phone}
aria-describedby={errors.phone ? "phone-error" : undefined}
className={getInputClasses("phone")}
/>
{touched.phone && errors.phone && (
<span
id="phone-error"
className="text-red-300 text-sm"
role="alert"
>
{errors.phone}
</span>
)}
</div>
</div>
{/* Row 3: Art Form (full width) */}
<div className="flex flex-col gap-2">
<label
htmlFor="artForm"
className="font-['Intro',sans-serif] text-2xl text-white"
>
Kunstvorm
</label>
<input
type="text"
id="artForm"
name="artForm"
value={formData.artForm}
onChange={handleChange}
placeholder="Muziek, Theater, Dans, etc."
required
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
/>
</div>
{/* Row 4: Experience (full width) */}
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label
htmlFor="experience"
className="font-['Intro',sans-serif] text-2xl text-white"
>
Ervaring
</label>
{/* Row 3: Wants to perform checkbox */}
<label
htmlFor="wantsToPerform"
className="flex cursor-pointer items-center gap-4"
>
<div className="relative flex shrink-0 self-center">
<input
type="text"
id="experience"
name="experience"
value={formData.experience}
type="checkbox"
id="wantsToPerform"
name="wantsToPerform"
checked={formData.wantsToPerform}
onChange={handleChange}
placeholder="Beginner / Gevorderd / Professional"
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
className="peer sr-only"
/>
<div className="h-6 w-6 border-2 border-white/50 bg-transparent transition-colors peer-checked:border-white peer-checked:bg-white peer-focus:ring-2 peer-focus:ring-white/50 peer-focus:ring-offset-2 peer-focus:ring-offset-[#214e51]" />
<svg
className="pointer-events-none absolute top-0 left-0 h-6 w-6 scale-0 text-[#214e51] transition-transform peer-checked:scale-100"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
aria-hidden="true"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div className="flex flex-col gap-1">
<span className="text-white text-xl md:text-2xl">
Ik wil optreden
</span>
<span className="text-sm text-white/60">
Vink aan als je wilt optreden. Laat dit leeg als je alleen
wilt komen kijken.
</span>
</div>
</label>
{/* Performer fields: shown only when wantsToPerform is checked */}
{formData.wantsToPerform && (
<div className="flex flex-col gap-6 border border-white/20 p-6 md:gap-8">
{/* Art Form */}
<div className="flex flex-col gap-2">
<label
htmlFor="artForm"
className="text-white text-xl md:text-2xl"
>
Kunstvorm <span className="text-red-300">*</span>
</label>
<input
type="text"
id="artForm"
name="artForm"
value={formData.artForm}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Muziek, Theater, Dans, etc."
autoComplete="off"
list="artFormSuggestions"
aria-required="true"
aria-invalid={touched.artForm && !!errors.artForm}
aria-describedby={
errors.artForm ? "artForm-error" : undefined
}
className={getInputClasses("artForm")}
/>
<datalist id="artFormSuggestions">
<option value="Muziek" />
<option value="Theater" />
<option value="Dans" />
<option value="Beeldende Kunst" />
<option value="Woordkunst" />
<option value="Comedy" />
</datalist>
{touched.artForm && errors.artForm && (
<span
id="artForm-error"
className="text-red-300 text-sm"
role="alert"
>
{errors.artForm}
</span>
)}
</div>
{/* Experience */}
<div className="flex flex-col gap-2">
<label
htmlFor="experience"
className="text-white text-xl md:text-2xl"
>
Ervaring
</label>
<input
type="text"
id="experience"
name="experience"
value={formData.experience}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Beginner / Gevorderd / Professional"
autoComplete="off"
list="experienceSuggestions"
className={getInputClasses("experience")}
/>
<datalist id="experienceSuggestions">
<option value="Beginner" />
<option value="Gevorderd" />
<option value="Professional" />
</datalist>
</div>
</div>
)}
{/* Extra questions / remarks */}
<div className="flex flex-col gap-2 border border-white/20 p-6">
<label
htmlFor="extraQuestions"
className="text-white text-xl md:text-2xl"
>
Vragen of opmerkingen
</label>
<textarea
id="extraQuestions"
name="extraQuestions"
value={formData.extraQuestions}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Heb je nog vragen of iets dat we moeten weten?"
rows={4}
autoComplete="off"
className="resize-none bg-transparent text-lg text-white transition-all placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-white/50 focus:ring-offset-2 focus:ring-offset-[#214e51]"
/>
</div>
</div>
@@ -197,17 +489,45 @@ export default function EventRegistrationForm() {
<button
type="submit"
disabled={submitMutation.isPending}
className="bg-white px-12 py-4 font-['Intro',sans-serif] text-2xl text-[#214e51] transition-all hover:scale-105 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
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 ? "Bezig..." : "Bevestigen"}
{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>
<p className="text-center font-['Intro',sans-serif] text-sm text-white/60">
Nu al een act / idee van wat jij graag zou willen brengen,{" "}
<span className="cursor-pointer underline hover:text-white">
klik HIER
</span>
</p>
<a
href="/contact"
className="link-hover block text-center text-sm text-white/60 transition-colors hover:text-white"
>
Nog vragen? Neem contact op
</a>
</div>
</form>
</div>