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

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#d82560"/>
<circle cx="50" cy="35" r="15" fill="white"/>
<rect x="40" y="45" width="20" height="30" fill="white"/>
<rect x="35" y="75" width="30" height="8" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
export function CookieConsent() {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const consent = localStorage.getItem("cookie-consent");
if (!consent) {
setIsVisible(true);
}
}, []);
const acceptCookies = () => {
localStorage.setItem("cookie-consent", "accepted");
setIsVisible(false);
// Enable analytics tracking
if (window.plausible) {
window.plausible("pageview");
}
};
const declineCookies = () => {
localStorage.setItem("cookie-consent", "declined");
setIsVisible(false);
};
if (!isVisible) return null;
return (
<div
role="dialog"
aria-label="Cookie toestemming"
className="fixed right-0 bottom-0 left-0 z-50 border-white/10 border-t bg-[#214e51] p-4 shadow-lg md:p-6"
>
<div className="mx-auto flex max-w-6xl flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex-1">
<p className="font-['Intro',sans-serif] text-white">
<strong>We gebruiken cookies</strong>
</p>
<p className="mt-1 text-sm text-white/80">
We gebruiken analytische cookies om onze website te verbeteren.
<a href="/privacy" className="underline hover:text-white">
Meer informatie
</a>
</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={declineCookies}
className="px-4 py-2 text-sm text-white/80 transition-colors hover:text-white"
>
Weigeren
</button>
<button
type="button"
onClick={acceptCookies}
className="bg-white px-6 py-2 font-['Intro',sans-serif] text-[#214e51] text-sm transition-all hover:scale-105"
>
Accepteren
</button>
</div>
</div>
</div>
);
}
// Add to window type
declare global {
interface Window {
plausible: (event: string, options?: Record<string, unknown>) => void;
}
}

View File

@@ -53,59 +53,71 @@ const artForms = [
export default function ArtForms() {
return (
<section className="snap-section relative z-25 min-h-screen w-full bg-[#f8f8f8] px-12 py-16">
<section className="relative z-25 w-full bg-[#f8f8f8] px-12 py-16">
<div className="mx-auto max-w-6xl">
<h2 className="mb-4 font-['Intro',sans-serif] text-5xl text-[#214e51]">
Kies Je Traject
</h2>
<p className="mb-12 max-w-2xl font-['Intro',sans-serif] text-gray-600 text-xl">
<p className="mb-12 max-w-2xl text-gray-600 text-xl">
Kunstenkamp biedt trajecten aan voor verschillende kunstvormen. Ontdek
waar jouw passie ligt en ontwikkel je talent onder begeleiding van
ervaringsdeskundigen.
</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{artForms.map((art) => {
{artForms.map((art, index) => {
const IconComponent = art.icon;
return (
<div
<article
key={art.title}
className="group relative overflow-hidden bg-white p-8 transition-all hover:-translate-y-2 hover:shadow-xl"
className="group relative overflow-hidden bg-white p-8 transition-all focus-within:ring-2 focus-within:ring-[#214e51] focus-within:ring-offset-2 hover:-translate-y-2 hover:shadow-xl motion-reduce:transition-none"
aria-labelledby={`art-title-${index}`}
>
{/* Color bar at top */}
<div
className="absolute top-0 left-0 h-1 w-full transition-all group-hover:h-2"
className="absolute top-0 left-0 h-1 w-full transition-all group-hover:h-2 motion-reduce:transition-none"
style={{ backgroundColor: art.color }}
aria-hidden="true"
/>
<div className="mb-4 flex items-center gap-4">
<div
className="flex h-14 w-14 items-center justify-center"
style={{ backgroundColor: art.color }}
aria-hidden="true"
>
<IconComponent className="h-7 w-7 text-white" />
<IconComponent
className="h-7 w-7 text-white"
aria-hidden="true"
/>
</div>
<h3 className="font-['Intro',sans-serif] text-2xl text-gray-900">
<h3
id={`art-title-${index}`}
className="font-['Intro',sans-serif] text-2xl text-gray-900"
>
{art.title}
</h3>
</div>
<p className="mb-6 font-['Intro',sans-serif] text-gray-600 leading-relaxed">
<p className="mb-6 text-gray-600 leading-relaxed">
{art.description}
</p>
<div className="flex items-center justify-between border-gray-100 border-t pt-4">
<span
className="font-['Intro',sans-serif] font-medium text-sm"
className="font-medium text-sm"
style={{ color: art.color }}
>
{art.trajectory}
</span>
<span className="text-2xl text-gray-300 transition-all group-hover:translate-x-1 group-hover:text-gray-600">
<span
className="text-2xl text-gray-300 transition-all group-hover:translate-x-1 group-hover:text-gray-600 motion-reduce:transition-none"
aria-hidden="true"
>
</span>
</div>
</div>
</article>
);
})}
</div>

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,19 +329,73 @@ 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) */}
{/* 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="checkbox"
id="wantsToPerform"
name="wantsToPerform"
checked={formData.wantsToPerform}
onChange={handleChange}
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="font-['Intro',sans-serif] text-2xl text-white"
className="text-white text-xl md:text-2xl"
>
Kunstvorm
Kunstvorm <span className="text-red-300">*</span>
</label>
<input
type="text"
@@ -164,18 +403,41 @@ export default function EventRegistrationForm() {
name="artForm"
value={formData.artForm}
onChange={handleChange}
onBlur={handleBlur}
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"
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>
{/* Row 4: Experience (full width) */}
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* Experience */}
<div className="flex flex-col gap-2">
<label
htmlFor="experience"
className="font-['Intro',sans-serif] text-2xl text-white"
className="text-white text-xl md:text-2xl"
>
Ervaring
</label>
@@ -185,11 +447,41 @@ export default function EventRegistrationForm() {
name="experience"
value={formData.experience}
onChange={handleChange}
onBlur={handleBlur}
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"
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>
{/* Submit button */}
@@ -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>

View File

@@ -17,7 +17,7 @@ export default function Footer() {
}, []);
return (
<footer className="snap-section relative z-40 flex h-[250px] flex-col items-center justify-center bg-[#d09035]">
<footer className="relative z-40 flex h-[250px] flex-col items-center justify-center bg-[#d09035]">
<div className="text-center">
<h3 className="mb-4 font-['Intro',sans-serif] text-2xl text-white">
Kunstenkamp
@@ -26,33 +26,47 @@ export default function Footer() {
Waar creativiteit tot leven komt
</p>
<div className="flex items-center justify-center gap-8 font-['Intro',sans-serif] text-sm text-white/70">
<a href="/privacy" className="transition-colors hover:text-white">
<div className="flex items-center justify-center gap-8 text-sm text-white/70">
<a
href="/privacy"
className="link-hover transition-colors hover:text-white"
>
Privacy Policy
</a>
<span className="text-white/40">|</span>
<a href="/terms" className="transition-colors hover:text-white">
<a
href="/terms"
className="link-hover transition-colors hover:text-white"
>
Terms of Service
</a>
<span className="text-white/40">|</span>
<a href="/contact" className="transition-colors hover:text-white">
<a
href="/contact"
className="link-hover transition-colors hover:text-white"
>
Contact
</a>
{!isLoading && isAdmin && (
<>
<span className="text-white/40">|</span>
<Link to="/admin" className="transition-colors hover:text-white">
<Link
to="/admin"
className="link-hover transition-colors hover:text-white"
>
Admin
</Link>
</>
)}
</div>
<div className="mt-6 font-['Intro',sans-serif] text-white/50 text-xs">
© 2026 Kunstenkamp. Alle rechten voorbehouden.
<div className="mt-6 text-white/50 text-xs">
© {new Date().getFullYear()} Kunstenkamp. Alle rechten voorbehouden.
</div>
<div className="font-['Intro',sans-serif] text-white/50 text-xs">
<a href="https://zias.be">Gemaakt met door zias.be</a>
<div className="text-white/50 text-xs transition-colors hover:text-white">
<a href="https://zias.be" className="link-hover">
Gemaakt met door zias.be
</a>
</div>
</div>
</footer>

View File

@@ -12,25 +12,22 @@ export default function Hero() {
}, []);
const scrollToRegistration = () => {
const registrationSection = document.getElementById("registration");
if (registrationSection) {
// Temporarily disable scroll-snap to prevent snap-back on mobile
document.documentElement.style.scrollSnapType = "none";
registrationSection.scrollIntoView({ behavior: "smooth" });
// Re-enable scroll-snap after smooth scroll completes
setTimeout(() => {
document.documentElement.style.scrollSnapType = "";
}, 800);
}
document
.getElementById("registration")
?.scrollIntoView({ behavior: "smooth" });
};
const scrollToInfo = () => {
document.getElementById("info")?.scrollIntoView({ behavior: "smooth" });
};
return (
<section className="snap-section relative h-[100dvh] w-full overflow-hidden bg-white">
<section className="relative h-[100dvh] w-full overflow-hidden bg-white">
{/* Desktop Layout - hidden on mobile */}
<div className="relative hidden h-full w-full flex-col gap-[10px] p-[10px] lg:flex">
{/* Desktop Microphone - positioned on top of top sections, under bottom sections */}
<div
className={`pointer-events-none absolute z-20 transition-all duration-1000 ease-out ${
className={`pointer-events-none absolute z-20 transition-all duration-1000 ease-out motion-reduce:transition-none ${
micVisible
? "translate-x-0 translate-y-0 opacity-100"
: "translate-x-24 -translate-y-24 opacity-0"
@@ -43,8 +40,10 @@ export default function Hero() {
>
<img
src="/assets/mic.png"
alt="Vintage microphone"
alt="Vintage microfoon - Open Mic Night"
className="h-full w-full object-contain drop-shadow-2xl"
loading="eager"
fetchPriority="high"
/>
</div>
@@ -57,9 +56,13 @@ export default function Hero() {
<br />
NIGHT
</h1>
<p className="mt-4 font-['Intro',sans-serif] font-normal text-[clamp(1.25rem,2.5vw,2.625rem)] text-white/90">
<button
onClick={scrollToInfo}
type="button"
className="link-hover mt-4 cursor-pointer text-left font-['Intro',sans-serif] font-normal text-[clamp(1.25rem,2.5vw,2.625rem)] text-white/70 transition-colors duration-200 hover:text-white"
>
Ongedesemd brood
</p>
</button>
</div>
{/* Top Right - Teal - under mic */}
@@ -88,13 +91,13 @@ export default function Hero() {
</div>
{/* Desktop CTA Button - centered at bottom */}
<div className="absolute bottom-[10px] left-1/2 z-30 -translate-x-1/2">
<div className="absolute bottom-[10px] left-1/2 z-30 -translate-x-1/2 py-12">
<button
onClick={scrollToRegistration}
type="button"
className="bg-black px-[40px] py-[10px] font-['Intro',sans-serif] font-normal text-[30px] text-white transition-all hover:scale-105 hover:bg-gray-900"
>
Registreer nu!
Registreer je nu!
</button>
</div>
</div>
@@ -116,7 +119,7 @@ export default function Hero() {
{/* Mobile Microphone - positioned inside magenta section, clipped by overflow */}
<div
className={`pointer-events-none absolute z-10 transition-all duration-1000 ease-out ${
className={`pointer-events-none absolute z-10 transition-all duration-1000 ease-out motion-reduce:transition-none ${
micVisible
? "translate-x-0 translate-y-0 opacity-100"
: "translate-x-12 -translate-y-12 opacity-0"
@@ -130,8 +133,9 @@ export default function Hero() {
>
<img
src="/assets/mic.png"
alt="Vintage microphone"
alt="Vintage microfoon - Open Mic Night"
className="h-full w-full object-contain object-bottom drop-shadow-2xl"
loading="lazy"
/>
</div>
</div>
@@ -162,7 +166,7 @@ export default function Hero() {
type="button"
className="w-full bg-black py-3 font-['Intro',sans-serif] font-normal text-lg text-white transition-all hover:scale-105 hover:bg-gray-900"
>
Registreer nu!
Registreer je nu!
</button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
const questions = [
const faqQuestions = [
{
question: "Wat is een open mic?",
answer:
@@ -23,9 +23,138 @@ const questions = [
export default function Info() {
return (
<section className="snap-section relative z-20 flex min-h-screen flex-col bg-[#d82560]/96 px-12 py-16">
<section id="info" className="relative z-20 flex flex-col bg-[#d82560]/96">
{/* Hero Section - Ongedesemd Brood */}
<div className="relative w-full border-white/20 border-b-4">
{/* Background pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}}
/>
<div className="relative flex flex-col items-center justify-center px-8 py-12 text-center">
{/* Decorative top element */}
<div className="mb-8 flex items-center gap-4">
<div className="h-px w-24 bg-gradient-to-r from-transparent via-white/60 to-transparent" />
<svg
className="h-14 w-20 text-white/80"
viewBox="0 0 80 56"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Ongedesemd brood"
>
{/* Flat matzo cracker - slightly rounded rect */}
<rect
x="2"
y="8"
width="76"
height="40"
rx="4"
fill="currentColor"
/>
{/* Perforation dots - characteristic matzo pattern */}
{[16, 28, 40, 52, 64].map((x) =>
[18, 28, 38].map((y) => (
<circle
key={`${x}-${y}`}
cx={x}
cy={y}
r="2"
fill="rgba(0,0,0,0.25)"
/>
)),
)}
{/* Score lines */}
<line
x1="2"
y1="22"
x2="78"
y2="22"
stroke="rgba(0,0,0,0.15)"
strokeWidth="1"
/>
<line
x1="2"
y1="34"
x2="78"
y2="34"
stroke="rgba(0,0,0,0.15)"
strokeWidth="1"
/>
</svg>
<div className="h-px w-24 bg-gradient-to-r from-transparent via-white/60 to-transparent" />
</div>
{/* Main Title */}
<h2 className="font-['Intro',sans-serif] font-black text-6xl text-white uppercase tracking-tight md:text-8xl lg:text-9xl">
Ongedesemd
<span className="block text-5xl md:text-7xl lg:text-8xl">
Brood?!
</span>
</h2>
{/* Subtitle / Tagline */}
<p className="mt-8 max-w-3xl font-['Intro',sans-serif] font-light text-2xl text-white/90 uppercase tracking-widest md:text-3xl">
Kunst zonder franje · Puur vanuit het hart
</p>
{/* Decorative bottom element */}
<div className="mt-10 flex items-center gap-3">
<div className="h-2 w-2 rotate-45 bg-white/60" />
<div className="h-2 w-2 rotate-45 bg-white/40" />
<div className="h-2 w-2 rotate-45 bg-white/60" />
</div>
</div>
</div>
{/* Content Section */}
<div className="w-full px-12 py-16">
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col justify-center gap-16">
{questions.map((item, idx) => (
{/* Ongedesemd Brood Explanation - Full Width Special Treatment */}
<div className="relative flex flex-col gap-6 rounded-2xl border-2 border-white/20 bg-white/5 p-8 md:p-12">
<div className="absolute top-0 -left-4 h-full w-1 bg-gradient-to-b from-white/0 via-white/60 to-white/0" />
<h3 className="font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Waarom deze naam?
</h3>
<div className="grid gap-8 md:grid-cols-2">
<div className="flex flex-col gap-4">
<p className="text-lg text-white/90 leading-relaxed">
In de Bijbel staat ongedesemd brood (ook wel{" "}
<em className="text-white">matze</em> genoemd) symbool voor
eenvoud en zuiverheid, zonder de &apos;ballast&apos; van
desem.
</p>
<p className="text-lg text-white/80 leading-relaxed">
Het werd gegeten tijdens Pesach als herinnering aan de
haastige uittocht uit Egypte, toen er geen tijd was om brood
te laten rijzen.
</p>
</div>
<div className="flex flex-col gap-4">
<p className="text-lg text-white/80 leading-relaxed">
Bij ons staat het voor kunst zonder franje: geen ingewikkelde
regels of hoge drempels, gewoon authentieke expressie vanuit
het hart.
</p>
<div className="rounded-xl border border-white/30 bg-white/10 p-6">
<p className="font-['Intro',sans-serif] font-semibold text-white text-xl">
Kom vooral ook gewoon kijken!
</p>
<p className="mt-2 text-white/80">
Je hoeft absoluut niet te performen om te genieten van een
inspirerende avond vol muziek, poëzie en andere kunst.
</p>
</div>
</div>
</div>
</div>
{/* FAQ Section */}
{faqQuestions.map((item, idx) => (
<div
key={item.question}
className={`flex flex-col gap-4 ${
@@ -35,12 +164,11 @@ export default function Info() {
<h3 className="font-['Intro',sans-serif] text-4xl text-white">
{item.question}
</h3>
<p className="max-w-xl font-['Intro',sans-serif] text-white/80 text-xl">
{item.answer}
</p>
<p className="max-w-xl text-white/80 text-xl">{item.answer}</p>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -1,5 +1,26 @@
@import "tailwindcss";
/* DM Sans font family - Self hosted */
@font-face {
font-family: "DM Sans";
src: url("/fonts/DMSans-latin.woff2") format("woff2");
font-weight: 400 700;
font-style: normal;
font-display: swap;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "DM Sans";
src: url("/fonts/DMSans-latin-ext.woff2") format("woff2");
font-weight: 400 700;
font-style: normal;
font-display: swap;
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Intro font family - Self hosted */
@font-face {
font-family: "Intro";
@@ -21,11 +42,54 @@
font-display: swap;
}
/* Font theme tokens */
@theme {
--font-body: "DM Sans", sans-serif;
}
body {
font-family: var(--font-body);
}
/* Link hover underline animation */
@utility link-hover {
@apply relative after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 after:bg-current after:transition-[width] after:duration-300 hover:after:w-full;
}
@utility link-hover {
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
height: 2px;
width: 0;
background-color: currentColor;
transition: width 300ms;
}
&:hover::after {
width: 100%;
}
}
/* Smooth scrolling for the entire page */
html {
scroll-behavior: smooth;
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms;
animation-iteration-count: 1;
transition-duration: 0.01ms;
scroll-behavior: auto;
}
}
/* Hide scrollbar but keep functionality */
html {
scrollbar-width: thin;
@@ -45,31 +109,25 @@ html {
border-radius: 3px;
}
/* Sticky scroll container with snap points */
.sticky-scroll-container {
height: 400vh; /* 4 sections x 100vh */
position: relative;
/* ─── Text Selection Colors ─────────────────────────────────────────────────
Each section gets a ::selection style that harmonizes with its background.
The goal: selection feels native to each section, not a browser default.
─────────────────────────────────────────────────────────────────────────── */
/* Default / White backgrounds (Hero, ArtForms) */
::selection {
background-color: #d82560;
color: #ffffff;
}
.sticky-section {
position: sticky;
top: 0;
height: 100vh;
width: 100%;
/* Info section — magenta background (#d82560/96) */
#info ::selection {
background-color: #ffffff;
color: #d82560;
}
/* Scroll snap for the whole page - mandatory snapping to closest section */
html {
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
}
.snap-section {
scroll-snap-align: start;
scroll-snap-stop: always;
}
/* Ensure snap works consistently in both directions */
.snap-section {
scroll-margin-top: 0;
/* Registration section — dark teal background (#214e51/96) */
#registration ::selection {
background-color: #d09035;
color: #1a1a1a;
}

View File

@@ -9,17 +9,35 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as TermsRouteImport } from './routes/terms'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as LoginRouteImport } from './routes/login'
import { Route as ContactRouteImport } from './routes/contact'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
const TermsRoute = TermsRouteImport.update({
id: '/terms',
path: '/terms',
getParentRoute: () => rootRouteImport,
} as any)
const PrivacyRoute = PrivacyRouteImport.update({
id: '/privacy',
path: '/privacy',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const ContactRoute = ContactRouteImport.update({
id: '/contact',
path: '/contact',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
@@ -44,14 +62,20 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/contact': typeof ContactRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/contact': typeof ContactRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
@@ -59,28 +83,73 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/contact': typeof ContactRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/admin' | '/login' | '/api/auth/$' | '/api/rpc/$'
fullPaths:
| '/'
| '/admin'
| '/contact'
| '/login'
| '/privacy'
| '/terms'
| '/api/auth/$'
| '/api/rpc/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/admin' | '/login' | '/api/auth/$' | '/api/rpc/$'
id: '__root__' | '/' | '/admin' | '/login' | '/api/auth/$' | '/api/rpc/$'
to:
| '/'
| '/admin'
| '/contact'
| '/login'
| '/privacy'
| '/terms'
| '/api/auth/$'
| '/api/rpc/$'
id:
| '__root__'
| '/'
| '/admin'
| '/contact'
| '/login'
| '/privacy'
| '/terms'
| '/api/auth/$'
| '/api/rpc/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRoute
ContactRoute: typeof ContactRoute
LoginRoute: typeof LoginRoute
PrivacyRoute: typeof PrivacyRoute
TermsRoute: typeof TermsRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/terms': {
id: '/terms'
path: '/terms'
fullPath: '/terms'
preLoaderRoute: typeof TermsRouteImport
parentRoute: typeof rootRouteImport
}
'/privacy': {
id: '/privacy'
path: '/privacy'
fullPath: '/privacy'
preLoaderRoute: typeof PrivacyRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@@ -88,6 +157,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/contact': {
id: '/contact'
path: '/contact'
fullPath: '/contact'
preLoaderRoute: typeof ContactRouteImport
parentRoute: typeof rootRouteImport
}
'/admin': {
id: '/admin'
path: '/admin'
@@ -122,7 +198,10 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRoute,
ContactRoute: ContactRoute,
LoginRoute: LoginRoute,
PrivacyRoute: PrivacyRoute,
TermsRoute: TermsRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
}

View File

@@ -8,10 +8,18 @@ import {
Scripts,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { CookieConsent } from "@/components/CookieConsent";
import { Toaster } from "@/components/ui/sonner";
import type { orpc } from "@/utils/orpc";
import appCss from "../index.css?url";
const siteUrl = "https://kunstenkamp.be";
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Brood";
const siteDescription =
"Doe mee met de Open Mic Night op 18 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional.";
const eventImage = `${siteUrl}/assets/og-image.jpg`;
export interface RouterAppContext {
orpc: typeof orpc;
queryClient: QueryClient;
@@ -28,7 +36,57 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
content: "width=device-width, initial-scale=1",
},
{
title: "My App",
title: siteTitle,
},
{
name: "description",
content: siteDescription,
},
{
name: "theme-color",
content: "#d82560",
},
// Open Graph
{
property: "og:type",
content: "website",
},
{
property: "og:url",
content: siteUrl,
},
{
property: "og:title",
content: siteTitle,
},
{
property: "og:description",
content: siteDescription,
},
{
property: "og:image",
content: eventImage,
},
{
property: "og:locale",
content: "nl_BE",
},
// Twitter Card
{
name: "twitter:card",
content: "summary_large_image",
},
{
name: "twitter:title",
content: siteTitle,
},
{
name: "twitter:description",
content: siteDescription,
},
{
name: "twitter:image",
content: eventImage,
},
],
links: [
@@ -36,17 +94,58 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
rel: "stylesheet",
href: appCss,
},
{
rel: "canonical",
href: siteUrl,
},
{
rel: "icon",
type: "image/svg+xml",
href: "/favicon.svg",
},
{
rel: "preconnect",
href: "https://analytics.zias.be",
},
],
scripts: [
{
src: "https://analytics.zias.be/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js",
defer: true,
"data-domain": "kunstenkamp.be",
"data-api": "https://analytics.zias.be/api/event",
},
{
children:
"window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }",
},
// JSON-LD Schema for Event
{
type: "application/ld+json",
children: JSON.stringify({
"@context": "https://schema.org",
"@type": "Event",
name: "Kunstenkamp Open Mic Night",
description:
"Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom om zijn of haar talent te tonen.",
startDate: "2026-04-18T19:00:00+02:00",
eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
organizer: {
"@type": "Organization",
name: "Kunstenkamp",
url: siteUrl,
},
image: eventImage,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "EUR",
availability: "https://schema.org/InStock",
validFrom: "2026-03-01T00:00:00+02:00",
},
}),
},
],
}),
@@ -55,13 +154,22 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
function RootDocument() {
return (
<html lang="en">
<html lang="nl">
<head>
<HeadContent />
</head>
<body>
<a
href="#main-content"
className="fixed top-0 left-0 z-[100] -translate-y-full bg-[#d82560] px-4 py-2 text-white transition-transform focus:translate-y-0"
>
Ga naar hoofdinhoud
</a>
<div id="main-content">
<Outlet />
</div>
<Toaster />
<CookieConsent />
<TanStackRouterDevtools position="bottom-left" />
<ReactQueryDevtools position="bottom" buttonPosition="bottom-right" />
<Scripts />

View File

@@ -250,9 +250,7 @@ function AdminPage() {
className="flex items-center justify-between text-sm"
>
<span className="text-white/80">{item.artForm}</span>
<span className="font-['Intro',sans-serif] text-white">
{item.count}
</span>
<span className="text-white">{item.count}</span>
</div>
))}
</div>

View File

@@ -0,0 +1,65 @@
import { createFileRoute, Link } from "@tanstack/react-router";
export const Route = createFileRoute("/contact")({
component: ContactPage,
});
function ContactPage() {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"
>
Terug naar home
</Link>
<h1 className="mb-8 font-['Intro',sans-serif] text-4xl text-white">
Contact
</h1>
<div className="space-y-6 text-white/80">
<section>
<h2 className="mb-3 text-2xl text-white">Heb je vragen?</h2>
<p className="mb-4">
We helpen je graag! Je kunt ons bereiken via de volgende kanalen:
</p>
</section>
<section className="rounded-lg bg-white/5 p-6">
<h3 className="mb-3 text-white text-xl">E-mail</h3>
<a
href="mailto:info@kunstenkamp.be"
className="link-hover text-white/80 hover:text-white"
>
info@kunstenkamp.be
</a>
</section>
<section className="rounded-lg bg-white/5 p-6">
<h3 className="mb-3 text-white text-xl">Open Mic Night</h3>
<p>Vrijdag 18 april 2026</p>
<p>Aanvang: 19:00 uur</p>
<p className="mt-2 text-white/60">
Locatie wordt later bekendgemaakt aan geregistreerde deelnemers.
</p>
</section>
<section className="rounded-lg bg-white/5 p-6">
<h3 className="mb-3 text-white text-xl">Organisatie</h3>
<p>Kunstenkamp vzw</p>
<p className="mt-2 text-white/60">
Een initiatief voor en door kunstenaars.
</p>
</section>
<section className="mt-8">
<p className="text-sm text-white/60">
We proberen je e-mail binnen 48 uur te beantwoorden.
</p>
</section>
</div>
</div>
</div>
);
}

View File

@@ -116,7 +116,7 @@ function LoginPage() {
};
const handleRequestAdmin = () => {
requestAdminMutation.mutate();
requestAdminMutation.mutate(undefined);
};
// If already logged in as admin, redirect

View File

@@ -0,0 +1,75 @@
import { createFileRoute, Link } from "@tanstack/react-router";
export const Route = createFileRoute("/privacy")({
component: PrivacyPage,
});
function PrivacyPage() {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"
>
Terug naar home
</Link>
<h1 className="mb-8 font-['Intro',sans-serif] text-4xl text-white">
Privacybeleid
</h1>
<div className="space-y-6 text-white/80">
<section>
<h2 className="mb-3 text-2xl text-white">
Welke gegevens verzamelen we?
</h2>
<p>
We verzamelen alleen de gegevens die je zelf invoert bij de
registratie: voornaam, achternaam, e-mailadres, telefoonnummer,
kunstvorm en ervaring.
</p>
</section>
<section>
<h2 className="mb-3 text-2xl text-white">
Waarom verzamelen we deze gegevens?
</h2>
<p>
We gebruiken je gegevens om je te kunnen contacteren over de Open
Mic Night, om het programma samen te stellen en om je te
informeren over aanvullende details over het evenement.
</p>
</section>
<section>
<h2 className="mb-3 text-2xl text-white">
Hoe lang bewaren we je gegevens?
</h2>
<p>
Je gegevens worden bewaard tot na het evenement en maximaal 6
maanden daarna, tenzij je vraagt om eerder verwijdering.
</p>
</section>
<section>
<h2 className="mb-3 text-2xl text-white">Je rechten</h2>
<p>
Je hebt het recht om je gegevens in te zien, te corrigeren of te
laten verwijderen. Neem hiervoor contact op via
info@kunstenkamp.be.
</p>
</section>
<section>
<h2 className="mb-3 text-2xl text-white">Cookies</h2>
<p>
We gebruiken alleen analytische cookies om het gebruik van onze
website te verbeteren. Deze cookies bevatten geen persoonlijke
informatie.
</p>
</section>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { createFileRoute, Link } from "@tanstack/react-router";
export const Route = createFileRoute("/terms")({
component: TermsPage,
});
function TermsPage() {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"
>
Terug naar home
</Link>
<h1 className="mb-8 font-['Intro',sans-serif] text-4xl text-white">
Algemene Voorwaarden
</h1>
<div className="space-y-6 text-white/80">
<section>
<h2 className="mb-3 text-2xl text-white">
Deelname aan Open Mic Night
</h2>
<p>
Door je te registreren voor de Open Mic Night ga je akkoord met
deze voorwaarden. Je krijgt 5-7 minuten podiumtijd, afhankelijk
van het aantal deelnemers.
</p>
</section>
<section>
<h2 className="mb-3 text-2xl text-white">Annulering</h2>
<p>
Als je niet kunt komen, geef dit dan minimaal 48 uur van tevoren
door. Zo kunnen we iemand anders de kans geven om op te treden.
</p>
</section>
<section>
<h2 className="mb-3 text-2xl text-white">Gedragsregels</h2>
<p>
We verwachten van alle deelnemers dat ze respectvol omgaan met
elkaar, het publiek en het materiaal. Discriminatie, intimidatie
of agressief gedrag worden niet getolereerd.
</p>
</section>
<section>
<h2 className="mb-3 text-2xl text-white">Foto's en video's</h2>
<p>
Tijdens het evenement kunnen foto's en video's worden gemaakt voor
promotionele doeleinden. Door deel te nemen ga je hiermee akkoord,
tenziij je dit expliciet aangeeft bij de organisatie.
</p>
</section>
<section>
<h2 className="mb-3 text-2xl text-white">Aansprakelijkheid</h2>
<p>
Kunstenkamp is niet aansprakelijk voor verlies of beschadiging van
persoonlijke eigendommen. Je bent zelf verantwoordelijk voor je
eigen materiaal en instrumenten.
</p>
</section>
</div>
</div>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import { adminRequest, registration } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import type { RouterClient } from "@orpc/server";
import { randomUUID } from "crypto";
import { and, count, desc, eq, gte, like, lte } from "drizzle-orm";
import { z } from "zod";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
@@ -12,8 +12,10 @@ const submitRegistrationSchema = z.object({
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
artForm: z.string().min(1),
wantsToPerform: z.boolean().default(false),
artForm: z.string().optional(),
experience: z.string().optional(),
extraQuestions: z.string().optional(),
});
const getRegistrationsSchema = z.object({
@@ -46,8 +48,10 @@ export const appRouter = {
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
artForm: input.artForm,
wantsToPerform: input.wantsToPerform,
artForm: input.artForm || null,
experience: input.experience || null,
extraQuestions: input.extraQuestions || null,
});
return { success: true, id: result.lastInsertRowid };
@@ -149,8 +153,10 @@ export const appRouter = {
"Last Name",
"Email",
"Phone",
"Wants To Perform",
"Art Form",
"Experience",
"Extra Questions",
"Created At",
];
const rows = data.map((r) => [
@@ -159,8 +165,10 @@ export const appRouter = {
r.lastName,
r.email,
r.phone || "",
r.artForm,
r.wantsToPerform ? "Yes" : "No",
r.artForm || "",
r.experience || "",
r.extraQuestions || "",
r.createdAt.toISOString(),
]);

Binary file not shown.

View File

@@ -0,0 +1,24 @@
ALTER TABLE `registration` ADD `wants_to_perform` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `extra_questions` text;--> statement-breakpoint
-- art_form was NOT NULL, make it nullable by recreating the table
-- SQLite does not support DROP NOT NULL directly, so we use the standard rename+recreate approach
CREATE TABLE `registration_new` (
`id` text PRIMARY KEY NOT NULL,
`first_name` text NOT NULL,
`last_name` text NOT NULL,
`email` text NOT NULL,
`phone` text,
`wants_to_perform` integer DEFAULT false NOT NULL,
`art_form` text,
`experience` text,
`extra_questions` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);--> statement-breakpoint
INSERT INTO `registration_new` (`id`, `first_name`, `last_name`, `email`, `phone`, `wants_to_perform`, `art_form`, `experience`, `extra_questions`, `created_at`)
SELECT `id`, `first_name`, `last_name`, `email`, `phone`, `wants_to_perform`, `art_form`, `experience`, `extra_questions`, `created_at`
FROM `registration`;--> statement-breakpoint
DROP TABLE `registration`;--> statement-breakpoint
ALTER TABLE `registration_new` RENAME TO `registration`;--> statement-breakpoint
CREATE INDEX `registration_email_idx` ON `registration` (`email`);--> statement-breakpoint
CREATE INDEX `registration_artForm_idx` ON `registration` (`art_form`);--> statement-breakpoint
CREATE INDEX `registration_createdAt_idx` ON `registration` (`created_at`);

View File

@@ -0,0 +1,558 @@
{
"version": "6",
"dialect": "sqlite",
"id": "dfeebea6-5c6c-4f28-9ecf-daf1f35f23ea",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"admin_request": {
"name": "admin_request",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"requested_at": {
"name": "requested_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"reviewed_at": {
"name": "reviewed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reviewed_by": {
"name": "reviewed_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"admin_request_user_id_unique": {
"name": "admin_request_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
},
"admin_request_userId_idx": {
"name": "admin_request_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"admin_request_status_idx": {
"name": "admin_request_status_idx",
"columns": [
"status"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"registration": {
"name": "registration",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"wants_to_perform": {
"name": "wants_to_perform",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"art_form": {
"name": "art_form",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"experience": {
"name": "experience",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"extra_questions": {
"name": "extra_questions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"registration_email_idx": {
"name": "registration_email_idx",
"columns": [
"email"
],
"isUnique": false
},
"registration_artForm_idx": {
"name": "registration_artForm_idx",
"columns": [
"art_form"
],
"isUnique": false
},
"registration_createdAt_idx": {
"name": "registration_createdAt_idx",
"columns": [
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1772480169471,
"tag": "0000_mean_sunspot",
"breakpoints": true
}
]
}

View File

@@ -9,8 +9,12 @@ export const registration = sqliteTable(
lastName: text("last_name").notNull(),
email: text("email").notNull(),
phone: text("phone"),
artForm: text("art_form").notNull(),
wantsToPerform: integer("wants_to_perform", { mode: "boolean" })
.notNull()
.default(false),
artForm: text("art_form"),
experience: text("experience"),
extraQuestions: text("extra_questions"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),