Compare commits

..

1 Commits

Author SHA1 Message Date
cf47f25a4d feat: gate registration behind 16 March 2026 19:00 opening date
- Add REGISTRATION_OPENS_AT const in lib/opening.ts as single source of truth
- Add useRegistrationOpen hook with live 1s countdown ticker
- Add CountdownBanner component (DD/HH/MM/SS) with canvas-confetti on open
- EventRegistrationForm shows countdown instead of form while closed
- Login page hides/disables signup while closed; login always available
- WatcherForm AccountModal guards signUp call as defence-in-depth

Closes #2
2026-03-10 13:18:30 +01:00
30 changed files with 842 additions and 1135 deletions

View File

@@ -30,6 +30,7 @@
"@tanstack/react-start": "^1.141.1",
"@tanstack/router-plugin": "^1.141.1",
"better-auth": "catalog:",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "catalog:",
@@ -56,6 +57,7 @@
"@tanstack/react-router-devtools": "^1.141.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/canvas-confetti": "^1.9.0",
"@types/qrcode": "^1.5.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",

View File

@@ -0,0 +1,119 @@
import confetti from "canvas-confetti";
import { useEffect, useRef } from "react";
import { REGISTRATION_OPENS_AT } from "@/lib/opening";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
function pad(n: number): string {
return String(n).padStart(2, "0");
}
interface UnitBoxProps {
value: string;
label: string;
}
function UnitBox({ value, label }: UnitBoxProps) {
return (
<div className="flex flex-col items-center gap-1">
<div className="flex items-center justify-center rounded-sm bg-white/10 px-4 py-3 font-['Intro',sans-serif] text-5xl text-white tabular-nums md:text-6xl lg:text-7xl">
{value}
</div>
<span className="font-['Intro',sans-serif] text-white/50 text-xs uppercase tracking-widest">
{label}
</span>
</div>
);
}
function fireConfetti() {
const colors = ["#d82560", "#52979b", "#d09035", "#214e51", "#ffffff"];
// Three bursts from different origins
confetti({
particleCount: 120,
spread: 80,
origin: { x: 0.3, y: 0.6 },
colors,
});
setTimeout(() => {
confetti({
particleCount: 120,
spread: 80,
origin: { x: 0.7, y: 0.6 },
colors,
});
}, 200);
setTimeout(() => {
confetti({
particleCount: 80,
spread: 100,
origin: { x: 0.5, y: 0.5 },
colors,
});
}, 400);
}
/**
* Shown in place of the registration form while registration is not yet open.
* Fires confetti the moment the gate opens (without a page reload).
*/
export function CountdownBanner() {
const { isOpen, days, hours, minutes, seconds } = useRegistrationOpen();
const confettiFired = useRef(false);
useEffect(() => {
if (isOpen && !confettiFired.current) {
confettiFired.current = true;
fireConfetti();
}
}, [isOpen]);
// Once open the parent component will unmount this — but render nothing just in case
if (isOpen) return null;
const openDate = REGISTRATION_OPENS_AT.toLocaleDateString("nl-BE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
const openTime = REGISTRATION_OPENS_AT.toLocaleTimeString("nl-BE", {
hour: "2-digit",
minute: "2-digit",
});
return (
<div className="flex flex-col items-center gap-8 py-4 text-center">
<div>
<p className="font-['Intro',sans-serif] text-lg text-white/70 md:text-xl">
Inschrijvingen openen op
</p>
<p className="mt-1 font-['Intro',sans-serif] text-white text-xl capitalize md:text-2xl">
{openDate} om {openTime}
</p>
</div>
{/* Countdown boxes */}
<div className="flex flex-wrap items-center justify-center gap-3 md:gap-6">
<UnitBox value={String(days)} label="dagen" />
<span className="mb-6 font-['Intro',sans-serif] text-4xl text-white/40">
:
</span>
<UnitBox value={pad(hours)} label="uren" />
<span className="mb-6 font-['Intro',sans-serif] text-4xl text-white/40">
:
</span>
<UnitBox value={pad(minutes)} label="minuten" />
<span className="mb-6 font-['Intro',sans-serif] text-4xl text-white/40">
:
</span>
<UnitBox value={pad(seconds)} label="seconden" />
</div>
<p className="max-w-md text-sm text-white/50">
Kom snel terug! Zodra de inschrijvingen openen kun je je hier
registreren als toeschouwer of als artiest.
</p>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import { CountdownBanner } from "@/components/homepage/CountdownBanner";
import { PerformerForm } from "@/components/registration/PerformerForm";
import { SuccessScreen } from "@/components/registration/SuccessScreen";
import { TypeSelector } from "@/components/registration/TypeSelector";
import { WatcherForm } from "@/components/registration/WatcherForm";
import { authClient } from "@/lib/auth-client";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
type RegistrationType = "performer" | "watcher";
@@ -16,6 +18,7 @@ interface SuccessState {
export default function EventRegistrationForm() {
const { data: session } = authClient.useSession();
const { isOpen } = useRegistrationOpen();
const [selectedType, setSelectedType] = useState<RegistrationType | null>(
null,
);
@@ -36,7 +39,6 @@ export default function EventRegistrationForm() {
token={successState.token}
email={successState.email}
name={successState.name}
isLoggedIn={isLoggedIn}
onReset={() => {
setSuccessState(null);
setSelectedType(null);
@@ -60,55 +62,62 @@ export default function EventRegistrationForm() {
<p className="mb-8 max-w-3xl text-lg text-white/80 md:text-xl">
Doe je mee of kom je kijken? Kies je rol en vul het formulier in.
</p>
{/* Login nudge — shown only to guests */}
{!isLoggedIn && (
<div className="mb-10 flex flex-col gap-3 rounded-lg border border-[#52979b]/40 bg-[#52979b]/10 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/80">
<span className="mr-1 font-semibold text-teal-300">
Al een account?
</span>
Log in om je gegevens automatisch in te vullen en je Drinkkaart
direct te activeren na registratie.
</p>
<div className="flex shrink-0 items-center gap-3 text-sm">
<Link
to="/login"
className="font-medium text-teal-300 underline-offset-2 transition-colors hover:underline"
>
Inloggen
</Link>
<span className="text-white/30">·</span>
<Link
to="/login"
search={{ signup: "1" }}
className="text-white/60 transition-colors hover:text-white"
>
Nog geen account? Aanmaken
</Link>
</div>
</div>
)}
{!selectedType && <TypeSelector onSelect={setSelectedType} />}
{selectedType === "performer" && (
<PerformerForm
onBack={() => setSelectedType(null)}
onSuccess={(token, email, name) =>
setSuccessState({ token, email, name })
}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/>
)}
{selectedType === "watcher" && (
<WatcherForm
onBack={() => setSelectedType(null)}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/>
{!isOpen ? (
<CountdownBanner />
) : (
<>
{/* Login nudge — shown only to guests */}
{!isLoggedIn && (
<div className="mb-10 flex flex-col gap-3 rounded-lg border border-[#52979b]/40 bg-[#52979b]/10 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/80">
<span className="mr-1 font-semibold text-teal-300">
Al een account?
</span>
Log in om je gegevens automatisch in te vullen en je
Drinkkaart direct te activeren na registratie.
</p>
<div className="flex shrink-0 items-center gap-3 text-sm">
<Link
to="/login"
className="font-medium text-teal-300 underline-offset-2 transition-colors hover:underline"
>
Inloggen
</Link>
<span className="text-white/30">·</span>
<Link
to="/login"
search={{ signup: "1" }}
className="text-white/60 transition-colors hover:text-white"
>
Nog geen account? Aanmaken
</Link>
</div>
</div>
)}
{!selectedType && <TypeSelector onSelect={setSelectedType} />}
{selectedType === "performer" && (
<PerformerForm
onBack={() => setSelectedType(null)}
onSuccess={(token, email, name) =>
setSuccessState({ token, email, name })
}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/>
)}
{selectedType === "watcher" && (
<WatcherForm
onBack={() => setSelectedType(null)}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/>
)}
</>
)}
</div>
</section>

View File

@@ -83,7 +83,7 @@ export default function Hero() {
{/* Bottom Right - Dark Teal with date - above mic */}
<div className="relative flex flex-1 items-center justify-end bg-[#214e51]">
<p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(2rem,5vw,6rem)] text-white uppercase leading-[1.1]">
VRIJDAG 24
VRIJDAG 18
<br />
april
</p>
@@ -153,7 +153,7 @@ export default function Hero() {
<p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight">
VRIJDAG
<br />
24 april
18 april
</p>
</div>
</div>

View File

@@ -204,51 +204,6 @@ export function GuestList({
)}
</div>
<div className="flex flex-col gap-2">
<label
htmlFor={`guest-${idx}-birthdate`}
className="text-white/80"
>
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id={`guest-${idx}-birthdate`}
value={guest.birthdate}
onChange={(e) => onChange(idx, "birthdate", e.target.value)}
autoComplete="off"
className={inputCls(!!errors[idx]?.birthdate)}
/>
{errors[idx]?.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].birthdate}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label
htmlFor={`guest-${idx}-postcode`}
className="text-white/80"
>
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id={`guest-${idx}-postcode`}
value={guest.postcode}
onChange={(e) => onChange(idx, "postcode", e.target.value)}
placeholder="1234 AB"
autoComplete="off"
className={inputCls(!!errors[idx]?.postcode)}
/>
{errors[idx]?.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].postcode}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`guest-${idx}-email`} className="text-white/80">
E-mail

View File

@@ -15,8 +15,6 @@ interface PerformerErrors {
lastName?: string;
email?: string;
phone?: string;
postcode?: string;
birthdate?: string;
artForm?: string;
isOver16?: string;
}
@@ -43,8 +41,6 @@ export function PerformerForm({
lastName: prefillLastName,
email: prefillEmail,
phone: "",
postcode: "",
birthdate: "",
artForm: "",
experience: "",
isOver16: false,
@@ -75,8 +71,6 @@ export function PerformerForm({
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined,
isOver16: !data.isOver16
? "Je moet 16 jaar of ouder zijn om op te treden"
@@ -88,8 +82,6 @@ export function PerformerForm({
lastName: true,
email: true,
phone: true,
postcode: true,
birthdate: true,
artForm: true,
isOver16: true,
});
@@ -118,14 +110,6 @@ export function PerformerForm({
),
email: validateEmail(name === "email" ? value : data.email),
phone: validatePhone(name === "phone" ? value : data.phone),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
artForm:
name === "artForm" && !value.trim()
? "Kunstvorm is verplicht"
@@ -145,14 +129,6 @@ export function PerformerForm({
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
artForm: !value.trim() ? "Kunstvorm is verplicht" : undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
@@ -169,8 +145,6 @@ export function PerformerForm({
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "performer",
artForm: data.artForm.trim() || undefined,
experience: data.experience.trim() || undefined,
@@ -332,54 +306,6 @@ export function PerformerForm({
</div>
</div>
{/* Postcode + Birthdate row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="p-postcode" className="text-white text-xl">
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id="p-postcode"
name="postcode"
value={data.postcode}
onChange={handleChange}
onBlur={handleBlur}
placeholder="1234 AB"
autoComplete="postal-code"
aria-required="true"
aria-invalid={touched.postcode && !!errors.postcode}
className={inputCls(!!touched.postcode && !!errors.postcode)}
/>
{touched.postcode && errors.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors.postcode}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="p-birthdate" className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id="p-birthdate"
name="birthdate"
value={data.birthdate}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="bday"
aria-invalid={touched.birthdate && !!errors.birthdate}
className={`${inputCls(!!touched.birthdate && !!errors.birthdate)} [color-scheme:dark]`}
/>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
</div>
{/* Performer-specific fields */}
<div className="border border-amber-400/20 bg-amber-400/5 p-6">
<p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider">

View File

@@ -5,16 +5,9 @@ interface Props {
email?: string;
name?: string;
onReset: () => void;
isLoggedIn?: boolean;
}
export function SuccessScreen({
token,
email,
name,
onReset,
isLoggedIn,
}: Props) {
export function SuccessScreen({ token, email, name, onReset }: Props) {
const manageUrl =
typeof window !== "undefined"
? `${window.location.origin}/manage/${token}`
@@ -104,8 +97,8 @@ export function SuccessScreen({
</button>
</div>
{/* Account creation prompt — hidden when already logged in */}
{!isLoggedIn && !drinkkaartPromptDismissed && (
{/* Account creation prompt */}
{!drinkkaartPromptDismissed && (
<div className="mt-8 rounded-xl border border-teal-400/30 bg-teal-400/10 p-6">
<h3 className="mb-1 font-['Intro',sans-serif] text-lg text-white">
Maak een gratis account aan

View File

@@ -12,6 +12,7 @@ import {
validatePhone,
validateTextField,
} from "@/lib/registration";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { client, orpc } from "@/utils/orpc";
import { GiftSelector } from "./GiftSelector";
import { GuestList } from "./GuestList";
@@ -21,8 +22,6 @@ interface WatcherErrors {
lastName?: string;
email?: string;
phone?: string;
postcode?: string;
birthdate?: string;
}
interface Props {
@@ -51,6 +50,7 @@ function AccountModal({
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState<string | undefined>();
const { isOpen } = useRegistrationOpen();
const signupMutation = useMutation({
mutationFn: async () => {
@@ -85,6 +85,10 @@ function AccountModal({
const handleSignup = (e: React.FormEvent) => {
e.preventDefault();
if (!isOpen) {
toast.error("Registratie is nog niet open");
return;
}
if (password.length < 8) {
setPasswordError("Wachtwoord moet minstens 8 tekens zijn");
return;
@@ -253,8 +257,6 @@ export function WatcherForm({
lastName: prefillLastName,
email: prefillEmail,
phone: "",
postcode: "",
birthdate: "",
extraQuestions: "",
});
const [errors, setErrors] = useState<WatcherErrors>({});
@@ -310,8 +312,6 @@ export function WatcherForm({
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
};
setErrors(fieldErrs);
setTouched({
@@ -319,8 +319,6 @@ export function WatcherForm({
lastName: true,
email: true,
phone: true,
postcode: true,
birthdate: true,
});
const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs);
@@ -338,14 +336,6 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
@@ -361,14 +351,6 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
@@ -389,14 +371,7 @@ export function WatcherForm({
if (guests.length >= 9) return;
setGuests((prev) => [
...prev,
{
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
{ firstName: "", lastName: "", email: "", phone: "" },
]);
setGuestErrors((prev) => [...prev, {}]);
}
@@ -417,16 +392,12 @@ export function WatcherForm({
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "watcher",
guests: guests.map((g) => ({
firstName: g.firstName.trim(),
lastName: g.lastName.trim(),
email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})),
extraQuestions: data.extraQuestions.trim() || undefined,
giftAmount,
@@ -620,54 +591,6 @@ export function WatcherForm({
</div>
</div>
{/* Postcode + Birthdate row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="w-postcode" className="text-white text-xl">
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id="w-postcode"
name="postcode"
value={data.postcode}
onChange={handleChange}
onBlur={handleBlur}
placeholder="1234 AB"
autoComplete="postal-code"
aria-required="true"
aria-invalid={touched.postcode && !!errors.postcode}
className={inputCls(!!touched.postcode && !!errors.postcode)}
/>
{touched.postcode && errors.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors.postcode}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="w-birthdate" className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id="w-birthdate"
name="birthdate"
value={data.birthdate}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="bday"
aria-invalid={touched.birthdate && !!errors.birthdate}
className={`${inputCls(!!touched.birthdate && !!errors.birthdate)} [color-scheme:dark]`}
/>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
</div>
{/* Guests */}
<GuestList
guests={guests}

View File

@@ -0,0 +1,5 @@
/**
* Single source-of-truth for when registration opens.
* Change this date to reschedule — all gating logic imports from here.
*/
export const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00");

View File

@@ -28,8 +28,6 @@ export interface GuestEntry {
lastName: string;
email: string;
phone: string;
birthdate: string;
postcode: string;
}
export interface GuestErrors {
@@ -37,8 +35,6 @@ export interface GuestErrors {
lastName?: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}
/**
@@ -55,8 +51,6 @@ export function parseGuests(raw: string | null | undefined): GuestEntry[] {
lastName: g.lastName ?? "",
email: g.email ?? "",
phone: g.phone ?? "",
birthdate: g.birthdate ?? "",
postcode: g.postcode ?? "",
}));
} catch {
return [];
@@ -108,8 +102,6 @@ export function validateGuests(guests: GuestEntry[]): {
g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, ""))
? "Voer een geldig telefoonnummer in"
: undefined,
birthdate: !g.birthdate.trim() ? "Geboortedatum is verplicht" : undefined,
postcode: !g.postcode.trim() ? "Postcode is verplicht" : undefined,
}));
const valid = !errors.some((e) => Object.values(e).some(Boolean));
return { errors, valid };

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { REGISTRATION_OPENS_AT } from "./opening";
interface RegistrationOpenState {
isOpen: boolean;
days: number;
hours: number;
minutes: number;
seconds: number;
}
function compute(): RegistrationOpenState {
const diff = REGISTRATION_OPENS_AT.getTime() - Date.now();
if (diff <= 0) {
return { isOpen: true, days: 0, hours: 0, minutes: 0, seconds: 0 };
}
const totalSeconds = Math.floor(diff / 1000);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return { isOpen: false, days, hours, minutes, seconds };
}
/**
* Returns live countdown state to {@link REGISTRATION_OPENS_AT}.
* Ticks every second; clears the interval once registration is open.
*/
export function useRegistrationOpen(): RegistrationOpenState {
const [state, setState] = useState<RegistrationOpenState>(compute);
useEffect(() => {
if (state.isOpen) return;
const id = setInterval(() => {
const next = compute();
setState(next);
if (next.isOpen) clearInterval(id);
}, 1000);
return () => clearInterval(id);
}, [state.isOpen]);
return state;
}

View File

@@ -19,7 +19,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as ManageTokenRouteImport } from './routes/manage.$token'
import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart'
import { Route as ApiWebhookLemonsqueezyRouteImport } from './routes/api/webhook/lemonsqueezy'
import { Route as ApiWebhookMollieRouteImport } from './routes/api/webhook/mollie'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
@@ -73,9 +73,9 @@ const AdminDrinkkaartRoute = AdminDrinkkaartRouteImport.update({
path: '/admin/drinkkaart',
getParentRoute: () => rootRouteImport,
} as any)
const ApiWebhookLemonsqueezyRoute = ApiWebhookLemonsqueezyRouteImport.update({
id: '/api/webhook/lemonsqueezy',
path: '/api/webhook/lemonsqueezy',
const ApiWebhookMollieRoute = ApiWebhookMollieRouteImport.update({
id: '/api/webhook/mollie',
path: '/api/webhook/mollie',
getParentRoute: () => rootRouteImport,
} as any)
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
@@ -102,7 +102,7 @@ export interface FileRoutesByFullPath {
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -117,7 +117,7 @@ export interface FileRoutesByTo {
'/admin': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -133,7 +133,7 @@ export interface FileRoutesById {
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -150,7 +150,7 @@ export interface FileRouteTypes {
| '/admin/'
| '/api/auth/$'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
| '/api/webhook/mollie'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -165,7 +165,7 @@ export interface FileRouteTypes {
| '/admin'
| '/api/auth/$'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
| '/api/webhook/mollie'
id:
| '__root__'
| '/'
@@ -180,7 +180,7 @@ export interface FileRouteTypes {
| '/admin/'
| '/api/auth/$'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
| '/api/webhook/mollie'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -196,7 +196,7 @@ export interface RootRouteChildren {
AdminIndexRoute: typeof AdminIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
ApiWebhookLemonsqueezyRoute: typeof ApiWebhookLemonsqueezyRoute
ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute
}
declare module '@tanstack/react-router' {
@@ -271,11 +271,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminDrinkkaartRouteImport
parentRoute: typeof rootRouteImport
}
'/api/webhook/lemonsqueezy': {
id: '/api/webhook/lemonsqueezy'
path: '/api/webhook/lemonsqueezy'
fullPath: '/api/webhook/lemonsqueezy'
preLoaderRoute: typeof ApiWebhookLemonsqueezyRouteImport
'/api/webhook/mollie': {
id: '/api/webhook/mollie'
path: '/api/webhook/mollie'
fullPath: '/api/webhook/mollie'
preLoaderRoute: typeof ApiWebhookMollieRouteImport
parentRoute: typeof rootRouteImport
}
'/api/rpc/$': {
@@ -308,7 +308,7 @@ const rootRouteChildren: RootRouteChildren = {
AdminIndexRoute: AdminIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
ApiWebhookLemonsqueezyRoute: ApiWebhookLemonsqueezyRoute,
ApiWebhookMollieRoute: ApiWebhookMollieRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -17,7 +17,7 @@ import appCss from "../index.css?url";
const siteUrl = "https://kunstenkamp.be";
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord";
const siteDescription =
"Doe mee met de Open Mic Night op 24 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional.";
"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 {

View File

@@ -90,7 +90,7 @@ function AccountPage() {
orpc.drinkkaart.getMyDrinkkaart.queryOptions(),
);
const registrationQuery = useQuery(orpc.getMyRegistrations.queryOptions());
const registrationQuery = useQuery(orpc.getMyRegistration.queryOptions());
// Handle topup=success redirect (from Lemon Squeezy returning to /account)
useEffect(() => {
@@ -128,7 +128,7 @@ function AccountPage() {
| { name?: string; email?: string }
| undefined;
const registrations = registrationQuery.data ?? [];
const registration = registrationQuery.data;
const drinkkaart = drinkkaartQuery.data;
const isLoading =
@@ -170,122 +170,112 @@ function AccountPage() {
Mijn Inschrijving
</h2>
{registrations.length > 0 ? (
<div className="flex flex-col gap-4">
{registrations.map((registration) => (
<div
key={registration.id}
className="rounded-2xl border border-white/10 bg-white/5 p-6"
>
{/* Type badge */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
{registration.registrationType === "performer" ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/20 px-3 py-1 font-medium text-amber-300 text-sm">
<Music className="h-3.5 w-3.5" />
Artiest
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-teal-500/20 px-3 py-1 font-medium text-sm text-teal-300">
<Users className="h-3.5 w-3.5" />
Bezoeker
</span>
)}
</div>
{(registration.registrationType !== "performer" ||
(registration.giftAmount ?? 0) > 0) && (
<PaymentBadge status={registration.paymentStatus} />
)}
</div>
{/* Name */}
<p className="mb-1 font-medium text-lg text-white">
{registration.firstName} {registration.lastName}
</p>
{/* Art form (performer only) */}
{registration.registrationType === "performer" &&
registration.artForm && (
<p className="mb-1 text-sm text-white/60">
Kunstvorm:{" "}
<span className="text-white/80">
{registration.artForm}
</span>
</p>
)}
{/* Guests (watcher only) */}
{registration.registrationType === "watcher" &&
registration.guests.length > 0 && (
<p className="mb-1 text-sm text-white/60">
{registration.guests.length + 1} personen (jij +{" "}
{registration.guests.length} gast
{registration.guests.length > 1 ? "en" : ""})
</p>
)}
{/* Drink card value */}
{registration.registrationType === "watcher" &&
(registration.drinkCardValue ?? 0) > 0 && (
<p className="mb-1 text-sm text-white/60">
Drinkkaart:{" "}
<span className="text-white/80">
{registration.drinkCardValue}
</span>
</p>
)}
{/* Gift */}
{(registration.giftAmount ?? 0) > 0 && (
<p className="mb-1 text-sm text-white/60">
Gift:{" "}
<span className="text-white/80">
{(registration.giftAmount ?? 0) / 100}
</span>
</p>
)}
{/* Date */}
<p className="mt-3 text-white/40 text-xs">
Ingeschreven op{" "}
{new Date(registration.createdAt).toLocaleDateString(
"nl-BE",
{
day: "numeric",
month: "long",
year: "numeric",
},
)}
</p>
{/* Action */}
{registration.managementToken && (
<div className="mt-5">
<Link
to="/manage/$token"
params={{ token: registration.managementToken }}
className="inline-flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm text-white transition-colors hover:bg-white/20"
>
Beheer inschrijving
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</Link>
</div>
{registration ? (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
{/* Type badge */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
{registration.registrationType === "performer" ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/20 px-3 py-1 font-medium text-amber-300 text-sm">
<Music className="h-3.5 w-3.5" />
Artiest
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-teal-500/20 px-3 py-1 font-medium text-sm text-teal-300">
<Users className="h-3.5 w-3.5" />
Bezoeker
</span>
)}
</div>
))}
<PaymentBadge status={registration.paymentStatus} />
</div>
{/* Name */}
<p className="mb-1 font-medium text-lg text-white">
{registration.firstName} {registration.lastName}
</p>
{/* Art form (performer only) */}
{registration.registrationType === "performer" &&
registration.artForm && (
<p className="mb-1 text-sm text-white/60">
Kunstvorm:{" "}
<span className="text-white/80">
{registration.artForm}
</span>
</p>
)}
{/* Guests (watcher only) */}
{registration.registrationType === "watcher" &&
registration.guests.length > 0 && (
<p className="mb-1 text-sm text-white/60">
{registration.guests.length + 1} personen (jij +{" "}
{registration.guests.length} gast
{registration.guests.length > 1 ? "en" : ""})
</p>
)}
{/* Drink card value */}
{registration.registrationType === "watcher" &&
(registration.drinkCardValue ?? 0) > 0 && (
<p className="mb-1 text-sm text-white/60">
Drinkkaart:{" "}
<span className="text-white/80">
{registration.drinkCardValue}
</span>
</p>
)}
{/* Gift */}
{(registration.giftAmount ?? 0) > 0 && (
<p className="mb-1 text-sm text-white/60">
Gift:{" "}
<span className="text-white/80">
{(registration.giftAmount ?? 0) / 100}
</span>
</p>
)}
{/* Date */}
<p className="mt-3 text-white/40 text-xs">
Ingeschreven op{" "}
{new Date(registration.createdAt).toLocaleDateString(
"nl-BE",
{
day: "numeric",
month: "long",
year: "numeric",
},
)}
</p>
{/* Action */}
{registration.managementToken && (
<div className="mt-5">
<Link
to="/manage/$token"
params={{ token: registration.managementToken }}
className="inline-flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm text-white transition-colors hover:bg-white/20"
>
Beheer inschrijving
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</Link>
</div>
)}
</div>
) : (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">

View File

@@ -13,7 +13,7 @@ import {
Users,
X,
} from "lucide-react";
import { Fragment, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -44,19 +44,6 @@ function AdminPage() {
const pageSize = 20;
const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandedGuests, setExpandedGuests] = useState<Set<string>>(new Set());
const toggleGuests = (id: string) => {
setExpandedGuests((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
// Get current session to check user role
const sessionQuery = useQuery({
@@ -291,25 +278,6 @@ function AdminPage() {
return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`;
};
const parseGuestsJson = (
raw: string | null | undefined,
): Array<{
firstName: string;
lastName: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}> => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw as string);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
// Check if user is admin
const user = sessionQuery.data?.data?.user as
| { role?: string; name?: string }
@@ -518,6 +486,7 @@ function AdminPage() {
</CardContent>
</Card>
)}
{/* Stats Cards */}
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-5 lg:gap-6">
<Card className="border-white/10 bg-white/5">
@@ -612,6 +581,79 @@ function AdminPage() {
<CardDescription className="text-pink-300/70 text-xs sm:text-sm">
Vrijwillige Gifts
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-white sm:text-3xl lg:text-4xl">
{stats?.today ?? 0}
</CardTitle>
</CardHeader>
<CardContent>
<span className="text-white/40 text-xs sm:text-sm">
Nieuwe registraties vandaag
</span>
</CardContent>
</Card>
<Card className="border-amber-400/20 bg-amber-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-amber-300/70">
Artiesten
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-amber-300 sm:text-3xl lg:text-4xl">
{performerCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{stats?.byArtForm.slice(0, 2).map((item) => (
<div
key={item.artForm}
className="flex items-center justify-between text-xs"
>
<span className="truncate text-amber-300/70">
{item.artForm || "Onbekend"}
</span>
<span className="text-amber-300">{item.count}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-teal-400/20 bg-teal-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-teal-300/70">
Bezoekers
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-teal-300 sm:text-3xl lg:text-4xl">
{watcherCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-xs">
{totalGuestCount > 0 && (
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Inclusief gasten</span>
<span className="text-teal-300">+{totalGuestCount}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Totaal aanwezig</span>
<span className="font-semibold text-teal-300">
{totalWatcherAttendees}
</span>
</div>
<div className="hidden sm:flex sm:items-center sm:justify-between">
<span className="text-teal-300/70">Drinkkaart</span>
<span className="text-teal-300">{totalDrinkCardValue}</span>
</div>
</div>
</CardContent>
</Card>
<Card className="border-pink-400/20 bg-pink-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-pink-300/70">
Vrijwillige Gifts
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-pink-300 sm:text-3xl lg:text-4xl">
{Math.round(totalGiftRevenue / 100)}
</CardTitle>
@@ -623,6 +665,7 @@ function AdminPage() {
</CardContent>
</Card>
</div>
{/* Filters */}
<Card className="mb-6 border-white/10 bg-white/5">
<CardHeader className="px-4 py-4 sm:px-6 sm:py-6">
@@ -730,6 +773,7 @@ function AdminPage() {
</div>
</CardContent>
</Card>
{/* Export Button */}
<div className="mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/60">
@@ -744,6 +788,7 @@ function AdminPage() {
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
</Button>
</div>
{/* Registrations Table / Cards */}
<Card className="border-white/10 bg-white/5">
<CardContent className="p-0">
@@ -788,21 +833,6 @@ function AdminPage() {
<th className={thClass} onClick={() => handleSort("datum")}>
Datum <SortIcon col="datum" />
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Postcode
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Geboortedatum
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Ervaring
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
16+
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Opmerkingen
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Link
</th>
@@ -812,7 +842,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? (
<tr>
<td
colSpan={15}
colSpan={10}
className="px-4 py-8 text-center text-white/60"
>
Laden...
@@ -821,7 +851,7 @@ function AdminPage() {
) : sortedRegistrations.length === 0 ? (
<tr>
<td
colSpan={15}
colSpan={10}
className="px-4 py-8 text-center text-white/60"
>
Geen registraties gevonden
@@ -831,9 +861,15 @@ function AdminPage() {
sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer";
const guests = parseGuestsJson(reg.guests);
const guestCount = guests.length;
const isGuestsExpanded = expandedGuests.has(reg.id);
const guestCount = (() => {
if (!reg.guests) return 0;
try {
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer
? reg.artForm || "-"
@@ -855,190 +891,93 @@ function AdminPage() {
})();
return (
<Fragment key={reg.id}>
<tr
key={reg.id}
className="border-white/5 border-b hover:bg-white/5"
>
<td className="px-4 py-3 font-medium text-white">
{reg.firstName} {reg.lastName}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{reg.email}
</td>
<td className="px-4 py-3 text-sm text-white/60">
{reg.phone || "-"}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
<tr
key={reg.id}
className="border-white/5 border-b hover:bg-white/5"
>
<td className="px-4 py-3 font-medium text-white">
{reg.firstName} {reg.lastName}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{reg.email}
</td>
<td className="px-4 py-3 text-sm text-white/60">
{reg.phone || "-"}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
</td>
<td className="px-4 py-3 text-sm text-white/70">
{detailLabel}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{guestCount > 0
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
: "-"}
</td>
<td className="px-4 py-3 text-pink-300/70 text-sm">
{formatCents(reg.giftAmount)}
</td>
<td className="px-4 py-3">
{isPerformer ? (
<span className="text-sm text-white/30">-</span>
) : reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
<Check className="h-3.5 w-3.5" />
Betaald
</span>
</td>
<td className="px-4 py-3 text-sm text-white/70">
{detailLabel}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{guestCount > 0 ? (
<button
type="button"
onClick={() => toggleGuests(reg.id)}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
{guestCount} gast
{guestCount === 1 ? "" : "en"}
{isGuestsExpanded ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</button>
) : (
"-"
)}
</td>
<td className="px-4 py-3 text-pink-300/70 text-sm">
{formatCents(reg.giftAmount)}
</td>
<td className="px-4 py-3">
{isPerformer ? (
<span className="text-sm text-white/30">-</span>
) : reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
<Check className="h-3.5 w-3.5" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
</span>
) : (
<span className="text-sm text-yellow-400">
Open
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
{dateLabel}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{reg.postcode || "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70 tabular-nums">
{reg.birthdate || "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{isPerformer ? reg.experience || "-" : "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{isPerformer ? (
reg.isOver16 ? (
<span className="text-green-400">Ja</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
</span>
) : (
<span className="text-sm text-yellow-400">
Open
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
{dateLabel}
</td>
<td className="px-4 py-3">
{reg.managementToken ? (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<span className="text-red-400">Nee</span>
)
) : (
"-"
)}
</td>
<td className="max-w-[200px] px-4 py-3 text-sm text-white/60">
<span
className="block truncate"
title={reg.extraQuestions ?? undefined}
>
{reg.extraQuestions || "-"}
</span>
</td>
<td className="px-4 py-3">
{reg.managementToken ? (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
) : (
<span className="text-sm text-white/20"></span>
)}
</td>
</tr>
{isGuestsExpanded &&
guests.map((guest, gi) => (
<tr
key={`${reg.id}-guest-${gi}`}
className="border-white/5 border-b bg-white/[0.02]"
>
<td className="py-2 pr-4 pl-8 text-sm text-white/60 italic">
{guest.firstName} {guest.lastName}
</td>
<td className="px-4 py-2 text-sm text-white/50">
{guest.email || "-"}
</td>
<td className="px-4 py-2 text-sm text-white/50">
{guest.phone || "-"}
</td>
<td className="px-4 py-2">
<span className="inline-flex items-center rounded-full bg-white/5 px-2 py-0.5 font-semibold text-white/40 text-xs">
Gast
</span>
</td>
<td className="px-4 py-2 text-sm text-white/50">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/50">
{guest.postcode || "-"}
</td>
<td className="px-4 py-2 text-sm text-white/50 tabular-nums">
{guest.birthdate || "-"}
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
</tr>
))}
</Fragment>
<Clipboard className="h-4 w-4" />
)}
</button>
) : (
<span className="text-sm text-white/20"></span>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Mobile Cards */}
<div className="lg:hidden">
{registrationsQuery.isLoading ? (
<div className="px-4 py-8 text-center text-white/60">
@@ -1053,9 +992,15 @@ function AdminPage() {
{sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer";
const guests = parseGuestsJson(reg.guests);
const guestCount = guests.length;
const isGuestsExpanded = expandedGuests.has(reg.id);
const guestCount = (() => {
if (!reg.guests) return 0;
try {
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer
? reg.artForm || "-"
@@ -1077,171 +1022,88 @@ function AdminPage() {
})();
return (
<div key={reg.id} className="hover:bg-white/5">
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-white">
{reg.firstName} {reg.lastName}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-white/60 text-xs">
<span className="truncate">{reg.email}</span>
{reg.phone && (
<span className="shrink-0">
{reg.phone}
</span>
)}
</div>
</div>
<div key={reg.id} className="p-4 hover:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={`shrink-0 rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
<span className="truncate font-medium text-white">
{reg.firstName} {reg.lastName}
</span>
{reg.managementToken && (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="shrink-0 rounded p-1.5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
</div>
<div className="mt-1 flex items-center gap-2 text-white/60 text-xs">
<span className="truncate">{reg.email}</span>
{reg.phone && (
<span className="shrink-0"> {reg.phone}</span>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<div className="text-white/70">
<span className="text-white/40">Details:</span>{" "}
{detailLabel}
</div>
{guestCount > 0 && (
<div className="flex items-center gap-2">
<span
className={`shrink-0 rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
{reg.managementToken && (
<button
type="button"
onClick={() => toggleGuests(reg.id)}
className="inline-flex items-center gap-1 text-white/70 transition-colors hover:text-white"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="shrink-0 rounded p-1.5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
>
<span className="text-white/40">Gasten:</span>{" "}
{guestCount}
{isGuestsExpanded ? (
<ChevronUp className="h-3 w-3" />
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<ChevronDown className="h-3 w-3" />
<Clipboard className="h-4 w-4" />
)}
</button>
)}
{(reg.giftAmount ?? 0) > 0 && (
<div className="text-pink-300">
<span className="text-white/40">Gift:</span>{" "}
{formatCents(reg.giftAmount)}
</div>
)}
{!isPerformer && (
<div>
<span className="text-white/40">Betaling:</span>{" "}
{reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400">
<Check className="h-3 w-3" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400">
<span className="h-1 w-1 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(
0,
)}
)
</span>
) : (
<span className="text-yellow-400">Open</span>
)}
</div>
)}
{reg.postcode && (
<div className="text-white/70">
<span className="text-white/40">Postcode:</span>{" "}
{reg.postcode}
</div>
)}
{reg.birthdate && (
<div className="text-white/70">
<span className="text-white/40">
Geboortedatum:
</span>{" "}
{reg.birthdate}
</div>
)}
{isPerformer && reg.experience && (
<div className="text-white/70">
<span className="text-white/40">Ervaring:</span>{" "}
{reg.experience}
</div>
)}
{isPerformer && (
<div className="text-white/70">
<span className="text-white/40">16+:</span>{" "}
{reg.isOver16 ? (
<span className="text-green-400">Ja</span>
) : (
<span className="text-red-400">Nee</span>
)}
</div>
)}
{reg.extraQuestions && (
<div className="w-full text-white/60">
<span className="text-white/40">
Opmerkingen:
</span>{" "}
{reg.extraQuestions}
</div>
)}
<div className="text-white/50">{dateLabel}</div>
</div>
</div>
{isGuestsExpanded && guestCount > 0 && (
<div className="border-white/5 border-t bg-white/[0.02] px-4 pt-2 pb-3">
<div className="mb-1 text-white/30 text-xs">
Gasten
</div>
<div className="flex flex-col gap-2">
{guests.map((guest, gi) => (
<div
key={`${reg.id}-guest-${gi}`}
className="rounded border border-white/10 bg-white/5 px-3 py-2 text-xs"
>
<div className="font-medium text-white/80">
{guest.firstName} {guest.lastName}
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-white/50">
{guest.email && <span>{guest.email}</span>}
{guest.phone && <span>{guest.phone}</span>}
{guest.birthdate && (
<span>{guest.birthdate}</span>
)}
{guest.postcode && (
<span>{guest.postcode}</span>
)}
</div>
</div>
))}
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<div className="text-white/70">
<span className="text-white/40">Details:</span>{" "}
{detailLabel}
</div>
)}
{guestCount > 0 && (
<div className="text-white/70">
<span className="text-white/40">Gasten:</span>{" "}
{guestCount}
</div>
)}
{(reg.giftAmount ?? 0) > 0 && (
<div className="text-pink-300">
<span className="text-white/40">Gift:</span>{" "}
{formatCents(reg.giftAmount)}
</div>
)}
{!isPerformer && (
<div>
<span className="text-white/40">Betaling:</span>{" "}
{reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400">
<Check className="h-3 w-3" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400">
<span className="h-1 w-1 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
</span>
) : (
<span className="text-yellow-400">Open</span>
)}
</div>
)}
<div className="text-white/50">{dateLabel}</div>
</div>
</div>
);
})}
@@ -1250,6 +1112,8 @@ function AdminPage() {
</div>
</CardContent>
</Card>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2 sm:mt-6">
<Button

View File

@@ -1,4 +1,4 @@
import { createHmac, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import { creditRegistrationToAccount } from "@kk/api/routers/index";
import { db } from "@kk/db";
import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema";
@@ -7,101 +7,106 @@ import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router";
import { and, eq } from "drizzle-orm";
// LemonSqueezy webhook payload types (order_created event)
interface LemonSqueezyOrderCreatedPayload {
meta: {
event_name: string;
custom_data?: {
type?: string;
registration_token?: string;
drinkkaartId?: string;
userId?: string;
};
};
data: {
id: string;
attributes: {
status: string;
customer_id: number;
total: number; // amount in cents
};
// Mollie payment object (relevant fields only)
interface MolliePayment {
id: string;
status: string;
amount: { value: string; currency: string };
customerId?: string;
metadata?: {
registration_token?: string;
type?: string;
drinkkaartId?: string;
userId?: string;
};
}
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
): boolean {
const hmac = createHmac("sha256", secret);
hmac.update(payload);
const digest = hmac.digest("hex");
return signature === digest;
async function fetchMolliePayment(paymentId: string): Promise<MolliePayment> {
const response = await fetch(
`https://api.mollie.com/v2/payments/${paymentId}`,
{
headers: {
Authorization: `Bearer ${env.MOLLIE_API_KEY}`,
},
},
);
if (!response.ok) {
throw new Error(
`Failed to fetch Mollie payment ${paymentId}: ${response.status}`,
);
}
return response.json() as Promise<MolliePayment>;
}
async function handleWebhook({ request }: { request: Request }) {
if (!env.LEMON_SQUEEZY_WEBHOOK_SECRET) {
console.error("LEMON_SQUEEZY_WEBHOOK_SECRET not configured");
if (!env.MOLLIE_API_KEY) {
console.error("MOLLIE_API_KEY not configured");
return new Response("Payment provider not configured", { status: 500 });
}
const payload = await request.text();
const signature = request.headers.get("X-Signature");
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
if (
!verifyWebhookSignature(
payload,
signature,
env.LEMON_SQUEEZY_WEBHOOK_SECRET,
)
) {
return new Response("Invalid signature", { status: 401 });
}
let event: LemonSqueezyOrderCreatedPayload;
// Mollie sends application/x-www-form-urlencoded with a single "id" field
let paymentId: string | null = null;
try {
event = JSON.parse(payload) as LemonSqueezyOrderCreatedPayload;
const body = await request.text();
const params = new URLSearchParams(body);
paymentId = params.get("id");
} catch {
return new Response("Invalid JSON", { status: 400 });
return new Response("Invalid request body", { status: 400 });
}
// Only handle order_created events
if (event.meta.event_name !== "order_created") {
return new Response("Event ignored", { status: 200 });
if (!paymentId) {
return new Response("Missing payment id", { status: 400 });
}
const orderId = event.data.id;
const customerId = String(event.data.attributes.customer_id);
const amountCents = event.data.attributes.total;
const customData = event.meta.custom_data;
// Fetch-to-verify: retrieve the actual payment from Mollie to confirm its
// status. A malicious webhook cannot fake a paid status this way.
let payment: MolliePayment;
try {
payment = await fetchMolliePayment(paymentId);
} catch (err) {
console.error("Failed to fetch Mollie payment:", err);
return new Response("Failed to fetch payment", { status: 500 });
}
// Only process paid payments
if (payment.status !== "paid") {
return new Response("Payment status ignored", { status: 200 });
}
const metadata = payment.metadata;
try {
// -------------------------------------------------------------------------
// Branch: Drinkkaart top-up
// -------------------------------------------------------------------------
if (customData?.type === "drinkkaart_topup") {
const { drinkkaartId, userId } = customData;
if (metadata?.type === "drinkkaart_topup") {
const { drinkkaartId, userId } = metadata;
if (!drinkkaartId || !userId) {
console.error(
"Missing drinkkaartId or userId in drinkkaart_topup custom_data",
"Missing drinkkaartId or userId in drinkkaart_topup payment metadata",
);
return new Response("Missing drinkkaart data", { status: 400 });
}
// Amount in cents — Mollie returns e.g. "10.00"; parse to integer cents
const amountCents = Math.round(
Number.parseFloat(payment.amount.value) * 100,
);
// Idempotency: skip if already processed
const existing = await db
.select({ id: drinkkaartTopup.id })
.from(drinkkaartTopup)
.where(eq(drinkkaartTopup.lemonsqueezyOrderId, orderId))
.where(eq(drinkkaartTopup.molliePaymentId, payment.id))
.limit(1)
.then((r) => r[0]);
if (existing) {
console.log(`Drinkkaart topup already processed for order ${orderId}`);
console.log(
`Drinkkaart topup already processed for payment ${payment.id}`,
);
return new Response("OK", { status: 200 });
}
@@ -136,7 +141,7 @@ async function handleWebhook({ request }: { request: Request }) {
);
if (result.rowsAffected === 0) {
// Return 500 so LemonSqueezy retries; idempotency check prevents double-credit
// Return 500 so Mollie retries; idempotency check prevents double-credit
console.error(
`Drinkkaart optimistic lock conflict for ${drinkkaartId}`,
);
@@ -151,14 +156,14 @@ async function handleWebhook({ request }: { request: Request }) {
balanceBefore,
balanceAfter,
type: "payment",
lemonsqueezyOrderId: orderId,
molliePaymentId: payment.id,
adminId: null,
reason: null,
paidAt: new Date(),
});
console.log(
`Drinkkaart topup successful: drinkkaart=${drinkkaartId}, amount=${amountCents}c, order=${orderId}`,
`Drinkkaart topup successful: drinkkaart=${drinkkaartId}, amount=${amountCents}c, payment=${payment.id}`,
);
return new Response("OK", { status: 200 });
@@ -167,9 +172,9 @@ async function handleWebhook({ request }: { request: Request }) {
// -------------------------------------------------------------------------
// Branch: Registration payment
// -------------------------------------------------------------------------
const registrationToken = customData?.registration_token;
const registrationToken = metadata?.registration_token;
if (!registrationToken) {
console.error("No registration token in order custom_data");
console.error("No registration token in payment metadata");
return new Response("Missing registration token", { status: 400 });
}
@@ -192,14 +197,13 @@ async function handleWebhook({ request }: { request: Request }) {
.set({
paymentStatus: "paid",
paymentAmount: 0,
lemonsqueezyOrderId: orderId,
lemonsqueezyCustomerId: customerId,
molliePaymentId: payment.id,
paidAt: new Date(),
})
.where(eq(registration.managementToken, registrationToken));
console.log(
`Payment successful for registration ${registrationToken}, order ${orderId}`,
`Payment successful for registration ${registrationToken}, payment ${payment.id}`,
);
// If this is a watcher with a drink card value, try to credit their
@@ -250,7 +254,7 @@ async function handleWebhook({ request }: { request: Request }) {
}
}
export const Route = createFileRoute("/api/webhook/lemonsqueezy")({
export const Route = createFileRoute("/api/webhook/mollie")({
server: {
handlers: {
POST: handleWebhook,

View File

@@ -38,7 +38,7 @@ function ContactPage() {
<section className="rounded-lg bg-white/5 p-6">
<h3 className="mb-3 text-white text-xl">Open Mic Night</h3>
<p>Vrijdag 24 april 2026</p>
<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.

View File

@@ -12,6 +12,8 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { REGISTRATION_OPENS_AT } from "@/lib/opening";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/login")({
@@ -31,6 +33,7 @@ export const Route = createFileRoute("/login")({
function LoginPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const { isOpen } = useRegistrationOpen();
const [isSignup, setIsSignup] = useState(() => search.signup === "1");
const [email, setEmail] = useState(() => search.email ?? "");
const [password, setPassword] = useState("");
@@ -204,6 +207,43 @@ function LoginPage() {
Terug naar website
</Link>
</div>
) : isSignup && !isOpen ? (
/* Signup is closed until registration opens */
<div className="space-y-4">
<div className="rounded-lg border border-white/10 bg-white/5 p-5 text-center">
<p className="mb-1 font-['Intro',sans-serif] text-white">
Registratie nog niet open
</p>
<p className="text-sm text-white/60">
Accounts aanmaken kan vanaf{" "}
<span className="text-teal-300">
{REGISTRATION_OPENS_AT.toLocaleDateString("nl-BE", {
weekday: "long",
day: "numeric",
month: "long",
})}{" "}
om{" "}
{REGISTRATION_OPENS_AT.toLocaleTimeString("nl-BE", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
.
</p>
</div>
<div className="flex flex-col gap-2 text-center">
<button
type="button"
onClick={() => setIsSignup(false)}
className="text-sm text-white/60 hover:text-white"
>
Al een account? Log in
</button>
<Link to="/" className="text-sm text-white/60 hover:text-white">
Terug naar website
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{isSignup && (
@@ -271,15 +311,29 @@ function LoginPage() {
: "Inloggen"}
</Button>
<div className="flex flex-col gap-2 text-center">
<button
type="button"
onClick={() => setIsSignup(!isSignup)}
className="text-sm text-white/60 hover:text-white"
>
{isSignup
? "Al een account? Log in"
: "Nog geen account? Registreer"}
</button>
{/* Only show signup toggle when registration is open */}
{isOpen && (
<button
type="button"
onClick={() => setIsSignup(!isSignup)}
className="text-sm text-white/60 hover:text-white"
>
{isSignup
? "Al een account? Log in"
: "Nog geen account? Registreer"}
</button>
)}
{!isOpen && isSignup === false && (
<button
type="button"
onClick={() => setIsSignup(true)}
className="cursor-not-allowed text-sm text-white/30"
disabled
title="Registratie opent op 16 maart om 19:00"
>
Nog geen account? (opent 16 maart)
</button>
)}
<Link to="/" className="text-sm text-white/60 hover:text-white">
Terug naar website
</Link>

View File

@@ -117,8 +117,6 @@ interface EditFormProps {
lastName: string;
email: string;
phone: string | null;
postcode: string | null;
birthdate: string | null;
registrationType: string;
artForm: string | null;
experience: string | null;
@@ -142,8 +140,6 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: initialData.lastName,
email: initialData.email,
phone: initialData.phone ?? "",
postcode: initialData.postcode ?? "",
birthdate: initialData.birthdate ?? "",
registrationType: initialType,
artForm: initialData.artForm ?? "",
experience: initialData.experience ?? "",
@@ -183,8 +179,6 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: formData.lastName.trim(),
email: formData.email.trim(),
phone: formData.phone.trim() || undefined,
postcode: formData.postcode.trim() || "",
birthdate: formData.birthdate || "",
registrationType: formData.registrationType,
artForm: performer ? formData.artForm.trim() || undefined : undefined,
experience: performer
@@ -198,8 +192,6 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: g.lastName.trim(),
email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})),
extraQuestions: formData.extraQuestions.trim() || undefined,
giftAmount,
@@ -279,42 +271,6 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
/>
</div>
{/* Postcode + Birthdate */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label htmlFor="postcode" className="mb-2 block text-white">
Postcode *
</label>
<input
id="postcode"
type="text"
required
value={formData.postcode}
onChange={(e) =>
setFormData((p) => ({ ...p, postcode: e.target.value }))
}
autoComplete="postal-code"
className={inputCls(false)}
/>
</div>
<div>
<label htmlFor="birthdate" className="mb-2 block text-white">
Geboortedatum *
</label>
<input
id="birthdate"
type="date"
required
value={formData.birthdate}
onChange={(e) =>
setFormData((p) => ({ ...p, birthdate: e.target.value }))
}
autoComplete="bday"
className={`${inputCls(false)} [color-scheme:dark]`}
/>
</div>
</div>
{/* Registration type toggle */}
<div>
<p className="mb-3 text-white">Type inschrijving</p>
@@ -437,14 +393,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
if (formGuests.length >= 9) return;
setFormGuests((prev) => [
...prev,
{
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
{ firstName: "", lastName: "", email: "", phone: "" },
]);
}}
onRemove={(idx) =>
@@ -620,7 +569,7 @@ function ManageRegistrationPage() {
Jouw inschrijving
</h1>
<p className="mb-8 text-white/60">
Open Mic Night vrijdag 24 april 2026
Open Mic Night vrijdag 18 april 2026
</p>
{/* Type badge */}
@@ -636,19 +585,18 @@ function ManageRegistrationPage() {
)}
</div>
{/* Payment status - not shown for performers without a gift */}
{(!isPerformer || (data.giftAmount ?? 0) > 0) &&
(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
<div className="mb-6">
{data.paymentStatus === "paid" ? (
<PaidBadge />
) : data.paymentStatus === "extra_payment_pending" ? (
<ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} />
) : (
<PendingBadge />
)}
</div>
)}
{/* Payment status - shown for everyone with pending/extra payment or gift */}
{(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
<div className="mb-6">
{data.paymentStatus === "paid" ? (
<PaidBadge />
) : data.paymentStatus === "extra_payment_pending" ? (
<ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} />
) : (
<PendingBadge />
)}
</div>
)}
{/* Gift display */}
{(data.giftAmount ?? 0) > 0 && (
@@ -678,14 +626,6 @@ function ManageRegistrationPage() {
<p className="text-sm text-white/50">Telefoon</p>
<p className="text-lg text-white">{data.phone || "—"}</p>
</div>
<div>
<p className="text-sm text-white/50">Postcode</p>
<p className="text-lg text-white">{data.postcode || "—"}</p>
</div>
<div>
<p className="text-sm text-white/50">Geboortedatum</p>
<p className="text-lg text-white">{data.birthdate || "—"}</p>
</div>
</div>
{isPerformer && (
@@ -720,16 +660,6 @@ function ManageRegistrationPage() {
<p className="text-white">
{g.firstName} {g.lastName}
</p>
{g.birthdate && (
<p className="text-sm text-white/60">
Geboortedatum: {g.birthdate}
</p>
)}
{g.postcode && (
<p className="text-sm text-white/60">
Postcode: {g.postcode}
</p>
)}
{g.email && (
<p className="text-sm text-white/60">{g.email}</p>
)}

View File

@@ -42,6 +42,7 @@
"@tanstack/react-start": "^1.141.1",
"@tanstack/router-plugin": "^1.141.1",
"better-auth": "catalog:",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "catalog:",
@@ -68,6 +69,7 @@
"@tanstack/react-router-devtools": "^1.141.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/canvas-confetti": "^1.9.0",
"@types/qrcode": "^1.5.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
@@ -909,6 +911,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -1027,6 +1031,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
"canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],

View File

@@ -86,7 +86,7 @@ function registrationConfirmationHtml(params: {
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 24 april 2026</strong> in goede orde ontvangen.
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;">

View File

@@ -134,82 +134,49 @@ export const drinkkaartRouter = {
.handler(async ({ input, context }) => {
const { env: serverEnv, session } = context;
if (
!serverEnv.LEMON_SQUEEZY_API_KEY ||
!serverEnv.LEMON_SQUEEZY_STORE_ID ||
!serverEnv.LEMON_SQUEEZY_VARIANT_ID
) {
if (!serverEnv.MOLLIE_API_KEY) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "LemonSqueezy is niet geconfigureerd",
message: "Mollie is niet geconfigureerd",
});
}
const card = await getOrCreateDrinkkaart(session.user.id);
const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts",
{
method: "POST",
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${serverEnv.LEMON_SQUEEZY_API_KEY}`,
},
body: JSON.stringify({
data: {
type: "checkouts",
attributes: {
custom_price: input.amountCents,
product_options: {
name: "Drinkkaart Opladen",
description: `Opwaardering van ${formatCents(input.amountCents)}`,
redirect_url: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
},
checkout_data: {
email: session.user.email,
name: session.user.name,
custom: {
type: "drinkkaart_topup",
drinkkaartId: card.id,
userId: session.user.id,
},
},
checkout_options: {
embed: false,
locale: "nl",
},
},
relationships: {
store: {
data: {
type: "stores",
id: serverEnv.LEMON_SQUEEZY_STORE_ID,
},
},
variant: {
data: {
type: "variants",
id: serverEnv.LEMON_SQUEEZY_VARIANT_ID,
},
},
},
},
}),
// Mollie amounts must be formatted as "10.00" (string, 2 decimal places)
const amountValue = (input.amountCents / 100).toFixed(2);
const response = await fetch("https://api.mollie.com/v2/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${serverEnv.MOLLIE_API_KEY}`,
},
);
body: JSON.stringify({
amount: { value: amountValue, currency: "EUR" },
description: `Drinkkaart Opladen — ${formatCents(input.amountCents)}`,
redirectUrl: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
webhookUrl: `${serverEnv.BETTER_AUTH_URL}/api/webhook/mollie`,
locale: "nl_NL",
metadata: {
type: "drinkkaart_topup",
drinkkaartId: card.id,
userId: session.user.id,
},
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("LemonSqueezy Drinkkaart checkout error:", errorData);
console.error("Mollie Drinkkaart checkout error:", errorData);
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Kon checkout niet aanmaken",
});
}
const data = (await response.json()) as {
data?: { attributes?: { url?: string } };
_links?: { checkout?: { href?: string } };
};
const checkoutUrl = data.data?.attributes?.url;
const checkoutUrl = data._links?.checkout?.href;
if (!checkoutUrl) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Geen checkout URL ontvangen",

View File

@@ -36,8 +36,6 @@ function parseGuestsJson(raw: string | null): Array<{
lastName: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}> {
if (!raw) return [];
try {
@@ -52,7 +50,7 @@ function parseGuestsJson(raw: string | null): Array<{
* Credits a watcher's drinkCardValue to their drinkkaart account.
*
* Safe to call multiple times — the `drinkkaartCreditedAt IS NULL` guard and the
* `lemonsqueezy_order_id` UNIQUE constraint together prevent double-crediting even
* `mollie_payment_id` UNIQUE constraint together prevent double-crediting even
* if called concurrently (webhook + signup racing each other).
*
* Returns a status string describing the outcome.
@@ -162,11 +160,11 @@ export async function creditRegistrationToAccount(
return { credited: false, amountCents: 0, status: "already_credited" };
}
// Record the topup. Re-use the registration's lemonsqueezyOrderId so the
// Record the topup. Re-use the registration's molliePaymentId so the
// topup row is clearly linked to the original payment. We prefix with
// "reg_" to distinguish from direct drinkkaart top-up orders if needed.
const topupPaymentId = reg.lemonsqueezyOrderId
? `reg_${reg.lemonsqueezyOrderId}`
const topupPaymentId = reg.molliePaymentId
? `reg_${reg.molliePaymentId}`
: null;
await db.insert(drinkkaartTopup).values({
@@ -177,7 +175,7 @@ export async function creditRegistrationToAccount(
balanceBefore,
balanceAfter,
type: "payment",
lemonsqueezyOrderId: topupPaymentId,
molliePaymentId: topupPaymentId,
adminId: null,
reason: "Drinkkaart bij registratie",
paidAt: reg.paidAt ?? new Date(),
@@ -220,8 +218,6 @@ const guestSchema = z.object({
lastName: z.string().min(1),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
birthdate: z.string().min(1),
postcode: z.string().min(1),
});
const coreRegistrationFields = {
@@ -229,8 +225,6 @@ const coreRegistrationFields = {
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
postcode: z.string().min(1),
birthdate: z.string().min(1),
registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(),
experience: z.string().optional(),
@@ -283,8 +277,6 @@ export const appRouter = {
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null,
@@ -372,8 +364,6 @@ export const appRouter = {
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null,
@@ -525,8 +515,6 @@ export const appRouter = {
"Last Name",
"Email",
"Phone",
"Postcode",
"Birthdate",
"Type",
"Art Form",
"Experience",
@@ -546,7 +534,7 @@ export const appRouter = {
const guestSummary = guests
.map(
(g) =>
`${g.firstName} ${g.lastName}${g.birthdate ? ` (${g.birthdate})` : ""}${g.postcode ? ` [${g.postcode}]` : ""}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
`${g.firstName} ${g.lastName}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
)
.join(" | ");
return [
@@ -555,8 +543,6 @@ export const appRouter = {
r.lastName,
r.email,
r.phone || "",
r.postcode || "",
r.birthdate || "",
r.registrationType,
r.artForm || "",
r.experience || "",
@@ -597,7 +583,7 @@ export const appRouter = {
return result;
}),
getMyRegistrations: protectedProcedure.handler(async ({ context }) => {
getMyRegistration: protectedProcedure.handler(async ({ context }) => {
const email = context.session.user.email;
const rows = await db
@@ -606,12 +592,16 @@ export const appRouter = {
.where(
and(eq(registration.email, email), isNull(registration.cancelledAt)),
)
.orderBy(desc(registration.createdAt));
.orderBy(desc(registration.createdAt))
.limit(1);
return rows.map((row) => ({
const row = rows[0];
if (!row) return null;
return {
...row,
guests: parseGuestsJson(row.guests),
}));
};
}),
// ---------------------------------------------------------------------------
@@ -751,12 +741,8 @@ export const appRouter = {
}),
)
.handler(async ({ input }) => {
if (
!env.LEMON_SQUEEZY_API_KEY ||
!env.LEMON_SQUEEZY_STORE_ID ||
!env.LEMON_SQUEEZY_VARIANT_ID
) {
throw new Error("LemonSqueezy is niet geconfigureerd");
if (!env.MOLLIE_API_KEY) {
throw new Error("Mollie is niet geconfigureerd");
}
const rows = await db
@@ -809,66 +795,37 @@ export const appRouter = {
const redirectUrl =
input.redirectUrl ?? `${env.CORS_ORIGIN}/manage/${input.token}`;
const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts",
{
method: "POST",
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${env.LEMON_SQUEEZY_API_KEY}`,
},
body: JSON.stringify({
data: {
type: "checkouts",
attributes: {
custom_price: amountInCents,
product_options: {
name: "Kunstenkamp Evenement",
description: productDescription,
redirect_url: redirectUrl,
},
checkout_data: {
email: row.email,
name: `${row.firstName} ${row.lastName}`,
custom: {
registration_token: input.token,
},
},
checkout_options: {
embed: false,
locale: "nl",
},
},
relationships: {
store: {
data: {
type: "stores",
id: env.LEMON_SQUEEZY_STORE_ID,
},
},
variant: {
data: {
type: "variants",
id: env.LEMON_SQUEEZY_VARIANT_ID,
},
},
},
},
}),
// Mollie amounts must be formatted as "10.00" (string, 2 decimal places)
const amountValue = (amountInCents / 100).toFixed(2);
const webhookUrl = `${env.CORS_ORIGIN}/api/webhook/mollie`;
const response = await fetch("https://api.mollie.com/v2/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.MOLLIE_API_KEY}`,
},
);
body: JSON.stringify({
amount: { value: amountValue, currency: "EUR" },
description: `Kunstenkamp Evenement — ${productDescription}`,
redirectUrl,
webhookUrl,
locale: "nl_NL",
metadata: { registration_token: input.token },
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("LemonSqueezy checkout error:", errorData);
console.error("Mollie checkout error:", errorData);
throw new Error("Kon checkout niet aanmaken");
}
const checkoutData = (await response.json()) as {
data?: { attributes?: { url?: string } };
_links?: { checkout?: { href?: string } };
};
const checkoutUrl = checkoutData.data?.attributes?.url;
const checkoutUrl = checkoutData._links?.checkout?.href;
if (!checkoutUrl) throw new Error("Geen checkout URL ontvangen");

View File

@@ -1,15 +0,0 @@
-- Migrate from Mollie back to LemonSqueezy
-- Renames payment provider columns in registration and drinkkaart_topup tables,
-- and restores the lemonsqueezy_customer_id column on registration.
-- registration table:
-- mollie_payment_id -> lemonsqueezy_order_id
-- (re-add) lemonsqueezy_customer_id
ALTER TABLE registration RENAME COLUMN mollie_payment_id TO lemonsqueezy_order_id;
ALTER TABLE registration ADD COLUMN lemonsqueezy_customer_id text;
-- drinkkaart_topup table:
-- mollie_payment_id -> lemonsqueezy_order_id
ALTER TABLE drinkkaart_topup RENAME COLUMN mollie_payment_id TO lemonsqueezy_order_id;

View File

@@ -1,2 +0,0 @@
ALTER TABLE registration ADD COLUMN postcode TEXT;
ALTER TABLE registration ADD COLUMN birthdate TEXT;

View File

@@ -29,13 +29,6 @@
"when": 1772530000000,
"tag": "0003_add_guests",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1741388400000,
"tag": "0008_add_postcode_birthdate",
"breakpoints": true
}
]
}

View File

@@ -46,7 +46,7 @@ export const drinkkaartTopup = sqliteTable("drinkkaart_topup", {
balanceBefore: integer("balance_before").notNull(),
balanceAfter: integer("balance_after").notNull(),
type: text("type", { enum: ["payment", "admin_credit"] }).notNull(),
lemonsqueezyOrderId: text("lemonsqueezy_order_id").unique(), // nullable; only for type="payment"
molliePaymentId: text("mollie_payment_id").unique(), // nullable; only for type="payment"
adminId: text("admin_id"), // nullable; only for type="admin_credit"
reason: text("reason"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }).notNull(),

View File

@@ -21,9 +21,6 @@ export const registration = sqliteTable(
drinkCardValue: integer("drink_card_value").default(0),
// Guests: JSON array of {firstName, lastName, email?, phone?} objects
guests: text("guests"),
// Contact / demographic
postcode: text("postcode"),
birthdate: text("birthdate"),
// Shared
extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(),
@@ -32,8 +29,7 @@ export const registration = sqliteTable(
paymentStatus: text("payment_status").notNull().default("pending"),
paymentAmount: integer("payment_amount").default(0),
giftAmount: integer("gift_amount").default(0),
lemonsqueezyOrderId: text("lemonsqueezy_order_id"),
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
molliePaymentId: text("mollie_payment_id"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }),
// Set when the drinkCardValue has been credited to the user's drinkkaart.
// Null means not yet credited (either unpaid, account doesn't exist yet, or
@@ -53,6 +49,6 @@ export const registration = sqliteTable(
index("registration_managementToken_idx").on(table.managementToken),
index("registration_paymentStatus_idx").on(table.paymentStatus),
index("registration_giftAmount_idx").on(table.giftAmount),
index("registration_lemonsqueezyOrderId_idx").on(table.lemonsqueezyOrderId),
index("registration_molliePaymentId_idx").on(table.molliePaymentId),
],
);

View File

@@ -25,10 +25,7 @@ export const env = createEnv({
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
LEMON_SQUEEZY_API_KEY: z.string().min(1).optional(),
LEMON_SQUEEZY_STORE_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_VARIANT_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_WEBHOOK_SECRET: z.string().min(1).optional(),
MOLLIE_API_KEY: z.string().min(1).optional(),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,

View File

@@ -30,11 +30,8 @@ export const web = await TanStackStart("web", {
SMTP_USER: getEnvVar("SMTP_USER"),
SMTP_PASS: getEnvVar("SMTP_PASS"),
SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (LemonSqueezy)
LEMON_SQUEEZY_API_KEY: getEnvVar("LEMON_SQUEEZY_API_KEY"),
LEMON_SQUEEZY_STORE_ID: getEnvVar("LEMON_SQUEEZY_STORE_ID"),
LEMON_SQUEEZY_VARIANT_ID: getEnvVar("LEMON_SQUEEZY_VARIANT_ID"),
LEMON_SQUEEZY_WEBHOOK_SECRET: getEnvVar("LEMON_SQUEEZY_WEBHOOK_SECRET"),
// Payments (Mollie)
MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"),
},
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
});