Compare commits

..

11 Commits

Author SHA1 Message Date
d25f4aa813 feat: show real attendee count with guests in admin, expandable guest details, fix CSV export 2026-03-11 11:37:51 +01:00
8a6d3035cb fix: small issues 2026-03-11 11:29:11 +01:00
0a849bd9c5 fix: Hide payment badge for performers with no payment obligation
Performers have nothing to pay unless they added a voluntary gift.
Only show PaymentBadge when registrationType is 'watcher' or giftAmount
> 0.
2026-03-11 11:27:19 +01:00
17c6315dad Simplify registration flow: mandatory signup redirect, payment emails, payment reminder cron
- EventRegistrationForm/WatcherForm/PerformerForm: remove session/login nudge/SuccessScreen;
  both roles redirect to /login?signup=1&email=<email>&next=/account after submit
- SuccessScreen.tsx deleted (no longer used)
- account.tsx: add 'Betaal nu' CTA via checkoutMutation (shown when watcher + paymentStatus pending)
- DB schema: add paymentReminderSentAt column to registration table
- Migration: 0008_payment_reminder_sent_at.sql
- email.ts: update registrationConfirmationHtml / sendConfirmationEmail with signupUrl param;
  add sendPaymentReminderEmail and sendPaymentConfirmationEmail functions
- routers/index.ts: wire payment reminder into runSendReminders() — queries watchers
  with paymentStatus pending, paymentReminderSentAt IS NULL, createdAt <= now-3days
- mollie.ts webhook: call sendPaymentConfirmationEmail after marking registration paid
2026-03-11 11:17:24 +01:00
e59a588a9d feat: confetti
Closes #5
2026-03-11 10:45:40 +01:00
dfc8ace186 feat: add password reset flow
- Add /forgot-password and /reset-password routes (Dutch UI)
- Wire sendResetPassword into emailAndPassword config (not a plugin)
- Send styled HTML email via nodemailer with 1-hour expiry
- Add 'Wachtwoord vergeten?' link on login page
- Update routeTree.gen.ts with new routes
2026-03-11 09:43:32 +01:00
439bbc5545 feat(web): set test open date 2026-03-11 09:22:01 +01:00
89043c60a3 feat(web): add Cloudflare Worker server entry with scheduled reminders
Sets up the server handler using TanStack React Start and implements
a scheduled event handler to run reminder tasks via Cloudflare cron triggers.
2026-03-10 16:29:56 +01:00
7eabe88d30 feat: reminder email opt-in 1 hour before registration opens 2026-03-10 15:48:41 +01:00
d180a33d6e feat: hoe inschrijven 2026-03-10 14:31:36 +01:00
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
47 changed files with 2277 additions and 1503 deletions

View File

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

View File

@@ -0,0 +1,260 @@
import { useState } from "react";
import { REGISTRATION_OPENS_AT } from "@/lib/opening";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { client } from "@/utils/orpc";
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 w-14 items-center justify-center rounded-sm bg-white/10 px-2 py-2 font-['Intro',sans-serif] text-3xl text-white tabular-nums sm:w-auto sm:px-4 sm:py-3 sm:text-5xl md:text-6xl lg:text-7xl">
{value}
</div>
<span className="font-['Intro',sans-serif] text-[9px] text-white/50 uppercase tracking-widest sm:text-xs">
{label}
</span>
</div>
);
}
// Reminder opt-in form — sits at the very bottom of the banner
function ReminderForm() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "subscribed" | "already_subscribed" | "error"
>("idle");
const [errorMessage, setErrorMessage] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || status === "loading") return;
setStatus("loading");
try {
const result = await client.subscribeReminder({ email });
if (!result.ok && result.reason === "already_open") {
setStatus("idle");
return;
}
setStatus("subscribed");
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : "Er ging iets mis.");
setStatus("error");
}
}
const reminderTime = REGISTRATION_OPENS_AT.toLocaleTimeString("nl-BE", {
hour: "2-digit",
minute: "2-digit",
});
return (
<div
className="w-full"
style={{
animation: "reminderFadeIn 0.6s ease both",
animationDelay: "0.3s",
}}
>
<style>{`
@keyframes reminderFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes reminderCheck {
from { opacity: 0; transform: scale(0.7); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
{/* Hairline divider */}
<div
className="mx-auto mb-6 h-px w-24"
style={{
background:
"linear-gradient(to right, transparent, rgba(255,255,255,0.15), transparent)",
}}
/>
{status === "subscribed" || status === "already_subscribed" ? (
<div
className="flex flex-col items-center gap-2"
style={{
animation: "reminderCheck 0.4s cubic-bezier(0.34,1.56,0.64,1) both",
}}
>
{/* Checkmark glyph */}
<div
className="flex h-8 w-8 items-center justify-center rounded-full"
style={{
background: "rgba(255,255,255,0.12)",
border: "1px solid rgba(255,255,255,0.2)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
aria-label="Vinkje"
role="img"
>
<path
d="M2.5 7L5.5 10L11.5 4"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<p
className="font-['Intro',sans-serif] text-sm tracking-wide"
style={{ color: "rgba(255,255,255,0.65)" }}
>
Herinnering ingepland voor {reminderTime}
</p>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<p
className="font-['Intro',sans-serif] text-xs uppercase tracking-widest"
style={{ color: "rgba(255,255,255,0.35)", letterSpacing: "0.14em" }}
>
Herinnering ontvangen?
</p>
{/* Fused pill input + button */}
<form
onSubmit={handleSubmit}
className="flex w-full max-w-xs overflow-hidden"
style={{
borderRadius: "3px",
border: "1px solid rgba(255,255,255,0.18)",
background: "rgba(255,255,255,0.07)",
backdropFilter: "blur(6px)",
}}
>
<input
type="email"
required
placeholder="jouw@email.be"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={status === "loading"}
className="min-w-0 flex-1 bg-transparent px-4 py-2.5 text-sm text-white outline-none disabled:opacity-50"
style={{
fontFamily: "'Intro', sans-serif",
fontSize: "0.8rem",
letterSpacing: "0.02em",
}}
/>
{/* Vertical separator */}
<div
style={{
width: "1px",
background: "rgba(255,255,255,0.15)",
flexShrink: 0,
}}
/>
<button
type="submit"
disabled={status === "loading" || !email}
className="shrink-0 px-4 py-2.5 font-semibold text-xs transition-all disabled:opacity-40"
style={{
fontFamily: "'Intro', sans-serif",
letterSpacing: "0.06em",
color:
status === "loading"
? "rgba(255,255,255,0.5)"
: "rgba(255,255,255,0.9)",
background: "transparent",
cursor:
status === "loading" || !email ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
}}
>
{status === "loading" ? "…" : "Stuur mij"}
</button>
</form>
{status === "error" && (
<p
className="font-['Intro',sans-serif] text-xs"
style={{ color: "rgba(255,140,140,0.8)" }}
>
{errorMessage}
</p>
)}
</div>
)}
</div>
);
}
/**
* Shown in place of the registration form while registration is not yet open.
*/
export function CountdownBanner() {
const { isOpen, days, hours, minutes, seconds } = useRegistrationOpen();
// 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-6 py-4 text-center sm:gap-8">
<div>
<p className="font-['Intro',sans-serif] text-base text-white/70 sm:text-lg md:text-xl">
Inschrijvingen openen op
</p>
<p className="mt-1 font-['Intro',sans-serif] text-lg text-white capitalize sm:text-xl md:text-2xl">
{openDate} om {openTime}
</p>
</div>
{/* Countdown — single row forced via grid, scales down on small screens */}
<div className="grid grid-cols-[auto_auto_auto_auto_auto_auto_auto] items-center gap-1.5 sm:flex sm:gap-4 md:gap-6">
<UnitBox value={String(days)} label="dagen" />
<span className="mb-6 font-['Intro',sans-serif] text-2xl text-white/40 sm:text-4xl">
:
</span>
<UnitBox value={pad(hours)} label="uren" />
<span className="mb-6 font-['Intro',sans-serif] text-2xl text-white/40 sm:text-4xl">
:
</span>
<UnitBox value={pad(minutes)} label="minuten" />
<span className="mb-6 font-['Intro',sans-serif] text-2xl text-white/40 sm:text-4xl">
:
</span>
<UnitBox value={pad(seconds)} label="seconden" />
</div>
<p className="max-w-md px-4 text-sm text-white/50">
Kom snel terug! Zodra de inschrijvingen openen kun je je hier
registreren als toeschouwer of als artiest.
</p>
{/* Email reminder opt-in — always last */}
<ReminderForm />
</div>
);
}

View File

@@ -1,47 +1,74 @@
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import confetti from "canvas-confetti";
import { useEffect, useRef, useState } from "react";
import { CountdownBanner } from "@/components/homepage/CountdownBanner";
import { PerformerForm } from "@/components/registration/PerformerForm";
import { SuccessScreen } from "@/components/registration/SuccessScreen";
import { TypeSelector } from "@/components/registration/TypeSelector";
import { WatcherForm } from "@/components/registration/WatcherForm";
import { authClient } from "@/lib/auth-client";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
function fireConfetti() {
const colors = ["#d82560", "#52979b", "#d09035", "#214e51", "#ffffff"];
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);
}
type RegistrationType = "performer" | "watcher";
interface SuccessState {
token: string;
email: string;
name: string;
function redirectToSignup(email: string) {
const params = new URLSearchParams({
signup: "1",
email,
next: "/account",
});
window.location.href = `/login?${params.toString()}`;
}
export default function EventRegistrationForm() {
const { data: session } = authClient.useSession();
const { isOpen } = useRegistrationOpen();
const confettiFired = useRef(false);
useEffect(() => {
if (isOpen && !confettiFired.current) {
confettiFired.current = true;
fireConfetti();
}
}, [isOpen]);
const [selectedType, setSelectedType] = useState<RegistrationType | null>(
null,
);
const [successState, setSuccessState] = useState<SuccessState | null>(null);
const isLoggedIn = !!session?.user;
const user = session?.user as { name?: string; email?: string } | undefined;
// Split "Jan De Smet" → firstName="Jan", lastName="De Smet"
const nameParts = (user?.name ?? "").trim().split(/\s+/);
const prefillFirstName = nameParts[0] ?? "";
const prefillLastName = nameParts.slice(1).join(" ");
const prefillEmail = user?.email ?? "";
if (successState) {
if (!isOpen) {
return (
<SuccessScreen
token={successState.token}
email={successState.email}
name={successState.name}
isLoggedIn={isLoggedIn}
onReset={() => {
setSuccessState(null);
setSelectedType(null);
}}
/>
<section
id="registration"
className="relative z-30 w-full bg-[#214e51]/96 px-6 py-16 md:px-12"
>
<div className="mx-auto w-full max-w-6xl">
<CountdownBanner />
</div>
</section>
);
}
@@ -60,54 +87,18 @@ export default function EventRegistrationForm() {
<p className="mb-8 max-w-3xl text-lg text-white/80 md:text-xl">
Doe je mee of kom je kijken? Kies je rol en vul het formulier in.
</p>
{/* Login nudge — shown only to guests */}
{!isLoggedIn && (
<div className="mb-10 flex flex-col gap-3 rounded-lg border border-[#52979b]/40 bg-[#52979b]/10 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/80">
<span className="mr-1 font-semibold text-teal-300">
Al een account?
</span>
Log in om je gegevens automatisch in te vullen en je Drinkkaart
direct te activeren na registratie.
</p>
<div className="flex shrink-0 items-center gap-3 text-sm">
<Link
to="/login"
className="font-medium text-teal-300 underline-offset-2 transition-colors hover:underline"
>
Inloggen
</Link>
<span className="text-white/30">·</span>
<Link
to="/login"
search={{ signup: "1" }}
className="text-white/60 transition-colors hover:text-white"
>
Nog geen account? Aanmaken
</Link>
</div>
</div>
)}
{!selectedType && <TypeSelector onSelect={setSelectedType} />}
{selectedType === "performer" && (
<PerformerForm
onBack={() => setSelectedType(null)}
onSuccess={(token, email, name) =>
setSuccessState({ token, email, name })
}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
onSuccess={(_token, email, _name) => redirectToSignup(email)}
/>
)}
{selectedType === "watcher" && (
<WatcherForm
onBack={() => setSelectedType(null)}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
onSuccess={(_token, email, _name) => redirectToSignup(email)}
/>
)}
</div>

View File

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

View File

@@ -0,0 +1,75 @@
export default function HoeInschrijven() {
return (
<section className="relative z-25 w-full bg-[#f8f8f8] px-6 py-16 md:px-12">
<div className="mx-auto max-w-6xl">
<h2 className="mb-4 font-['Intro',sans-serif] text-4xl text-[#214e51] md:text-5xl">
Hoe schrijf ik mij in?
</h2>
<p className="mb-10 max-w-3xl text-[#214e51]/70 text-lg">
Inschrijven doe je eenvoudig via deze website zodra de inschrijvingen
openen.
</p>
{/* Step cards */}
<div className="grid gap-6 md:grid-cols-3">
{/* Step 1 */}
<div className="flex flex-col gap-3 rounded-2xl border border-[#214e51]/15 bg-white p-6 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d82560] font-['Intro',sans-serif] font-bold text-lg text-white">
1
</div>
<h3 className="font-['Intro',sans-serif] text-[#214e51] text-xl">
Kies je rol
</h3>
<p className="text-[#214e51]/70 text-base leading-relaxed">
Registreer je als{" "}
<strong className="text-[#214e51]">artiest</strong> als je wil
optreden, of als{" "}
<strong className="text-[#214e51]">bezoeker</strong> als je wil
komen kijken. Je kunt ook aangeven met hoeveel mensen je komt.
</p>
</div>
{/* Step 2 */}
<div className="flex flex-col gap-3 rounded-2xl border border-[#214e51]/15 bg-white p-6 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d82560] font-['Intro',sans-serif] font-bold text-lg text-white">
2
</div>
<h3 className="font-['Intro',sans-serif] text-[#214e51] text-xl">
Bevestig je plaats
</h3>
<p className="text-[#214e51]/70 text-base leading-relaxed">
We vragen een bijdrage van{" "}
<strong className="text-[#214e51]">5 per inschrijving</strong> en{" "}
<strong className="text-[#214e51]">2 per extra persoon</strong>{" "}
die je meebrengt.
</p>
</div>
{/* Step 3 */}
<div className="flex flex-col gap-3 rounded-2xl border border-[#214e51]/15 bg-white p-6 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d82560] font-['Intro',sans-serif] font-bold text-lg text-white">
3
</div>
<h3 className="font-['Intro',sans-serif] text-[#214e51] text-xl">
Geniet van de avond
</h3>
<p className="text-[#214e51]/70 text-base leading-relaxed">
Je bijdrage gaat{" "}
<strong className="text-[#214e51]">niet verloren</strong> het
wordt volledig op{" "}
<strong className="text-[#214e51]">je drankkaart</strong> gezet.
Zo kun je tijdens de avond iets drinken of een snack nemen.
</p>
</div>
</div>
{/* Bottom note */}
<p className="mt-8 max-w-3xl text-[#214e51]/60 text-base leading-relaxed">
Zo zorgen we ervoor dat iedereen die zich inschrijft ook echt een
plaats heeft en dat we samen kunnen genieten van een gezellige,
inspirerende avond vol muziek, poëzie en andere kunst.
</p>
</div>
</section>
);
}

View File

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

View File

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

View File

@@ -1,157 +0,0 @@
import { useState } from "react";
interface Props {
token: string;
email?: string;
name?: string;
onReset: () => void;
isLoggedIn?: boolean;
}
export function SuccessScreen({
token,
email,
name,
onReset,
isLoggedIn,
}: Props) {
const manageUrl =
typeof window !== "undefined"
? `${window.location.origin}/manage/${token}`
: `/manage/${token}`;
const [drinkkaartPromptDismissed, setDrinkkaartPromptDismissed] = useState(
() => {
try {
return (
typeof localStorage !== "undefined" &&
localStorage.getItem("drinkkaart_prompt_dismissed") === "1"
);
} catch {
return false;
}
},
);
const handleDismissPrompt = () => {
try {
localStorage.setItem("drinkkaart_prompt_dismissed", "1");
} catch {
// ignore
}
setDrinkkaartPromptDismissed(true);
};
// Build signup URL with pre-filled email query param so /login can pre-fill
const signupUrl = email
? `/login?signup=1&email=${encodeURIComponent(email)}`
: "/login?signup=1";
return (
<section
id="registration"
className="relative z-30 flex w-full items-center justify-center bg-[#214e51]/96 px-6 py-16 md:px-12"
>
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg border border-white/20 bg-white/5 p-8 md:p-12">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-500/20 text-green-400">
<svg
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-label="Succes"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="mb-4 font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Gelukt!
</h2>
<p className="mb-6 text-lg text-white/80">
Je inschrijving is bevestigd. We sturen je zo dadelijk een
bevestigingsmail.
</p>
<div className="mb-8 rounded-lg border border-white/10 bg-white/5 p-6">
<p className="mb-2 text-sm text-white/60">
Geen mail ontvangen? Gebruik deze link:
</p>
<a
href={manageUrl}
className="break-all text-sm text-white/80 underline underline-offset-2 hover:text-white"
>
{manageUrl}
</a>
</div>
<div className="flex flex-wrap items-center gap-4">
<a
href={manageUrl}
className="inline-flex items-center bg-white px-6 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100"
>
Bekijk mijn inschrijving
</a>
<button
type="button"
onClick={onReset}
className="text-white/60 underline underline-offset-2 transition-colors hover:text-white"
>
Nog een inschrijving
</button>
</div>
{/* Account creation prompt — hidden when already logged in */}
{!isLoggedIn && !drinkkaartPromptDismissed && (
<div className="mt-8 rounded-xl border border-teal-400/30 bg-teal-400/10 p-6">
<h3 className="mb-1 font-['Intro',sans-serif] text-lg text-white">
Maak een gratis account aan
</h3>
<p className="mb-4 text-sm text-white/70">
Met een account zie je je inschrijving, activeer je je
Drinkkaart en laad je saldo op vóór het evenement.
</p>
<ul className="mb-5 space-y-1.5 text-sm text-white/70">
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Beheer je inschrijving op één plek
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Digitale Drinkkaart met QR-code
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Saldo opladen vóór het evenement
</li>
</ul>
<div className="flex flex-wrap items-center gap-3">
<a
href={signupUrl}
className="inline-flex items-center rounded-lg bg-white px-5 py-2.5 font-['Intro',sans-serif] text-[#214e51] text-sm transition-all hover:scale-105 hover:bg-gray-100"
>
Account aanmaken
{name && (
<span className="ml-1.5 font-normal text-xs opacity-60">
als {name}
</span>
)}
</a>
<button
type="button"
onClick={handleDismissPrompt}
className="text-sm text-white/40 underline underline-offset-2 transition-colors hover:text-white/70"
>
Overslaan
</button>
</div>
</div>
)}
</div>
</div>
</section>
);
}

View File

@@ -1,7 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import {
calculateDrinkCard,
type GuestEntry,
@@ -12,7 +11,7 @@ import {
validatePhone,
validateTextField,
} from "@/lib/registration";
import { client, orpc } from "@/utils/orpc";
import { orpc } from "@/utils/orpc";
import { GiftSelector } from "./GiftSelector";
import { GuestList } from "./GuestList";
@@ -21,240 +20,21 @@ interface WatcherErrors {
lastName?: string;
email?: string;
phone?: string;
postcode?: string;
birthdate?: string;
}
interface Props {
onBack: () => void;
prefillFirstName?: string;
prefillLastName?: string;
prefillEmail?: string;
isLoggedIn?: boolean;
}
// ── Account creation modal shown after successful registration ─────────────
interface AccountModalProps {
prefillName: string;
prefillEmail: string;
onDone: () => void;
}
function AccountModal({
prefillName,
prefillEmail,
onDone,
}: AccountModalProps) {
const [name, setName] = useState(prefillName);
const [email] = useState(prefillEmail); // email is fixed (tied to registration)
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState<string | undefined>();
const signupMutation = useMutation({
mutationFn: async () => {
if (password.length < 8)
throw new Error("Wachtwoord moet minstens 8 tekens zijn");
if (password !== confirmPassword)
throw new Error("Wachtwoorden komen niet overeen");
const result = await authClient.signUp.email({ email, password, name });
if (result.error) throw new Error(result.error.message);
return result.data;
},
onSuccess: () => {
toast.success(
"Account aangemaakt! Je wordt nu doorgestuurd naar betaling.",
);
onDone();
},
onError: (error) => {
toast.error(`Account aanmaken mislukt: ${error.message}`);
},
});
const handlePasswordChange = (val: string) => {
setPassword(val);
if (passwordError) setPasswordError(undefined);
};
const handleConfirmChange = (val: string) => {
setConfirmPassword(val);
if (passwordError) setPasswordError(undefined);
};
const handleSignup = (e: React.FormEvent) => {
e.preventDefault();
if (password.length < 8) {
setPasswordError("Wachtwoord moet minstens 8 tekens zijn");
return;
}
if (password !== confirmPassword) {
setPasswordError("Wachtwoorden komen niet overeen");
return;
}
signupMutation.mutate();
};
// Receive the checkout URL from parent by exposing a setter via ref pattern
// (simpler: the parent renders the modal and passes a prop)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-[#1a3d40] p-8 shadow-2xl">
{/* Header */}
<div className="mb-6 flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-teal-400/20 text-teal-300 text-xl">
</div>
<div>
<h2 className="font-['Intro',sans-serif] text-2xl text-white">
Inschrijving gelukt!
</h2>
<p className="mt-1 text-sm text-white/60">
Maak een gratis account aan om je Drinkkaart bij te houden.
</p>
</div>
</div>
{/* Value prop */}
<div className="mb-6 rounded-lg border border-teal-400/20 bg-teal-400/10 p-4">
<ul className="space-y-1.5 text-sm text-white/80">
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Zie je drinkkaart-saldo en QR-code
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Laad je kaart op vóór het evenement
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Beheer je inschrijving op één plek
</li>
</ul>
</div>
{/* Signup form */}
<form onSubmit={handleSignup} className="space-y-4">
{/* Name */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-name"
>
Naam
</label>
<input
id="modal-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none"
/>
</div>
{/* Email (read-only) */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-email"
>
Email
</label>
<input
id="modal-email"
type="email"
value={email}
readOnly
className="w-full cursor-not-allowed rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white/60"
/>
</div>
{/* Password */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-password"
>
Wachtwoord
</label>
<input
id="modal-password"
type="password"
placeholder="Minstens 8 tekens"
value={password}
onChange={(e) => handlePasswordChange(e.target.value)}
required
minLength={8}
className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none"
/>
</div>
{/* Confirm password */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-confirm"
>
Bevestig wachtwoord
</label>
<input
id="modal-confirm"
type="password"
placeholder="Herhaal je wachtwoord"
value={confirmPassword}
onChange={(e) => handleConfirmChange(e.target.value)}
required
className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none"
/>
</div>
{passwordError && (
<p className="text-red-300 text-sm">{passwordError}</p>
)}
<button
type="submit"
disabled={signupMutation.isPending}
className="w-full rounded-lg bg-white py-3 font-['Intro',sans-serif] text-[#214e51] transition-all hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{signupMutation.isPending
? "Bezig..."
: "Account aanmaken & betalen"}
</button>
</form>
{/* Skip */}
<button
type="button"
onClick={onDone}
className="mt-4 w-full text-center text-sm text-white/40 transition-colors hover:text-white/70"
>
Overslaan, verder naar betaling
</button>
</div>
</div>
);
onSuccess: (token: string, email: string, name: string) => void;
}
// ── Main watcher form ──────────────────────────────────────────────────────
export function WatcherForm({
onBack,
prefillFirstName = "",
prefillLastName = "",
prefillEmail = "",
isLoggedIn = false,
}: Props) {
export function WatcherForm({ onBack, onSuccess }: Props) {
const [data, setData] = useState({
firstName: prefillFirstName,
lastName: prefillLastName,
email: prefillEmail,
firstName: "",
lastName: "",
email: "",
phone: "",
postcode: "",
birthdate: "",
extraQuestions: "",
});
const [errors, setErrors] = useState<WatcherErrors>({});
@@ -263,40 +43,15 @@ export function WatcherForm({
const [guestErrors, setGuestErrors] = useState<GuestErrors[]>([]);
const [giftAmount, setGiftAmount] = useState(0);
// Modal state: shown after successful registration while we fetch checkout URL
const [modalState, setModalState] = useState<{
prefillName: string;
prefillEmail: string;
checkoutUrl: string;
} | null>(null);
const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(),
onSuccess: async (result) => {
if (!result.managementToken) return;
try {
const redirectUrl = isLoggedIn
? `${window.location.origin}/account?topup=success`
: undefined;
const checkout = await client.getCheckoutUrl({
token: result.managementToken,
redirectUrl,
});
if (isLoggedIn) {
// Already logged in — skip the account-creation modal, go straight to checkout
window.location.href = checkout.checkoutUrl;
} else {
// Show the account-creation modal before redirecting to checkout
setModalState({
prefillName:
`${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
prefillEmail: data.email.trim(),
checkoutUrl: checkout.checkoutUrl,
});
}
} catch (error) {
console.error("Checkout error:", error);
toast.error("Er is iets misgegaan bij het aanmaken van de betaling");
onSuccess: (result) => {
if (result.managementToken) {
onSuccess(
result.managementToken,
data.email.trim(),
`${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
);
}
},
onError: (error) => {
@@ -310,8 +65,6 @@ export function WatcherForm({
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
};
setErrors(fieldErrs);
setTouched({
@@ -319,8 +72,6 @@ export function WatcherForm({
lastName: true,
email: true,
phone: true,
postcode: true,
birthdate: true,
});
const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs);
@@ -338,14 +89,6 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
@@ -361,14 +104,6 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
@@ -389,14 +124,7 @@ export function WatcherForm({
if (guests.length >= 9) return;
setGuests((prev) => [
...prev,
{
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
{ firstName: "", lastName: "", email: "", phone: "" },
]);
setGuestErrors((prev) => [...prev, {}]);
}
@@ -417,16 +145,12 @@ export function WatcherForm({
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "watcher",
guests: guests.map((g) => ({
firstName: g.firstName.trim(),
lastName: g.lastName.trim(),
email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})),
extraQuestions: data.extraQuestions.trim() || undefined,
giftAmount,
@@ -437,19 +161,6 @@ export function WatcherForm({
return (
<div>
{/* Account-creation interstitial modal */}
{modalState && (
<AccountModal
prefillName={modalState.prefillName}
prefillEmail={modalState.prefillEmail}
onDone={() => {
const url = modalState.checkoutUrl;
setModalState(null);
window.location.href = url;
}}
/>
)}
{/* Back + type header */}
<div className="mb-8 flex items-center gap-4">
<button
@@ -579,15 +290,14 @@ export function WatcherForm({
id="w-email"
name="email"
value={data.email}
onChange={isLoggedIn ? undefined : handleChange}
onBlur={isLoggedIn ? undefined : handleBlur}
readOnly={isLoggedIn}
onChange={handleChange}
onBlur={handleBlur}
placeholder="jouw@email.be"
autoComplete="email"
inputMode="email"
aria-required="true"
aria-invalid={touched.email && !!errors.email}
className={`${inputCls(!!touched.email && !!errors.email)}${isLoggedIn ? "cursor-not-allowed opacity-60" : ""}`}
className={inputCls(!!touched.email && !!errors.email)}
/>
{touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert">
@@ -620,54 +330,6 @@ export function WatcherForm({
</div>
</div>
{/* Postcode + Birthdate row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="w-postcode" className="text-white text-xl">
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id="w-postcode"
name="postcode"
value={data.postcode}
onChange={handleChange}
onBlur={handleBlur}
placeholder="1234 AB"
autoComplete="postal-code"
aria-required="true"
aria-invalid={touched.postcode && !!errors.postcode}
className={inputCls(!!touched.postcode && !!errors.postcode)}
/>
{touched.postcode && errors.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors.postcode}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="w-birthdate" className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id="w-birthdate"
name="birthdate"
value={data.birthdate}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="bday"
aria-invalid={touched.birthdate && !!errors.birthdate}
className={`${inputCls(!!touched.birthdate && !!errors.birthdate)} [color-scheme:dark]`}
/>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
</div>
{/* Guests */}
<GuestList
guests={guests}
@@ -736,7 +398,7 @@ export function WatcherForm({
Bezig...
</span>
) : (
"Bevestigen & betalen"
"Bevestigen"
)}
</button>
<a

View File

@@ -1,3 +1,3 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({});
export const authClient = createAuthClient();

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-11T10:30:00+01:00");

View File

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

View File

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

View File

@@ -10,8 +10,10 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as TermsRouteImport } from './routes/terms'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as LoginRouteImport } from './routes/login'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as DrinkkaartRouteImport } from './routes/drinkkaart'
import { Route as ContactRouteImport } from './routes/contact'
import { Route as AccountRouteImport } from './routes/account'
@@ -19,8 +21,9 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as ManageTokenRouteImport } from './routes/manage.$token'
import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart'
import { Route as ApiWebhookLemonsqueezyRouteImport } from './routes/api/webhook/lemonsqueezy'
import { Route as ApiWebhookMollieRouteImport } from './routes/api/webhook/mollie'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
import { Route as ApiCronRemindersRouteImport } from './routes/api/cron/reminders'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
const TermsRoute = TermsRouteImport.update({
@@ -28,6 +31,11 @@ const TermsRoute = TermsRouteImport.update({
path: '/terms',
getParentRoute: () => rootRouteImport,
} as any)
const ResetPasswordRoute = ResetPasswordRouteImport.update({
id: '/reset-password',
path: '/reset-password',
getParentRoute: () => rootRouteImport,
} as any)
const PrivacyRoute = PrivacyRouteImport.update({
id: '/privacy',
path: '/privacy',
@@ -38,6 +46,11 @@ const LoginRoute = LoginRouteImport.update({
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
id: '/forgot-password',
path: '/forgot-password',
getParentRoute: () => rootRouteImport,
} as any)
const DrinkkaartRoute = DrinkkaartRouteImport.update({
id: '/drinkkaart',
path: '/drinkkaart',
@@ -73,9 +86,9 @@ const AdminDrinkkaartRoute = AdminDrinkkaartRouteImport.update({
path: '/admin/drinkkaart',
getParentRoute: () => rootRouteImport,
} as any)
const ApiWebhookLemonsqueezyRoute = ApiWebhookLemonsqueezyRouteImport.update({
id: '/api/webhook/lemonsqueezy',
path: '/api/webhook/lemonsqueezy',
const ApiWebhookMollieRoute = ApiWebhookMollieRouteImport.update({
id: '/api/webhook/mollie',
path: '/api/webhook/mollie',
getParentRoute: () => rootRouteImport,
} as any)
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
@@ -83,6 +96,11 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
path: '/api/rpc/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiCronRemindersRoute = ApiCronRemindersRouteImport.update({
id: '/api/cron/reminders',
path: '/api/cron/reminders',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
@@ -94,30 +112,36 @@ export interface FileRoutesByFullPath {
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
'/admin': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -125,15 +149,18 @@ export interface FileRoutesById {
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -142,45 +169,54 @@ export interface FileRouteTypes {
| '/account'
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/login'
| '/privacy'
| '/reset-password'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
| '/admin/'
| '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
| '/api/webhook/mollie'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/account'
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/login'
| '/privacy'
| '/reset-password'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
| '/admin'
| '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
| '/api/webhook/mollie'
id:
| '__root__'
| '/'
| '/account'
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/login'
| '/privacy'
| '/reset-password'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
| '/admin/'
| '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
| '/api/webhook/mollie'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -188,15 +224,18 @@ export interface RootRouteChildren {
AccountRoute: typeof AccountRoute
ContactRoute: typeof ContactRoute
DrinkkaartRoute: typeof DrinkkaartRoute
ForgotPasswordRoute: typeof ForgotPasswordRoute
LoginRoute: typeof LoginRoute
PrivacyRoute: typeof PrivacyRoute
ResetPasswordRoute: typeof ResetPasswordRoute
TermsRoute: typeof TermsRoute
AdminDrinkkaartRoute: typeof AdminDrinkkaartRoute
ManageTokenRoute: typeof ManageTokenRoute
AdminIndexRoute: typeof AdminIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiCronRemindersRoute: typeof ApiCronRemindersRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
ApiWebhookLemonsqueezyRoute: typeof ApiWebhookLemonsqueezyRoute
ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute
}
declare module '@tanstack/react-router' {
@@ -208,6 +247,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TermsRouteImport
parentRoute: typeof rootRouteImport
}
'/reset-password': {
id: '/reset-password'
path: '/reset-password'
fullPath: '/reset-password'
preLoaderRoute: typeof ResetPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/privacy': {
id: '/privacy'
path: '/privacy'
@@ -222,6 +268,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/forgot-password': {
id: '/forgot-password'
path: '/forgot-password'
fullPath: '/forgot-password'
preLoaderRoute: typeof ForgotPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/drinkkaart': {
id: '/drinkkaart'
path: '/drinkkaart'
@@ -271,11 +324,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminDrinkkaartRouteImport
parentRoute: typeof rootRouteImport
}
'/api/webhook/lemonsqueezy': {
id: '/api/webhook/lemonsqueezy'
path: '/api/webhook/lemonsqueezy'
fullPath: '/api/webhook/lemonsqueezy'
preLoaderRoute: typeof ApiWebhookLemonsqueezyRouteImport
'/api/webhook/mollie': {
id: '/api/webhook/mollie'
path: '/api/webhook/mollie'
fullPath: '/api/webhook/mollie'
preLoaderRoute: typeof ApiWebhookMollieRouteImport
parentRoute: typeof rootRouteImport
}
'/api/rpc/$': {
@@ -285,6 +338,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiRpcSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/cron/reminders': {
id: '/api/cron/reminders'
path: '/api/cron/reminders'
fullPath: '/api/cron/reminders'
preLoaderRoute: typeof ApiCronRemindersRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
@@ -300,15 +360,18 @@ const rootRouteChildren: RootRouteChildren = {
AccountRoute: AccountRoute,
ContactRoute: ContactRoute,
DrinkkaartRoute: DrinkkaartRoute,
ForgotPasswordRoute: ForgotPasswordRoute,
LoginRoute: LoginRoute,
PrivacyRoute: PrivacyRoute,
ResetPasswordRoute: ResetPasswordRoute,
TermsRoute: TermsRoute,
AdminDrinkkaartRoute: AdminDrinkkaartRoute,
ManageTokenRoute: ManageTokenRoute,
AdminIndexRoute: AdminIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiCronRemindersRoute: ApiCronRemindersRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
ApiWebhookLemonsqueezyRoute: ApiWebhookLemonsqueezyRoute,
ApiWebhookMollieRoute: ApiWebhookMollieRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -17,7 +17,7 @@ import appCss from "../index.css?url";
const siteUrl = "https://kunstenkamp.be";
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord";
const siteDescription =
"Doe mee met de Open Mic Night op 24 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional.";
"Doe mee met de Open Mic Night op 18 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional.";
const eventImage = `${siteUrl}/assets/og-image.jpg`;
export interface RouterAppContext {

View File

@@ -1,4 +1,4 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import {
Calendar,
@@ -74,6 +74,22 @@ function AccountPage() {
const [showQr, setShowQr] = useState(false);
const [showTopUp, setShowTopUp] = useState(false);
const checkoutMutation = useMutation({
mutationFn: async (token: string) => {
const result = await client.getCheckoutUrl({
token,
redirectUrl: `${window.location.origin}/account?topup=success`,
});
return result;
},
onSuccess: (data) => {
window.location.href = data.checkoutUrl;
},
onError: () => {
toast.error("Er is iets misgegaan bij het aanmaken van de betaling");
},
});
const sessionQuery = useQuery({
queryKey: ["session"],
queryFn: () => authClient.getSession(),
@@ -90,7 +106,7 @@ function AccountPage() {
orpc.drinkkaart.getMyDrinkkaart.queryOptions(),
);
const registrationQuery = useQuery(orpc.getMyRegistrations.queryOptions());
const registrationQuery = useQuery(orpc.getMyRegistration.queryOptions());
// Handle topup=success redirect (from Lemon Squeezy returning to /account)
useEffect(() => {
@@ -128,7 +144,7 @@ function AccountPage() {
| { name?: string; email?: string }
| undefined;
const registrations = registrationQuery.data ?? [];
const registration = registrationQuery.data;
const drinkkaart = drinkkaartQuery.data;
const isLoading =
@@ -170,122 +186,172 @@ function AccountPage() {
Mijn Inschrijving
</h2>
{registrations.length > 0 ? (
<div className="flex flex-col gap-4">
{registrations.map((registration) => (
<div
key={registration.id}
className="rounded-2xl border border-white/10 bg-white/5 p-6"
>
{/* Type badge */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
{registration.registrationType === "performer" ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/20 px-3 py-1 font-medium text-amber-300 text-sm">
<Music className="h-3.5 w-3.5" />
Artiest
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-teal-500/20 px-3 py-1 font-medium text-sm text-teal-300">
<Users className="h-3.5 w-3.5" />
Bezoeker
</span>
)}
</div>
{(registration.registrationType !== "performer" ||
(registration.giftAmount ?? 0) > 0) && (
<PaymentBadge status={registration.paymentStatus} />
)}
</div>
{/* Name */}
<p className="mb-1 font-medium text-lg text-white">
{registration.firstName} {registration.lastName}
</p>
{/* Art form (performer only) */}
{registration.registrationType === "performer" &&
registration.artForm && (
<p className="mb-1 text-sm text-white/60">
Kunstvorm:{" "}
<span className="text-white/80">
{registration.artForm}
</span>
</p>
)}
{/* Guests (watcher only) */}
{registration.registrationType === "watcher" &&
registration.guests.length > 0 && (
<p className="mb-1 text-sm text-white/60">
{registration.guests.length + 1} personen (jij +{" "}
{registration.guests.length} gast
{registration.guests.length > 1 ? "en" : ""})
</p>
)}
{/* Drink card value */}
{registration.registrationType === "watcher" &&
(registration.drinkCardValue ?? 0) > 0 && (
<p className="mb-1 text-sm text-white/60">
Drinkkaart:{" "}
<span className="text-white/80">
{registration.drinkCardValue}
</span>
</p>
)}
{/* Gift */}
{(registration.giftAmount ?? 0) > 0 && (
<p className="mb-1 text-sm text-white/60">
Gift:{" "}
<span className="text-white/80">
{(registration.giftAmount ?? 0) / 100}
</span>
</p>
)}
{/* Date */}
<p className="mt-3 text-white/40 text-xs">
Ingeschreven op{" "}
{new Date(registration.createdAt).toLocaleDateString(
"nl-BE",
{
day: "numeric",
month: "long",
year: "numeric",
},
)}
</p>
{/* Action */}
{registration.managementToken && (
<div className="mt-5">
<Link
to="/manage/$token"
params={{ token: registration.managementToken }}
className="inline-flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm text-white transition-colors hover:bg-white/20"
>
Beheer inschrijving
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</Link>
</div>
{registration ? (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
{/* Type badge */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
{registration.registrationType === "performer" ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/20 px-3 py-1 font-medium text-amber-300 text-sm">
<Music className="h-3.5 w-3.5" />
Artiest
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-teal-500/20 px-3 py-1 font-medium text-sm text-teal-300">
<Users className="h-3.5 w-3.5" />
Bezoeker
</span>
)}
</div>
))}
{/* Only show a payment badge when there's actually something to pay:
watchers always have a drinkcard fee; anyone with a gift amount does too.
Performers with no gift have nothing to pay → hide the badge. */}
{(registration.registrationType === "watcher" ||
(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>
)}
{/* Pay now CTA — shown for watchers with pending payment */}
{registration.registrationType === "watcher" &&
registration.paymentStatus === "pending" &&
registration.managementToken && (
<div className="mt-5 rounded-lg border border-teal-400/30 bg-teal-400/10 p-4">
<p className="mb-3 text-sm text-white/80">
Je inschrijving is bevestigd maar de betaling staat nog
open. Betaal nu om je Drinkkaart te activeren.
</p>
<button
type="button"
disabled={checkoutMutation.isPending}
onClick={() =>
checkoutMutation.mutate(
registration.managementToken ?? "",
)
}
className="inline-flex items-center gap-2 rounded-lg bg-white px-4 py-2 font-semibold text-[#214e51] text-sm transition-colors hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{checkoutMutation.isPending ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Laden...
</>
) : (
<>
<Wallet className="h-4 w-4" />
Betaal nu
</>
)}
</button>
</div>
)}
</div>
) : (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">

View File

@@ -3,6 +3,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import {
Check,
ChevronDown,
ChevronRight,
ChevronsUpDown,
ChevronUp,
Clipboard,
@@ -13,7 +14,7 @@ import {
Users,
X,
} from "lucide-react";
import { Fragment, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -31,6 +32,23 @@ export const Route = createFileRoute("/admin/")({
component: AdminPage,
});
type Guest = {
firstName: string;
lastName: string;
email?: string;
phone?: string;
};
function parseGuests(raw: unknown): Guest[] {
if (!raw) return [];
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function AdminPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
@@ -46,18 +64,6 @@ function AdminPage() {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandedGuests, setExpandedGuests] = useState<Set<string>>(new Set());
const toggleGuests = (id: string) => {
setExpandedGuests((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
// Get current session to check user role
const sessionQuery = useQuery({
queryKey: ["session"],
@@ -73,6 +79,15 @@ function AdminPage() {
});
};
const toggleGuestExpand = (id: string) => {
setExpandedGuests((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
type SortKey =
| "naam"
| "email"
@@ -192,21 +207,12 @@ function AdminPage() {
const watcherCount =
stats?.byType.find((t) => t.registrationType === "watcher")?.count ?? 0;
// Calculate total attendees including guests
const totalRegistrations = registrationsQuery.data?.data ?? [];
const watcherWithGuests = totalRegistrations.filter(
(r) => r.registrationType === "watcher" && r.guests,
);
const totalGuestCount = watcherWithGuests.reduce((sum, r) => {
try {
const guests = JSON.parse(r.guests as string);
return sum + (Array.isArray(guests) ? guests.length : 0);
} catch {
return sum;
}
}, 0);
// Use server-side totalGuestCount from stats (accurate across all pages)
const totalGuestCount =
(stats as { totalGuestCount?: number } | undefined)?.totalGuestCount ?? 0;
const totalWatcherAttendees = watcherCount + totalGuestCount;
const totalDrinkCardValue = totalRegistrations
const totalDrinkCardValue = registrations
.filter((r) => r.registrationType === "watcher")
.reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0);
const totalGiftRevenue = (stats?.totalGiftRevenue as number | undefined) ?? 0;
@@ -239,17 +245,8 @@ function AdminPage() {
: `${b.drinkCardValue ?? 5}`) ?? "";
break;
case "gasten": {
const countGuests = (g: string | null) => {
if (!g) return 0;
try {
const p = JSON.parse(g);
return Array.isArray(p) ? p.length : 0;
} catch {
return 0;
}
};
valA = countGuests(a.guests as string | null);
valB = countGuests(b.guests as string | null);
valA = parseGuests(a.guests).length;
valB = parseGuests(b.guests).length;
break;
}
case "gift":
@@ -291,25 +288,6 @@ function AdminPage() {
return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`;
};
const parseGuestsJson = (
raw: string | null | undefined,
): Array<{
firstName: string;
lastName: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}> => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw as string);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
// Check if user is admin
const user = sessionQuery.data?.data?.user as
| { role?: string; name?: string }
@@ -498,8 +476,7 @@ function AdminPage() {
className="bg-green-600 text-white hover:bg-green-700"
>
<Check className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">Goedkeuren</span>
<span className="sm:hidden">Goedkeuren</span>
Goedkeuren
</Button>
<Button
onClick={() => handleReject(request.id)}
@@ -518,8 +495,10 @@ function AdminPage() {
</CardContent>
</Card>
)}
{/* Stats Cards */}
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-5 lg:gap-6">
{/* Total registrations */}
<Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2">
<CardDescription className="text-white/60 text-xs sm:text-sm">
@@ -534,6 +513,7 @@ function AdminPage() {
</CardContent>
</Card>
{/* Today */}
<Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2">
<CardDescription className="text-white/60 text-xs sm:text-sm">
@@ -550,6 +530,7 @@ function AdminPage() {
</CardContent>
</Card>
{/* Performers */}
<Card className="border-amber-400/20 bg-amber-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-amber-300/70 text-xs sm:text-sm">
@@ -576,29 +557,28 @@ function AdminPage() {
</CardContent>
</Card>
{/* Watchers + guests (real attendees count) */}
<Card className="border-teal-400/20 bg-teal-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-teal-300/70 text-xs sm:text-sm">
Bezoekers
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-teal-300 sm:text-3xl lg:text-4xl">
{watcherCount}
{totalWatcherAttendees}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-xs">
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Inschrijvingen</span>
<span className="text-teal-300">{watcherCount}</span>
</div>
{totalGuestCount > 0 && (
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Inclusief gasten</span>
<span className="text-teal-300/70">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>
@@ -607,6 +587,7 @@ function AdminPage() {
</CardContent>
</Card>
{/* Gifts */}
<Card className="border-pink-400/20 bg-pink-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-pink-300/70 text-xs sm:text-sm">
@@ -623,6 +604,7 @@ function AdminPage() {
</CardContent>
</Card>
</div>
{/* Filters */}
<Card className="mb-6 border-white/10 bg-white/5">
<CardHeader className="px-4 py-4 sm:px-6 sm:py-6">
@@ -730,10 +712,17 @@ function AdminPage() {
</div>
</CardContent>
</Card>
{/* Export Button */}
<div className="mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/60">
{pagination?.total ?? 0} registraties gevonden
{totalGuestCount > 0 && (
<span className="ml-2 text-teal-300/70">
(+{totalGuestCount} gasten ={" "}
{(pagination?.total ?? 0) + totalGuestCount} aanwezigen totaal)
</span>
)}
</p>
<Button
onClick={handleExport}
@@ -744,6 +733,7 @@ function AdminPage() {
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
</Button>
</div>
{/* Registrations Table / Cards */}
<Card className="border-white/10 bg-white/5">
<CardContent className="p-0">
@@ -788,21 +778,6 @@ function AdminPage() {
<th className={thClass} onClick={() => handleSort("datum")}>
Datum <SortIcon col="datum" />
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Postcode
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Geboortedatum
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Ervaring
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
16+
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Opmerkingen
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Link
</th>
@@ -812,7 +787,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? (
<tr>
<td
colSpan={15}
colSpan={10}
className="px-4 py-8 text-center text-white/60"
>
Laden...
@@ -821,7 +796,7 @@ function AdminPage() {
) : sortedRegistrations.length === 0 ? (
<tr>
<td
colSpan={15}
colSpan={10}
className="px-4 py-8 text-center text-white/60"
>
Geen registraties gevonden
@@ -830,10 +805,9 @@ function AdminPage() {
) : (
sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer";
const guests = parseGuestsJson(reg.guests);
const guests = parseGuests(reg.guests);
const guestCount = guests.length;
const isGuestsExpanded = expandedGuests.has(reg.id);
const isExpanded = expandedGuests.has(reg.id);
const detailLabel = isPerformer
? reg.artForm || "-"
@@ -855,7 +829,7 @@ function AdminPage() {
})();
return (
<Fragment key={reg.id}>
<>
<tr
key={reg.id}
className="border-white/5 border-b hover:bg-white/5"
@@ -883,16 +857,16 @@ function AdminPage() {
{guestCount > 0 ? (
<button
type="button"
onClick={() => toggleGuests(reg.id)}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => toggleGuestExpand(reg.id)}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-teal-300 transition-colors hover:bg-teal-400/10"
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{guestCount} gast
{guestCount === 1 ? "" : "en"}
{isGuestsExpanded ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</button>
) : (
"-"
@@ -925,34 +899,6 @@ function AdminPage() {
<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>
)
) : (
"-"
)}
</td>
<td className="max-w-[200px] px-4 py-3 text-sm text-white/60">
<span
className="block truncate"
title={reg.extraQuestions ?? undefined}
>
{reg.extraQuestions || "-"}
</span>
</td>
<td className="px-4 py-3">
{reg.managementToken ? (
<button
@@ -977,68 +923,59 @@ function AdminPage() {
)}
</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>
{/* Expandable guest details row */}
{isExpanded && guestCount > 0 && (
<tr
key={`${reg.id}-guests`}
className="border-white/5 border-b bg-teal-900/20"
>
<td colSpan={10} className="px-6 py-3">
<div className="space-y-1.5">
<p className="mb-2 font-medium text-teal-300/70 text-xs uppercase tracking-wider">
Gasten van {reg.firstName} {reg.lastName}
</p>
{guests.map((g, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: guest order is stable within a registration
key={i}
className="flex flex-wrap items-center gap-x-4 gap-y-0.5 rounded bg-teal-400/5 px-3 py-1.5 text-sm"
>
<span className="w-5 shrink-0 text-white/40 text-xs">
{i + 1}.
</span>
<span className="font-medium text-white">
{g.firstName} {g.lastName}
</span>
{g.email && (
<span className="text-white/60">
{g.email}
</span>
)}
{g.phone && (
<span className="text-white/50">
{g.phone}
</span>
)}
{!g.email && !g.phone && (
<span className="text-white/30 text-xs italic">
geen contactgegevens
</span>
)}
</div>
))}
</div>
</td>
</tr>
)}
</>
);
})
)}
</tbody>
</table>
</div>
{/* Mobile Cards */}
<div className="lg:hidden">
{registrationsQuery.isLoading ? (
<div className="px-4 py-8 text-center text-white/60">
@@ -1052,10 +989,9 @@ function AdminPage() {
<div className="divide-y divide-white/5">
{sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer";
const guests = parseGuestsJson(reg.guests);
const guests = parseGuests(reg.guests);
const guestCount = guests.length;
const isGuestsExpanded = expandedGuests.has(reg.id);
const isExpanded = expandedGuests.has(reg.id);
const detailLabel = isPerformer
? reg.artForm || "-"
@@ -1077,8 +1013,8 @@ function AdminPage() {
})();
return (
<div key={reg.id} className="hover:bg-white/5">
<div className="p-4">
<div key={reg.id}>
<div className="p-4 hover:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -1131,16 +1067,15 @@ function AdminPage() {
{guestCount > 0 && (
<button
type="button"
onClick={() => toggleGuests(reg.id)}
className="inline-flex items-center gap-1 text-white/70 transition-colors hover:text-white"
onClick={() => toggleGuestExpand(reg.id)}
className="inline-flex items-center gap-1 text-teal-300"
>
<span className="text-white/40">Gasten:</span>{" "}
{guestCount}
{isGuestsExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
{guestCount} gast{guestCount === 1 ? "" : "en"}
</button>
)}
{(reg.giftAmount ?? 0) > 0 && (
@@ -1172,71 +1107,39 @@ function AdminPage() {
)}
</div>
)}
{reg.postcode && (
<div className="text-white/70">
<span className="text-white/40">Postcode:</span>{" "}
{reg.postcode}
</div>
)}
{reg.birthdate && (
<div className="text-white/70">
<span className="text-white/40">
Geboortedatum:
</span>{" "}
{reg.birthdate}
</div>
)}
{isPerformer && reg.experience && (
<div className="text-white/70">
<span className="text-white/40">Ervaring:</span>{" "}
{reg.experience}
</div>
)}
{isPerformer && (
<div className="text-white/70">
<span className="text-white/40">16+:</span>{" "}
{reg.isOver16 ? (
<span className="text-green-400">Ja</span>
) : (
<span className="text-red-400">Nee</span>
)}
</div>
)}
{reg.extraQuestions && (
<div className="w-full text-white/60">
<span className="text-white/40">
Opmerkingen:
</span>{" "}
{reg.extraQuestions}
</div>
)}
<div className="text-white/50">{dateLabel}</div>
</div>
</div>
{isGuestsExpanded && guestCount > 0 && (
<div className="border-white/5 border-t bg-white/[0.02] px-4 pt-2 pb-3">
<div className="mb-1 text-white/30 text-xs">
{/* Expandable guest details — mobile */}
{isExpanded && guestCount > 0 && (
<div className="border-teal-400/10 border-t bg-teal-900/20 px-4 pt-3 pb-4">
<p className="mb-2 font-medium text-teal-300/70 text-xs uppercase tracking-wider">
Gasten
</div>
<div className="flex flex-col gap-2">
{guests.map((guest, gi) => (
</p>
<div className="space-y-2">
{guests.map((g, i) => (
<div
key={`${reg.id}-guest-${gi}`}
className="rounded border border-white/10 bg-white/5 px-3 py-2 text-xs"
// biome-ignore lint/suspicious/noArrayIndexKey: guest order is stable within a registration
key={i}
className="rounded bg-teal-400/5 px-3 py-2 text-sm"
>
<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>
<span className="mr-2 text-white/40 text-xs">
{i + 1}.
</span>
<span className="font-medium text-white">
{g.firstName} {g.lastName}
</span>
{g.email && (
<div className="mt-0.5 text-white/60 text-xs">
{g.email}
</div>
)}
{g.phone && (
<div className="text-white/50 text-xs">
{g.phone}
</div>
)}
</div>
))}
</div>
@@ -1250,6 +1153,8 @@ function AdminPage() {
</div>
</CardContent>
</Card>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2 sm:mt-6">
<Button

View File

@@ -0,0 +1,40 @@
import { runSendReminders } from "@kk/api/routers/index";
import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router";
async function handleCronReminders({ request }: { request: Request }) {
const secret = env.CRON_SECRET;
if (!secret) {
return new Response("CRON_SECRET not configured", { status: 500 });
}
// Accept the secret via Authorization header (Bearer) or JSON body
const authHeader = request.headers.get("Authorization");
let providedSecret: string | null = null;
if (authHeader?.startsWith("Bearer ")) {
providedSecret = authHeader.slice(7);
} else {
try {
const body = await request.json();
providedSecret = (body as { secret?: string }).secret ?? null;
} catch {
// ignore parse errors
}
}
if (!providedSecret || providedSecret !== secret) {
return new Response("Unauthorized", { status: 401 });
}
const result = await runSendReminders();
return Response.json(result);
}
export const Route = createFileRoute("/api/cron/reminders")({
server: {
handlers: {
POST: handleCronReminders,
},
},
});

View File

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

View File

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

View File

@@ -0,0 +1,118 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
export const Route = createFileRoute("/forgot-password")({
component: ForgotPasswordPage,
});
function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const mutation = useMutation({
mutationFn: async (email: string) => {
const result = await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
if (result.error) {
throw new Error(result.error.message);
}
return result.data;
},
onSuccess: () => {
setSent(true);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate(email);
};
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Wachtwoord vergeten
</CardTitle>
<CardDescription className="text-white/60">
{sent
? "Controleer je inbox"
: "Vul je e-mailadres in en we sturen je een link"}
</CardDescription>
</CardHeader>
<CardContent>
{sent ? (
<div className="space-y-4">
<div className="rounded-lg border border-white/10 bg-white/5 p-5 text-center">
<p className="text-sm text-white/80 leading-relaxed">
Als er een account bestaat voor{" "}
<span className="font-medium text-white">{email}</span>, is er
een e-mail verstuurd met een link om je wachtwoord opnieuw in
te stellen. De link is 1 uur geldig.
</p>
</div>
<Link
to="/login"
className="block text-center text-sm text-white/60 hover:text-white"
>
Terug naar inloggen
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="mb-2 block text-sm text-white/60"
>
E-mailadres
</label>
<Input
id="email"
type="email"
placeholder="jouw@email.be"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
{mutation.isError && (
<p className="text-red-400 text-sm">{mutation.error.message}</p>
)}
<Button
type="submit"
disabled={mutation.isPending}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
{mutation.isPending ? "Bezig..." : "Stuur reset-link"}
</Button>
<div className="text-center">
<Link
to="/login"
className="text-sm text-white/60 hover:text-white"
>
Terug naar inloggen
</Link>
</div>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,9 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import ArtForms from "@/components/homepage/ArtForms";
import EventRegistrationForm from "@/components/homepage/EventRegistrationForm";
import Footer from "@/components/homepage/Footer";
import Hero from "@/components/homepage/Hero";
import HoeInschrijven from "@/components/homepage/HoeInschrijven";
import Info from "@/components/homepage/Info";
export const Route = createFileRoute("/")({
@@ -16,7 +15,7 @@ function HomePage() {
<main className="relative">
<Hero />
<Info />
<ArtForms />
<HoeInschrijven />
<EventRegistrationForm />
<Footer />
</main>

View File

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

View File

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

View File

@@ -0,0 +1,154 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
export const Route = createFileRoute("/reset-password")({
validateSearch: (search: Record<string, unknown>) => {
const token = typeof search.token === "string" ? search.token : undefined;
return { ...(token !== undefined && { token }) } as { token?: string };
},
component: ResetPasswordPage,
});
function ResetPasswordPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const token = search.token;
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const mutation = useMutation({
mutationFn: async ({
token,
newPassword,
}: {
token: string;
newPassword: string;
}) => {
const result = await authClient.resetPassword({ token, newPassword });
if (result.error) {
throw new Error(result.error.message);
}
return result.data;
},
onSuccess: () => {
toast.success("Wachtwoord succesvol gewijzigd!");
navigate({ to: "/login" });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirm) {
toast.error("De wachtwoorden komen niet overeen.");
return;
}
if (!token) {
toast.error("Ongeldige reset-link. Vraag een nieuwe aan.");
return;
}
mutation.mutate({ token, newPassword: password });
};
if (!token) {
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center px-4 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Ongeldige link
</CardTitle>
<CardDescription className="text-white/60">
Deze reset-link is ongeldig of verlopen.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<Link
to="/forgot-password"
className="text-sm text-white/60 hover:text-white"
>
Vraag een nieuwe reset-link aan
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Nieuw wachtwoord
</CardTitle>
<CardDescription className="text-white/60">
Kies een nieuw wachtwoord voor je account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="mb-2 block text-sm text-white/60"
>
Nieuw wachtwoord
</label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
<div>
<label
htmlFor="confirm"
className="mb-2 block text-sm text-white/60"
>
Bevestig wachtwoord
</label>
<Input
id="confirm"
type="password"
placeholder="••••••••"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
minLength={8}
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
{mutation.isError && (
<p className="text-red-400 text-sm">{mutation.error.message}</p>
)}
<Button
type="submit"
disabled={mutation.isPending}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
{mutation.isPending ? "Bezig..." : "Stel wachtwoord in"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

18
apps/web/src/server.ts Normal file
View File

@@ -0,0 +1,18 @@
import { runSendReminders } from "@kk/api/routers/index";
import {
createStartHandler,
defaultStreamHandler,
} from "@tanstack/react-start/server";
const fetch = createStartHandler(defaultStreamHandler);
export default {
fetch,
async scheduled(
_event: { cron: string; scheduledTime: number },
_env: Record<string, unknown>,
ctx: { waitUntil: (promise: Promise<unknown>) => void },
) {
ctx.waitUntil(runSendReminders());
},
};

View File

@@ -42,6 +42,7 @@
"@tanstack/react-start": "^1.141.1",
"@tanstack/router-plugin": "^1.141.1",
"better-auth": "catalog:",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "catalog:",
@@ -68,6 +69,7 @@
"@tanstack/react-router-devtools": "^1.141.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/canvas-confetti": "^1.9.0",
"@types/qrcode": "^1.5.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
@@ -109,10 +111,12 @@
"@kk/env": "workspace:*",
"better-auth": "catalog:",
"dotenv": "catalog:",
"nodemailer": "^8.0.2",
"zod": "catalog:",
},
"devDependencies": {
"@kk/config": "workspace:*",
"@types/nodemailer": "^7.0.11",
"typescript": "catalog:",
},
},
@@ -909,6 +913,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -1027,6 +1033,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
"canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
@@ -1979,6 +1987,8 @@
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kk/auth/nodemailer": ["nodemailer@8.0.2", "", {}, "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw=="],
"@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],

View File

@@ -30,6 +30,7 @@ const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be";
function registrationConfirmationHtml(params: {
firstName: string;
manageUrl: string;
signupUrl: string;
wantsToPerform: boolean;
artForm?: string | null;
giftAmount?: number;
@@ -86,7 +87,7 @@ function registrationConfirmationHtml(params: {
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
We hebben je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 24 april 2026</strong> in goede orde ontvangen.
We hebben je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> in goede orde ontvangen.
</p>
<!-- Registration summary -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
@@ -99,13 +100,26 @@ function registrationConfirmationHtml(params: {
</table>
${paymentSummary}
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet.
Maak een account aan om je inschrijving te beheren en (voor bezoekers) je Drinkkaart te activeren.
</p>
<!-- CTA button -->
<table cellpadding="0" cellspacing="0">
<!-- Account creation CTA -->
<table cellpadding="0" cellspacing="0" style="margin-bottom:16px;">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${params.manageUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
<a href="${params.signupUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Account aanmaken
</a>
</td>
</tr>
</table>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet.
</p>
<!-- Manage CTA button -->
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:rgba(255,255,255,0.15);">
<a href="${params.manageUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;">
Beheer mijn inschrijving
</a>
</td>
@@ -280,6 +294,8 @@ export async function sendConfirmationEmail(params: {
artForm?: string | null;
giftAmount?: number;
drinkCardValue?: number;
/** Pre-filled signup URL, e.g. /login?signup=1&email=…&next=/account */
signupUrl?: string;
}) {
const transport = getTransport();
if (!transport) {
@@ -287,6 +303,9 @@ export async function sendConfirmationEmail(params: {
return;
}
const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
const signupUrl =
params.signupUrl ??
`${baseUrl}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`;
await transport.sendMail({
from,
to: params.to,
@@ -294,6 +313,7 @@ export async function sendConfirmationEmail(params: {
html: registrationConfirmationHtml({
firstName: params.firstName,
manageUrl,
signupUrl,
wantsToPerform: params.wantsToPerform,
artForm: params.artForm,
giftAmount: params.giftAmount,
@@ -348,3 +368,316 @@ export async function sendCancellationEmail(params: {
html: cancellationHtml({ firstName: params.firstName }),
});
}
function reminderHtml(params: { firstName?: string | null }) {
const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!";
// Registration opens at 2026-03-11T10:30:00+01:00
const openDateStr = "woensdag 11 maart 2026 om 10:30";
const registrationUrl = `${baseUrl}/#registration`;
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nog 1 uur — inschrijvingen openen straks!</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<!-- Header -->
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Nog 1 uur!</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
${greeting}
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Over ongeveer <strong style="color:#ffffff;">1 uur</strong> openen de inschrijvingen voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong>.
</p>
<!-- Time box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 8px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Inschrijvingen openen</p>
<p style="margin:0;font-size:18px;color:#ffffff;font-weight:600;">${openDateStr}</p>
</td>
</tr>
</table>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Wees er snel bij — de plaatsen zijn beperkt!
</p>
<!-- CTA button -->
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${registrationUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Schrijf je in
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Of ga naar: <span style="color:rgba(255,255,255,0.6);">${registrationUrl}</span>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd.<br/>
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export async function sendReminderEmail(params: {
to: string;
firstName?: string | null;
}) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping reminder email");
return;
}
await transport.sendMail({
from,
to: params.to,
subject: "Nog 1 uur — inschrijvingen openen straks!",
html: reminderHtml({ firstName: params.firstName }),
});
}
function paymentReminderHtml(params: {
firstName: string;
accountUrl: string;
manageUrl: string;
drinkCardValue?: number;
giftAmount?: number;
}) {
const formatEuro = (cents: number) =>
`${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
const giftCents = params.giftAmount ?? 0;
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
const totalCents = drinkCardCents + giftCents;
const totalStr = totalCents > 0 ? formatEuro(totalCents) : "";
const amountLine = totalStr
? `<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Je betaling van <strong style="color:#ffffff;">${totalStr}</strong> staat nog open. Log in op je account om te betalen.
</p>`
: `<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Je betaling staat nog open. Log in op je account om te betalen.
</p>`;
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Herinnering: betaling open</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Betaling nog open</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Je hebt je ingeschreven voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong>, maar we hebben nog geen betaling ontvangen.
</p>
${amountLine}
<!-- Pay CTA -->
<table cellpadding="0" cellspacing="0" style="margin-bottom:16px;">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${params.accountUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Betaal nu
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Je inschrijving beheren? <a href="${params.manageUrl}" style="color:rgba(255,255,255,0.6);">Klik hier</a>
</p>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export async function sendPaymentReminderEmail(params: {
to: string;
firstName: string;
managementToken: string;
drinkCardValue?: number;
giftAmount?: number;
}) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping payment reminder email");
return;
}
const accountUrl = `${baseUrl}/account`;
const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
await transport.sendMail({
from,
to: params.to,
subject: "Herinnering: betaling open — Open Mic Night",
html: paymentReminderHtml({
firstName: params.firstName,
accountUrl,
manageUrl,
drinkCardValue: params.drinkCardValue,
giftAmount: params.giftAmount,
}),
});
}
function paymentConfirmationHtml(params: {
firstName: string;
manageUrl: string;
drinkCardValue?: number;
giftAmount?: number;
}) {
const formatEuro = (cents: number) =>
`${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
const giftCents = params.giftAmount ?? 0;
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
const totalCents = drinkCardCents + giftCents;
const paymentSummary =
totalCents > 0
? `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:16px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Betaling ontvangen</p>
${drinkCardCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Drinkkaart: <span style="color:#ffffff;font-weight:500;">${formatEuro(drinkCardCents)}</span></p>` : ""}
${giftCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Vrijwillige gift: <span style="color:#ffffff;font-weight:500;">${formatEuro(giftCents)}</span></p>` : ""}
<p style="margin:16px 0 0;padding-top:12px;border-top:1px solid rgba(255,255,255,0.1);font-size:18px;color:#ffffff;font-weight:600;">Totaal: ${formatEuro(totalCents)}</p>
</td>
</tr>
</table>`
: "";
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Betaling ontvangen</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Betaling ontvangen!</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
We hebben je betaling voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> in goede orde ontvangen. Tot dan!
</p>
${paymentSummary}
<!-- Manage CTA -->
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:rgba(255,255,255,0.15);">
<a href="${params.manageUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#ffffff;text-decoration:none;">
Beheer mijn inschrijving
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export async function sendPaymentConfirmationEmail(params: {
to: string;
firstName: string;
managementToken: string;
drinkCardValue?: number;
giftAmount?: number;
}) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping payment confirmation email");
return;
}
const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
await transport.sendMail({
from,
to: params.to,
subject: "Betaling ontvangen — Open Mic Night",
html: paymentConfirmationHtml({
firstName: params.firstName,
manageUrl,
drinkCardValue: params.drinkCardValue,
giftAmount: params.giftAmount,
}),
});
}

View File

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

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import { adminRequest, registration } from "@kk/db/schema";
import { adminRequest, registration, reminder } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
import { env } from "@kk/env/server";
@@ -10,12 +10,20 @@ import { z } from "zod";
import {
sendCancellationEmail,
sendConfirmationEmail,
sendPaymentReminderEmail,
sendReminderEmail,
sendUpdateEmail,
} from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { generateQrSecret } from "../lib/drinkkaart-utils";
import { drinkkaartRouter } from "./drinkkaart";
// Registration opens at this date — reminders fire 1 hour before
const REGISTRATION_OPENS_AT = new Date("2026-03-11T10:30:00+01:00");
const REMINDER_WINDOW_START = new Date(
REGISTRATION_OPENS_AT.getTime() - 60 * 60 * 1000,
);
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
@@ -36,8 +44,6 @@ function parseGuestsJson(raw: string | null): Array<{
lastName: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}> {
if (!raw) return [];
try {
@@ -52,7 +58,7 @@ function parseGuestsJson(raw: string | null): Array<{
* Credits a watcher's drinkCardValue to their drinkkaart account.
*
* Safe to call multiple times — the `drinkkaartCreditedAt IS NULL` guard and the
* `lemonsqueezy_order_id` UNIQUE constraint together prevent double-crediting even
* `mollie_payment_id` UNIQUE constraint together prevent double-crediting even
* if called concurrently (webhook + signup racing each other).
*
* Returns a status string describing the outcome.
@@ -162,11 +168,11 @@ export async function creditRegistrationToAccount(
return { credited: false, amountCents: 0, status: "already_credited" };
}
// Record the topup. Re-use the registration's lemonsqueezyOrderId so the
// Record the topup. Re-use the registration's molliePaymentId so the
// topup row is clearly linked to the original payment. We prefix with
// "reg_" to distinguish from direct drinkkaart top-up orders if needed.
const topupPaymentId = reg.lemonsqueezyOrderId
? `reg_${reg.lemonsqueezyOrderId}`
const topupPaymentId = reg.molliePaymentId
? `reg_${reg.molliePaymentId}`
: null;
await db.insert(drinkkaartTopup).values({
@@ -177,7 +183,7 @@ export async function creditRegistrationToAccount(
balanceBefore,
balanceAfter,
type: "payment",
lemonsqueezyOrderId: topupPaymentId,
molliePaymentId: topupPaymentId,
adminId: null,
reason: "Drinkkaart bij registratie",
paidAt: reg.paidAt ?? new Date(),
@@ -220,8 +226,6 @@ const guestSchema = z.object({
lastName: z.string().min(1),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
birthdate: z.string().min(1),
postcode: z.string().min(1),
});
const coreRegistrationFields = {
@@ -229,8 +233,6 @@ const coreRegistrationFields = {
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
postcode: z.string().min(1),
birthdate: z.string().min(1),
registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(),
experience: z.string().optional(),
@@ -283,8 +285,6 @@ export const appRouter = {
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null,
@@ -372,8 +372,6 @@ export const appRouter = {
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null,
@@ -476,27 +474,49 @@ export const appRouter = {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [totalResult, todayResult, artFormResult, typeResult, giftResult] =
await Promise.all([
db.select({ count: count() }).from(registration),
db
.select({ count: count() })
.from(registration)
.where(gte(registration.createdAt, today)),
db
.select({ artForm: registration.artForm, count: count() })
.from(registration)
.where(eq(registration.registrationType, "performer"))
.groupBy(registration.artForm),
db
.select({
registrationType: registration.registrationType,
count: count(),
})
.from(registration)
.groupBy(registration.registrationType),
db.select({ total: sum(registration.giftAmount) }).from(registration),
]);
const [
totalResult,
todayResult,
artFormResult,
typeResult,
giftResult,
watcherGuestRows,
] = await Promise.all([
db.select({ count: count() }).from(registration),
db
.select({ count: count() })
.from(registration)
.where(gte(registration.createdAt, today)),
db
.select({ artForm: registration.artForm, count: count() })
.from(registration)
.where(eq(registration.registrationType, "performer"))
.groupBy(registration.artForm),
db
.select({
registrationType: registration.registrationType,
count: count(),
})
.from(registration)
.groupBy(registration.registrationType),
db.select({ total: sum(registration.giftAmount) }).from(registration),
// Fetch guests column for all watcher registrations to count total guests
db
.select({ guests: registration.guests })
.from(registration)
.where(
and(
eq(registration.registrationType, "watcher"),
isNull(registration.cancelledAt),
),
),
]);
// Sum up all guest counts across all watcher registrations
const totalGuestCount = watcherGuestRows.reduce((sum, r) => {
const guests = parseGuestsJson(r.guests);
return sum + guests.length;
}, 0);
return {
total: totalResult[0]?.count ?? 0,
@@ -510,6 +530,7 @@ export const appRouter = {
count: r.count,
})),
totalGiftRevenue: giftResult[0]?.total ?? 0,
totalGuestCount,
};
}),
@@ -519,63 +540,110 @@ export const appRouter = {
.from(registration)
.orderBy(desc(registration.createdAt));
const escapeCell = (val: string) => `"${val.replace(/"/g, '""')}"`;
// Main registration headers
const headers = [
"ID",
"First Name",
"Last Name",
"Email",
"Phone",
"Postcode",
"Birthdate",
"Type",
"Art Form",
"Experience",
"Is Over 16",
"Drink Card Value",
"Gift Amount",
"Drink Card Value (EUR)",
"Gift Amount (cents)",
"Guest Count",
"Guests",
"Guest 1 First Name",
"Guest 1 Last Name",
"Guest 1 Email",
"Guest 1 Phone",
"Guest 2 First Name",
"Guest 2 Last Name",
"Guest 2 Email",
"Guest 2 Phone",
"Guest 3 First Name",
"Guest 3 Last Name",
"Guest 3 Email",
"Guest 3 Phone",
"Guest 4 First Name",
"Guest 4 Last Name",
"Guest 4 Email",
"Guest 4 Phone",
"Guest 5 First Name",
"Guest 5 Last Name",
"Guest 5 Email",
"Guest 5 Phone",
"Guest 6 First Name",
"Guest 6 Last Name",
"Guest 6 Email",
"Guest 6 Phone",
"Guest 7 First Name",
"Guest 7 Last Name",
"Guest 7 Email",
"Guest 7 Phone",
"Guest 8 First Name",
"Guest 8 Last Name",
"Guest 8 Email",
"Guest 8 Phone",
"Guest 9 First Name",
"Guest 9 Last Name",
"Guest 9 Email",
"Guest 9 Phone",
"Payment Status",
"Paid At",
"Extra Questions",
"Cancelled At",
"Created At",
];
const MAX_GUESTS = 9;
const rows = data.map((r) => {
const guests = parseGuestsJson(r.guests);
const guestSummary = guests
.map(
(g) =>
`${g.firstName} ${g.lastName}${g.birthdate ? ` (${g.birthdate})` : ""}${g.postcode ? ` [${g.postcode}]` : ""}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
)
.join(" | ");
// Build guest columns (up to 9 guests, 4 fields each)
const guestCols: string[] = [];
for (let i = 0; i < MAX_GUESTS; i++) {
const g = guests[i];
guestCols.push(g?.firstName ?? "");
guestCols.push(g?.lastName ?? "");
guestCols.push(g?.email ?? "");
guestCols.push(g?.phone ?? "");
}
return [
r.id,
r.firstName,
r.lastName,
r.email,
r.phone || "",
r.postcode || "",
r.birthdate || "",
r.registrationType,
r.registrationType ?? "",
r.artForm || "",
r.experience || "",
r.isOver16 ? "Yes" : "No",
String(r.drinkCardValue ?? 0),
String(r.giftAmount ?? 0),
String(guests.length),
guestSummary,
r.paymentStatus === "paid" ? "Paid" : "Pending",
...guestCols,
r.paymentStatus === "paid"
? "Paid"
: r.paymentStatus === "extra_payment_pending"
? "Extra Payment Pending"
: "Pending",
r.paidAt ? r.paidAt.toISOString() : "",
r.extraQuestions || "",
r.cancelledAt ? r.cancelledAt.toISOString() : "",
r.createdAt.toISOString(),
];
});
const csvContent = [
headers.join(","),
headers.map(escapeCell).join(","),
...rows.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
row.map((cell) => escapeCell(String(cell))).join(","),
),
].join("\n");
@@ -597,7 +665,7 @@ export const appRouter = {
return result;
}),
getMyRegistrations: protectedProcedure.handler(async ({ context }) => {
getMyRegistration: protectedProcedure.handler(async ({ context }) => {
const email = context.session.user.email;
const rows = await db
@@ -606,12 +674,16 @@ export const appRouter = {
.where(
and(eq(registration.email, email), isNull(registration.cancelledAt)),
)
.orderBy(desc(registration.createdAt));
.orderBy(desc(registration.createdAt))
.limit(1);
return rows.map((row) => ({
const row = rows[0];
if (!row) return null;
return {
...row,
guests: parseGuestsJson(row.guests),
}));
};
}),
// ---------------------------------------------------------------------------
@@ -739,6 +811,97 @@ export const appRouter = {
return { success: true, message: "Admin toegang geweigerd" };
}),
// ---------------------------------------------------------------------------
// Reminder opt-in
// ---------------------------------------------------------------------------
/**
* Subscribe an email address to receive a reminder 1 hour before registration
* opens. Silently deduplicates — if the email is already subscribed, returns
* ok:true without error. Returns ok:false if registration is already open.
*/
subscribeReminder: publicProcedure
.input(z.object({ email: z.string().email() }))
.handler(async ({ input }) => {
const now = Date.now();
// Registration is already open — no point subscribing
if (now >= REGISTRATION_OPENS_AT.getTime()) {
return { ok: false, reason: "already_open" as const };
}
// Check for an existing subscription (silent dedup)
const existing = await db
.select({ id: reminder.id })
.from(reminder)
.where(eq(reminder.email, input.email))
.limit(1)
.then((rows) => rows[0]);
if (existing) {
return { ok: true };
}
await db.insert(reminder).values({
id: randomUUID(),
email: input.email,
});
return { ok: true };
}),
/**
* Send pending reminder emails to all opted-in addresses.
* Protected by a shared secret (`CRON_SECRET`) passed as an Authorization
* header. Should be called by a Cloudflare Cron Trigger at the top of
* every hour, or directly via `POST /api/cron/reminders`.
*
* Only sends when the current time is within the 1-hour reminder window
* (between REGISTRATION_OPENS_AT - 1h and REGISTRATION_OPENS_AT).
*/
sendReminders: publicProcedure
.input(z.object({ secret: z.string() }))
.handler(async ({ input }) => {
// Validate the shared secret
if (!env.CRON_SECRET || input.secret !== env.CRON_SECRET) {
throw new Error("Unauthorized");
}
const now = Date.now();
const windowStart = REMINDER_WINDOW_START.getTime();
const windowEnd = REGISTRATION_OPENS_AT.getTime();
// Only send during the reminder window
if (now < windowStart || now >= windowEnd) {
return { sent: 0, skipped: true, reason: "outside_window" as const };
}
// Fetch all unsent reminders
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sentAt));
let sent = 0;
const errors: string[] = [];
for (const row of pending) {
try {
await sendReminderEmail({ to: row.email });
await db
.update(reminder)
.set({ sentAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`${row.email}: ${String(err)}`);
console.error(`Failed to send reminder to ${row.email}:`, err);
}
}
return { sent, skipped: false, errors };
}),
// ---------------------------------------------------------------------------
// Checkout
// ---------------------------------------------------------------------------
@@ -751,12 +914,8 @@ export const appRouter = {
}),
)
.handler(async ({ input }) => {
if (
!env.LEMON_SQUEEZY_API_KEY ||
!env.LEMON_SQUEEZY_STORE_ID ||
!env.LEMON_SQUEEZY_VARIANT_ID
) {
throw new Error("LemonSqueezy is niet geconfigureerd");
if (!env.MOLLIE_API_KEY) {
throw new Error("Mollie is niet geconfigureerd");
}
const rows = await db
@@ -809,66 +968,37 @@ export const appRouter = {
const redirectUrl =
input.redirectUrl ?? `${env.CORS_ORIGIN}/manage/${input.token}`;
const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts",
{
method: "POST",
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${env.LEMON_SQUEEZY_API_KEY}`,
},
body: JSON.stringify({
data: {
type: "checkouts",
attributes: {
custom_price: amountInCents,
product_options: {
name: "Kunstenkamp Evenement",
description: productDescription,
redirect_url: redirectUrl,
},
checkout_data: {
email: row.email,
name: `${row.firstName} ${row.lastName}`,
custom: {
registration_token: input.token,
},
},
checkout_options: {
embed: false,
locale: "nl",
},
},
relationships: {
store: {
data: {
type: "stores",
id: env.LEMON_SQUEEZY_STORE_ID,
},
},
variant: {
data: {
type: "variants",
id: env.LEMON_SQUEEZY_VARIANT_ID,
},
},
},
},
}),
// Mollie amounts must be formatted as "10.00" (string, 2 decimal places)
const amountValue = (amountInCents / 100).toFixed(2);
const webhookUrl = `${env.CORS_ORIGIN}/api/webhook/mollie`;
const response = await fetch("https://api.mollie.com/v2/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.MOLLIE_API_KEY}`,
},
);
body: JSON.stringify({
amount: { value: amountValue, currency: "EUR" },
description: `Kunstenkamp Evenement — ${productDescription}`,
redirectUrl,
webhookUrl,
locale: "nl_NL",
metadata: { registration_token: input.token },
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("LemonSqueezy checkout error:", errorData);
console.error("Mollie checkout error:", errorData);
throw new Error("Kon checkout niet aanmaken");
}
const checkoutData = (await response.json()) as {
data?: { attributes?: { url?: string } };
_links?: { checkout?: { href?: string } };
};
const checkoutUrl = checkoutData.data?.attributes?.url;
const checkoutUrl = checkoutData._links?.checkout?.href;
if (!checkoutUrl) throw new Error("Geen checkout URL ontvangen");
@@ -878,3 +1008,82 @@ export const appRouter = {
export type AppRouter = typeof appRouter;
export type AppRouterClient = RouterClient<typeof appRouter>;
/**
* Standalone function that sends pending reminder emails.
* Exported so that the Cloudflare Cron HTTP handler can call it directly
* without needing drizzle-orm as a direct dependency of apps/web.
*/
export async function runSendReminders(): Promise<{
sent: number;
skipped: boolean;
reason?: string;
errors: string[];
}> {
const now = Date.now();
const windowStart = REMINDER_WINDOW_START.getTime();
const windowEnd = REGISTRATION_OPENS_AT.getTime();
if (now < windowStart || now >= windowEnd) {
return { sent: 0, skipped: true, reason: "outside_window", errors: [] };
}
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sentAt));
let sent = 0;
const errors: string[] = [];
for (const row of pending) {
try {
await sendReminderEmail({ to: row.email });
await db
.update(reminder)
.set({ sentAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`${row.email}: ${String(err)}`);
console.error(`Failed to send reminder to ${row.email}:`, err);
}
}
// Payment reminders: watchers with pending payment older than 3 days
const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000;
const unpaidWatchers = await db
.select()
.from(registration)
.where(
and(
eq(registration.registrationType, "watcher"),
eq(registration.paymentStatus, "pending"),
isNull(registration.paymentReminderSentAt),
lte(registration.createdAt, new Date(threeDaysAgo)),
),
);
for (const reg of unpaidWatchers) {
if (!reg.managementToken) continue;
try {
await sendPaymentReminderEmail({
to: reg.email,
firstName: reg.firstName,
managementToken: reg.managementToken,
drinkCardValue: reg.drinkCardValue ?? undefined,
giftAmount: reg.giftAmount ?? undefined,
});
await db
.update(registration)
.set({ paymentReminderSentAt: new Date() })
.where(eq(registration.id, reg.id));
sent++;
} catch (err) {
errors.push(`payment-reminder ${reg.email}: ${String(err)}`);
console.error(`Failed to send payment reminder to ${reg.email}:`, err);
}
}
return { sent, skipped: false, errors };
}

View File

@@ -15,10 +15,12 @@
"@kk/env": "workspace:*",
"better-auth": "catalog:",
"dotenv": "catalog:",
"nodemailer": "^8.0.2",
"zod": "catalog:"
},
"devDependencies": {
"@kk/config": "workspace:*",
"@types/nodemailer": "^7.0.11",
"typescript": "catalog:"
}
}

View File

@@ -5,6 +5,96 @@ import { env } from "@kk/env/server";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";
import nodemailer from "nodemailer";
const _smtpFrom = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
let _transport: nodemailer.Transporter | null | undefined;
function getTransport(): nodemailer.Transporter | null {
if (_transport !== undefined) return _transport;
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
_transport = null;
return null;
}
_transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
});
return _transport;
}
async function sendPasswordResetEmail(to: string, resetUrl: string) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping password reset email");
return;
}
const html = `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wachtwoord opnieuw instellen</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Wachtwoord opnieuw instellen</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
We hebben een aanvraag ontvangen om het wachtwoord van je Kunstenkamp-account opnieuw in te stellen.
</p>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Klik op de knop hieronder om een nieuw wachtwoord in te stellen. Deze link is <strong style="color:#ffffff;">1 uur geldig</strong>.
</p>
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${resetUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Stel wachtwoord in
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Of kopieer deze link: <span style="color:rgba(255,255,255,0.6);">${resetUrl}</span>
</p>
<p style="margin:24px 0 0;font-size:14px;color:rgba(255,255,255,0.5);line-height:1.6;">
Heb je dit niet aangevraagd? Dan hoef je niets te doen — je wachtwoord blijft ongewijzigd.
</p>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
await transport.sendMail({
from: _smtpFrom,
to,
subject: "Wachtwoord opnieuw instellen — Kunstenkamp",
html,
});
}
export const auth = betterAuth({
database: drizzleAdapter(db, {
@@ -35,6 +125,9 @@ export const auth = betterAuth({
return keyBuffer.equals(hashBuffer);
},
},
sendResetPassword: async ({ user, url }) => {
await sendPasswordResetEmail(user.email, url);
},
},
user: {
additionalFields: {

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -0,0 +1,9 @@
-- Add reminder table for email reminders 1 hour before registration opens
CREATE TABLE `reminder` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`sent_at` integer,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `reminder_email_idx` ON `reminder` (`email`);

View File

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

View File

@@ -0,0 +1,2 @@
-- Migration: add payment_reminder_sent_at column to registration table
ALTER TABLE registration ADD COLUMN payment_reminder_sent_at INTEGER;

View File

@@ -31,10 +31,31 @@
"breakpoints": true
},
{
"idx": 8,
"idx": 4,
"version": "6",
"when": 1741388400000,
"tag": "0008_add_postcode_birthdate",
"when": 1772536320000,
"tag": "0004_drinkkaart",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1772596240000,
"tag": "0005_registration_drinkkaart_credit",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1772672880000,
"tag": "0006_mollie_migration",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1772931300000,
"tag": "0007_reminder",
"breakpoints": true
}
]

View File

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

View File

@@ -2,3 +2,4 @@ export * from "./admin-requests";
export * from "./auth";
export * from "./drinkkaart";
export * from "./registrations";
export * from "./reminders";

View File

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

View File

@@ -0,0 +1,18 @@
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const reminder = sqliteTable(
"reminder",
{
id: text("id").primaryKey(), // UUID
email: text("email").notNull(),
sentAt: integer("sent_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(t) => [index("reminder_email_idx").on(t.email)],
);
export type Reminder = typeof reminder.$inferSelect;
export type NewReminder = typeof reminder.$inferInsert;

View File

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

View File

@@ -30,12 +30,13 @@ export const web = await TanStackStart("web", {
SMTP_USER: getEnvVar("SMTP_USER"),
SMTP_PASS: getEnvVar("SMTP_PASS"),
SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (LemonSqueezy)
LEMON_SQUEEZY_API_KEY: getEnvVar("LEMON_SQUEEZY_API_KEY"),
LEMON_SQUEEZY_STORE_ID: getEnvVar("LEMON_SQUEEZY_STORE_ID"),
LEMON_SQUEEZY_VARIANT_ID: getEnvVar("LEMON_SQUEEZY_VARIANT_ID"),
LEMON_SQUEEZY_WEBHOOK_SECRET: getEnvVar("LEMON_SQUEEZY_WEBHOOK_SECRET"),
// Payments (Mollie)
MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"),
// Cron secret for protected scheduled endpoints
CRON_SECRET: getEnvVar("CRON_SECRET"),
},
// Fire every hour so the reminder check can run at 09:30 on 2026-03-11
crons: ["0 * * * *"],
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
});

View File

@@ -18,14 +18,17 @@
"persistent": true
},
"db:push": {
"cache": false
"cache": false,
"interactive": true
},
"db:generate": {
"cache": false
"cache": false,
"interactive": true
},
"db:migrate": {
"cache": false,
"persistent": true
"persistent": true,
"interactive": true
},
"db:studio": {
"cache": false,