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/react-start": "^1.141.1",
"@tanstack/router-plugin": "^1.141.1", "@tanstack/router-plugin": "^1.141.1",
"better-auth": "catalog:", "better-auth": "catalog:",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "catalog:", "dotenv": "catalog:",
@@ -56,6 +57,7 @@
"@tanstack/react-router-devtools": "^1.141.1", "@tanstack/react-router-devtools": "^1.141.1",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/canvas-confetti": "^1.9.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@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 { Link } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { CountdownBanner } from "@/components/homepage/CountdownBanner";
import { PerformerForm } from "@/components/registration/PerformerForm"; import { PerformerForm } from "@/components/registration/PerformerForm";
import { SuccessScreen } from "@/components/registration/SuccessScreen"; import { SuccessScreen } from "@/components/registration/SuccessScreen";
import { TypeSelector } from "@/components/registration/TypeSelector"; import { TypeSelector } from "@/components/registration/TypeSelector";
import { WatcherForm } from "@/components/registration/WatcherForm"; import { WatcherForm } from "@/components/registration/WatcherForm";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
type RegistrationType = "performer" | "watcher"; type RegistrationType = "performer" | "watcher";
@@ -16,6 +18,7 @@ interface SuccessState {
export default function EventRegistrationForm() { export default function EventRegistrationForm() {
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const { isOpen } = useRegistrationOpen();
const [selectedType, setSelectedType] = useState<RegistrationType | null>( const [selectedType, setSelectedType] = useState<RegistrationType | null>(
null, null,
); );
@@ -36,7 +39,6 @@ export default function EventRegistrationForm() {
token={successState.token} token={successState.token}
email={successState.email} email={successState.email}
name={successState.name} name={successState.name}
isLoggedIn={isLoggedIn}
onReset={() => { onReset={() => {
setSuccessState(null); setSuccessState(null);
setSelectedType(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"> <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. Doe je mee of kom je kijken? Kies je rol en vul het formulier in.
</p> </p>
{/* Login nudge — shown only to guests */}
{!isLoggedIn && ( {!isOpen ? (
<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"> <CountdownBanner />
<p className="text-sm text-white/80"> ) : (
<span className="mr-1 font-semibold text-teal-300"> <>
Al een account? {/* Login nudge — shown only to guests */}
</span> {!isLoggedIn && (
Log in om je gegevens automatisch in te vullen en je Drinkkaart <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">
direct te activeren na registratie. <p className="text-sm text-white/80">
</p> <span className="mr-1 font-semibold text-teal-300">
<div className="flex shrink-0 items-center gap-3 text-sm"> Al een account?
<Link </span>
to="/login" Log in om je gegevens automatisch in te vullen en je
className="font-medium text-teal-300 underline-offset-2 transition-colors hover:underline" Drinkkaart direct te activeren na registratie.
> </p>
Inloggen <div className="flex shrink-0 items-center gap-3 text-sm">
</Link> <Link
<span className="text-white/30">·</span> to="/login"
<Link className="font-medium text-teal-300 underline-offset-2 transition-colors hover:underline"
to="/login" >
search={{ signup: "1" }} Inloggen
className="text-white/60 transition-colors hover:text-white" </Link>
> <span className="text-white/30">·</span>
Nog geen account? Aanmaken <Link
</Link> to="/login"
</div> search={{ signup: "1" }}
</div> className="text-white/60 transition-colors hover:text-white"
)} >
{!selectedType && <TypeSelector onSelect={setSelectedType} />} Nog geen account? Aanmaken
{selectedType === "performer" && ( </Link>
<PerformerForm </div>
onBack={() => setSelectedType(null)} </div>
onSuccess={(token, email, name) => )}
setSuccessState({ token, email, name }) {!selectedType && <TypeSelector onSelect={setSelectedType} />}
} {selectedType === "performer" && (
prefillFirstName={prefillFirstName} <PerformerForm
prefillLastName={prefillLastName} onBack={() => setSelectedType(null)}
prefillEmail={prefillEmail} onSuccess={(token, email, name) =>
isLoggedIn={isLoggedIn} setSuccessState({ token, email, name })
/> }
)} prefillFirstName={prefillFirstName}
{selectedType === "watcher" && ( prefillLastName={prefillLastName}
<WatcherForm prefillEmail={prefillEmail}
onBack={() => setSelectedType(null)} isLoggedIn={isLoggedIn}
prefillFirstName={prefillFirstName} />
prefillLastName={prefillLastName} )}
prefillEmail={prefillEmail} {selectedType === "watcher" && (
isLoggedIn={isLoggedIn} <WatcherForm
/> onBack={() => setSelectedType(null)}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/>
)}
</>
)} )}
</div> </div>
</section> </section>

View File

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

View File

@@ -204,51 +204,6 @@ export function GuestList({
)} )}
</div> </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"> <div className="flex flex-col gap-2">
<label htmlFor={`guest-${idx}-email`} className="text-white/80"> <label htmlFor={`guest-${idx}-email`} className="text-white/80">
E-mail E-mail

View File

@@ -15,8 +15,6 @@ interface PerformerErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
postcode?: string;
birthdate?: string;
artForm?: string; artForm?: string;
isOver16?: string; isOver16?: string;
} }
@@ -43,8 +41,6 @@ export function PerformerForm({
lastName: prefillLastName, lastName: prefillLastName,
email: prefillEmail, email: prefillEmail,
phone: "", phone: "",
postcode: "",
birthdate: "",
artForm: "", artForm: "",
experience: "", experience: "",
isOver16: false, isOver16: false,
@@ -75,8 +71,6 @@ export function PerformerForm({
lastName: validateTextField(data.lastName, true, "Achternaam"), lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email), email: validateEmail(data.email),
phone: validatePhone(data.phone), 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, artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined,
isOver16: !data.isOver16 isOver16: !data.isOver16
? "Je moet 16 jaar of ouder zijn om op te treden" ? "Je moet 16 jaar of ouder zijn om op te treden"
@@ -88,8 +82,6 @@ export function PerformerForm({
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
postcode: true,
birthdate: true,
artForm: true, artForm: true,
isOver16: true, isOver16: true,
}); });
@@ -118,14 +110,6 @@ export function PerformerForm({
), ),
email: validateEmail(name === "email" ? value : data.email), email: validateEmail(name === "email" ? value : data.email),
phone: validatePhone(name === "phone" ? value : data.phone), 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: artForm:
name === "artForm" && !value.trim() name === "artForm" && !value.trim()
? "Kunstvorm is verplicht" ? "Kunstvorm is verplicht"
@@ -145,14 +129,6 @@ export function PerformerForm({
lastName: validateTextField(value, true, "Achternaam"), lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value), email: validateEmail(value),
phone: validatePhone(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, artForm: !value.trim() ? "Kunstvorm is verplicht" : undefined,
}; };
setErrors((prev) => ({ ...prev, [name]: errMap[name] })); setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
@@ -169,8 +145,6 @@ export function PerformerForm({
lastName: data.lastName.trim(), lastName: data.lastName.trim(),
email: data.email.trim(), email: data.email.trim(),
phone: data.phone.trim() || undefined, phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "performer", registrationType: "performer",
artForm: data.artForm.trim() || undefined, artForm: data.artForm.trim() || undefined,
experience: data.experience.trim() || undefined, experience: data.experience.trim() || undefined,
@@ -332,54 +306,6 @@ export function PerformerForm({
</div> </div>
</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 */} {/* Performer-specific fields */}
<div className="border border-amber-400/20 bg-amber-400/5 p-6"> <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"> <p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider">

View File

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

View File

@@ -12,6 +12,7 @@ import {
validatePhone, validatePhone,
validateTextField, validateTextField,
} from "@/lib/registration"; } from "@/lib/registration";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { client, orpc } from "@/utils/orpc"; import { client, orpc } from "@/utils/orpc";
import { GiftSelector } from "./GiftSelector"; import { GiftSelector } from "./GiftSelector";
import { GuestList } from "./GuestList"; import { GuestList } from "./GuestList";
@@ -21,8 +22,6 @@ interface WatcherErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
postcode?: string;
birthdate?: string;
} }
interface Props { interface Props {
@@ -51,6 +50,7 @@ function AccountModal({
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState<string | undefined>(); const [passwordError, setPasswordError] = useState<string | undefined>();
const { isOpen } = useRegistrationOpen();
const signupMutation = useMutation({ const signupMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@@ -85,6 +85,10 @@ function AccountModal({
const handleSignup = (e: React.FormEvent) => { const handleSignup = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!isOpen) {
toast.error("Registratie is nog niet open");
return;
}
if (password.length < 8) { if (password.length < 8) {
setPasswordError("Wachtwoord moet minstens 8 tekens zijn"); setPasswordError("Wachtwoord moet minstens 8 tekens zijn");
return; return;
@@ -253,8 +257,6 @@ export function WatcherForm({
lastName: prefillLastName, lastName: prefillLastName,
email: prefillEmail, email: prefillEmail,
phone: "", phone: "",
postcode: "",
birthdate: "",
extraQuestions: "", extraQuestions: "",
}); });
const [errors, setErrors] = useState<WatcherErrors>({}); const [errors, setErrors] = useState<WatcherErrors>({});
@@ -310,8 +312,6 @@ export function WatcherForm({
lastName: validateTextField(data.lastName, true, "Achternaam"), lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email), email: validateEmail(data.email),
phone: validatePhone(data.phone), phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
}; };
setErrors(fieldErrs); setErrors(fieldErrs);
setTouched({ setTouched({
@@ -319,8 +319,6 @@ export function WatcherForm({
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
postcode: true,
birthdate: true,
}); });
const { errors: gErrs, valid: gValid } = validateGuests(guests); const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs); setGuestErrors(gErrs);
@@ -338,14 +336,6 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"), lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value), email: validateEmail(value),
phone: validatePhone(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] })); setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
} }
@@ -361,14 +351,6 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"), lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value), email: validateEmail(value),
phone: validatePhone(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] })); setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
} }
@@ -389,14 +371,7 @@ export function WatcherForm({
if (guests.length >= 9) return; if (guests.length >= 9) return;
setGuests((prev) => [ setGuests((prev) => [
...prev, ...prev,
{ { firstName: "", lastName: "", email: "", phone: "" },
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
]); ]);
setGuestErrors((prev) => [...prev, {}]); setGuestErrors((prev) => [...prev, {}]);
} }
@@ -417,16 +392,12 @@ export function WatcherForm({
lastName: data.lastName.trim(), lastName: data.lastName.trim(),
email: data.email.trim(), email: data.email.trim(),
phone: data.phone.trim() || undefined, phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "watcher", registrationType: "watcher",
guests: guests.map((g) => ({ guests: guests.map((g) => ({
firstName: g.firstName.trim(), firstName: g.firstName.trim(),
lastName: g.lastName.trim(), lastName: g.lastName.trim(),
email: g.email.trim() || undefined, email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined, phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})), })),
extraQuestions: data.extraQuestions.trim() || undefined, extraQuestions: data.extraQuestions.trim() || undefined,
giftAmount, giftAmount,
@@ -620,54 +591,6 @@ export function WatcherForm({
</div> </div>
</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 */} {/* Guests */}
<GuestList <GuestList
guests={guests} 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; lastName: string;
email: string; email: string;
phone: string; phone: string;
birthdate: string;
postcode: string;
} }
export interface GuestErrors { export interface GuestErrors {
@@ -37,8 +35,6 @@ export interface GuestErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
} }
/** /**
@@ -55,8 +51,6 @@ export function parseGuests(raw: string | null | undefined): GuestEntry[] {
lastName: g.lastName ?? "", lastName: g.lastName ?? "",
email: g.email ?? "", email: g.email ?? "",
phone: g.phone ?? "", phone: g.phone ?? "",
birthdate: g.birthdate ?? "",
postcode: g.postcode ?? "",
})); }));
} catch { } catch {
return []; return [];
@@ -108,8 +102,6 @@ export function validateGuests(guests: GuestEntry[]): {
g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, "")) g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, ""))
? "Voer een geldig telefoonnummer in" ? "Voer een geldig telefoonnummer in"
: undefined, : 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)); const valid = !errors.some((e) => Object.values(e).some(Boolean));
return { errors, valid }; 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 AdminIndexRouteImport } from './routes/admin/index'
import { Route as ManageTokenRouteImport } from './routes/manage.$token' import { Route as ManageTokenRouteImport } from './routes/manage.$token'
import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart' 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 ApiRpcSplatRouteImport } from './routes/api/rpc/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
@@ -73,9 +73,9 @@ const AdminDrinkkaartRoute = AdminDrinkkaartRouteImport.update({
path: '/admin/drinkkaart', path: '/admin/drinkkaart',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiWebhookLemonsqueezyRoute = ApiWebhookLemonsqueezyRouteImport.update({ const ApiWebhookMollieRoute = ApiWebhookMollieRouteImport.update({
id: '/api/webhook/lemonsqueezy', id: '/api/webhook/mollie',
path: '/api/webhook/lemonsqueezy', path: '/api/webhook/mollie',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
@@ -102,7 +102,7 @@ export interface FileRoutesByFullPath {
'/admin/': typeof AdminIndexRoute '/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute '/api/webhook/mollie': typeof ApiWebhookMollieRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -117,7 +117,7 @@ export interface FileRoutesByTo {
'/admin': typeof AdminIndexRoute '/admin': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute '/api/webhook/mollie': typeof ApiWebhookMollieRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -133,7 +133,7 @@ export interface FileRoutesById {
'/admin/': typeof AdminIndexRoute '/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute '/api/webhook/mollie': typeof ApiWebhookMollieRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -150,7 +150,7 @@ export interface FileRouteTypes {
| '/admin/' | '/admin/'
| '/api/auth/$' | '/api/auth/$'
| '/api/rpc/$' | '/api/rpc/$'
| '/api/webhook/lemonsqueezy' | '/api/webhook/mollie'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -165,7 +165,7 @@ export interface FileRouteTypes {
| '/admin' | '/admin'
| '/api/auth/$' | '/api/auth/$'
| '/api/rpc/$' | '/api/rpc/$'
| '/api/webhook/lemonsqueezy' | '/api/webhook/mollie'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -180,7 +180,7 @@ export interface FileRouteTypes {
| '/admin/' | '/admin/'
| '/api/auth/$' | '/api/auth/$'
| '/api/rpc/$' | '/api/rpc/$'
| '/api/webhook/lemonsqueezy' | '/api/webhook/mollie'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -196,7 +196,7 @@ export interface RootRouteChildren {
AdminIndexRoute: typeof AdminIndexRoute AdminIndexRoute: typeof AdminIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute
ApiWebhookLemonsqueezyRoute: typeof ApiWebhookLemonsqueezyRoute ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -271,11 +271,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminDrinkkaartRouteImport preLoaderRoute: typeof AdminDrinkkaartRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/webhook/lemonsqueezy': { '/api/webhook/mollie': {
id: '/api/webhook/lemonsqueezy' id: '/api/webhook/mollie'
path: '/api/webhook/lemonsqueezy' path: '/api/webhook/mollie'
fullPath: '/api/webhook/lemonsqueezy' fullPath: '/api/webhook/mollie'
preLoaderRoute: typeof ApiWebhookLemonsqueezyRouteImport preLoaderRoute: typeof ApiWebhookMollieRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/rpc/$': { '/api/rpc/$': {
@@ -308,7 +308,7 @@ const rootRouteChildren: RootRouteChildren = {
AdminIndexRoute: AdminIndexRoute, AdminIndexRoute: AdminIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute,
ApiWebhookLemonsqueezyRoute: ApiWebhookLemonsqueezyRoute, ApiWebhookMollieRoute: ApiWebhookMollieRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -17,7 +17,7 @@ import appCss from "../index.css?url";
const siteUrl = "https://kunstenkamp.be"; const siteUrl = "https://kunstenkamp.be";
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord"; const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord";
const siteDescription = 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`; const eventImage = `${siteUrl}/assets/og-image.jpg`;
export interface RouterAppContext { export interface RouterAppContext {

View File

@@ -90,7 +90,7 @@ function AccountPage() {
orpc.drinkkaart.getMyDrinkkaart.queryOptions(), 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) // Handle topup=success redirect (from Lemon Squeezy returning to /account)
useEffect(() => { useEffect(() => {
@@ -128,7 +128,7 @@ function AccountPage() {
| { name?: string; email?: string } | { name?: string; email?: string }
| undefined; | undefined;
const registrations = registrationQuery.data ?? []; const registration = registrationQuery.data;
const drinkkaart = drinkkaartQuery.data; const drinkkaart = drinkkaartQuery.data;
const isLoading = const isLoading =
@@ -170,122 +170,112 @@ function AccountPage() {
Mijn Inschrijving Mijn Inschrijving
</h2> </h2>
{registrations.length > 0 ? ( {registration ? (
<div className="flex flex-col gap-4"> <div className="rounded-2xl border border-white/10 bg-white/5 p-6">
{registrations.map((registration) => ( {/* Type badge */}
<div <div className="mb-4 flex items-center justify-between">
key={registration.id} <div className="flex items-center gap-2">
className="rounded-2xl border border-white/10 bg-white/5 p-6" {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">
{/* Type badge */} <Music className="h-3.5 w-3.5" />
<div className="mb-4 flex items-center justify-between"> Artiest
<div className="flex items-center gap-2"> </span>
{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"> <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">
<Music className="h-3.5 w-3.5" /> <Users className="h-3.5 w-3.5" />
Artiest Bezoeker
</span> </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>
)} )}
</div> </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>
) : ( ) : (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6"> <div className="rounded-2xl border border-white/10 bg-white/5 p-6">

View File

@@ -13,7 +13,7 @@ import {
Users, Users,
X, X,
} from "lucide-react"; } from "lucide-react";
import { Fragment, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -44,19 +44,6 @@ function AdminPage() {
const pageSize = 20; const pageSize = 20;
const [copiedId, setCopiedId] = useState<string | null>(null); 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 // Get current session to check user role
const sessionQuery = useQuery({ const sessionQuery = useQuery({
@@ -291,25 +278,6 @@ function AdminPage() {
return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`; 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 // Check if user is admin
const user = sessionQuery.data?.data?.user as const user = sessionQuery.data?.data?.user as
| { role?: string; name?: string } | { role?: string; name?: string }
@@ -518,6 +486,7 @@ function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Stats Cards */} {/* 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"> <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"> <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"> <CardDescription className="text-pink-300/70 text-xs sm:text-sm">
Vrijwillige Gifts Vrijwillige Gifts
</CardDescription> </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"> <CardTitle className="font-['Intro',sans-serif] text-2xl text-pink-300 sm:text-3xl lg:text-4xl">
{Math.round(totalGiftRevenue / 100)} {Math.round(totalGiftRevenue / 100)}
</CardTitle> </CardTitle>
@@ -623,6 +665,7 @@ function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Filters */} {/* Filters */}
<Card className="mb-6 border-white/10 bg-white/5"> <Card className="mb-6 border-white/10 bg-white/5">
<CardHeader className="px-4 py-4 sm:px-6 sm:py-6"> <CardHeader className="px-4 py-4 sm:px-6 sm:py-6">
@@ -730,6 +773,7 @@ function AdminPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Export Button */} {/* Export Button */}
<div className="mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-center sm:justify-between"> <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"> <p className="text-sm text-white/60">
@@ -744,6 +788,7 @@ function AdminPage() {
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"} {exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
</Button> </Button>
</div> </div>
{/* Registrations Table / Cards */} {/* Registrations Table / Cards */}
<Card className="border-white/10 bg-white/5"> <Card className="border-white/10 bg-white/5">
<CardContent className="p-0"> <CardContent className="p-0">
@@ -788,21 +833,6 @@ function AdminPage() {
<th className={thClass} onClick={() => handleSort("datum")}> <th className={thClass} onClick={() => handleSort("datum")}>
Datum <SortIcon col="datum" /> Datum <SortIcon col="datum" />
</th> </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"> <th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Link Link
</th> </th>
@@ -812,7 +842,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? ( {registrationsQuery.isLoading ? (
<tr> <tr>
<td <td
colSpan={15} colSpan={10}
className="px-4 py-8 text-center text-white/60" className="px-4 py-8 text-center text-white/60"
> >
Laden... Laden...
@@ -821,7 +851,7 @@ function AdminPage() {
) : sortedRegistrations.length === 0 ? ( ) : sortedRegistrations.length === 0 ? (
<tr> <tr>
<td <td
colSpan={15} colSpan={10}
className="px-4 py-8 text-center text-white/60" className="px-4 py-8 text-center text-white/60"
> >
Geen registraties gevonden Geen registraties gevonden
@@ -831,9 +861,15 @@ function AdminPage() {
sortedRegistrations.map((reg) => { sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer"; const isPerformer = reg.registrationType === "performer";
const guests = parseGuestsJson(reg.guests); const guestCount = (() => {
const guestCount = guests.length; if (!reg.guests) return 0;
const isGuestsExpanded = expandedGuests.has(reg.id); try {
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer const detailLabel = isPerformer
? reg.artForm || "-" ? reg.artForm || "-"
@@ -855,190 +891,93 @@ function AdminPage() {
})(); })();
return ( return (
<Fragment key={reg.id}> <tr
<tr key={reg.id}
key={reg.id} className="border-white/5 border-b hover:bg-white/5"
className="border-white/5 border-b hover:bg-white/5" >
> <td className="px-4 py-3 font-medium text-white">
<td className="px-4 py-3 font-medium text-white"> {reg.firstName} {reg.lastName}
{reg.firstName} {reg.lastName} </td>
</td> <td className="px-4 py-3 text-sm text-white/70">
<td className="px-4 py-3 text-sm text-white/70"> {reg.email}
{reg.email} </td>
</td> <td className="px-4 py-3 text-sm text-white/60">
<td className="px-4 py-3 text-sm text-white/60"> {reg.phone || "-"}
{reg.phone || "-"} </td>
</td> <td className="px-4 py-3">
<td className="px-4 py-3"> <span
<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"}`}
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"}
{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> </span>
</td> ) : reg.paymentStatus ===
<td className="px-4 py-3 text-sm text-white/70"> "extra_payment_pending" ? (
{detailLabel} <span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
</td> <span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
<td className="px-4 py-3 text-sm text-white/70"> Extra (
{guestCount > 0 ? ( {((reg.paymentAmount ?? 0) / 100).toFixed(0)})
<button </span>
type="button" ) : (
onClick={() => toggleGuests(reg.id)} <span className="text-sm text-yellow-400">
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" Open
> </span>
{guestCount} gast )}
{guestCount === 1 ? "" : "en"} </td>
{isGuestsExpanded ? ( <td className="px-4 py-3 text-sm text-white/50 tabular-nums">
<ChevronUp className="h-3.5 w-3.5" /> {dateLabel}
) : ( </td>
<ChevronDown className="h-3.5 w-3.5" /> <td className="px-4 py-3">
)} {reg.managementToken ? (
</button> <button
) : ( type="button"
"-" title="Kopieer beheerlink"
)} onClick={() =>
</td> handleCopyManageUrl(
<td className="px-4 py-3 text-pink-300/70 text-sm"> reg.managementToken as string,
{formatCents(reg.giftAmount)} reg.id,
</td> )
<td className="px-4 py-3"> }
{isPerformer ? ( className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
<span className="text-sm text-white/30">-</span> >
) : reg.paymentStatus === "paid" ? ( {copiedId === reg.id ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm"> <ClipboardCheck className="h-4 w-4 text-green-400" />
<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>
) : ( ) : (
<span className="text-red-400">Nee</span> <Clipboard className="h-4 w-4" />
) )}
) : ( </button>
"-" ) : (
)} <span className="text-sm text-white/20"></span>
</td> )}
<td className="max-w-[200px] px-4 py-3 text-sm text-white/60"> </td>
<span </tr>
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>
); );
}) })
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Mobile Cards */}
<div className="lg:hidden"> <div className="lg:hidden">
{registrationsQuery.isLoading ? ( {registrationsQuery.isLoading ? (
<div className="px-4 py-8 text-center text-white/60"> <div className="px-4 py-8 text-center text-white/60">
@@ -1053,9 +992,15 @@ function AdminPage() {
{sortedRegistrations.map((reg) => { {sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer"; const isPerformer = reg.registrationType === "performer";
const guests = parseGuestsJson(reg.guests); const guestCount = (() => {
const guestCount = guests.length; if (!reg.guests) return 0;
const isGuestsExpanded = expandedGuests.has(reg.id); try {
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer const detailLabel = isPerformer
? reg.artForm || "-" ? reg.artForm || "-"
@@ -1077,171 +1022,88 @@ function AdminPage() {
})(); })();
return ( return (
<div key={reg.id} className="hover:bg-white/5"> <div key={reg.id} className="p-4 hover:bg-white/5">
<div className="p-4"> <div className="flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-3"> <div className="min-w-0 flex-1">
<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 className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span className="truncate font-medium text-white">
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"}`} {reg.firstName} {reg.lastName}
>
{isPerformer ? "Artiest" : "Bezoeker"}
</span> </span>
{reg.managementToken && ( </div>
<button <div className="mt-1 flex items-center gap-2 text-white/60 text-xs">
type="button" <span className="truncate">{reg.email}</span>
title="Kopieer beheerlink" {reg.phone && (
onClick={() => <span className="shrink-0"> {reg.phone}</span>
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>
</div> </div>
<div className="flex items-center gap-2">
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs"> <span
<div className="text-white/70"> 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"}`}
<span className="text-white/40">Details:</span>{" "} >
{detailLabel} {isPerformer ? "Artiest" : "Bezoeker"}
</div> </span>
{guestCount > 0 && ( {reg.managementToken && (
<button <button
type="button" type="button"
onClick={() => toggleGuests(reg.id)} title="Kopieer beheerlink"
className="inline-flex items-center gap-1 text-white/70 transition-colors hover:text-white" 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>{" "} {copiedId === reg.id ? (
{guestCount} <ClipboardCheck className="h-4 w-4 text-green-400" />
{isGuestsExpanded ? (
<ChevronUp className="h-3 w-3" />
) : ( ) : (
<ChevronDown className="h-3 w-3" /> <Clipboard className="h-4 w-4" />
)} )}
</button> </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>
</div> </div>
{isGuestsExpanded && guestCount > 0 && (
<div className="border-white/5 border-t bg-white/[0.02] px-4 pt-2 pb-3"> <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<div className="mb-1 text-white/30 text-xs"> <div className="text-white/70">
Gasten <span className="text-white/40">Details:</span>{" "}
</div> {detailLabel}
<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> </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> </div>
); );
})} })}
@@ -1250,6 +1112,8 @@ function AdminPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && ( {pagination && pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2 sm:mt-6"> <div className="mt-4 flex items-center justify-center gap-2 sm:mt-6">
<Button <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 { creditRegistrationToAccount } from "@kk/api/routers/index";
import { db } from "@kk/db"; import { db } from "@kk/db";
import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema"; 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 { createFileRoute } from "@tanstack/react-router";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
// LemonSqueezy webhook payload types (order_created event) // Mollie payment object (relevant fields only)
interface LemonSqueezyOrderCreatedPayload { interface MolliePayment {
meta: { id: string;
event_name: string; status: string;
custom_data?: { amount: { value: string; currency: string };
type?: string; customerId?: string;
registration_token?: string; metadata?: {
drinkkaartId?: string; registration_token?: string;
userId?: string; type?: string;
}; drinkkaartId?: string;
}; userId?: string;
data: {
id: string;
attributes: {
status: string;
customer_id: number;
total: number; // amount in cents
};
}; };
} }
function verifyWebhookSignature( async function fetchMolliePayment(paymentId: string): Promise<MolliePayment> {
payload: string, const response = await fetch(
signature: string, `https://api.mollie.com/v2/payments/${paymentId}`,
secret: string, {
): boolean { headers: {
const hmac = createHmac("sha256", secret); Authorization: `Bearer ${env.MOLLIE_API_KEY}`,
hmac.update(payload); },
const digest = hmac.digest("hex"); },
return signature === digest; );
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 }) { async function handleWebhook({ request }: { request: Request }) {
if (!env.LEMON_SQUEEZY_WEBHOOK_SECRET) { if (!env.MOLLIE_API_KEY) {
console.error("LEMON_SQUEEZY_WEBHOOK_SECRET not configured"); console.error("MOLLIE_API_KEY not configured");
return new Response("Payment provider not configured", { status: 500 }); return new Response("Payment provider not configured", { status: 500 });
} }
const payload = await request.text(); // Mollie sends application/x-www-form-urlencoded with a single "id" field
const signature = request.headers.get("X-Signature"); let paymentId: string | null = null;
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;
try { try {
event = JSON.parse(payload) as LemonSqueezyOrderCreatedPayload; const body = await request.text();
const params = new URLSearchParams(body);
paymentId = params.get("id");
} catch { } catch {
return new Response("Invalid JSON", { status: 400 }); return new Response("Invalid request body", { status: 400 });
} }
// Only handle order_created events if (!paymentId) {
if (event.meta.event_name !== "order_created") { return new Response("Missing payment id", { status: 400 });
return new Response("Event ignored", { status: 200 });
} }
const orderId = event.data.id; // Fetch-to-verify: retrieve the actual payment from Mollie to confirm its
const customerId = String(event.data.attributes.customer_id); // status. A malicious webhook cannot fake a paid status this way.
const amountCents = event.data.attributes.total; let payment: MolliePayment;
const customData = event.meta.custom_data; 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 { try {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Branch: Drinkkaart top-up // Branch: Drinkkaart top-up
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
if (customData?.type === "drinkkaart_topup") { if (metadata?.type === "drinkkaart_topup") {
const { drinkkaartId, userId } = customData; const { drinkkaartId, userId } = metadata;
if (!drinkkaartId || !userId) { if (!drinkkaartId || !userId) {
console.error( 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 }); 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 // Idempotency: skip if already processed
const existing = await db const existing = await db
.select({ id: drinkkaartTopup.id }) .select({ id: drinkkaartTopup.id })
.from(drinkkaartTopup) .from(drinkkaartTopup)
.where(eq(drinkkaartTopup.lemonsqueezyOrderId, orderId)) .where(eq(drinkkaartTopup.molliePaymentId, payment.id))
.limit(1) .limit(1)
.then((r) => r[0]); .then((r) => r[0]);
if (existing) { 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 }); return new Response("OK", { status: 200 });
} }
@@ -136,7 +141,7 @@ async function handleWebhook({ request }: { request: Request }) {
); );
if (result.rowsAffected === 0) { 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( console.error(
`Drinkkaart optimistic lock conflict for ${drinkkaartId}`, `Drinkkaart optimistic lock conflict for ${drinkkaartId}`,
); );
@@ -151,14 +156,14 @@ async function handleWebhook({ request }: { request: Request }) {
balanceBefore, balanceBefore,
balanceAfter, balanceAfter,
type: "payment", type: "payment",
lemonsqueezyOrderId: orderId, molliePaymentId: payment.id,
adminId: null, adminId: null,
reason: null, reason: null,
paidAt: new Date(), paidAt: new Date(),
}); });
console.log( 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 }); return new Response("OK", { status: 200 });
@@ -167,9 +172,9 @@ async function handleWebhook({ request }: { request: Request }) {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Branch: Registration payment // Branch: Registration payment
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const registrationToken = customData?.registration_token; const registrationToken = metadata?.registration_token;
if (!registrationToken) { 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 }); return new Response("Missing registration token", { status: 400 });
} }
@@ -192,14 +197,13 @@ async function handleWebhook({ request }: { request: Request }) {
.set({ .set({
paymentStatus: "paid", paymentStatus: "paid",
paymentAmount: 0, paymentAmount: 0,
lemonsqueezyOrderId: orderId, molliePaymentId: payment.id,
lemonsqueezyCustomerId: customerId,
paidAt: new Date(), paidAt: new Date(),
}) })
.where(eq(registration.managementToken, registrationToken)); .where(eq(registration.managementToken, registrationToken));
console.log( 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 // 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: { server: {
handlers: { handlers: {
POST: handleWebhook, POST: handleWebhook,

View File

@@ -38,7 +38,7 @@ function ContactPage() {
<section className="rounded-lg bg-white/5 p-6"> <section className="rounded-lg bg-white/5 p-6">
<h3 className="mb-3 text-white text-xl">Open Mic Night</h3> <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>Aanvang: 19:00 uur</p>
<p className="mt-2 text-white/60"> <p className="mt-2 text-white/60">
Locatie wordt later bekendgemaakt aan geregistreerde deelnemers. Locatie wordt later bekendgemaakt aan geregistreerde deelnemers.

View File

@@ -12,6 +12,8 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { REGISTRATION_OPENS_AT } from "@/lib/opening";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/login")({ export const Route = createFileRoute("/login")({
@@ -31,6 +33,7 @@ export const Route = createFileRoute("/login")({
function LoginPage() { function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const search = Route.useSearch(); const search = Route.useSearch();
const { isOpen } = useRegistrationOpen();
const [isSignup, setIsSignup] = useState(() => search.signup === "1"); const [isSignup, setIsSignup] = useState(() => search.signup === "1");
const [email, setEmail] = useState(() => search.email ?? ""); const [email, setEmail] = useState(() => search.email ?? "");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -204,6 +207,43 @@ function LoginPage() {
Terug naar website Terug naar website
</Link> </Link>
</div> </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"> <form onSubmit={handleSubmit} className="space-y-4">
{isSignup && ( {isSignup && (
@@ -271,15 +311,29 @@ function LoginPage() {
: "Inloggen"} : "Inloggen"}
</Button> </Button>
<div className="flex flex-col gap-2 text-center"> <div className="flex flex-col gap-2 text-center">
<button {/* Only show signup toggle when registration is open */}
type="button" {isOpen && (
onClick={() => setIsSignup(!isSignup)} <button
className="text-sm text-white/60 hover:text-white" type="button"
> onClick={() => setIsSignup(!isSignup)}
{isSignup className="text-sm text-white/60 hover:text-white"
? "Al een account? Log in" >
: "Nog geen account? Registreer"} {isSignup
</button> ? "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"> <Link to="/" className="text-sm text-white/60 hover:text-white">
Terug naar website Terug naar website
</Link> </Link>

View File

@@ -117,8 +117,6 @@ interface EditFormProps {
lastName: string; lastName: string;
email: string; email: string;
phone: string | null; phone: string | null;
postcode: string | null;
birthdate: string | null;
registrationType: string; registrationType: string;
artForm: string | null; artForm: string | null;
experience: string | null; experience: string | null;
@@ -142,8 +140,6 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: initialData.lastName, lastName: initialData.lastName,
email: initialData.email, email: initialData.email,
phone: initialData.phone ?? "", phone: initialData.phone ?? "",
postcode: initialData.postcode ?? "",
birthdate: initialData.birthdate ?? "",
registrationType: initialType, registrationType: initialType,
artForm: initialData.artForm ?? "", artForm: initialData.artForm ?? "",
experience: initialData.experience ?? "", experience: initialData.experience ?? "",
@@ -183,8 +179,6 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: formData.lastName.trim(), lastName: formData.lastName.trim(),
email: formData.email.trim(), email: formData.email.trim(),
phone: formData.phone.trim() || undefined, phone: formData.phone.trim() || undefined,
postcode: formData.postcode.trim() || "",
birthdate: formData.birthdate || "",
registrationType: formData.registrationType, registrationType: formData.registrationType,
artForm: performer ? formData.artForm.trim() || undefined : undefined, artForm: performer ? formData.artForm.trim() || undefined : undefined,
experience: performer experience: performer
@@ -198,8 +192,6 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: g.lastName.trim(), lastName: g.lastName.trim(),
email: g.email.trim() || undefined, email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined, phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})), })),
extraQuestions: formData.extraQuestions.trim() || undefined, extraQuestions: formData.extraQuestions.trim() || undefined,
giftAmount, giftAmount,
@@ -279,42 +271,6 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
/> />
</div> </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 */} {/* Registration type toggle */}
<div> <div>
<p className="mb-3 text-white">Type inschrijving</p> <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; if (formGuests.length >= 9) return;
setFormGuests((prev) => [ setFormGuests((prev) => [
...prev, ...prev,
{ { firstName: "", lastName: "", email: "", phone: "" },
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
]); ]);
}} }}
onRemove={(idx) => onRemove={(idx) =>
@@ -620,7 +569,7 @@ function ManageRegistrationPage() {
Jouw inschrijving Jouw inschrijving
</h1> </h1>
<p className="mb-8 text-white/60"> <p className="mb-8 text-white/60">
Open Mic Night vrijdag 24 april 2026 Open Mic Night vrijdag 18 april 2026
</p> </p>
{/* Type badge */} {/* Type badge */}
@@ -636,19 +585,18 @@ function ManageRegistrationPage() {
)} )}
</div> </div>
{/* Payment status - not shown for performers without a gift */} {/* Payment status - shown for everyone with pending/extra payment or gift */}
{(!isPerformer || (data.giftAmount ?? 0) > 0) && {(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && ( <div className="mb-6">
<div className="mb-6"> {data.paymentStatus === "paid" ? (
{data.paymentStatus === "paid" ? ( <PaidBadge />
<PaidBadge /> ) : data.paymentStatus === "extra_payment_pending" ? (
) : data.paymentStatus === "extra_payment_pending" ? ( <ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} />
<ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} /> ) : (
) : ( <PendingBadge />
<PendingBadge /> )}
)} </div>
</div> )}
)}
{/* Gift display */} {/* Gift display */}
{(data.giftAmount ?? 0) > 0 && ( {(data.giftAmount ?? 0) > 0 && (
@@ -678,14 +626,6 @@ function ManageRegistrationPage() {
<p className="text-sm text-white/50">Telefoon</p> <p className="text-sm text-white/50">Telefoon</p>
<p className="text-lg text-white">{data.phone || "—"}</p> <p className="text-lg text-white">{data.phone || "—"}</p>
</div> </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> </div>
{isPerformer && ( {isPerformer && (
@@ -720,16 +660,6 @@ function ManageRegistrationPage() {
<p className="text-white"> <p className="text-white">
{g.firstName} {g.lastName} {g.firstName} {g.lastName}
</p> </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 && ( {g.email && (
<p className="text-sm text-white/60">{g.email}</p> <p className="text-sm text-white/60">{g.email}</p>
)} )}

View File

@@ -42,6 +42,7 @@
"@tanstack/react-start": "^1.141.1", "@tanstack/react-start": "^1.141.1",
"@tanstack/router-plugin": "^1.141.1", "@tanstack/router-plugin": "^1.141.1",
"better-auth": "catalog:", "better-auth": "catalog:",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "catalog:", "dotenv": "catalog:",
@@ -68,6 +69,7 @@
"@tanstack/react-router-devtools": "^1.141.1", "@tanstack/react-router-devtools": "^1.141.1",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/canvas-confetti": "^1.9.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@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/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/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@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=="], "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=="], "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=="], "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}, Hoi ${params.firstName},
</p> </p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;"> <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> </p>
<!-- Registration summary --> <!-- Registration summary -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;"> <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 }) => { .handler(async ({ input, context }) => {
const { env: serverEnv, session } = context; const { env: serverEnv, session } = context;
if ( if (!serverEnv.MOLLIE_API_KEY) {
!serverEnv.LEMON_SQUEEZY_API_KEY ||
!serverEnv.LEMON_SQUEEZY_STORE_ID ||
!serverEnv.LEMON_SQUEEZY_VARIANT_ID
) {
throw new ORPCError("INTERNAL_SERVER_ERROR", { throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "LemonSqueezy is niet geconfigureerd", message: "Mollie is niet geconfigureerd",
}); });
} }
const card = await getOrCreateDrinkkaart(session.user.id); const card = await getOrCreateDrinkkaart(session.user.id);
const response = await fetch( // Mollie amounts must be formatted as "10.00" (string, 2 decimal places)
"https://api.lemonsqueezy.com/v1/checkouts", const amountValue = (input.amountCents / 100).toFixed(2);
{
method: "POST", const response = await fetch("https://api.mollie.com/v2/payments", {
headers: { method: "POST",
Accept: "application/vnd.api+json", headers: {
"Content-Type": "application/vnd.api+json", "Content-Type": "application/json",
Authorization: `Bearer ${serverEnv.LEMON_SQUEEZY_API_KEY}`, Authorization: `Bearer ${serverEnv.MOLLIE_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,
},
},
},
},
}),
}, },
); 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) { if (!response.ok) {
const errorData = await response.json(); 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", { throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Kon checkout niet aanmaken", message: "Kon checkout niet aanmaken",
}); });
} }
const data = (await response.json()) as { 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) { if (!checkoutUrl) {
throw new ORPCError("INTERNAL_SERVER_ERROR", { throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Geen checkout URL ontvangen", message: "Geen checkout URL ontvangen",

View File

@@ -36,8 +36,6 @@ function parseGuestsJson(raw: string | null): Array<{
lastName: string; lastName: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
}> { }> {
if (!raw) return []; if (!raw) return [];
try { try {
@@ -52,7 +50,7 @@ function parseGuestsJson(raw: string | null): Array<{
* Credits a watcher's drinkCardValue to their drinkkaart account. * Credits a watcher's drinkCardValue to their drinkkaart account.
* *
* Safe to call multiple times — the `drinkkaartCreditedAt IS NULL` guard and the * 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). * if called concurrently (webhook + signup racing each other).
* *
* Returns a status string describing the outcome. * Returns a status string describing the outcome.
@@ -162,11 +160,11 @@ export async function creditRegistrationToAccount(
return { credited: false, amountCents: 0, status: "already_credited" }; 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 // topup row is clearly linked to the original payment. We prefix with
// "reg_" to distinguish from direct drinkkaart top-up orders if needed. // "reg_" to distinguish from direct drinkkaart top-up orders if needed.
const topupPaymentId = reg.lemonsqueezyOrderId const topupPaymentId = reg.molliePaymentId
? `reg_${reg.lemonsqueezyOrderId}` ? `reg_${reg.molliePaymentId}`
: null; : null;
await db.insert(drinkkaartTopup).values({ await db.insert(drinkkaartTopup).values({
@@ -177,7 +175,7 @@ export async function creditRegistrationToAccount(
balanceBefore, balanceBefore,
balanceAfter, balanceAfter,
type: "payment", type: "payment",
lemonsqueezyOrderId: topupPaymentId, molliePaymentId: topupPaymentId,
adminId: null, adminId: null,
reason: "Drinkkaart bij registratie", reason: "Drinkkaart bij registratie",
paidAt: reg.paidAt ?? new Date(), paidAt: reg.paidAt ?? new Date(),
@@ -220,8 +218,6 @@ const guestSchema = z.object({
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email().optional().or(z.literal("")), email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(), phone: z.string().optional(),
birthdate: z.string().min(1),
postcode: z.string().min(1),
}); });
const coreRegistrationFields = { const coreRegistrationFields = {
@@ -229,8 +225,6 @@ const coreRegistrationFields = {
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email(), email: z.string().email(),
phone: z.string().optional(), phone: z.string().optional(),
postcode: z.string().min(1),
birthdate: z.string().min(1),
registrationType: registrationTypeSchema.default("watcher"), registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(), artForm: z.string().optional(),
experience: z.string().optional(), experience: z.string().optional(),
@@ -283,8 +277,6 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType, registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null, artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null, experience: isPerformer ? input.experience || null : null,
@@ -372,8 +364,6 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType, registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null, artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null, experience: isPerformer ? input.experience || null : null,
@@ -525,8 +515,6 @@ export const appRouter = {
"Last Name", "Last Name",
"Email", "Email",
"Phone", "Phone",
"Postcode",
"Birthdate",
"Type", "Type",
"Art Form", "Art Form",
"Experience", "Experience",
@@ -546,7 +534,7 @@ export const appRouter = {
const guestSummary = guests const guestSummary = guests
.map( .map(
(g) => (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(" | "); .join(" | ");
return [ return [
@@ -555,8 +543,6 @@ export const appRouter = {
r.lastName, r.lastName,
r.email, r.email,
r.phone || "", r.phone || "",
r.postcode || "",
r.birthdate || "",
r.registrationType, r.registrationType,
r.artForm || "", r.artForm || "",
r.experience || "", r.experience || "",
@@ -597,7 +583,7 @@ export const appRouter = {
return result; return result;
}), }),
getMyRegistrations: protectedProcedure.handler(async ({ context }) => { getMyRegistration: protectedProcedure.handler(async ({ context }) => {
const email = context.session.user.email; const email = context.session.user.email;
const rows = await db const rows = await db
@@ -606,12 +592,16 @@ export const appRouter = {
.where( .where(
and(eq(registration.email, email), isNull(registration.cancelledAt)), 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, ...row,
guests: parseGuestsJson(row.guests), guests: parseGuestsJson(row.guests),
})); };
}), }),
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -751,12 +741,8 @@ export const appRouter = {
}), }),
) )
.handler(async ({ input }) => { .handler(async ({ input }) => {
if ( if (!env.MOLLIE_API_KEY) {
!env.LEMON_SQUEEZY_API_KEY || throw new Error("Mollie is niet geconfigureerd");
!env.LEMON_SQUEEZY_STORE_ID ||
!env.LEMON_SQUEEZY_VARIANT_ID
) {
throw new Error("LemonSqueezy is niet geconfigureerd");
} }
const rows = await db const rows = await db
@@ -809,66 +795,37 @@ export const appRouter = {
const redirectUrl = const redirectUrl =
input.redirectUrl ?? `${env.CORS_ORIGIN}/manage/${input.token}`; input.redirectUrl ?? `${env.CORS_ORIGIN}/manage/${input.token}`;
const response = await fetch( // Mollie amounts must be formatted as "10.00" (string, 2 decimal places)
"https://api.lemonsqueezy.com/v1/checkouts", const amountValue = (amountInCents / 100).toFixed(2);
{
method: "POST", const webhookUrl = `${env.CORS_ORIGIN}/api/webhook/mollie`;
headers: {
Accept: "application/vnd.api+json", const response = await fetch("https://api.mollie.com/v2/payments", {
"Content-Type": "application/vnd.api+json", method: "POST",
Authorization: `Bearer ${env.LEMON_SQUEEZY_API_KEY}`, headers: {
}, "Content-Type": "application/json",
body: JSON.stringify({ Authorization: `Bearer ${env.MOLLIE_API_KEY}`,
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,
},
},
},
},
}),
}, },
); 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) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
console.error("LemonSqueezy checkout error:", errorData); console.error("Mollie checkout error:", errorData);
throw new Error("Kon checkout niet aanmaken"); throw new Error("Kon checkout niet aanmaken");
} }
const checkoutData = (await response.json()) as { 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"); 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, "when": 1772530000000,
"tag": "0003_add_guests", "tag": "0003_add_guests",
"breakpoints": true "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(), balanceBefore: integer("balance_before").notNull(),
balanceAfter: integer("balance_after").notNull(), balanceAfter: integer("balance_after").notNull(),
type: text("type", { enum: ["payment", "admin_credit"] }).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" adminId: text("admin_id"), // nullable; only for type="admin_credit"
reason: text("reason"), reason: text("reason"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }).notNull(), 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), drinkCardValue: integer("drink_card_value").default(0),
// Guests: JSON array of {firstName, lastName, email?, phone?} objects // Guests: JSON array of {firstName, lastName, email?, phone?} objects
guests: text("guests"), guests: text("guests"),
// Contact / demographic
postcode: text("postcode"),
birthdate: text("birthdate"),
// Shared // Shared
extraQuestions: text("extra_questions"), extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(), managementToken: text("management_token").unique(),
@@ -32,8 +29,7 @@ export const registration = sqliteTable(
paymentStatus: text("payment_status").notNull().default("pending"), paymentStatus: text("payment_status").notNull().default("pending"),
paymentAmount: integer("payment_amount").default(0), paymentAmount: integer("payment_amount").default(0),
giftAmount: integer("gift_amount").default(0), giftAmount: integer("gift_amount").default(0),
lemonsqueezyOrderId: text("lemonsqueezy_order_id"), molliePaymentId: text("mollie_payment_id"),
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }), paidAt: integer("paid_at", { mode: "timestamp_ms" }),
// Set when the drinkCardValue has been credited to the user's drinkkaart. // 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 // 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_managementToken_idx").on(table.managementToken),
index("registration_paymentStatus_idx").on(table.paymentStatus), index("registration_paymentStatus_idx").on(table.paymentStatus),
index("registration_giftAmount_idx").on(table.giftAmount), 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 NODE_ENV: z
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),
LEMON_SQUEEZY_API_KEY: z.string().min(1).optional(), MOLLIE_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(),
}, },
runtimeEnv: process.env, runtimeEnv: process.env,
emptyStringAsUndefined: true, emptyStringAsUndefined: true,

View File

@@ -30,11 +30,8 @@ export const web = await TanStackStart("web", {
SMTP_USER: getEnvVar("SMTP_USER"), SMTP_USER: getEnvVar("SMTP_USER"),
SMTP_PASS: getEnvVar("SMTP_PASS"), SMTP_PASS: getEnvVar("SMTP_PASS"),
SMTP_FROM: getEnvVar("SMTP_FROM"), SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (LemonSqueezy) // Payments (Mollie)
LEMON_SQUEEZY_API_KEY: getEnvVar("LEMON_SQUEEZY_API_KEY"), MOLLIE_API_KEY: getEnvVar("MOLLIE_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"),
}, },
domains: ["kunstenkamp.be", "www.kunstenkamp.be"], domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
}); });