Compare commits

...

1 Commits

Author SHA1 Message Date
4b0e132b03 feat: add registration management with token-based access
Add management tokens to registrations allowing users to view, edit, and
cancel their registration via a unique URL. Implement email notifications
for confirmations, updates, and cancellations using nodemailer. Simplify
art forms grid from 6 to 4 items and remove trajectory links. Translate
footer links to Dutch and fix matzah spelling in info section.
2026-03-02 22:27:21 +01:00
18 changed files with 2092 additions and 627 deletions

View File

@@ -1,4 +1,4 @@
import { Camera, Drama, Mic2, Music, Palette, PenTool } from "lucide-react"; import { Drama, Music, Palette, PersonStanding } from "lucide-react";
const artForms = [ const artForms = [
{ {
@@ -6,15 +6,13 @@ const artForms = [
title: "Muziek", title: "Muziek",
description: description:
"Van akoestische singer-songwriter sets tot volledige band optredens. Ontdek je sound en deel je muziek met een warm publiek.", "Van akoestische singer-songwriter sets tot volledige band optredens. Ontdek je sound en deel je muziek met een warm publiek.",
trajectory: "Muziek Traject",
color: "#d82560", color: "#d82560",
}, },
{ {
icon: Drama, icon: PersonStanding,
title: "Theater", title: "Dans",
description: description:
"Monologen, sketches, improvisatie of mime. Het podium is van jou — breng karakters tot leven en vertel verhalen die raken.", "Contemporary, ballet, hiphop of freestyle. Beweging vertelt verhalen die woorden niet kunnen vangen.",
trajectory: "Theater Traject",
color: "#52979b", color: "#52979b",
}, },
{ {
@@ -22,33 +20,15 @@ const artForms = [
title: "Beeldende Kunst", title: "Beeldende Kunst",
description: description:
"Live schilderen, illustraties maken, of mixed media performances. Toon je creatieve proces terwijl het publiek toekijkt.", "Live schilderen, illustraties maken, of mixed media performances. Toon je creatieve proces terwijl het publiek toekijkt.",
trajectory: "Beeldende Kunst Traject",
color: "#d09035", color: "#d09035",
}, },
{ {
icon: PenTool, icon: Drama,
title: "Woordkunst", title: "Drama",
description: description:
"Poëzie, spoken word, storytelling of rap. Laat je woorden dansen en raak het publiek met de kracht van taal.", "Monologen, sketches, improvisatie of mime. Het podium is van jou — breng karakters tot leven en vertel verhalen die raken.",
trajectory: "Woordkunst Traject",
color: "#214e51", color: "#214e51",
}, },
{
icon: Camera,
title: "Dans",
description:
"Contemporary, ballet, hiphop of freestyle. Beweging vertelt verhalen die woorden niet kunnen vangen.",
trajectory: "Dans Traject",
color: "#d82560",
},
{
icon: Mic2,
title: "Comedy",
description:
"Stand-up, improv of cabaret. Maak het publiek aan het lachen met je unieke kijk op de wereld.",
trajectory: "Comedy Traject",
color: "#52979b",
},
]; ];
export default function ArtForms() { export default function ArtForms() {
@@ -64,18 +44,18 @@ export default function ArtForms() {
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-4">
{artForms.map((art, index) => { {artForms.map((art, index) => {
const IconComponent = art.icon; const IconComponent = art.icon;
return ( return (
<article <article
key={art.title} key={art.title}
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" className="relative overflow-hidden bg-white p-8"
aria-labelledby={`art-title-${index}`} 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 motion-reduce:transition-none" className="absolute top-0 left-0 h-1 w-full"
style={{ backgroundColor: art.color }} style={{ backgroundColor: art.color }}
aria-hidden="true" aria-hidden="true"
/> />
@@ -102,21 +82,6 @@ export default function ArtForms() {
<p className="mb-6 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">
<span
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 motion-reduce:transition-none"
aria-hidden="true"
>
</span>
</div>
</article> </article>
); );
})} })}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useCallback, 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";
@@ -27,6 +28,7 @@ export default function EventRegistrationForm() {
}); });
const [errors, setErrors] = useState<FormErrors>({}); const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({}); const [touched, setTouched] = useState<Record<string, boolean>>({});
const [successToken, setSuccessToken] = useState<string | null>(null);
const validateField = useCallback( const validateField = useCallback(
( (
@@ -67,10 +69,10 @@ export default function EventRegistrationForm() {
const submitMutation = useMutation({ const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(), ...orpc.submitRegistration.mutationOptions(),
onSuccess: () => { onSuccess: (data) => {
toast.success( if (data.managementToken) {
"Registratie succesvol! We nemen binnenkort contact met je op.", setSuccessToken(data.managementToken);
); }
setFormData({ setFormData({
firstName: "", firstName: "",
lastName: "", lastName: "",
@@ -89,6 +91,10 @@ export default function EventRegistrationForm() {
}, },
}); });
const handleReset = useCallback(() => {
setSuccessToken(null);
}, []);
const handleChange = useCallback( const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target; const { name, value, type } = e.target;
@@ -191,6 +197,76 @@ export default function EventRegistrationForm() {
return `${baseClasses} ${errorClasses}`; return `${baseClasses} ${errorClasses}`;
}; };
const manageUrl = successToken
? `${typeof window !== "undefined" ? window.location.origin : ""}/manage/${successToken}`
: "";
if (successToken) {
return (
<section
id="registration"
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 w-full max-w-6xl flex-col">
<div className="rounded-lg border border-white/20 bg-white/5 p-8 md:p-12">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-500/20 text-green-400">
<svg
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-label="Succes"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="mb-4 font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Gelukt!
</h2>
<p className="mb-6 max-w-2xl text-lg text-white/80">
Je inschrijving is bevestigd. We sturen je zo dadelijk een
bevestigingsmail met alle details.
</p>
<div className="mb-8 rounded-lg border border-white/10 bg-white/5 p-6">
<p className="mb-2 text-sm text-white/60">
Geen mail ontvangen? Controleer je spam-map of gebruik deze
link:
</p>
<a
href={manageUrl}
className="break-all text-sm text-white/80 underline underline-offset-2 hover:text-white"
>
{manageUrl}
</a>
</div>
<div className="flex flex-wrap items-center gap-4">
<a
href={manageUrl}
className="inline-flex items-center bg-white px-6 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100"
>
Bekijk mijn inschrijving
</a>
<button
type="button"
onClick={handleReset}
className="text-white/60 underline underline-offset-2 transition-colors hover:text-white"
>
Nog een inschrijving
</button>
</div>
</div>
</div>
</section>
);
}
return ( return (
<section <section
id="registration" id="registration"

View File

@@ -31,14 +31,14 @@ export default function Footer() {
href="/privacy" href="/privacy"
className="link-hover transition-colors hover:text-white" className="link-hover transition-colors hover:text-white"
> >
Privacy Policy Privacy Beleid
</a> </a>
<span className="text-white/40">|</span> <span className="text-white/40">|</span>
<a <a
href="/terms" href="/terms"
className="link-hover transition-colors hover:text-white" className="link-hover transition-colors hover:text-white"
> >
Terms of Service Algemene Voorwaarden
</a> </a>
<span className="text-white/40">|</span> <span className="text-white/40">|</span>
<a <a
@@ -64,7 +64,12 @@ export default function Footer() {
© {new Date().getFullYear()} Kunstenkamp. Alle rechten voorbehouden. © {new Date().getFullYear()} Kunstenkamp. Alle rechten voorbehouden.
</div> </div>
<div className="text-white/50 text-xs transition-colors hover:text-white"> <div className="text-white/50 text-xs transition-colors hover:text-white">
<a href="https://zias.be" className="link-hover"> <a
href="https://zias.be"
target="_blank"
rel="noopener noreferrer"
className="link-hover"
>
Gemaakt met door zias.be Gemaakt met door zias.be
</a> </a>
</div> </div>

View File

@@ -124,7 +124,7 @@ export default function Info() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-lg text-white/90 leading-relaxed"> <p className="text-lg text-white/90 leading-relaxed">
In de Bijbel staat ongedesemd brood (ook wel{" "} In de Bijbel staat ongedesemd brood (ook wel{" "}
<em className="text-white">matze</em> genoemd) symbool voor <em className="text-white">matzah</em> genoemd) symbool voor
eenvoud en zuiverheid, zonder de &apos;ballast&apos; van eenvoud en zuiverheid, zonder de &apos;ballast&apos; van
desem. desem.
</p> </p>

View File

@@ -15,6 +15,7 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as ContactRouteImport } from './routes/contact' 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 ManageTokenRouteImport } from './routes/manage.$token'
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/$'
@@ -48,6 +49,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ManageTokenRoute = ManageTokenRouteImport.update({
id: '/manage/$token',
path: '/manage/$token',
getParentRoute: () => rootRouteImport,
} as any)
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
id: '/api/rpc/$', id: '/api/rpc/$',
path: '/api/rpc/$', path: '/api/rpc/$',
@@ -66,6 +72,7 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute '/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute '/terms': typeof TermsRoute
'/manage/$token': typeof ManageTokenRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
@@ -76,6 +83,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute '/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute '/terms': typeof TermsRoute
'/manage/$token': typeof ManageTokenRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
@@ -87,6 +95,7 @@ export interface FileRoutesById {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute '/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute '/terms': typeof TermsRoute
'/manage/$token': typeof ManageTokenRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
} }
@@ -99,6 +108,7 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/privacy' | '/privacy'
| '/terms' | '/terms'
| '/manage/$token'
| '/api/auth/$' | '/api/auth/$'
| '/api/rpc/$' | '/api/rpc/$'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -109,6 +119,7 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/privacy' | '/privacy'
| '/terms' | '/terms'
| '/manage/$token'
| '/api/auth/$' | '/api/auth/$'
| '/api/rpc/$' | '/api/rpc/$'
id: id:
@@ -119,6 +130,7 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/privacy' | '/privacy'
| '/terms' | '/terms'
| '/manage/$token'
| '/api/auth/$' | '/api/auth/$'
| '/api/rpc/$' | '/api/rpc/$'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -130,6 +142,7 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
PrivacyRoute: typeof PrivacyRoute PrivacyRoute: typeof PrivacyRoute
TermsRoute: typeof TermsRoute TermsRoute: typeof TermsRoute
ManageTokenRoute: typeof ManageTokenRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute
} }
@@ -178,6 +191,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/manage/$token': {
id: '/manage/$token'
path: '/manage/$token'
fullPath: '/manage/$token'
preLoaderRoute: typeof ManageTokenRouteImport
parentRoute: typeof rootRouteImport
}
'/api/rpc/$': { '/api/rpc/$': {
id: '/api/rpc/$' id: '/api/rpc/$'
path: '/api/rpc/$' path: '/api/rpc/$'
@@ -202,6 +222,7 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
PrivacyRoute: PrivacyRoute, PrivacyRoute: PrivacyRoute,
TermsRoute: TermsRoute, TermsRoute: TermsRoute,
ManageTokenRoute: ManageTokenRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute,
} }

View File

@@ -0,0 +1,430 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useParams } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/manage/$token")({
component: ManageRegistrationPage,
});
function ManageRegistrationPage() {
const { token } = useParams({ from: "/manage/$token" });
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
wantsToPerform: false,
artForm: "",
experience: "",
extraQuestions: "",
});
const { data, isLoading, error } = useQuery({
...orpc.getRegistrationByToken.queryOptions({ input: { token } }),
});
const updateMutation = useMutation({
...orpc.updateRegistration.mutationOptions(),
onSuccess: () => {
toast.success("Inschrijving bijgewerkt!");
queryClient.invalidateQueries({ queryKey: ["getRegistrationByToken"] });
setIsEditing(false);
},
onError: (err) => {
toast.error(`Opslaan mislukt: ${err.message}`);
},
});
const cancelMutation = useMutation({
...orpc.cancelRegistration.mutationOptions(),
onSuccess: () => {
toast.success("Inschrijving geannuleerd");
queryClient.invalidateQueries({ queryKey: ["getRegistrationByToken"] });
},
onError: (err) => {
toast.error(`Annuleren mislukt: ${err.message}`);
},
});
const handleEdit = () => {
if (data) {
setFormData({
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: data.phone || "",
wantsToPerform: data.wantsToPerform ?? false,
artForm: data.artForm || "",
experience: data.experience || "",
extraQuestions: data.extraQuestions || "",
});
setIsEditing(true);
}
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate({
token,
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,
});
};
const handleCancel = () => {
if (
confirm(
"Weet je zeker dat je je inschrijving wilt annuleren? Dit kan niet ongedaan worden gemaakt.",
)
) {
cancelMutation.mutate({ token });
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
<div className="animate-pulse text-white/60">Laden...</div>
</div>
</div>
);
}
if (error || !data || data.cancelledAt) {
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-4 font-['Intro',sans-serif] text-4xl text-white">
Inschrijving niet gevonden
</h1>
<p className="mb-6 text-white/80">
Deze link is ongeldig of de inschrijving is geannuleerd.
</p>
<a
href="/#registration"
className="inline-flex items-center bg-white px-6 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100"
>
Nieuwe inschrijving
</a>
</div>
</div>
);
}
if (isEditing) {
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">
Bewerk inschrijving
</h1>
<form onSubmit={handleSave} className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label htmlFor="firstName" className="mb-2 block text-white">
Voornaam *
</label>
<input
id="firstName"
type="text"
required
value={formData.firstName}
onChange={(e) =>
setFormData((p) => ({ ...p, firstName: e.target.value }))
}
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
/>
</div>
<div>
<label htmlFor="lastName" className="mb-2 block text-white">
Achternaam *
</label>
<input
id="lastName"
type="text"
required
value={formData.lastName}
onChange={(e) =>
setFormData((p) => ({ ...p, lastName: e.target.value }))
}
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
/>
</div>
</div>
<div>
<label htmlFor="email" className="mb-2 block text-white">
E-mail *
</label>
<input
id="email"
type="email"
required
value={formData.email}
onChange={(e) =>
setFormData((p) => ({ ...p, email: e.target.value }))
}
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
/>
</div>
<div>
<label htmlFor="phone" className="mb-2 block text-white">
Telefoon
</label>
<input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) =>
setFormData((p) => ({ ...p, phone: e.target.value }))
}
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
/>
</div>
<label
htmlFor="wantsToPerform"
className="flex cursor-pointer items-center gap-4"
>
<div className="relative flex shrink-0">
<input
id="wantsToPerform"
type="checkbox"
checked={formData.wantsToPerform}
onChange={(e) =>
setFormData((p) => ({
...p,
wantsToPerform: e.target.checked,
artForm: e.target.checked ? p.artForm : "",
experience: e.target.checked ? p.experience : "",
}))
}
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" />
<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-label="Geselecteerd"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<span className="text-white text-xl">Ik wil optreden</span>
</label>
{formData.wantsToPerform && (
<div className="space-y-6 border border-white/20 p-6">
<div>
<label htmlFor="artForm" className="mb-2 block text-white">
Kunstvorm *
</label>
<input
id="artForm"
type="text"
required={formData.wantsToPerform}
value={formData.artForm}
onChange={(e) =>
setFormData((p) => ({
...p,
artForm: e.target.value,
}))
}
list="artFormSuggestions"
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
/>
<datalist id="artFormSuggestions">
<option value="Muziek" />
<option value="Theater" />
<option value="Dans" />
<option value="Beeldende Kunst" />
<option value="Woordkunst" />
<option value="Comedy" />
</datalist>
</div>
<div>
<label htmlFor="experience" className="mb-2 block text-white">
Ervaring
</label>
<input
id="experience"
type="text"
value={formData.experience}
onChange={(e) =>
setFormData((p) => ({
...p,
experience: e.target.value,
}))
}
list="experienceSuggestions"
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
/>
<datalist id="experienceSuggestions">
<option value="Beginner" />
<option value="Gevorderd" />
<option value="Professional" />
</datalist>
</div>
</div>
)}
<div>
<label htmlFor="extraQuestions" className="mb-2 block text-white">
Vragen of opmerkingen
</label>
<textarea
id="extraQuestions"
rows={4}
value={formData.extraQuestions}
onChange={(e) =>
setFormData((p) => ({
...p,
extraQuestions: e.target.value,
}))
}
className="w-full resize-none bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:outline-none"
/>
</div>
<div className="flex flex-wrap items-center gap-4 pt-4">
<button
type="submit"
disabled={updateMutation.isPending}
className="bg-white px-8 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100 disabled:opacity-50"
>
{updateMutation.isPending ? "Opslaan..." : "Opslaan"}
</button>
<button
type="button"
onClick={() => setIsEditing(false)}
className="text-white/60 underline underline-offset-2 transition-colors hover:text-white"
>
Annuleren
</button>
</div>
</form>
</div>
</div>
);
}
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-2 font-['Intro',sans-serif] text-4xl text-white">
Jouw inschrijving
</h1>
<p className="mb-8 text-white/60">
Open Mic Night vrijdag 18 april 2026
</p>
<div className="space-y-6 rounded-lg border border-white/20 bg-white/5 p-6 md:p-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<p className="text-sm text-white/50">Voornaam</p>
<p className="text-lg text-white">{data.firstName}</p>
</div>
<div>
<p className="text-sm text-white/50">Achternaam</p>
<p className="text-lg text-white">{data.lastName}</p>
</div>
<div>
<p className="text-sm text-white/50">E-mail</p>
<p className="text-lg text-white">{data.email}</p>
</div>
<div>
<p className="text-sm text-white/50">Telefoon</p>
<p className="text-lg text-white">{data.phone || "—"}</p>
</div>
</div>
<div className="border-white/10 border-t pt-6">
<p className="mb-2 text-sm text-white/50">Rol</p>
<p className="text-lg text-white">
{data.wantsToPerform
? `Optreden${data.artForm ? `${data.artForm}` : ""}`
: "Toeschouwer"}
</p>
{data.wantsToPerform && data.experience && (
<p className="mt-1 text-white/60">{data.experience}</p>
)}
</div>
{data.extraQuestions && (
<div className="border-white/10 border-t pt-6">
<p className="mb-2 text-sm text-white/50">
Vragen of opmerkingen
</p>
<p className="text-white">{data.extraQuestions}</p>
</div>
)}
</div>
<div className="mt-8 flex flex-wrap items-center gap-4">
<button
type="button"
onClick={handleEdit}
className="bg-white px-8 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100"
>
Bewerken
</button>
<button
type="button"
onClick={handleCancel}
disabled={cancelMutation.isPending}
className="border border-red-400/50 px-8 py-3 text-lg text-red-400 transition-all hover:bg-red-400/10 disabled:opacity-50"
>
{cancelMutation.isPending
? "Annuleren..."
: "Inschrijving annuleren"}
</button>
</div>
<p className="mt-8 text-sm text-white/40">
Deze link is uniek voor jouw inschrijving. Bewaar deze pagina of de
bevestigingsmail om je gegevens later aan te passen.
</p>
</div>
</div>
);
}

View File

@@ -16,4 +16,5 @@ export default defineConfig({
server: { server: {
port: 3001, port: 3001,
}, },
envDir: "../../packages/env",
}); });

View File

@@ -89,10 +89,12 @@
"@orpc/zod": "catalog:", "@orpc/zod": "catalog:",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"nodemailer": "^8.0.1",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@kk/config": "workspace:*", "@kk/config": "workspace:*",
"@types/nodemailer": "^7.0.11",
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
@@ -909,6 +911,8 @@
"@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
"@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
@@ -1481,6 +1485,8 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],

View File

@@ -20,10 +20,12 @@
"@orpc/zod": "catalog:", "@orpc/zod": "catalog:",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"nodemailer": "^8.0.1",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@kk/config": "workspace:*", "@kk/config": "workspace:*",
"@types/nodemailer": "^7.0.11",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }

286
packages/api/src/email.ts Normal file
View File

@@ -0,0 +1,286 @@
import { env } from "@kk/env/server";
import nodemailer from "nodemailer";
function createTransport() {
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
return null;
}
return nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
});
}
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be";
function registrationConfirmationHtml(params: {
firstName: string;
manageUrl: string;
wantsToPerform: boolean;
artForm?: string | null;
}) {
const role = params.wantsToPerform
? `Optreden${params.artForm ? `${params.artForm}` : ""}`
: "Toeschouwer";
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bevestiging inschrijving</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<!-- Header -->
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Je inschrijving is bevestigd!</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
We hebben je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> in goede orde ontvangen.
</p>
<!-- Registration summary -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 8px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Jouw rol</p>
<p style="margin:0;font-size:18px;color:#ffffff;font-weight:600;">${role}</p>
</td>
</tr>
</table>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet.
</p>
<!-- CTA button -->
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${params.manageUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Beheer mijn inschrijving
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Of kopieer deze link: <span style="color:rgba(255,255,255,0.6);">${params.manageUrl}</span>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function updateConfirmationHtml(params: {
firstName: string;
manageUrl: string;
wantsToPerform: boolean;
artForm?: string | null;
}) {
const role = params.wantsToPerform
? `Optreden${params.artForm ? `${params.artForm}` : ""}`
: "Toeschouwer";
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<title>Inschrijving bijgewerkt</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Inschrijving bijgewerkt</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> is succesvol bijgewerkt.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 8px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Jouw rol</p>
<p style="margin:0;font-size:18px;color:#ffffff;font-weight:600;">${role}</p>
</td>
</tr>
</table>
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${params.manageUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Bekijk mijn inschrijving
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function cancellationHtml(params: { firstName: string }) {
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<title>Inschrijving geannuleerd</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Inschrijving geannuleerd</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 40px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> is geannuleerd.
</p>
<p style="margin:0;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven via <a href="${baseUrl}/#registration" style="color:#ffffff;">kunstenkamp.be</a>.
</p>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export async function sendConfirmationEmail(params: {
to: string;
firstName: string;
managementToken: string;
wantsToPerform: boolean;
artForm?: string | null;
}) {
const transport = createTransport();
if (!transport) {
console.warn("SMTP not configured — skipping confirmation email");
return;
}
const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
await transport.sendMail({
from,
to: params.to,
subject: "Bevestiging inschrijving — Open Mic Night",
html: registrationConfirmationHtml({
firstName: params.firstName,
manageUrl,
wantsToPerform: params.wantsToPerform,
artForm: params.artForm,
}),
});
}
export async function sendUpdateEmail(params: {
to: string;
firstName: string;
managementToken: string;
wantsToPerform: boolean;
artForm?: string | null;
}) {
const transport = createTransport();
if (!transport) {
console.warn("SMTP not configured — skipping update email");
return;
}
const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
await transport.sendMail({
from,
to: params.to,
subject: "Inschrijving bijgewerkt — Open Mic Night",
html: updateConfirmationHtml({
firstName: params.firstName,
manageUrl,
wantsToPerform: params.wantsToPerform,
artForm: params.artForm,
}),
});
}
export async function sendCancellationEmail(params: {
to: string;
firstName: string;
}) {
const transport = createTransport();
if (!transport) {
console.warn("SMTP not configured — skipping cancellation email");
return;
}
await transport.sendMail({
from,
to: params.to,
subject: "Inschrijving geannuleerd — Open Mic Night",
html: cancellationHtml({ firstName: params.firstName }),
});
}

View File

@@ -3,8 +3,13 @@ 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 { and, count, desc, eq, gte, like, lte } from "drizzle-orm"; import { and, count, desc, eq, gte, isNull, like, lte } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import {
sendCancellationEmail,
sendConfirmationEmail,
sendUpdateEmail,
} from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index"; import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
const submitRegistrationSchema = z.object({ const submitRegistrationSchema = z.object({
@@ -42,7 +47,8 @@ export const appRouter = {
submitRegistration: publicProcedure submitRegistration: publicProcedure
.input(submitRegistrationSchema) .input(submitRegistrationSchema)
.handler(async ({ input }) => { .handler(async ({ input }) => {
const result = await db.insert(registration).values({ const managementToken = randomUUID();
await db.insert(registration).values({
id: randomUUID(), id: randomUUID(),
firstName: input.firstName, firstName: input.firstName,
lastName: input.lastName, lastName: input.lastName,
@@ -52,9 +58,122 @@ export const appRouter = {
artForm: input.artForm || null, artForm: input.artForm || null,
experience: input.experience || null, experience: input.experience || null,
extraQuestions: input.extraQuestions || null, extraQuestions: input.extraQuestions || null,
managementToken,
}); });
return { success: true, id: result.lastInsertRowid }; await sendConfirmationEmail({
to: input.email,
firstName: input.firstName,
managementToken,
wantsToPerform: input.wantsToPerform,
artForm: input.artForm,
}).catch((err) =>
console.error("Failed to send confirmation email:", err),
);
return { success: true, managementToken };
}),
getRegistrationByToken: publicProcedure
.input(z.object({ token: z.string().uuid() }))
.handler(async ({ input }) => {
const rows = await db
.select()
.from(registration)
.where(eq(registration.managementToken, input.token))
.limit(1);
const row = rows[0];
if (!row) throw new Error("Inschrijving niet gevonden");
if (row.cancelledAt) throw new Error("Deze inschrijving is geannuleerd");
return row;
}),
updateRegistration: publicProcedure
.input(
z.object({
token: z.string().uuid(),
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
wantsToPerform: z.boolean().default(false),
artForm: z.string().optional(),
experience: z.string().optional(),
extraQuestions: z.string().optional(),
}),
)
.handler(async ({ input }) => {
const rows = await db
.select()
.from(registration)
.where(
and(
eq(registration.managementToken, input.token),
isNull(registration.cancelledAt),
),
)
.limit(1);
const row = rows[0];
if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd");
await db
.update(registration)
.set({
firstName: input.firstName,
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
wantsToPerform: input.wantsToPerform,
artForm: input.artForm || null,
experience: input.experience || null,
extraQuestions: input.extraQuestions || null,
})
.where(eq(registration.managementToken, input.token));
await sendUpdateEmail({
to: input.email,
firstName: input.firstName,
managementToken: input.token,
wantsToPerform: input.wantsToPerform,
artForm: input.artForm,
}).catch((err) => console.error("Failed to send update email:", err));
return { success: true };
}),
cancelRegistration: publicProcedure
.input(z.object({ token: z.string().uuid() }))
.handler(async ({ input }) => {
const rows = await db
.select()
.from(registration)
.where(
and(
eq(registration.managementToken, input.token),
isNull(registration.cancelledAt),
),
)
.limit(1);
const row = rows[0];
if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd");
await db
.update(registration)
.set({ cancelledAt: new Date() })
.where(eq(registration.managementToken, input.token));
await sendCancellationEmail({
to: row.email,
firstName: row.firstName,
}).catch((err) =>
console.error("Failed to send cancellation email:", err),
);
return { success: true };
}), }),
getRegistrations: adminProcedure getRegistrations: adminProcedure

View File

@@ -0,0 +1,4 @@
ALTER TABLE `registration` ADD `management_token` text;--> statement-breakpoint
ALTER TABLE `registration` ADD `cancelled_at` integer;--> statement-breakpoint
CREATE UNIQUE INDEX `registration_management_token_unique` ON `registration` (`management_token`);--> statement-breakpoint
CREATE INDEX `registration_managementToken_idx` ON `registration` (`management_token`);

View File

@@ -55,23 +55,17 @@
"indexes": { "indexes": {
"admin_request_user_id_unique": { "admin_request_user_id_unique": {
"name": "admin_request_user_id_unique", "name": "admin_request_user_id_unique",
"columns": [ "columns": ["user_id"],
"user_id"
],
"isUnique": true "isUnique": true
}, },
"admin_request_userId_idx": { "admin_request_userId_idx": {
"name": "admin_request_userId_idx", "name": "admin_request_userId_idx",
"columns": [ "columns": ["user_id"],
"user_id"
],
"isUnique": false "isUnique": false
}, },
"admin_request_status_idx": { "admin_request_status_idx": {
"name": "admin_request_status_idx", "name": "admin_request_status_idx",
"columns": [ "columns": ["status"],
"status"
],
"isUnique": false "isUnique": false
} }
}, },
@@ -179,9 +173,7 @@
"indexes": { "indexes": {
"account_userId_idx": { "account_userId_idx": {
"name": "account_userId_idx", "name": "account_userId_idx",
"columns": [ "columns": ["user_id"],
"user_id"
],
"isUnique": false "isUnique": false
} }
}, },
@@ -190,12 +182,8 @@
"name": "account_user_id_user_id_fk", "name": "account_user_id_user_id_fk",
"tableFrom": "account", "tableFrom": "account",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -268,16 +256,12 @@
"indexes": { "indexes": {
"session_token_unique": { "session_token_unique": {
"name": "session_token_unique", "name": "session_token_unique",
"columns": [ "columns": ["token"],
"token"
],
"isUnique": true "isUnique": true
}, },
"session_userId_idx": { "session_userId_idx": {
"name": "session_userId_idx", "name": "session_userId_idx",
"columns": [ "columns": ["user_id"],
"user_id"
],
"isUnique": false "isUnique": false
} }
}, },
@@ -286,12 +270,8 @@
"name": "session_user_id_user_id_fk", "name": "session_user_id_user_id_fk",
"tableFrom": "session", "tableFrom": "session",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -367,9 +347,7 @@
"indexes": { "indexes": {
"user_email_unique": { "user_email_unique": {
"name": "user_email_unique", "name": "user_email_unique",
"columns": [ "columns": ["email"],
"email"
],
"isUnique": true "isUnique": true
} }
}, },
@@ -429,9 +407,7 @@
"indexes": { "indexes": {
"verification_identifier_idx": { "verification_identifier_idx": {
"name": "verification_identifier_idx", "name": "verification_identifier_idx",
"columns": [ "columns": ["identifier"],
"identifier"
],
"isUnique": false "isUnique": false
} }
}, },
@@ -519,23 +495,17 @@
"indexes": { "indexes": {
"registration_email_idx": { "registration_email_idx": {
"name": "registration_email_idx", "name": "registration_email_idx",
"columns": [ "columns": ["email"],
"email"
],
"isUnique": false "isUnique": false
}, },
"registration_artForm_idx": { "registration_artForm_idx": {
"name": "registration_artForm_idx", "name": "registration_artForm_idx",
"columns": [ "columns": ["art_form"],
"art_form"
],
"isUnique": false "isUnique": false
}, },
"registration_createdAt_idx": { "registration_createdAt_idx": {
"name": "registration_createdAt_idx", "name": "registration_createdAt_idx",
"columns": [ "columns": ["created_at"],
"created_at"
],
"isUnique": false "isUnique": false
} }
}, },

View File

@@ -0,0 +1,552 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d9a4f07e-e6ae-45d0-be82-c919ae7fbe09",
"prevId": "dfeebea6-5c6c-4f28-9ecf-daf1f35f23ea",
"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
},
"management_token": {
"name": "management_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cancelled_at": {
"name": "cancelled_at",
"type": "integer",
"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_management_token_unique": {
"name": "registration_management_token_unique",
"columns": ["management_token"],
"isUnique": true
},
"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
},
"registration_managementToken_idx": {
"name": "registration_managementToken_idx",
"columns": ["management_token"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1772480169471, "when": 1772480169471,
"tag": "0000_mean_sunspot", "tag": "0000_mean_sunspot",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1772480778632,
"tag": "0001_third_stark_industries",
"breakpoints": true
} }
] ]
} }

View File

@@ -15,6 +15,8 @@ export const registration = sqliteTable(
artForm: text("art_form"), artForm: text("art_form"),
experience: text("experience"), experience: text("experience"),
extraQuestions: text("extra_questions"), extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(),
cancelledAt: integer("cancelled_at", { mode: "timestamp_ms" }),
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(),
@@ -23,5 +25,6 @@ export const registration = sqliteTable(
index("registration_email_idx").on(table.email), index("registration_email_idx").on(table.email),
index("registration_artForm_idx").on(table.artForm), index("registration_artForm_idx").on(table.artForm),
index("registration_createdAt_idx").on(table.createdAt), index("registration_createdAt_idx").on(table.createdAt),
index("registration_managementToken_idx").on(table.managementToken),
], ],
); );

View File

@@ -1,13 +1,26 @@
import "dotenv/config"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createEnv } from "@t3-oss/env-core"; import { createEnv } from "@t3-oss/env-core";
import { config } from "dotenv";
import { z } from "zod"; import { z } from "zod";
// Only load .env file in development (not in Cloudflare Workers)
if (process.env.NODE_ENV !== "production") {
const __dirname = dirname(fileURLToPath(import.meta.url));
config({ path: resolve(__dirname, "../.env") });
}
export const env = createEnv({ export const env = createEnv({
server: { server: {
DATABASE_URL: z.string().min(1), DATABASE_URL: z.string().min(1),
BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.url(), BETTER_AUTH_URL: z.url(),
CORS_ORIGIN: z.url(), CORS_ORIGIN: z.url(),
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.coerce.number().default(587),
SMTP_USER: z.string().min(1).optional(),
SMTP_PASS: z.string().min(1).optional(),
SMTP_FROM: z.string().min(1).optional(),
NODE_ENV: z NODE_ENV: z
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),

View File

@@ -3,7 +3,7 @@ import { TanStackStart } from "alchemy/cloudflare";
import { config } from "dotenv"; import { config } from "dotenv";
config({ path: "./.env" }); config({ path: "./.env" });
config({ path: "../../apps/web/.env" }); config({ path: "../env/.env" });
const app = await alchemy("kk"); const app = await alchemy("kk");
@@ -23,6 +23,11 @@ export const web = await TanStackStart("web", {
CORS_ORIGIN: getEnvVar("CORS_ORIGIN"), CORS_ORIGIN: getEnvVar("CORS_ORIGIN"),
BETTER_AUTH_SECRET: getEnvVar("BETTER_AUTH_SECRET"), BETTER_AUTH_SECRET: getEnvVar("BETTER_AUTH_SECRET"),
BETTER_AUTH_URL: getEnvVar("BETTER_AUTH_URL"), BETTER_AUTH_URL: getEnvVar("BETTER_AUTH_URL"),
SMTP_HOST: getEnvVar("SMTP_HOST"),
SMTP_PORT: getEnvVar("SMTP_PORT"),
SMTP_USER: getEnvVar("SMTP_USER"),
SMTP_PASS: getEnvVar("SMTP_PASS"),
SMTP_FROM: getEnvVar("SMTP_FROM"),
}, },
domains: ["kunstenkamp.be"], domains: ["kunstenkamp.be"],
}); });