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() { export default function ArtForms() {
return ( 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"> <div className="mx-auto max-w-6xl">
<h2 className="mb-4 font-['Intro',sans-serif] text-5xl text-[#214e51]"> <h2 className="mb-4 font-['Intro',sans-serif] text-5xl text-[#214e51]">
Kies Je Traject Kies Je Traject
</h2> </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 Kunstenkamp biedt trajecten aan voor verschillende kunstvormen. Ontdek
waar jouw passie ligt en ontwikkel je talent onder begeleiding van waar jouw passie ligt en ontwikkel je talent onder begeleiding van
ervaringsdeskundigen. ervaringsdeskundigen.
</p> </p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <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; const IconComponent = art.icon;
return ( return (
<div <article
key={art.title} 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 */} {/* Color bar at top */}
<div <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 }} style={{ backgroundColor: art.color }}
aria-hidden="true"
/> />
<div className="mb-4 flex items-center gap-4"> <div className="mb-4 flex items-center gap-4">
<div <div
className="flex h-14 w-14 items-center justify-center" className="flex h-14 w-14 items-center justify-center"
style={{ backgroundColor: art.color }} 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> </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} {art.title}
</h3> </h3>
</div> </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} {art.description}
</p> </p>
<div className="flex items-center justify-between border-gray-100 border-t pt-4"> <div className="flex items-center justify-between border-gray-100 border-t pt-4">
<span <span
className="font-['Intro',sans-serif] font-medium text-sm" className="font-medium text-sm"
style={{ color: art.color }} style={{ color: art.color }}
> >
{art.trajectory} {art.trajectory}
</span> </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> </span>
</div> </div>
</div> </article>
); );
})} })}
</div> </div>

View File

@@ -1,82 +1,223 @@
"use client"; "use client";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
interface FormErrors {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
artForm?: string;
experience?: string;
}
export default function EventRegistrationForm() { export default function EventRegistrationForm() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
firstName: "", firstName: "",
lastName: "", lastName: "",
email: "", email: "",
phone: "", phone: "",
wantsToPerform: false,
artForm: "", artForm: "",
experience: "", 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({ const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(), ...orpc.submitRegistration.mutationOptions(),
onSuccess: () => { onSuccess: () => {
toast.success("Registratie succesvol!"); toast.success(
"Registratie succesvol! We nemen binnenkort contact met je op.",
);
setFormData({ setFormData({
firstName: "", firstName: "",
lastName: "", lastName: "",
email: "", email: "",
phone: "", phone: "",
wantsToPerform: false,
artForm: "", artForm: "",
experience: "", experience: "",
extraQuestions: "",
}); });
setErrors({});
setTouched({});
}, },
onError: (error) => { onError: (error) => {
toast.error(`Er is iets misgegaan: ${error.message}`); toast.error(`Er is iets misgegaan: ${error.message}`);
}, },
}); });
const handleSubmit = (e: React.FormEvent) => { const handleChange = useCallback(
e.preventDefault(); (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
submitMutation.mutate({ const { name, value, type } = e.target;
firstName: formData.firstName, const checked = (e.target as HTMLInputElement).checked;
lastName: formData.lastName, const newValue = type === "checkbox" ? checked : value;
email: formData.email,
phone: formData.phone || undefined,
artForm: formData.artForm,
experience: formData.experience || undefined,
});
};
const handleChange = ( setFormData((prev) => {
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, const updated = { ...prev, [name]: newValue };
) => { // Clear performer fields when unchecking wantsToPerform
setFormData((prev) => ({ if (name === "wantsToPerform" && !checked) {
...prev, updated.artForm = "";
[e.target.name]: e.target.value, 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 ( return (
<section <section
id="registration" 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"> <div className="mx-auto flex w-full max-w-6xl flex-col">
<h2 className="mb-2 font-['Intro',sans-serif] text-4xl text-white"> <h2 className="mb-2 font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Schrijf je nu in! Schrijf je nu in!
</h2> </h2>
<p className="mb-12 max-w-3xl font-['Intro',sans-serif] text-white/80 text-xl"> <p className="mb-12 max-w-3xl text-lg text-white/80 md:text-xl">
Doet dit jouw creatieve geest borellen? Vul nog even dit formulier in Doet dit jouw creatieve geest borrelen? Vul nog even dit formulier in
</p> </p>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col"> <form
<div className="flex flex-col gap-8"> 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 */} {/* 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"> <div className="flex flex-col gap-2">
<label <label
htmlFor="firstName" 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> </label>
<input <input
type="text" type="text"
@@ -84,18 +225,33 @@ export default function EventRegistrationForm() {
name="firstName" name="firstName"
value={formData.firstName} value={formData.firstName}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur}
placeholder="Jouw voornaam" placeholder="Jouw voornaam"
required autoComplete="given-name"
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" 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>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label <label
htmlFor="lastName" 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> </label>
<input <input
type="text" type="text"
@@ -103,21 +259,36 @@ export default function EventRegistrationForm() {
name="lastName" name="lastName"
value={formData.lastName} value={formData.lastName}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur}
placeholder="Jouw achternaam" placeholder="Jouw achternaam"
required autoComplete="family-name"
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" 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>
</div> </div>
{/* Row 2: Email + Phone */} {/* 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"> <div className="flex flex-col gap-2">
<label <label
htmlFor="email" 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> </label>
<input <input
type="email" type="email"
@@ -125,16 +296,30 @@ export default function EventRegistrationForm() {
name="email" name="email"
value={formData.email} value={formData.email}
onChange={handleChange} onChange={handleChange}
placeholder="jouw@email.nl" onBlur={handleBlur}
required placeholder="jouw@email.be"
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="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>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label <label
htmlFor="phone" htmlFor="phone"
className="font-['Intro',sans-serif] text-2xl text-white" className="text-white text-xl md:text-2xl"
> >
Telefoon Telefoon
</label> </label>
@@ -144,51 +329,158 @@ export default function EventRegistrationForm() {
name="phone" name="phone"
value={formData.phone} value={formData.phone}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur}
placeholder="06-12345678" 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>
</div> </div>
{/* Row 3: Art Form (full width) */} {/* Row 3: Wants to perform checkbox */}
<div className="flex flex-col gap-2"> <label
<label htmlFor="wantsToPerform"
htmlFor="artForm" className="flex cursor-pointer items-center gap-4"
className="font-['Intro',sans-serif] text-2xl text-white" >
> <div className="relative flex shrink-0 self-center">
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>
<input <input
type="text" type="checkbox"
id="experience" id="wantsToPerform"
name="experience" name="wantsToPerform"
value={formData.experience} checked={formData.wantsToPerform}
onChange={handleChange} onChange={handleChange}
placeholder="Beginner / Gevorderd / Professional" className="peer sr-only"
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 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>
<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>
</div> </div>
@@ -197,17 +489,45 @@ export default function EventRegistrationForm() {
<button <button
type="submit" type="submit"
disabled={submitMutation.isPending} 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> </button>
<p className="text-center font-['Intro',sans-serif] text-sm text-white/60"> <a
Nu al een act / idee van wat jij graag zou willen brengen,{" "} href="/contact"
<span className="cursor-pointer underline hover:text-white"> className="link-hover block text-center text-sm text-white/60 transition-colors hover:text-white"
klik HIER >
</span> Nog vragen? Neem contact op
</p> </a>
</div> </div>
</form> </form>
</div> </div>

View File

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

View File

@@ -12,25 +12,22 @@ export default function Hero() {
}, []); }, []);
const scrollToRegistration = () => { const scrollToRegistration = () => {
const registrationSection = document.getElementById("registration"); document
if (registrationSection) { .getElementById("registration")
// Temporarily disable scroll-snap to prevent snap-back on mobile ?.scrollIntoView({ behavior: "smooth" });
document.documentElement.style.scrollSnapType = "none"; };
registrationSection.scrollIntoView({ behavior: "smooth" });
// Re-enable scroll-snap after smooth scroll completes const scrollToInfo = () => {
setTimeout(() => { document.getElementById("info")?.scrollIntoView({ behavior: "smooth" });
document.documentElement.style.scrollSnapType = "";
}, 800);
}
}; };
return ( 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 */} {/* Desktop Layout - hidden on mobile */}
<div className="relative hidden h-full w-full flex-col gap-[10px] p-[10px] lg:flex"> <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 */} {/* Desktop Microphone - positioned on top of top sections, under bottom sections */}
<div <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 micVisible
? "translate-x-0 translate-y-0 opacity-100" ? "translate-x-0 translate-y-0 opacity-100"
: "translate-x-24 -translate-y-24 opacity-0" : "translate-x-24 -translate-y-24 opacity-0"
@@ -43,8 +40,10 @@ export default function Hero() {
> >
<img <img
src="/assets/mic.png" src="/assets/mic.png"
alt="Vintage microphone" alt="Vintage microfoon - Open Mic Night"
className="h-full w-full object-contain drop-shadow-2xl" className="h-full w-full object-contain drop-shadow-2xl"
loading="eager"
fetchPriority="high"
/> />
</div> </div>
@@ -57,9 +56,13 @@ export default function Hero() {
<br /> <br />
NIGHT NIGHT
</h1> </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 Ongedesemd brood
</p> </button>
</div> </div>
{/* Top Right - Teal - under mic */} {/* Top Right - Teal - under mic */}
@@ -88,13 +91,13 @@ export default function Hero() {
</div> </div>
{/* Desktop CTA Button - centered at bottom */} {/* 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 <button
onClick={scrollToRegistration} onClick={scrollToRegistration}
type="button" 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" 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> </button>
</div> </div>
</div> </div>
@@ -116,7 +119,7 @@ export default function Hero() {
{/* Mobile Microphone - positioned inside magenta section, clipped by overflow */} {/* Mobile Microphone - positioned inside magenta section, clipped by overflow */}
<div <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 micVisible
? "translate-x-0 translate-y-0 opacity-100" ? "translate-x-0 translate-y-0 opacity-100"
: "translate-x-12 -translate-y-12 opacity-0" : "translate-x-12 -translate-y-12 opacity-0"
@@ -130,8 +133,9 @@ export default function Hero() {
> >
<img <img
src="/assets/mic.png" 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" className="h-full w-full object-contain object-bottom drop-shadow-2xl"
loading="lazy"
/> />
</div> </div>
</div> </div>
@@ -162,7 +166,7 @@ export default function Hero() {
type="button" 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" 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> </button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
const questions = [ const faqQuestions = [
{ {
question: "Wat is een open mic?", question: "Wat is een open mic?",
answer: answer:
@@ -23,23 +23,151 @@ const questions = [
export default function Info() { export default function Info() {
return ( 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">
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col justify-center gap-16"> {/* Hero Section - Ongedesemd Brood */}
{questions.map((item, idx) => ( <div className="relative w-full border-white/20 border-b-4">
<div {/* Background pattern */}
key={item.question} <div
className={`flex flex-col gap-4 ${ className="absolute inset-0 opacity-10"
idx % 2 === 0 ? "items-start text-left" : "items-end text-right" 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")`,
> }}
<h3 className="font-['Intro',sans-serif] text-4xl text-white"> />
{item.question}
</h3> <div className="relative flex flex-col items-center justify-center px-8 py-12 text-center">
<p className="max-w-xl font-['Intro',sans-serif] text-white/80 text-xl"> {/* Decorative top element */}
{item.answer} <div className="mb-8 flex items-center gap-4">
</p> <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> </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">
{/* 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 ${
idx % 2 === 0 ? "items-start text-left" : "items-end text-right"
}`}
>
<h3 className="font-['Intro',sans-serif] text-4xl text-white">
{item.question}
</h3>
<p className="max-w-xl text-white/80 text-xl">{item.answer}</p>
</div>
))}
</div>
</div> </div>
</section> </section>
); );

View File

@@ -1,5 +1,26 @@
@import "tailwindcss"; @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 */ /* Intro font family - Self hosted */
@font-face { @font-face {
font-family: "Intro"; font-family: "Intro";
@@ -21,11 +42,54 @@
font-display: swap; 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 */ /* Smooth scrolling for the entire page */
html { html {
scroll-behavior: smooth; 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 */ /* Hide scrollbar but keep functionality */
html { html {
scrollbar-width: thin; scrollbar-width: thin;
@@ -45,31 +109,25 @@ html {
border-radius: 3px; border-radius: 3px;
} }
/* Sticky scroll container with snap points */ /* ─── Text Selection Colors ─────────────────────────────────────────────────
.sticky-scroll-container { Each section gets a ::selection style that harmonizes with its background.
height: 400vh; /* 4 sections x 100vh */ The goal: selection feels native to each section, not a browser default.
position: relative; ─────────────────────────────────────────────────────────────────────────── */
/* Default / White backgrounds (Hero, ArtForms) */
::selection {
background-color: #d82560;
color: #ffffff;
} }
.sticky-section { /* Info section — magenta background (#d82560/96) */
position: sticky; #info ::selection {
top: 0; background-color: #ffffff;
height: 100vh; color: #d82560;
width: 100%;
} }
/* Scroll snap for the whole page - mandatory snapping to closest section */ /* Registration section — dark teal background (#214e51/96) */
html { #registration ::selection {
scroll-snap-type: y mandatory; background-color: #d09035;
scroll-behavior: smooth; color: #1a1a1a;
}
.snap-section {
scroll-snap-align: start;
scroll-snap-stop: always;
}
/* Ensure snap works consistently in both directions */
.snap-section {
scroll-margin-top: 0;
} }

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. // 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 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 LoginRouteImport } from './routes/login'
import { Route as ContactRouteImport } from './routes/contact'
import { Route as AdminRouteImport } from './routes/admin' import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' 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({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
path: '/login', path: '/login',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ContactRoute = ContactRouteImport.update({
id: '/contact',
path: '/contact',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({ const AdminRoute = AdminRouteImport.update({
id: '/admin', id: '/admin',
path: '/admin', path: '/admin',
@@ -44,14 +62,20 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/admin': typeof AdminRoute '/admin': typeof AdminRoute
'/contact': typeof ContactRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/admin': typeof AdminRoute '/admin': typeof AdminRoute
'/contact': typeof ContactRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
@@ -59,28 +83,73 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/admin': typeof AdminRoute '/admin': typeof AdminRoute
'/contact': typeof ContactRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/admin' | '/login' | '/api/auth/$' | '/api/rpc/$' fullPaths:
| '/'
| '/admin'
| '/contact'
| '/login'
| '/privacy'
| '/terms'
| '/api/auth/$'
| '/api/rpc/$'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/admin' | '/login' | '/api/auth/$' | '/api/rpc/$' to:
id: '__root__' | '/' | '/admin' | '/login' | '/api/auth/$' | '/api/rpc/$' | '/'
| '/admin'
| '/contact'
| '/login'
| '/privacy'
| '/terms'
| '/api/auth/$'
| '/api/rpc/$'
id:
| '__root__'
| '/'
| '/admin'
| '/contact'
| '/login'
| '/privacy'
| '/terms'
| '/api/auth/$'
| '/api/rpc/$'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRoute AdminRoute: typeof AdminRoute
ContactRoute: typeof ContactRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
PrivacyRoute: typeof PrivacyRoute
TermsRoute: typeof TermsRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { 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': { '/login': {
id: '/login' id: '/login'
path: '/login' path: '/login'
@@ -88,6 +157,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/contact': {
id: '/contact'
path: '/contact'
fullPath: '/contact'
preLoaderRoute: typeof ContactRouteImport
parentRoute: typeof rootRouteImport
}
'/admin': { '/admin': {
id: '/admin' id: '/admin'
path: '/admin' path: '/admin'
@@ -122,7 +198,10 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AdminRoute: AdminRoute, AdminRoute: AdminRoute,
ContactRoute: ContactRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
PrivacyRoute: PrivacyRoute,
TermsRoute: TermsRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute,
} }

View File

@@ -8,10 +8,18 @@ import {
Scripts, Scripts,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { CookieConsent } from "@/components/CookieConsent";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import type { orpc } from "@/utils/orpc"; import type { orpc } from "@/utils/orpc";
import appCss from "../index.css?url"; 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 { export interface RouterAppContext {
orpc: typeof orpc; orpc: typeof orpc;
queryClient: QueryClient; queryClient: QueryClient;
@@ -28,7 +36,57 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
content: "width=device-width, initial-scale=1", 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: [ links: [
@@ -36,17 +94,58 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
rel: "stylesheet", rel: "stylesheet",
href: appCss, href: appCss,
}, },
{
rel: "canonical",
href: siteUrl,
},
{
rel: "icon",
type: "image/svg+xml",
href: "/favicon.svg",
},
{
rel: "preconnect",
href: "https://analytics.zias.be",
},
], ],
scripts: [ scripts: [
{ {
src: "https://analytics.zias.be/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js", src: "https://analytics.zias.be/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js",
defer: true, defer: true,
"data-domain": "kunstenkamp.be", "data-domain": "kunstenkamp.be",
"data-api": "https://analytics.zias.be/api/event",
}, },
{ {
children: children:
"window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }", "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() { function RootDocument() {
return ( return (
<html lang="en"> <html lang="nl">
<head> <head>
<HeadContent /> <HeadContent />
</head> </head>
<body> <body>
<Outlet /> <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 /> <Toaster />
<CookieConsent />
<TanStackRouterDevtools position="bottom-left" /> <TanStackRouterDevtools position="bottom-left" />
<ReactQueryDevtools position="bottom" buttonPosition="bottom-right" /> <ReactQueryDevtools position="bottom" buttonPosition="bottom-right" />
<Scripts /> <Scripts />

View File

@@ -250,9 +250,7 @@ function AdminPage() {
className="flex items-center justify-between text-sm" className="flex items-center justify-between text-sm"
> >
<span className="text-white/80">{item.artForm}</span> <span className="text-white/80">{item.artForm}</span>
<span className="font-['Intro',sans-serif] text-white"> <span className="text-white">{item.count}</span>
{item.count}
</span>
</div> </div>
))} ))}
</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 = () => { const handleRequestAdmin = () => {
requestAdminMutation.mutate(); requestAdminMutation.mutate(undefined);
}; };
// If already logged in as admin, redirect // 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 { db } from "@kk/db";
import { adminRequest, registration } from "@kk/db/schema"; import { adminRequest, registration } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth"; import { user } from "@kk/db/schema/auth";
import type { RouterClient } from "@orpc/server"; import type { RouterClient } from "@orpc/server";
import { randomUUID } from "crypto";
import { and, count, desc, eq, gte, like, lte } from "drizzle-orm"; import { and, count, desc, eq, gte, like, lte } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index"; import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
@@ -12,8 +12,10 @@ const submitRegistrationSchema = z.object({
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email(), email: z.string().email(),
phone: z.string().optional(), phone: z.string().optional(),
artForm: z.string().min(1), wantsToPerform: z.boolean().default(false),
artForm: z.string().optional(),
experience: z.string().optional(), experience: z.string().optional(),
extraQuestions: z.string().optional(),
}); });
const getRegistrationsSchema = z.object({ const getRegistrationsSchema = z.object({
@@ -46,8 +48,10 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
artForm: input.artForm, wantsToPerform: input.wantsToPerform,
artForm: input.artForm || null,
experience: input.experience || null, experience: input.experience || null,
extraQuestions: input.extraQuestions || null,
}); });
return { success: true, id: result.lastInsertRowid }; return { success: true, id: result.lastInsertRowid };
@@ -149,8 +153,10 @@ export const appRouter = {
"Last Name", "Last Name",
"Email", "Email",
"Phone", "Phone",
"Wants To Perform",
"Art Form", "Art Form",
"Experience", "Experience",
"Extra Questions",
"Created At", "Created At",
]; ];
const rows = data.map((r) => [ const rows = data.map((r) => [
@@ -159,8 +165,10 @@ export const appRouter = {
r.lastName, r.lastName,
r.email, r.email,
r.phone || "", r.phone || "",
r.artForm, r.wantsToPerform ? "Yes" : "No",
r.artForm || "",
r.experience || "", r.experience || "",
r.extraQuestions || "",
r.createdAt.toISOString(), 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(), lastName: text("last_name").notNull(),
email: text("email").notNull(), email: text("email").notNull(),
phone: text("phone"), 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"), experience: text("experience"),
extraQuestions: text("extra_questions"),
createdAt: integer("created_at", { mode: "timestamp_ms" }) createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(), .notNull(),