Compare commits

...

18 Commits

Author SHA1 Message Date
9502fec01f chore: fmt 2026-04-20 21:35:20 +02:00
ee77644d83 fix: deps 2026-04-20 21:34:58 +02:00
130cb9186d feat: postponed 2026-04-20 21:30:00 +02:00
d3a4554b0d fix: links 2026-04-06 18:05:29 +02:00
71b85d6874 chore: fmt 2026-04-01 14:42:24 +02:00
7b3d5461ef feat: kamp page 2026-04-01 14:18:07 +02:00
56942275b8 feat: add homepage selector 2026-03-29 16:28:12 +02:00
845624dfd3 feat(admin): improve export to put guests in their own row 2026-03-19 10:31:12 +01:00
ee0e9e68a9 feat(admin): add birthdate & postcode 2026-03-19 10:28:07 +01:00
f113770348 feat(registration): add birthdate & postcode 2026-03-19 10:15:45 +01:00
19d784b2e5 feat(drinkkaart): implement QR scan and balance deduction interface
Convert /drinkkaart route from a redirect to a full admin scanning interface.
Add flow: scan QR → resolve user → select amount → confirm deduction → success.
Refactor QrScanner to use Html5Qrcode API for better control and cleanup.
Update event date/time to April 24, 2026 at 19:30.

BREAKING CHANGE: /drinkkaart now requires admin role and provides scanner UI
instead of redirecting to /account.
2026-03-15 14:47:36 +01:00
0d99f7c5f5 feat(registration): add watcher capacity limits and update pricing
Add 70-person capacity limit for watchers with real-time availability checks.
Update drink card pricing to €5 per person (was €5 base + €2 per guest).
Add feature flag to bypass registration countdown.
Show under-review notice for performer registrations.
Update FAQ performance duration from 5-7 to 5 minutes.
2026-03-14 19:37:52 +01:00
3a2e403ee9 Update event date to 24 april, add venue, add Ichtus Antwerpen as co-sponsor
- Change event date from 18 to 24 april across all pages, hero, meta and constants
- Add venue (Lange Winkelstraat 5, 2000 Antwerpen) to contact page, Q&A, and transactional emails
- Add LOCATION constant to packages/api/src/constants.ts
- Add Ichtus Antwerpen (ichtusantwerpen.com) as co-sponsor in footer and contact page
- Add location info card to confirmation, update, payment reminder and payment confirmation emails
2026-03-11 18:17:40 +01:00
e5d2b13b21 feat(email): route all email sends through Cloudflare Queue
Introduces a CF Queue binding (kk-email-queue) to decouple email
delivery from request handlers, preventing slow responses and
providing automatic retries. All send*Email calls now go through
the queue when the binding is available, with direct-send fallbacks
for local dev. Reminder fan-outs mark DB rows optimistically before
enqueueing to prevent re-enqueue on subsequent cron ticks.
2026-03-11 17:13:35 +01:00
7f431c0091 fix: email 2026-03-11 16:34:51 +01:00
17aa8956a7 feat(log): improve logging for emails 2026-03-11 14:23:39 +01:00
5e5efcaf6f feat: simplify mails and add reminders 2026-03-11 13:39:27 +01:00
0c8e827cda feat: set opening date! 2026-03-11 12:37:01 +01:00
49 changed files with 4517 additions and 909 deletions

View File

@@ -34,6 +34,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "^0.45.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"libsql": "catalog:", "libsql": "catalog:",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
@@ -52,6 +53,7 @@
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.17.1", "@cloudflare/vite-plugin": "^1.17.1",
"@tanstack/router-core": "^1.141.1",
"@kk/config": "workspace:*", "@kk/config": "workspace:*",
"@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-router-devtools": "^1.141.1", "@tanstack/react-router-devtools": "^1.141.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -8,11 +8,14 @@ import { Header } from "./Header";
export function SiteHeader() { export function SiteHeader() {
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const routerState = useRouterState(); const routerState = useRouterState();
const isHomepage = routerState.location.pathname === "/"; const pathname = routerState.location.pathname;
const isHomepage = pathname === "/";
const isKamp = pathname === "/kamp";
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
useEffect(() => { useEffect(() => {
if (isKamp) return;
if (!isHomepage) { if (!isHomepage) {
setIsVisible(true); setIsVisible(true);
return; return;
@@ -25,7 +28,9 @@ export function SiteHeader() {
handleScroll(); handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true }); window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, [isHomepage]); }, [isHomepage, isKamp]);
if (isKamp) return null;
if (!session?.user) { if (!session?.user) {
return <Header isGuest isVisible={isVisible} isHomepage={isHomepage} />; return <Header isGuest isVisible={isVisible} isHomepage={isHomepage} />;

View File

@@ -5,14 +5,16 @@ import { Input } from "@/components/ui/input";
interface QrScannerProps { interface QrScannerProps {
onScan: (token: string) => void; onScan: (token: string) => void;
onCancel: () => void; onCancel: () => void;
/** When true the scanner takes up more vertical space (mobile full-screen mode) */
fullHeight?: boolean;
} }
export function QrScanner({ onScan, onCancel }: QrScannerProps) { export function QrScanner({ onScan, onCancel, fullHeight }: QrScannerProps) {
const scannerDivId = "qr-reader-container"; const videoContainerId = "qr-video-container";
const scannerRef = useRef<unknown>(null); const scannerRef = useRef<unknown>(null);
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
const [manualToken, setManualToken] = useState(""); const [manualToken, setManualToken] = useState("");
// Keep a stable ref to onScan so the effect never re-runs due to identity changes // Keep a stable ref so the effect never re-runs due to callback identity changes
const onScanRef = useRef(onScan); const onScanRef = useRef(onScan);
useEffect(() => { useEffect(() => {
onScanRef.current = onScan; onScanRef.current = onScan;
@@ -22,32 +24,34 @@ export function QrScanner({ onScan, onCancel }: QrScannerProps) {
let stopped = false; let stopped = false;
import("html5-qrcode") import("html5-qrcode")
.then(({ Html5QrcodeScanner }) => { .then(({ Html5Qrcode }) => {
if (stopped) return; if (stopped) return;
const scanner = new Html5QrcodeScanner( const scanner = new Html5Qrcode(videoContainerId);
scannerDivId,
{
fps: 10,
qrbox: { width: 250, height: 250 },
},
false,
);
scannerRef.current = scanner; scannerRef.current = scanner;
scanner.render( scanner
.start(
{ facingMode: "environment" },
{ fps: 15, qrbox: { width: 240, height: 240 } },
(decodedText: string) => { (decodedText: string) => {
scanner.clear().catch(console.error); // Stop immediately so we don't fire multiple times
onScanRef.current(decodedText); scanner
.stop()
.catch(console.error)
.finally(() => {
if (!stopped) onScanRef.current(decodedText);
});
}, },
(error: string) => { // Per-frame error (QR not found) — suppress
// Suppress routine "not found" scan errors () => {},
if (!error.includes("No QR code found")) { )
console.debug("QR scan error:", error); .catch((err: unknown) => {
if (!stopped) {
console.error("Camera start failed:", err);
setHasError(true);
} }
}, });
);
}) })
.catch((err) => { .catch((err) => {
console.error("Failed to load html5-qrcode:", err); console.error("Failed to load html5-qrcode:", err);
@@ -57,27 +61,40 @@ export function QrScanner({ onScan, onCancel }: QrScannerProps) {
return () => { return () => {
stopped = true; stopped = true;
if (scannerRef.current) { if (scannerRef.current) {
(
scannerRef.current as {
stop: () => Promise<void>;
clear: () => Promise<void>;
}
)
.stop()
.catch(() => {})
.finally(() => {
(scannerRef.current as { clear: () => Promise<void> }) (scannerRef.current as { clear: () => Promise<void> })
.clear() ?.clear?.()
.catch(console.error); .catch(() => {});
});
} }
}; };
}, []); // empty deps — runs once on mount, cleans up on unmount }, []); // runs once on mount
const handleManualSubmit = () => { const handleManualSubmit = () => {
const token = manualToken.trim(); const token = manualToken.trim();
if (token) { if (token) onScan(token);
onScan(token);
}
}; };
const containerHeight = fullHeight ? "h-64 sm:h-80" : "h-52";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{!hasError ? ( {!hasError ? (
<div id={scannerDivId} className="overflow-hidden rounded-xl" /> <div
id={videoContainerId}
className={`w-full overflow-hidden rounded-xl bg-black ${containerHeight} [&_#qr-shaded-region]:!border-white/30 [&>video]:h-full [&>video]:w-full [&>video]:object-cover`}
/>
) : ( ) : (
<div className="rounded-xl border border-white/10 bg-white/5 p-4 text-center"> <div className="rounded-xl border border-white/10 bg-white/5 p-6 text-center">
<p className="mb-2 text-sm text-white/60"> <p className="text-sm text-white/60">
Camera niet beschikbaar. Plak het QR-token hieronder. Camera niet beschikbaar. Plak het QR-token hieronder.
</p> </p>
</div> </div>

View File

@@ -1,10 +1,173 @@
import { useQuery } from "@tanstack/react-query";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { CountdownBanner } from "@/components/homepage/CountdownBanner"; import { CountdownBanner } from "@/components/homepage/CountdownBanner";
import { PerformerForm } from "@/components/registration/PerformerForm"; import { PerformerForm } from "@/components/registration/PerformerForm";
import { TypeSelector } from "@/components/registration/TypeSelector"; import { TypeSelector } from "@/components/registration/TypeSelector";
import { WatcherForm } from "@/components/registration/WatcherForm"; import { WatcherForm } from "@/components/registration/WatcherForm";
import { POSTPONED } from "@/lib/opening";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen"; import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { client, orpc } from "@/utils/orpc";
function PostponedBanner() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "subscribed" | "error"
>("idle");
const [errorMessage, setErrorMessage] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || status === "loading") return;
setStatus("loading");
try {
await client.subscribeReminder({ email });
setStatus("subscribed");
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : "Er ging iets mis.");
setStatus("error");
}
}
return (
<div className="flex flex-col items-center gap-8 py-4 text-center">
{/* Postponed notice */}
<div className="flex flex-col items-center gap-3">
<div
className="inline-block px-4 py-1 font-['Intro',sans-serif] text-xs uppercase tracking-widest"
style={{
background: "rgba(255,255,255,0.12)",
color: "rgba(255,255,255,0.7)",
}}
>
Aankondiging
</div>
<h2 className="font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Dit evenement is uitgesteld
</h2>
<p className="max-w-lg text-base text-white/70 md:text-lg">
De Open Mic Night van 24 april gaat helaas niet door op de geplande
datum. We werken aan een nieuwe datum en laten je zo snel mogelijk
weten wanneer het evenement plaatsvindt.
</p>
</div>
{/* Hairline divider */}
<div
className="h-px w-24"
style={{
background:
"linear-gradient(to right, transparent, rgba(255,255,255,0.2), transparent)",
}}
/>
{/* Waitlist signup */}
<div className="flex w-full max-w-md flex-col items-center gap-4">
<p className="font-['Intro',sans-serif] text-sm text-white/50 uppercase tracking-widest">
Schrijf je in op de wachtlijst
</p>
<p className="text-sm text-white/60">
Ontvang een e-mail zodra de nieuwe datum bekend is.
</p>
{status === "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",
}}
>
<style>{`
@keyframes reminderCheck {
from { opacity: 0; transform: scale(0.7); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
<div
className="flex h-10 w-10 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="16"
height="16"
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 text-white/70 tracking-wide">
Je staat op de wachtlijst! We houden je op de hoogte.
</p>
</div>
) : (
<form
onSubmit={handleSubmit}
className="flex w-full 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-3 text-sm text-white outline-none placeholder:text-white/30 disabled:opacity-50"
style={{ fontFamily: "'Intro', sans-serif", fontSize: "0.85rem" }}
/>
<div
style={{
width: "1px",
background: "rgba(255,255,255,0.15)",
flexShrink: 0,
}}
/>
<button
type="submit"
disabled={status === "loading" || !email}
className="shrink-0 px-5 py-3 font-['Intro',sans-serif] font-semibold text-white/90 text-xs tracking-wide transition-all disabled:opacity-40"
style={{
background: "transparent",
cursor:
status === "loading" || !email ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
}}
>
{status === "loading" ? "…" : "Inschrijven"}
</button>
</form>
)}
{status === "error" && (
<p
className="font-['Intro',sans-serif] text-xs"
style={{ color: "rgba(255,140,140,0.8)" }}
>
{errorMessage}
</p>
)}
</div>
</div>
);
}
function fireConfetti() { function fireConfetti() {
const colors = ["#d82560", "#52979b", "#d09035", "#214e51", "#ffffff"]; const colors = ["#d82560", "#52979b", "#d09035", "#214e51", "#ffffff"];
@@ -48,8 +211,10 @@ export default function EventRegistrationForm() {
const { isOpen } = useRegistrationOpen(); const { isOpen } = useRegistrationOpen();
const confettiFired = useRef(false); const confettiFired = useRef(false);
const { data: capacity } = useQuery(orpc.getWatcherCapacity.queryOptions());
useEffect(() => { useEffect(() => {
if (isOpen && !confettiFired.current) { if (isOpen && !confettiFired.current && !POSTPONED) {
confettiFired.current = true; confettiFired.current = true;
fireConfetti(); fireConfetti();
} }
@@ -59,6 +224,19 @@ export default function EventRegistrationForm() {
null, null,
); );
if (POSTPONED) {
return (
<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">
<PostponedBanner />
</div>
</section>
);
}
if (!isOpen) { if (!isOpen) {
return ( return (
<section <section
@@ -88,7 +266,13 @@ export default function EventRegistrationForm() {
Doe je mee of kom je kijken? Kies je rol en vul het formulier in. Doe je mee of kom je kijken? Kies je rol en vul het formulier in.
</p> </p>
{!selectedType && <TypeSelector onSelect={setSelectedType} />} {!selectedType && (
<TypeSelector
onSelect={setSelectedType}
watcherIsFull={capacity?.isFull ?? false}
watcherAvailable={capacity?.available ?? null}
/>
)}
{selectedType === "performer" && ( {selectedType === "performer" && (
<PerformerForm <PerformerForm
onBack={() => setSelectedType(null)} onBack={() => setSelectedType(null)}

View File

@@ -86,6 +86,26 @@ export default function Footer() {
{/* Vertical rule */} {/* Vertical rule */}
<div className="hidden h-28 w-px bg-white/20 md:block" /> <div className="hidden h-28 w-px bg-white/20 md:block" />
{/* Ichtus Antwerpen */}
<a
href="https://ichtusantwerpen.com"
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-3 opacity-90 transition-opacity hover:opacity-100"
>
<img
src="/assets/ichtusantwerpen.png"
alt="Ichtus Antwerpen"
className="h-14 w-auto"
/>
<p className="text-center font-['DM_Sans',sans-serif] font-medium text-white/60 text-xs uppercase tracking-[0.15em]">
Ichtus Antwerpen
</p>
</a>
{/* Vertical rule */}
<div className="hidden h-28 w-px bg-white/20 md:block" />
{/* Vlaanderen */} {/* Vlaanderen */}
<a <a
href="https://www.vlaanderen.be/cjm/nl" href="https://www.vlaanderen.be/cjm/nl"

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { POSTPONED } from "@/lib/opening";
export default function Hero() { export default function Hero() {
const [micVisible, setMicVisible] = useState(false); const [micVisible, setMicVisible] = useState(false);
@@ -82,11 +83,17 @@ export default function Hero() {
{/* Bottom Right - Dark Teal with date - above mic */} {/* Bottom Right - Dark Teal with date - above mic */}
<div className="relative flex flex-1 items-center justify-end bg-[#214e51]"> <div className="relative flex flex-1 items-center justify-end bg-[#214e51]">
{POSTPONED ? (
<p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(1.5rem,4vw,4.5rem)] text-white uppercase leading-[1.1]">
UITGESTELD
</p>
) : (
<p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(2rem,5vw,6rem)] text-white uppercase leading-[1.1]"> <p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(2rem,5vw,6rem)] text-white uppercase leading-[1.1]">
VRIJDAG 18 VRIJDAG 24
<br /> <br />
april april
</p> </p>
)}
</div> </div>
</div> </div>
@@ -97,7 +104,7 @@ export default function Hero() {
type="button" type="button"
className="bg-black px-[40px] py-[10px] font-['Intro',sans-serif] font-normal text-[30px] text-white transition-all hover:scale-105 hover:bg-gray-900" className="bg-black px-[40px] py-[10px] font-['Intro',sans-serif] font-normal text-[30px] text-white transition-all hover:scale-105 hover:bg-gray-900"
> >
Registreer je nu! {POSTPONED ? "Schrijf je in op de wachtlijst" : "Registreer je nu!"}
</button> </button>
</div> </div>
</div> </div>
@@ -150,11 +157,17 @@ export default function Hero() {
</p> </p>
</div> </div>
<div className="flex flex-1 flex-col items-end justify-center bg-[#214e51] p-4"> <div className="flex flex-1 flex-col items-end justify-center bg-[#214e51] p-4">
{POSTPONED ? (
<p className="text-right font-['Intro',sans-serif] font-normal text-[6vw] text-white uppercase leading-tight">
UITGESTELD
</p>
) : (
<p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight"> <p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight">
VRIJDAG VRIJDAG
<br /> <br />
18 april 24 april
</p> </p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -166,7 +179,7 @@ export default function Hero() {
type="button" type="button"
className="w-full bg-black py-3 font-['Intro',sans-serif] font-normal text-lg text-white transition-all hover:scale-105 hover:bg-gray-900" className="w-full bg-black py-3 font-['Intro',sans-serif] font-normal text-lg text-white transition-all hover:scale-105 hover:bg-gray-900"
> >
Registreer je nu! {POSTPONED ? "Schrijf je in op de wachtlijst" : "Registreer je nu!"}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -39,9 +39,8 @@ export default function HoeInschrijven() {
</h3> </h3>
<p className="text-[#214e51]/70 text-base leading-relaxed"> <p className="text-[#214e51]/70 text-base leading-relaxed">
We vragen een bijdrage van{" "} We vragen een bijdrage van{" "}
<strong className="text-[#214e51]">5 per inschrijving</strong> en{" "} <strong className="text-[#214e51]">5 per persoon</strong> voor
<strong className="text-[#214e51]">2 per extra persoon</strong>{" "} jou én iedereen die je meebrengt.
die je meebrengt.
</p> </p>
</div> </div>

View File

@@ -12,7 +12,13 @@ const faqQuestions = [
{ {
question: "Hoelang mag mijn optreden duren?", question: "Hoelang mag mijn optreden duren?",
answer: answer:
"Elke deelnemer krijgt 5-7 minuten podiumtijd. Zo houden we de avond dynamisch en krijgt iedereen een kans om te shinen.", "Elke deelnemer krijgt 5 minuten podiumtijd. Zo houden we de avond dynamisch en krijgt iedereen een kans om te shinen.",
},
{
question: "Waar vindt het plaats?",
answer:
"De Open Mic Night vindt plaats op Lange Winkelstraat 5, 2000 Antwerpen.",
mapUrl: "https://maps.app.goo.gl/kU6iug3QVKwWD1vR7",
}, },
{ {
question: "Wat moet ik meenemen?", question: "Wat moet ik meenemen?",
@@ -179,6 +185,16 @@ export default function Info() {
{item.question} {item.question}
</h3> </h3>
<p className="max-w-xl text-white/80 text-xl">{item.answer}</p> <p className="max-w-xl text-white/80 text-xl">{item.answer}</p>
{"mapUrl" in item && (
<a
href={item.mapUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-white/60 underline hover:text-white/90"
>
Bekijk op Google Maps
</a>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -245,6 +245,169 @@ export function GuestList({
</span> </span>
)} )}
</div> </div>
{/* Birthdate */}
<div className="flex flex-col gap-2 md:col-span-2">
<p className="text-white/80">
Geboortedatum <span className="text-red-300">*</span>
</p>
<fieldset className="m-0 flex gap-2 border-0 p-0">
<legend className="sr-only">
Geboortedatum medebezoeker {idx + 1}
</legend>
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`guest-${idx}-birthdateDay`}
className="sr-only"
>
Dag
</label>
<select
id={`guest-${idx}-birthdateDay`}
value={guest.birthdate?.split("-")[2] ?? ""}
onChange={(e) => {
const parts = (guest.birthdate || "--").split("-");
onChange(
idx,
"birthdate",
`${parts[0] || ""}-${parts[1] || ""}-${e.target.value.padStart(2, "0")}`,
);
}}
aria-label="Dag"
className={`border-b bg-transparent pb-2 text-sm text-white transition-colors focus:outline-none ${errors[idx]?.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
DD
</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option
key={d}
value={String(d).padStart(2, "0")}
className="bg-[#214e51]"
>
{String(d).padStart(2, "0")}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`guest-${idx}-birthdateMonth`}
className="sr-only"
>
Maand
</label>
<select
id={`guest-${idx}-birthdateMonth`}
value={guest.birthdate?.split("-")[1] ?? ""}
onChange={(e) => {
const parts = (guest.birthdate || "--").split("-");
onChange(
idx,
"birthdate",
`${parts[0] || ""}-${e.target.value.padStart(2, "0")}-${parts[2] || ""}`,
);
}}
aria-label="Maand"
className={`border-b bg-transparent pb-2 text-sm text-white transition-colors focus:outline-none ${errors[idx]?.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
MM
</option>
{[
"Jan",
"Feb",
"Mrt",
"Apr",
"Mei",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dec",
].map((m, i) => (
<option
key={m}
value={String(i + 1).padStart(2, "0")}
className="bg-[#214e51]"
>
{m}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`guest-${idx}-birthdateYear`}
className="sr-only"
>
Jaar
</label>
<select
id={`guest-${idx}-birthdateYear`}
value={guest.birthdate?.split("-")[0] ?? ""}
onChange={(e) => {
const parts = (guest.birthdate || "--").split("-");
onChange(
idx,
"birthdate",
`${e.target.value}-${parts[1] || ""}-${parts[2] || ""}`,
);
}}
aria-label="Jaar"
className={`border-b bg-transparent pb-2 text-sm text-white transition-colors focus:outline-none ${errors[idx]?.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
JJJJ
</option>
{Array.from(
{ length: 100 },
(_, i) => new Date().getFullYear() - i,
).map((y) => (
<option
key={y}
value={String(y)}
className="bg-[#214e51]"
>
{y}
</option>
))}
</select>
</div>
</fieldset>
{errors[idx]?.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].birthdate}
</span>
)}
</div>
{/* Postcode */}
<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="bv. 9000"
autoComplete="off"
inputMode="numeric"
className={inputCls(!!errors[idx]?.postcode)}
/>
{errors[idx]?.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].postcode}
</span>
)}
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -4,8 +4,10 @@ import { toast } from "sonner";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { import {
inputCls, inputCls,
validateBirthdate,
validateEmail, validateEmail,
validatePhone, validatePhone,
validatePostcode,
validateTextField, validateTextField,
} from "@/lib/registration"; } from "@/lib/registration";
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
@@ -16,6 +18,8 @@ interface PerformerErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
artForm?: string; artForm?: string;
isOver16?: string; isOver16?: string;
} }
@@ -34,6 +38,10 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: "", lastName: "",
email: "", email: "",
phone: "", phone: "",
birthdateDay: "",
birthdateMonth: "",
birthdateYear: "",
postcode: "",
artForm: "", artForm: "",
experience: "", experience: "",
isOver16: false, isOver16: false,
@@ -64,12 +72,21 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
}, },
}); });
function getBirthdate(): string {
const { birthdateYear, birthdateMonth, birthdateDay } = data;
if (!birthdateYear || !birthdateMonth || !birthdateDay) return "";
return `${birthdateYear}-${birthdateMonth.padStart(2, "0")}-${birthdateDay.padStart(2, "0")}`;
}
function validate(): boolean { function validate(): boolean {
const birthdate = getBirthdate();
const errs: PerformerErrors = { const errs: PerformerErrors = {
firstName: validateTextField(data.firstName, true, "Voornaam"), firstName: validateTextField(data.firstName, true, "Voornaam"),
lastName: validateTextField(data.lastName, true, "Achternaam"), lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email), email: validateEmail(data.email),
phone: validatePhone(data.phone), phone: validatePhone(data.phone),
birthdate: validateBirthdate(birthdate),
postcode: validatePostcode(data.postcode),
artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined, artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined,
isOver16: !data.isOver16 isOver16: !data.isOver16
? "Je moet 16 jaar of ouder zijn om op te treden" ? "Je moet 16 jaar of ouder zijn om op te treden"
@@ -81,6 +98,8 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
birthdate: true,
postcode: true,
artForm: true, artForm: true,
isOver16: true, isOver16: true,
}); });
@@ -144,6 +163,8 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: data.lastName.trim(), lastName: data.lastName.trim(),
email: data.email.trim(), email: data.email.trim(),
phone: data.phone.trim() || undefined, phone: data.phone.trim() || undefined,
birthdate: getBirthdate(),
postcode: data.postcode.trim(),
registrationType: "performer", registrationType: "performer",
artForm: data.artForm.trim() || undefined, artForm: data.artForm.trim() || undefined,
experience: data.experience.trim() || undefined, experience: data.experience.trim() || undefined,
@@ -305,6 +326,149 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
</div> </div>
</div> </div>
{/* Birthdate + Postcode row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<p className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</p>
<fieldset className="m-0 flex gap-2 border-0 p-0">
<legend className="sr-only">Geboortedatum</legend>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="p-birthdateDay" className="sr-only">
Dag
</label>
<select
id="p-birthdateDay"
name="birthdateDay"
value={data.birthdateDay}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateDay: e.target.value,
}))
}
aria-label="Dag"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
DD
</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={String(d)} className="bg-[#214e51]">
{String(d).padStart(2, "0")}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="p-birthdateMonth" className="sr-only">
Maand
</label>
<select
id="p-birthdateMonth"
name="birthdateMonth"
value={data.birthdateMonth}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateMonth: e.target.value,
}))
}
aria-label="Maand"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
MM
</option>
{[
"Jan",
"Feb",
"Mrt",
"Apr",
"Mei",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dec",
].map((m, i) => (
<option
key={m}
value={String(i + 1)}
className="bg-[#214e51]"
>
{m}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="p-birthdateYear" className="sr-only">
Jaar
</label>
<select
id="p-birthdateYear"
name="birthdateYear"
value={data.birthdateYear}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateYear: e.target.value,
}))
}
aria-label="Jaar"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
JJJJ
</option>
{Array.from(
{ length: 100 },
(_, i) => new Date().getFullYear() - i,
).map((y) => (
<option key={y} value={String(y)} className="bg-[#214e51]">
{y}
</option>
))}
</select>
</div>
</fieldset>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
<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="bv. 9000"
autoComplete="postal-code"
inputMode="numeric"
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>
{/* Performer-specific fields */} {/* Performer-specific fields */}
<div className="border border-amber-400/20 bg-amber-400/5 p-6"> <div className="border border-amber-400/20 bg-amber-400/5 p-6">
<p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider"> <p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider">
@@ -441,6 +605,33 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
<GiftSelector value={giftAmount} onChange={setGiftAmount} /> <GiftSelector value={giftAmount} onChange={setGiftAmount} />
</div> </div>
{/* Under review notice */}
<div className="flex items-start gap-3 rounded-lg border border-amber-400/30 bg-amber-400/10 p-4">
<svg
className="mt-0.5 h-5 w-5 shrink-0 text-amber-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<div>
<p className="font-semibold text-amber-300 text-sm">
Jouw inschrijving staat onder voorbehoud
</p>
<p className="mt-1 text-sm text-white/70">
Na het indienen wordt je aanvraag beoordeeld. Je ontvangt een
bevestiging of je effectief uitgenodigd wordt om op te treden.
</p>
</div>
</div>
<div className="flex flex-col items-center gap-4 pt-4"> <div className="flex flex-col items-center gap-4 pt-4">
<button <button
type="submit" type="submit"

View File

@@ -2,9 +2,15 @@ type RegistrationType = "performer" | "watcher";
interface Props { interface Props {
onSelect: (type: RegistrationType) => void; onSelect: (type: RegistrationType) => void;
watcherIsFull?: boolean;
watcherAvailable?: number | null;
} }
export function TypeSelector({ onSelect }: Props) { export function TypeSelector({
onSelect,
watcherIsFull = false,
watcherAvailable = null,
}: Props) {
return ( return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Performer card */} {/* Performer card */}
@@ -59,10 +65,17 @@ export function TypeSelector({ onSelect }: Props) {
{/* Watcher card */} {/* Watcher card */}
<button <button
type="button" type="button"
onClick={() => onSelect("watcher")} onClick={() => !watcherIsFull && onSelect("watcher")}
className="group relative flex flex-col overflow-hidden border border-white/20 bg-white/5 p-8 text-left transition-all duration-300 hover:border-white/50 hover:bg-white/10 md:p-10" disabled={watcherIsFull}
className={`group relative flex flex-col overflow-hidden border p-8 text-left transition-all duration-300 md:p-10 ${
watcherIsFull
? "cursor-not-allowed border-white/10 bg-white/3 opacity-60"
: "border-white/20 bg-white/5 hover:border-white/50 hover:bg-white/10"
}`}
> >
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-teal-400 to-cyan-400 opacity-80" /> <div
className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-teal-400 to-cyan-400 ${watcherIsFull ? "opacity-30" : "opacity-80"}`}
/>
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-teal-400/15 text-teal-300"> <div className="mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-teal-400/15 text-teal-300">
<svg <svg
className="h-7 w-7" className="h-7 w-7"
@@ -83,14 +96,29 @@ export function TypeSelector({ onSelect }: Props) {
Ik wil komen kijken Ik wil komen kijken
</h3> </h3>
<p className="mb-4 text-white/70"> <p className="mb-4 text-white/70">
Geniet van een avond vol talent en kunst. Je betaald 5EUR inkom dat je Geniet van een avond vol talent en kunst. Je betaalt 5 per persoon
mag gebruiken op je drinkkaart. dit gaat volledig naar je drinkkaart.
</p> </p>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-teal-400/40 bg-teal-400/10 px-4 py-1.5"> {watcherIsFull ? (
<span className="font-semibold text-sm text-teal-300"> <div className="mb-6 inline-flex items-center gap-2 rounded-full border border-red-400/40 bg-red-400/10 px-4 py-1.5">
Drinkkaart 5 EUR te betalen bij registratie <span className="font-semibold text-red-300 text-sm">
Volzet geen plaatsen meer beschikbaar
</span> </span>
</div> </div>
) : (
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-teal-400/40 bg-teal-400/10 px-4 py-1.5">
<span className="font-semibold text-sm text-teal-300">
5 per persoon drinkkaart inbegrepen
{watcherAvailable !== null && watcherAvailable <= 10 && (
<span className="ml-1 text-amber-300">
({watcherAvailable}{" "}
{watcherAvailable === 1 ? "plaats" : "plaatsen"} vrij)
</span>
)}
</span>
</div>
)}
{!watcherIsFull && (
<div className="mt-auto flex items-center gap-2 font-medium text-sm text-teal-300"> <div className="mt-auto flex items-center gap-2 font-medium text-sm text-teal-300">
<span>Inschrijven als bezoeker</span> <span>Inschrijven als bezoeker</span>
<svg <svg
@@ -108,6 +136,7 @@ export function TypeSelector({ onSelect }: Props) {
/> />
</svg> </svg>
</div> </div>
)}
</button> </button>
</div> </div>
); );

View File

@@ -7,9 +7,11 @@ import {
type GuestEntry, type GuestEntry,
type GuestErrors, type GuestErrors,
inputCls, inputCls,
validateBirthdate,
validateEmail, validateEmail,
validateGuests, validateGuests,
validatePhone, validatePhone,
validatePostcode,
validateTextField, validateTextField,
} from "@/lib/registration"; } from "@/lib/registration";
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
@@ -21,6 +23,8 @@ interface WatcherErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
} }
interface Props { interface Props {
@@ -39,6 +43,10 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
lastName: "", lastName: "",
email: "", email: "",
phone: "", phone: "",
birthdateDay: "",
birthdateMonth: "",
birthdateYear: "",
postcode: "",
extraQuestions: "", extraQuestions: "",
}); });
const [errors, setErrors] = useState<WatcherErrors>({}); const [errors, setErrors] = useState<WatcherErrors>({});
@@ -69,12 +77,21 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
}, },
}); });
function getBirthdate(): string {
const { birthdateYear, birthdateMonth, birthdateDay } = data;
if (!birthdateYear || !birthdateMonth || !birthdateDay) return "";
return `${birthdateYear}-${birthdateMonth.padStart(2, "0")}-${birthdateDay.padStart(2, "0")}`;
}
function validate(): boolean { function validate(): boolean {
const birthdate = getBirthdate();
const fieldErrs: WatcherErrors = { const fieldErrs: WatcherErrors = {
firstName: validateTextField(data.firstName, true, "Voornaam"), firstName: validateTextField(data.firstName, true, "Voornaam"),
lastName: validateTextField(data.lastName, true, "Achternaam"), lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email), email: validateEmail(data.email),
phone: validatePhone(data.phone), phone: validatePhone(data.phone),
birthdate: validateBirthdate(birthdate),
postcode: validatePostcode(data.postcode),
}; };
setErrors(fieldErrs); setErrors(fieldErrs);
setTouched({ setTouched({
@@ -82,6 +99,8 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
birthdate: true,
postcode: true,
}); });
const { errors: gErrs, valid: gValid } = validateGuests(guests); const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs); setGuestErrors(gErrs);
@@ -134,7 +153,14 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
if (guests.length >= 9) return; if (guests.length >= 9) return;
setGuests((prev) => [ setGuests((prev) => [
...prev, ...prev,
{ firstName: "", lastName: "", email: "", phone: "" }, {
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
]); ]);
setGuestErrors((prev) => [...prev, {}]); setGuestErrors((prev) => [...prev, {}]);
} }
@@ -155,12 +181,16 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
lastName: data.lastName.trim(), lastName: data.lastName.trim(),
email: data.email.trim(), email: data.email.trim(),
phone: data.phone.trim() || undefined, phone: data.phone.trim() || undefined,
birthdate: getBirthdate(),
postcode: data.postcode.trim(),
registrationType: "watcher", registrationType: "watcher",
guests: guests.map((g) => ({ guests: guests.map((g) => ({
firstName: g.firstName.trim(), firstName: g.firstName.trim(),
lastName: g.lastName.trim(), lastName: g.lastName.trim(),
email: g.email.trim() || undefined, email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined, phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})), })),
extraQuestions: data.extraQuestions.trim() || undefined, extraQuestions: data.extraQuestions.trim() || undefined,
giftAmount, giftAmount,
@@ -227,7 +257,7 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
<p className="font-semibold text-white">Drinkkaart inbegrepen</p> <p className="font-semibold text-white">Drinkkaart inbegrepen</p>
<p className="text-sm text-white/70"> <p className="text-sm text-white/70">
Je betaald bij registratie Je betaald bij registratie
<strong className="text-teal-300">5</strong> (+ 2 per <strong className="text-teal-300"> 5</strong> (+ 5 per
medebezoeker) dat gaat naar je drinkkaart. medebezoeker) dat gaat naar je drinkkaart.
{guests.length > 0 && ( {guests.length > 0 && (
<span className="ml-1 font-semibold text-teal-300"> <span className="ml-1 font-semibold text-teal-300">
@@ -341,6 +371,146 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
</div> </div>
</div> </div>
{/* Birthdate + Postcode row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<p className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</p>
<fieldset className="m-0 flex gap-2 border-0 p-0">
<legend className="sr-only">Geboortedatum</legend>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="w-birthdateDay" className="sr-only">
Dag
</label>
<select
id="w-birthdateDay"
value={data.birthdateDay}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateDay: e.target.value,
}))
}
aria-label="Dag"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
DD
</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={String(d)} className="bg-[#214e51]">
{String(d).padStart(2, "0")}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="w-birthdateMonth" className="sr-only">
Maand
</label>
<select
id="w-birthdateMonth"
value={data.birthdateMonth}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateMonth: e.target.value,
}))
}
aria-label="Maand"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
MM
</option>
{[
"Jan",
"Feb",
"Mrt",
"Apr",
"Mei",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dec",
].map((m, i) => (
<option
key={m}
value={String(i + 1)}
className="bg-[#214e51]"
>
{m}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="w-birthdateYear" className="sr-only">
Jaar
</label>
<select
id="w-birthdateYear"
value={data.birthdateYear}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateYear: e.target.value,
}))
}
aria-label="Jaar"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
JJJJ
</option>
{Array.from(
{ length: 100 },
(_, i) => new Date().getFullYear() - i,
).map((y) => (
<option key={y} value={String(y)} className="bg-[#214e51]">
{y}
</option>
))}
</select>
</div>
</fieldset>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
<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="bv. 9000"
autoComplete="postal-code"
inputMode="numeric"
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>
{/* Guests */} {/* Guests */}
<GuestList <GuestList
guests={guests} guests={guests}

View File

@@ -2,4 +2,17 @@
* Single source-of-truth for when registration opens. * Single source-of-truth for when registration opens.
* Change this date to reschedule — all gating logic imports from here. * 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"); export const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00");
/**
* Feature flag for the registration countdown gate.
* Set to `false` to bypass the countdown and always show the registration form.
* Set to `true` to enforce the countdown until REGISTRATION_OPENS_AT.
*/
export const COUNTDOWN_ENABLED = true;
/**
* Set to `true` when the event has been postponed.
* Hides the registration form and shows a waitlist signup instead.
*/
export const POSTPONED = true;

View File

@@ -6,8 +6,9 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const DRINK_CARD_BASE = 5; // €5 for primary registrant export const DRINK_CARD_BASE = 5; // €5 for primary registrant
export const DRINK_CARD_PER_GUEST = 2; // €2 per additional guest export const DRINK_CARD_PER_GUEST = 5; // €5 per additional guest (same as primary)
export const MAX_GUESTS = 9; export const MAX_GUESTS = 9;
export const MAX_WATCHERS = 70; // max total watcher spots (people, not registrations)
/** Returns drink card value in euros for a given number of extra guests. */ /** Returns drink card value in euros for a given number of extra guests. */
export function calculateDrinkCard(guestCount: number): number { export function calculateDrinkCard(guestCount: number): number {
@@ -28,6 +29,8 @@ export interface GuestEntry {
lastName: string; lastName: string;
email: string; email: string;
phone: string; phone: string;
birthdate: string;
postcode: string;
} }
export interface GuestErrors { export interface GuestErrors {
@@ -35,6 +38,8 @@ export interface GuestErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
} }
/** /**
@@ -51,6 +56,8 @@ export function parseGuests(raw: string | null | undefined): GuestEntry[] {
lastName: g.lastName ?? "", lastName: g.lastName ?? "",
email: g.email ?? "", email: g.email ?? "",
phone: g.phone ?? "", phone: g.phone ?? "",
birthdate: g.birthdate ?? "",
postcode: g.postcode ?? "",
})); }));
} catch { } catch {
return []; return [];
@@ -86,6 +93,22 @@ export function validatePhone(value: string): string | undefined {
return undefined; return undefined;
} }
export function validateBirthdate(value: string): string | undefined {
if (!value.trim()) return "Geboortedatum is verplicht";
// Expect YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
return "Voer een geldige geboortedatum in";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Voer een geldige geboortedatum in";
if (date > new Date()) return "Geboortedatum mag niet in de toekomst liggen";
return undefined;
}
export function validatePostcode(value: string): string | undefined {
if (!value.trim()) return "Postcode is verplicht";
return undefined;
}
/** Validates all guests and returns errors array + overall validity flag. */ /** Validates all guests and returns errors array + overall validity flag. */
export function validateGuests(guests: GuestEntry[]): { export function validateGuests(guests: GuestEntry[]): {
errors: GuestErrors[]; errors: GuestErrors[];
@@ -102,6 +125,8 @@ export function validateGuests(guests: GuestEntry[]): {
g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, "")) g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, ""))
? "Voer een geldig telefoonnummer in" ? "Voer een geldig telefoonnummer in"
: undefined, : undefined,
birthdate: validateBirthdate(g.birthdate),
postcode: validatePostcode(g.postcode),
})); }));
const valid = !errors.some((e) => Object.values(e).some(Boolean)); const valid = !errors.some((e) => Object.values(e).some(Boolean));
return { errors, valid }; return { errors, valid };

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { REGISTRATION_OPENS_AT } from "./opening"; import { COUNTDOWN_ENABLED, REGISTRATION_OPENS_AT } from "./opening";
interface RegistrationOpenState { interface RegistrationOpenState {
isOpen: boolean; isOpen: boolean;
@@ -10,6 +10,9 @@ interface RegistrationOpenState {
} }
function compute(): RegistrationOpenState { function compute(): RegistrationOpenState {
if (!COUNTDOWN_ENABLED) {
return { isOpen: true, days: 0, hours: 0, minutes: 0, seconds: 0 };
}
const diff = REGISTRATION_OPENS_AT.getTime() - Date.now(); const diff = REGISTRATION_OPENS_AT.getTime() - Date.now();
if (diff <= 0) { if (diff <= 0) {
return { isOpen: true, days: 0, hours: 0, minutes: 0, seconds: 0 }; return { isOpen: true, days: 0, hours: 0, minutes: 0, seconds: 0 };

View File

@@ -13,6 +13,7 @@ import { Route as TermsRouteImport } from './routes/terms'
import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as PrivacyRouteImport } from './routes/privacy' import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as KampRouteImport } from './routes/kamp'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password' import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as DrinkkaartRouteImport } from './routes/drinkkaart' import { Route as DrinkkaartRouteImport } from './routes/drinkkaart'
import { Route as ContactRouteImport } from './routes/contact' import { Route as ContactRouteImport } from './routes/contact'
@@ -46,6 +47,11 @@ const LoginRoute = LoginRouteImport.update({
path: '/login', path: '/login',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const KampRoute = KampRouteImport.update({
id: '/kamp',
path: '/kamp',
getParentRoute: () => rootRouteImport,
} as any)
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
id: '/forgot-password', id: '/forgot-password',
path: '/forgot-password', path: '/forgot-password',
@@ -113,6 +119,7 @@ export interface FileRoutesByFullPath {
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute '/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute '/forgot-password': typeof ForgotPasswordRoute
'/kamp': typeof KampRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute '/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
@@ -131,6 +138,7 @@ export interface FileRoutesByTo {
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute '/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute '/forgot-password': typeof ForgotPasswordRoute
'/kamp': typeof KampRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute '/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
@@ -150,6 +158,7 @@ export interface FileRoutesById {
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute '/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute '/forgot-password': typeof ForgotPasswordRoute
'/kamp': typeof KampRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute '/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
@@ -170,6 +179,7 @@ export interface FileRouteTypes {
| '/contact' | '/contact'
| '/drinkkaart' | '/drinkkaart'
| '/forgot-password' | '/forgot-password'
| '/kamp'
| '/login' | '/login'
| '/privacy' | '/privacy'
| '/reset-password' | '/reset-password'
@@ -188,6 +198,7 @@ export interface FileRouteTypes {
| '/contact' | '/contact'
| '/drinkkaart' | '/drinkkaart'
| '/forgot-password' | '/forgot-password'
| '/kamp'
| '/login' | '/login'
| '/privacy' | '/privacy'
| '/reset-password' | '/reset-password'
@@ -206,6 +217,7 @@ export interface FileRouteTypes {
| '/contact' | '/contact'
| '/drinkkaart' | '/drinkkaart'
| '/forgot-password' | '/forgot-password'
| '/kamp'
| '/login' | '/login'
| '/privacy' | '/privacy'
| '/reset-password' | '/reset-password'
@@ -225,6 +237,7 @@ export interface RootRouteChildren {
ContactRoute: typeof ContactRoute ContactRoute: typeof ContactRoute
DrinkkaartRoute: typeof DrinkkaartRoute DrinkkaartRoute: typeof DrinkkaartRoute
ForgotPasswordRoute: typeof ForgotPasswordRoute ForgotPasswordRoute: typeof ForgotPasswordRoute
KampRoute: typeof KampRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
PrivacyRoute: typeof PrivacyRoute PrivacyRoute: typeof PrivacyRoute
ResetPasswordRoute: typeof ResetPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute
@@ -268,6 +281,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/kamp': {
id: '/kamp'
path: '/kamp'
fullPath: '/kamp'
preLoaderRoute: typeof KampRouteImport
parentRoute: typeof rootRouteImport
}
'/forgot-password': { '/forgot-password': {
id: '/forgot-password' id: '/forgot-password'
path: '/forgot-password' path: '/forgot-password'
@@ -361,6 +381,7 @@ const rootRouteChildren: RootRouteChildren = {
ContactRoute: ContactRoute, ContactRoute: ContactRoute,
DrinkkaartRoute: DrinkkaartRoute, DrinkkaartRoute: DrinkkaartRoute,
ForgotPasswordRoute: ForgotPasswordRoute, ForgotPasswordRoute: ForgotPasswordRoute,
KampRoute: KampRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
PrivacyRoute: PrivacyRoute, PrivacyRoute: PrivacyRoute,
ResetPasswordRoute: ResetPasswordRoute, ResetPasswordRoute: ResetPasswordRoute,

View File

@@ -1,3 +1,4 @@
import type { EmailMessage } from "@kk/api/email-queue";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { createRouter as createTanStackRouter } from "@tanstack/react-router"; import { createRouter as createTanStackRouter } from "@tanstack/react-router";
@@ -6,6 +7,18 @@ import Loader from "./components/loader";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { orpc, queryClient } from "./utils/orpc"; import { orpc, queryClient } from "./utils/orpc";
// Minimal CF Queue binding shape needed for type inference
type Queue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
export const getRouter = () => { export const getRouter = () => {
const router = createTanStackRouter({ const router = createTanStackRouter({
routeTree, routeTree,
@@ -26,3 +39,9 @@ declare module "@tanstack/react-router" {
router: ReturnType<typeof getRouter>; router: ReturnType<typeof getRouter>;
} }
} }
declare module "@tanstack/router-core" {
interface Register {
server: { requestContext: { emailQueue?: Queue } };
}
}

View File

@@ -17,7 +17,7 @@ import appCss from "../index.css?url";
const siteUrl = "https://kunstenkamp.be"; const siteUrl = "https://kunstenkamp.be";
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord"; const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord";
const siteDescription = const siteDescription =
"Doe mee met de Open Mic Night op 18 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 24 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional.";
const eventImage = `${siteUrl}/assets/og-image.jpg`; const eventImage = `${siteUrl}/assets/og-image.jpg`;
export interface RouterAppContext { export interface RouterAppContext {
@@ -128,7 +128,7 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
name: "Kunstenkamp Open Mic Night", name: "Kunstenkamp Open Mic Night",
description: description:
"Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom om zijn of haar talent te tonen.", "Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom om zijn of haar talent te tonen.",
startDate: "2026-04-18T19:00:00+02:00", startDate: "2026-04-24T19:30:00+02:00",
eventStatus: "https://schema.org/EventScheduled", eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode", eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
organizer: { organizer: {
@@ -142,7 +142,7 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
price: "0", price: "0",
priceCurrency: "EUR", priceCurrency: "EUR",
availability: "https://schema.org/InStock", availability: "https://schema.org/InStock",
validFrom: "2026-03-01T00:00:00+02:00", validFrom: "2026-03-16T19:00:00+01:00",
}, },
}), }),
}, },

View File

@@ -30,6 +30,8 @@ type Guest = {
lastName: string; lastName: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
}; };
function parseGuests(raw: unknown): Guest[] { function parseGuests(raw: unknown): Guest[] {
@@ -179,6 +181,7 @@ function AdminPage() {
}), }),
); );
const adminRequestsQuery = useQuery(orpc.getAdminRequests.queryOptions()); const adminRequestsQuery = useQuery(orpc.getAdminRequests.queryOptions());
const waitlistQuery = useQuery(orpc.getWaitlistSubscribers.queryOptions());
const exportMutation = useMutation({ const exportMutation = useMutation({
...orpc.exportRegistrations.mutationOptions(), ...orpc.exportRegistrations.mutationOptions(),
@@ -551,6 +554,55 @@ function AdminPage() {
/> />
</div> </div>
{/* ── Wachtlijst ── */}
{waitlistQuery.data && waitlistQuery.data.length > 0 && (
<div className="rounded-xl border border-white/8 bg-white/3 p-4">
<div className="mb-3 flex items-center gap-2">
<p className="font-mono text-[10px] text-white/40 uppercase tracking-widest">
Wachtlijst
</p>
<span className="rounded-full bg-white/10 px-2 py-0.5 font-mono text-[10px] text-white/60">
{waitlistQuery.data.length}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-white/8 border-b text-left">
<th className="pb-2 font-mono text-[10px] text-white/30 uppercase tracking-widest">
E-mail
</th>
<th className="pb-2 font-mono text-[10px] text-white/30 uppercase tracking-widest">
Ingeschreven op
</th>
</tr>
</thead>
<tbody>
{waitlistQuery.data.map((row) => (
<tr
key={row.id}
className="border-white/5 border-b last:border-0"
>
<td className="py-2 pr-8 font-mono text-white/70 text-xs">
{row.email}
</td>
<td className="py-2 font-mono text-white/40 text-xs">
{new Date(row.createdAt).toLocaleString("nl-BE", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ── Filters + export row ── */} {/* ── Filters + export row ── */}
<div className="rounded-xl border border-white/8 bg-white/3 p-4"> <div className="rounded-xl border border-white/8 bg-white/3 p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-6"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-6">
@@ -664,6 +716,10 @@ function AdminPage() {
Email <SortIcon col="email" /> Email <SortIcon col="email" />
</th> </th>
<th className={`${thCls} hidden xl:table-cell`}>Telefoon</th> <th className={`${thCls} hidden xl:table-cell`}>Telefoon</th>
<th className={`${thCls} hidden xl:table-cell`}>
Geboortedatum
</th>
<th className={`${thCls} hidden xl:table-cell`}>Postcode</th>
<th className={thCls} onClick={() => handleSort("type")}> <th className={thCls} onClick={() => handleSort("type")}>
Type <SortIcon col="type" /> Type <SortIcon col="type" />
</th> </th>
@@ -690,7 +746,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? ( {registrationsQuery.isLoading ? (
<tr> <tr>
<td <td
colSpan={12} colSpan={14}
className="px-4 py-12 text-center font-mono text-sm text-white/30" className="px-4 py-12 text-center font-mono text-sm text-white/30"
> >
laden... laden...
@@ -699,7 +755,7 @@ function AdminPage() {
) : sortedRegistrations.length === 0 ? ( ) : sortedRegistrations.length === 0 ? (
<tr> <tr>
<td <td
colSpan={12} colSpan={14}
className="px-4 py-12 text-center font-mono text-sm text-white/30" className="px-4 py-12 text-center font-mono text-sm text-white/30"
> >
Geen registraties gevonden Geen registraties gevonden
@@ -757,6 +813,12 @@ function AdminPage() {
<td className="hidden px-3 py-3 font-mono text-white/45 text-xs xl:table-cell"> <td className="hidden px-3 py-3 font-mono text-white/45 text-xs xl:table-cell">
{reg.phone || "—"} {reg.phone || "—"}
</td> </td>
<td className="hidden px-3 py-3 font-mono text-white/45 text-xs xl:table-cell">
{reg.birthdate || "—"}
</td>
<td className="hidden px-3 py-3 font-mono text-white/45 text-xs xl:table-cell">
{reg.postcode || "—"}
</td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<span <span
className={`rounded-full px-2 py-0.5 font-mono font-semibold text-[10px] uppercase tracking-wide ${isPerformer ? "bg-amber-400/12 text-amber-300" : "bg-teal-400/12 text-teal-300"}`} className={`rounded-full px-2 py-0.5 font-mono font-semibold text-[10px] uppercase tracking-wide ${isPerformer ? "bg-amber-400/12 text-amber-300" : "bg-teal-400/12 text-teal-300"}`}
@@ -833,7 +895,7 @@ function AdminPage() {
key={`${reg.id}-detail`} key={`${reg.id}-detail`}
className="border-white/5 border-b bg-white/5" className="border-white/5 border-b bg-white/5"
> >
<td colSpan={12} className="px-6 pt-3 pb-5"> <td colSpan={14} className="px-6 pt-3 pb-5">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{/* Guests */} {/* Guests */}
{guestCount > 0 && ( {guestCount > 0 && (
@@ -864,6 +926,16 @@ function AdminPage() {
{g.phone} {g.phone}
</span> </span>
)} )}
{g.birthdate && (
<span className="font-mono text-white/35 text-xs">
{g.birthdate}
</span>
)}
{g.postcode && (
<span className="font-mono text-white/35 text-xs">
{g.postcode}
</span>
)}
{!g.email && !g.phone && ( {!g.email && !g.phone && (
<span className="font-mono text-white/25 text-xs italic"> <span className="font-mono text-white/25 text-xs italic">
geen contactgegevens geen contactgegevens
@@ -875,7 +947,7 @@ function AdminPage() {
</div> </div>
)} )}
{/* Notes */} {/* Registrant birthdate + postcode */}
{hasNotes && ( {hasNotes && (
<div> <div>
<p className="mb-2 font-mono text-[10px] text-white/40 uppercase tracking-widest"> <p className="mb-2 font-mono text-[10px] text-white/40 uppercase tracking-widest">
@@ -980,6 +1052,12 @@ function AdminPage() {
{reg.phone} {reg.phone}
</p> </p>
)} )}
{reg.birthdate && (
<p className="font-mono text-white/30 text-xs">
{reg.birthdate}
{reg.postcode && ` · ${reg.postcode}`}
</p>
)}
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-1.5"> <div className="flex shrink-0 flex-col items-end gap-1.5">
<span <span
@@ -1067,6 +1145,12 @@ function AdminPage() {
{reg.phone} {reg.phone}
</p> </p>
)} )}
{reg.birthdate && (
<p className="font-mono text-white/30 text-xs">
{reg.birthdate}
{reg.postcode && ` · ${reg.postcode}`}
</p>
)}
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-1.5"> <div className="flex shrink-0 flex-col items-end gap-1.5">
<span <span
@@ -1157,6 +1241,13 @@ function AdminPage() {
{g.phone} {g.phone}
</p> </p>
)} )}
{(g.birthdate || g.postcode) && (
<p className="font-mono text-white/30 text-xs">
{g.birthdate}
{g.birthdate && g.postcode && " · "}
{g.postcode}
</p>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { createContext } from "@kk/api/context"; import { createContext } from "@kk/api/context";
import type { EmailMessage } from "@kk/api/email-queue";
import { appRouter } from "@kk/api/routers/index"; import { appRouter } from "@kk/api/routers/index";
import { OpenAPIHandler } from "@orpc/openapi/fetch"; import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
@@ -7,6 +8,18 @@ import { RPCHandler } from "@orpc/server/fetch";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
// Minimal CF Queue binding shape — mirrors the declaration in router.tsx / context.ts
type Queue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
const rpcHandler = new RPCHandler(appRouter, { const rpcHandler = new RPCHandler(appRouter, {
interceptors: [ interceptors: [
onError((error) => { onError((error) => {
@@ -28,16 +41,21 @@ const apiHandler = new OpenAPIHandler(appRouter, {
], ],
}); });
async function handle({ request }: { request: Request }) { async function handle(ctx: { request: Request; context?: unknown }) {
const rpcResult = await rpcHandler.handle(request, { // emailQueue is threaded in via the fetch wrapper in server.ts
const emailQueue = (ctx.context as { emailQueue?: Queue } | undefined)
?.emailQueue;
const context = await createContext({ req: ctx.request, emailQueue });
const rpcResult = await rpcHandler.handle(ctx.request, {
prefix: "/api/rpc", prefix: "/api/rpc",
context: await createContext({ req: request }), context,
}); });
if (rpcResult.response) return rpcResult.response; if (rpcResult.response) return rpcResult.response;
const apiResult = await apiHandler.handle(request, { const apiResult = await apiHandler.handle(ctx.request, {
prefix: "/api/rpc/api-reference", prefix: "/api/rpc/api-reference",
context: await createContext({ req: request }), context,
}); });
if (apiResult.response) return apiResult.response; if (apiResult.response) return apiResult.response;

View File

@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { sendPaymentConfirmationEmail } from "@kk/api/email"; import { sendPaymentConfirmationEmail } from "@kk/api/email";
import type { EmailMessage } from "@kk/api/email-queue";
import { creditRegistrationToAccount } from "@kk/api/routers/index"; import { creditRegistrationToAccount } from "@kk/api/routers/index";
import { db } from "@kk/db"; import { db } from "@kk/db";
import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema"; import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema";
@@ -8,6 +9,14 @@ import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
// Minimal CF Queue binding shape used to enqueue emails
type EmailQueue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
};
// Mollie payment object (relevant fields only) // Mollie payment object (relevant fields only)
interface MolliePayment { interface MolliePayment {
id: string; id: string;
@@ -41,7 +50,15 @@ async function fetchMolliePayment(paymentId: string): Promise<MolliePayment> {
return response.json() as Promise<MolliePayment>; return response.json() as Promise<MolliePayment>;
} }
async function handleWebhook({ request }: { request: Request }) { async function handleWebhook({
request,
context,
}: {
request: Request;
context?: unknown;
}) {
const emailQueue = (context as { emailQueue?: EmailQueue } | undefined)
?.emailQueue;
if (!env.MOLLIE_API_KEY) { if (!env.MOLLIE_API_KEY) {
console.error("MOLLIE_API_KEY not configured"); console.error("MOLLIE_API_KEY not configured");
return new Response("Payment provider not configured", { status: 500 }); return new Response("Payment provider not configured", { status: 500 });
@@ -207,19 +224,25 @@ async function handleWebhook({ request }: { request: Request }) {
`Payment successful for registration ${registrationToken}, payment ${payment.id}`, `Payment successful for registration ${registrationToken}, payment ${payment.id}`,
); );
// Send payment confirmation email (fire-and-forget; don't block webhook) // Send payment confirmation email via queue when available, otherwise direct.
sendPaymentConfirmationEmail({ const confirmMsg: EmailMessage = {
type: "paymentConfirmation",
to: regRow.email, to: regRow.email,
firstName: regRow.firstName, firstName: regRow.firstName,
managementToken: regRow.managementToken ?? registrationToken, managementToken: regRow.managementToken ?? registrationToken,
drinkCardValue: regRow.drinkCardValue ?? undefined, drinkCardValue: regRow.drinkCardValue ?? undefined,
giftAmount: regRow.giftAmount ?? undefined, giftAmount: regRow.giftAmount ?? undefined,
}).catch((err) => };
if (emailQueue) {
await emailQueue.send(confirmMsg);
} else {
sendPaymentConfirmationEmail(confirmMsg).catch((err) =>
console.error( console.error(
`Failed to send payment confirmation email for ${regRow.email}:`, `Failed to send payment confirmation email for ${regRow.email}:`,
err, err,
), ),
); );
}
// If this is a watcher with a drink card value, try to credit their // If this is a watcher with a drink card value, try to credit their
// drinkkaart immediately — but only if they already have an account. // drinkkaart immediately — but only if they already have an account.

View File

@@ -38,10 +38,10 @@ function ContactPage() {
<section className="rounded-lg bg-white/5 p-6"> <section className="rounded-lg bg-white/5 p-6">
<h3 className="mb-3 text-white text-xl">Open Mic Night</h3> <h3 className="mb-3 text-white text-xl">Open Mic Night</h3>
<p>Vrijdag 18 april 2026</p> <p>Vrijdag 24 april 2026</p>
<p>Aanvang: 19:00 uur</p> <p>Aanvang: 19:30 uur</p>
<p className="mt-2 text-white/60"> <p className="mt-2 text-white/60">
Locatie wordt later bekendgemaakt aan geregistreerde deelnemers. Lange Winkelstraat 5, 2000 Antwerpen
</p> </p>
</section> </section>
@@ -72,6 +72,14 @@ function ContactPage() {
> >
Vlaanderen Cultuur, Jeugd & Media Vlaanderen Cultuur, Jeugd & Media
</a> </a>
<a
href="https://ichtusantwerpen.com"
target="_blank"
rel="noopener noreferrer"
className="link-hover text-white/80 hover:text-white"
>
Ichtus Antwerpen
</a>
</div> </div>
</section> </section>

View File

@@ -1,21 +1,575 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { DEDUCTION_PRESETS_CENTS, formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
// /drinkkaart redirects to /account, preserving ?topup=success for
// backward-compatibility with the Lemon Squeezy webhook return URL.
export const Route = createFileRoute("/drinkkaart")({ export const Route = createFileRoute("/drinkkaart")({
validateSearch: (search: Record<string, unknown>) => ({ beforeLoad: async () => {
topup: search.topup as string | undefined,
}),
beforeLoad: async ({ search }) => {
const session = await authClient.getSession(); const session = await authClient.getSession();
if (!session.data?.user) { if (!session.data?.user) {
throw redirect({ to: "/login" }); throw redirect({ to: "/login" });
} }
throw redirect({ const user = session.data.user as { role?: string };
to: "/account", if (user.role !== "admin") {
search: search.topup ? { topup: search.topup } : {}, throw redirect({ to: "/" });
}
},
component: DrinkkaartScanPage,
});
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ScanState =
| { step: "scanning" }
| { step: "resolving" }
| {
step: "resolved";
drinkkaartId: string;
userId: string;
userName: string;
userEmail: string;
balance: number;
}
| {
step: "confirming";
drinkkaartId: string;
userName: string;
userEmail: string;
balance: number;
amountCents: number;
}
| {
step: "success";
userName: string;
amountCents: number;
balanceAfter: number;
}
| { step: "error"; message: string };
// ---------------------------------------------------------------------------
// ScanningView — full-screen camera with manual fallback
// ---------------------------------------------------------------------------
function ScanningView({ onScan }: { onScan: (token: string) => void }) {
const videoContainerId = "qr-video-container";
const scannerRef = useRef<unknown>(null);
const [hasError, setHasError] = useState(false);
const [manualToken, setManualToken] = useState("");
const [showManual, setShowManual] = useState(false);
const onScanRef = useRef(onScan);
useEffect(() => {
onScanRef.current = onScan;
}, [onScan]);
useEffect(() => {
if (hasError) return;
let stopped = false;
import("html5-qrcode")
.then(({ Html5Qrcode }) => {
if (stopped) return;
const scanner = new Html5Qrcode(videoContainerId);
scannerRef.current = scanner;
scanner
.start(
{ facingMode: "environment" },
{ fps: 15, qrbox: { width: 240, height: 240 } },
(decodedText: string) => {
scanner
.stop()
.catch(console.error)
.finally(() => {
if (!stopped) onScanRef.current(decodedText);
}); });
}, },
component: () => null, () => {},
)
.catch((err: unknown) => {
if (!stopped) {
console.error("Camera start failed:", err);
setHasError(true);
setShowManual(true);
}
}); });
})
.catch((err) => {
console.error("Failed to load html5-qrcode:", err);
setHasError(true);
setShowManual(true);
});
return () => {
stopped = true;
const s = scannerRef.current as {
stop: () => Promise<void>;
clear: () => Promise<void>;
} | null;
s?.stop()
.catch(() => {})
.finally(() => s?.clear().catch(() => {}));
};
}, [hasError]);
const handleManualSubmit = () => {
const token = manualToken.trim();
if (token) onScan(token);
};
return (
<div className="flex flex-1 flex-col">
{/* Camera — fills all available space above the hint */}
{!hasError && (
<div
id={videoContainerId}
className="[&_#qr-shaded-region]:!border-white/40 flex-1 overflow-hidden bg-black [&>video]:h-full [&>video]:w-full [&>video]:object-cover"
/>
)}
{hasError && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-white/5 p-6 text-center">
<p className="text-white/60">Camera niet beschikbaar.</p>
</div>
</div>
)}
{/* Bottom controls */}
<div className="shrink-0 space-y-3 px-4 pt-4 pb-8">
{showManual ? (
<div className="flex gap-2">
<Input
autoFocus
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
placeholder="Plak QR token..."
className="border-white/20 bg-white/10 text-white placeholder:text-white/30"
onKeyDown={(e) => e.key === "Enter" && handleManualSubmit()}
/>
<Button
onClick={handleManualSubmit}
disabled={!manualToken.trim()}
className="shrink-0 bg-white text-[#214e51] hover:bg-white/90"
>
OK
</Button>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-sm text-white/40">
Richt de camera op de QR-code
</p>
<button
type="button"
onClick={() => setShowManual(true)}
className="text-sm text-white/30 hover:text-white/60"
>
Handmatig
</button>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
function DrinkkaartScanPage() {
const [scanState, setScanState] = useState<ScanState>({ step: "scanning" });
const [customCents, setCustomCents] = useState("");
const [useCustom, setUseCustom] = useState(false);
const [selectedCents, setSelectedCents] = useState<number>(
DEDUCTION_PRESETS_CENTS[1],
);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-return to scanner 1.5s after success
useEffect(() => {
if (scanState.step === "success") {
successTimerRef.current = setTimeout(() => {
setScanState({ step: "scanning" });
setCustomCents("");
setUseCustom(false);
setSelectedCents(DEDUCTION_PRESETS_CENTS[1]);
}, 1500);
}
return () => {
if (successTimerRef.current) clearTimeout(successTimerRef.current);
};
}, [scanState.step]);
// --- Mutations ---
const resolveQrMutation = useMutation(
orpc.drinkkaart.resolveQrToken.mutationOptions(),
);
const deductMutation = useMutation(
orpc.drinkkaart.deductBalance.mutationOptions(),
);
// --- Handlers ---
const handleScan = useCallback(
(token: string) => {
setScanState({ step: "resolving" });
resolveQrMutation.mutate(
{ token },
{
onSuccess: (data) => {
setScanState({
step: "resolved",
drinkkaartId: data.drinkkaartId,
userId: data.userId,
userName: data.userName,
userEmail: data.userEmail,
balance: data.balance,
});
},
onError: (err: Error) => {
setScanState({
step: "error",
message: err.message ?? "Ongeldig QR-token",
});
},
},
);
},
[resolveQrMutation],
);
const handlePresetSelect = (cents: number) => {
setSelectedCents(cents);
setUseCustom(false);
};
const getAmountCents = () => {
if (useCustom) {
return Math.round((Number.parseFloat(customCents || "0") || 0) * 100);
}
return selectedCents;
};
const handleDeductTap = () => {
if (scanState.step !== "resolved") return;
const amountCents = getAmountCents();
if (!amountCents || amountCents < 1 || amountCents > scanState.balance)
return;
setScanState({
step: "confirming",
drinkkaartId: scanState.drinkkaartId,
userName: scanState.userName,
userEmail: scanState.userEmail,
balance: scanState.balance,
amountCents,
});
};
const handleConfirm = () => {
if (scanState.step !== "confirming") return;
deductMutation.mutate(
{
drinkkaartId: scanState.drinkkaartId,
amountCents: scanState.amountCents,
},
{
onSuccess: (data) => {
toast.success(`${formatCents(scanState.amountCents)} afgeschreven`);
setScanState({
step: "success",
userName: scanState.userName,
amountCents: scanState.amountCents,
balanceAfter: data.balanceAfter,
});
deductMutation.reset();
},
onError: (err: Error) => {
setScanState({
step: "error",
message: err.message ?? "Fout bij afschrijving",
});
deductMutation.reset();
},
},
);
};
const restartScanner = () => {
setScanState({ step: "scanning" });
setCustomCents("");
setUseCustom(false);
setSelectedCents(DEDUCTION_PRESETS_CENTS[1]);
deductMutation.reset();
};
// Determine the display amount for the deduct button (only in resolved step)
const resolvedAmountCents =
scanState.step === "resolved" ? getAmountCents() : 0;
const resolvedBalance = scanState.step === "resolved" ? scanState.balance : 0;
const deductIsValid =
scanState.step === "resolved" &&
Number.isInteger(resolvedAmountCents) &&
resolvedAmountCents >= 1 &&
resolvedAmountCents <= resolvedBalance;
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="flex h-dvh flex-col bg-[#214e51]">
{/* ------------------------------------------------------------------ */}
{/* Header */}
{/* ------------------------------------------------------------------ */}
<header className="flex shrink-0 items-center justify-between border-white/10 border-b px-4 py-3">
<h1 className="font-['Intro',sans-serif] text-lg text-white">
Drinkkaart
</h1>
{/* Only show the admin link when not mid-flow */}
{(scanState.step === "scanning" || scanState.step === "error") && (
<a
href="/admin/drinkkaart"
className="rounded-lg px-3 py-1.5 text-sm text-white/50 hover:bg-white/10 hover:text-white"
>
Beheer
</a>
)}
{scanState.step === "resolved" && (
<button
type="button"
onClick={restartScanner}
className="rounded-lg px-3 py-1.5 text-sm text-white/50 hover:bg-white/10 hover:text-white"
>
Annuleer
</button>
)}
{scanState.step === "confirming" && (
<button
type="button"
onClick={() => {
// Go back to resolved state
const s = scanState;
setScanState({
step: "resolved",
drinkkaartId: s.drinkkaartId,
userId: "",
userName: s.userName,
userEmail: s.userEmail,
balance: s.balance,
});
}}
className="rounded-lg px-3 py-1.5 text-sm text-white/50 hover:bg-white/10 hover:text-white"
>
Terug
</button>
)}
</header>
{/* ------------------------------------------------------------------ */}
{/* Main content area */}
{/* ------------------------------------------------------------------ */}
<main className="flex flex-1 flex-col overflow-hidden">
{/* ---- SCANNING ---- */}
{scanState.step === "scanning" && <ScanningView onScan={handleScan} />}
{/* ---- RESOLVING ---- */}
{scanState.step === "resolving" && (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-white/20 border-t-white" />
<p className="text-white/60">Controleren...</p>
</div>
)}
{/* ---- RESOLVED: show person + deduct controls ---- */}
{scanState.step === "resolved" && (
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Person card */}
<div className="mx-4 mt-4 rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-lg text-white">
{scanState.userName}
</p>
<p className="truncate text-sm text-white/50">
{scanState.userEmail}
</p>
</div>
<div className="ml-4 shrink-0 text-right">
<p className="text-white/40 text-xs">Saldo</p>
<p className="font-['Intro',sans-serif] text-3xl text-white">
{formatCents(scanState.balance)}
</p>
</div>
</div>
</div>
{/* Amount selection */}
<div className="mx-4 mt-4 space-y-4">
<p className="text-sm text-white/60">Bedrag kiezen</p>
{/* Preset grid */}
<div className="grid grid-cols-4 gap-2">
{DEDUCTION_PRESETS_CENTS.map((cents) => (
<button
key={cents}
type="button"
onClick={() => handlePresetSelect(cents)}
className={`rounded-xl border py-4 font-semibold text-sm transition-colors ${
!useCustom && selectedCents === cents
? "border-white bg-white text-[#214e51]"
: "border-white/20 text-white hover:border-white/50 active:bg-white/10"
}`}
>
{formatCents(cents)}
</button>
))}
</div>
{/* Custom amount */}
<div className="relative">
<span className="absolute top-1/2 left-3.5 -translate-y-1/2 text-white/50">
</span>
<Input
type="number"
min="0.01"
step="0.01"
placeholder="Eigen bedrag"
value={customCents}
onChange={(e) => {
setCustomCents(e.target.value);
setUseCustom(true);
}}
onFocus={() => setUseCustom(true)}
className="border-white/20 bg-white/10 py-4 pl-8 text-base text-white placeholder:text-white/30"
/>
</div>
{/* Insufficient balance warning */}
{resolvedAmountCents > resolvedBalance && (
<p className="text-red-400 text-sm">
Onvoldoende saldo ({formatCents(resolvedBalance)})
</p>
)}
</div>
{/* Deduct button — sticky at bottom */}
<div className="mt-auto px-4 pt-4 pb-8">
<Button
onClick={handleDeductTap}
disabled={!deductIsValid}
className="h-14 w-full bg-white font-semibold text-[#214e51] text-lg hover:bg-white/90 disabled:opacity-40"
>
{deductIsValid
? `Afschrijven — ${formatCents(resolvedAmountCents)}`
: "Kies een bedrag"}
</Button>
</div>
</div>
)}
{/* ---- CONFIRMING ---- */}
{scanState.step === "confirming" && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-6">
{/* Summary card */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 text-center">
<p className="text-sm text-white/60">Afschrijving voor</p>
<p className="mt-1 font-semibold text-white text-xl">
{scanState.userName}
</p>
<p className="mt-4 font-['Intro',sans-serif] text-5xl text-white">
{formatCents(scanState.amountCents)}
</p>
<p className="mt-3 text-sm text-white/50">
Nieuw saldo:{" "}
<span className="text-white">
{formatCents(scanState.balance - scanState.amountCents)}
</span>
</p>
</div>
{deductMutation.isError && (
<p className="text-center text-red-400 text-sm">
{(deductMutation.error as Error)?.message ??
"Fout bij afschrijving"}
</p>
)}
{/* Confirm button */}
<Button
onClick={handleConfirm}
disabled={deductMutation.isPending}
className="h-14 w-full bg-white font-semibold text-[#214e51] text-lg hover:bg-white/90 disabled:opacity-50"
>
{deductMutation.isPending ? "Verwerken..." : "Bevestigen"}
</Button>
</div>
</div>
)}
{/* ---- SUCCESS ---- */}
{scanState.step === "success" && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-4 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-400/20">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className="h-8 w-8 text-green-400"
aria-label="Betaling geslaagd"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div>
<p className="font-semibold text-green-300 text-xl">
{formatCents(scanState.amountCents)} afgeschreven
</p>
<p className="mt-1 text-sm text-white/60">
{scanState.userName} nieuw saldo:{" "}
<strong className="text-white">
{formatCents(scanState.balanceAfter)}
</strong>
</p>
</div>
<p className="text-white/30 text-xs">
Volgende scan wordt gestart
</p>
</div>
</div>
)}
{/* ---- ERROR ---- */}
{scanState.step === "error" && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-5">
<div className="rounded-2xl border border-red-400/20 bg-red-400/5 p-5 text-center">
<p className="font-semibold text-lg text-red-300">Fout</p>
<p className="mt-2 text-sm text-white/60">
{scanState.message}
</p>
</div>
<Button
onClick={restartScanner}
className="h-14 w-full bg-white font-semibold text-[#214e51] text-lg hover:bg-white/90"
>
Opnieuw scannen
</Button>
</div>
</div>
)}
</main>
</div>
);
}

View File

@@ -1,16 +1,162 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import EventRegistrationForm from "@/components/homepage/EventRegistrationForm"; import EventRegistrationForm from "@/components/homepage/EventRegistrationForm";
import Footer from "@/components/homepage/Footer"; import Footer from "@/components/homepage/Footer";
import Hero from "@/components/homepage/Hero"; import Hero from "@/components/homepage/Hero";
import HoeInschrijven from "@/components/homepage/HoeInschrijven"; import HoeInschrijven from "@/components/homepage/HoeInschrijven";
import Info from "@/components/homepage/Info"; import Info from "@/components/homepage/Info";
const KAMP_BANNER_KEY = "kk_kamp_banner_dismissed";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: HomePage, component: HomePage,
}); });
function HomePage() { function KampBanner({ onDismiss }: { onDismiss: () => void }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!document.getElementById("kamp-banner-font")) {
const el = document.createElement("link");
el.id = "kamp-banner-font";
el.rel = "stylesheet";
el.href =
"https://fonts.googleapis.com/css2?family=Special+Elite&display=swap";
document.head.appendChild(el);
}
const t = setTimeout(() => setVisible(true), 600);
return () => clearTimeout(t);
}, []);
const handleDismiss = () => {
setVisible(false);
setTimeout(onDismiss, 300);
};
return ( return (
<header
style={{
position: "fixed",
bottom: "1.25rem",
left: "50%",
transform: visible
? "translateX(-50%) translateY(0)"
: "translateX(-50%) translateY(120%)",
transition: "transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)",
zIndex: 50,
width: "min(calc(100vw - 2rem), 480px)",
}}
>
<div
style={{
background: "#ede4c8",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.06'/%3E%3C/svg%3E")`,
border: "1px solid #12100e",
outline: "3px solid rgba(18,16,14,0.1)",
outlineOffset: "3px",
borderRadius: 0,
padding: "12px 14px 14px",
boxShadow: "3px 5px 20px rgba(0,0,0,0.25)",
}}
>
{/* Top double rule */}
<div style={{ marginBottom: "10px" }}>
<div
style={{
height: "2px",
background: "#12100e",
marginBottom: "3px",
}}
/>
<div style={{ height: "1px", background: "rgba(18,16,14,0.35)" }} />
</div>
<div style={{ display: "flex", alignItems: "flex-start", gap: "10px" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<p
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "8px",
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "rgba(18,16,14,0.45)",
marginBottom: "6px",
}}
>
Aankondiging · Zomerkamp 2026
</p>
<p
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 700,
fontSize: "14px",
color: "#12100e",
lineHeight: 1.35,
marginBottom: "12px",
}}
>
Inschrijvingen voor de zomerkampen zijn open!
</p>
<Link
to="/kamp"
style={{
display: "inline-block",
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.22em",
textTransform: "uppercase",
textDecoration: "none",
color: "#ede4c8",
background: "#12100e",
border: "1px solid rgba(18,16,14,0.4)",
outline: "2px solid rgba(18,16,14,0.08)",
outlineOffset: "3px",
padding: "7px 12px 6px",
}}
>
Schrijf je in
</Link>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Sluit deze melding"
style={{
background: "none",
border: "none",
color: "rgba(18,16,14,0.3)",
cursor: "pointer",
padding: "2px",
flexShrink: 0,
fontSize: "18px",
lineHeight: 1,
fontFamily: "'Special Elite', cursive",
}}
>
×
</button>
</div>
</div>
</header>
);
}
function HomePage() {
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
if (!localStorage.getItem(KAMP_BANNER_KEY)) {
setShowBanner(true);
}
}, []);
const dismissBanner = () => {
localStorage.setItem(KAMP_BANNER_KEY, "1");
setShowBanner(false);
};
return (
<>
<div className="relative"> <div className="relative">
<main className="relative"> <main className="relative">
<Hero /> <Hero />
@@ -20,5 +166,7 @@ function HomePage() {
<Footer /> <Footer />
</main> </main>
</div> </div>
{showBanner && <KampBanner onDismiss={dismissBanner} />}
</>
); );
} }

View File

@@ -0,0 +1,473 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
const KAMP_URL = "https://ejv.be/jong/kampen/kunstenkamp/";
export const Route = createFileRoute("/kamp")({
component: KampPage,
});
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=UnifrakturMaguntia&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400;1,700&family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Special+Elite&display=swap');
@keyframes kampPressIn {
from { opacity: 0; }
to { opacity: 1; }
}
body.kamp-page {
background-color: #d6cdb0 !important;
overflow: hidden;
}
body.kamp-page ::selection {
background: #12100e;
color: #ede4c8;
}
.kamp-scroll::-webkit-scrollbar {
width: 6px;
}
.kamp-scroll::-webkit-scrollbar-track {
background: #c9c0a4;
}
.kamp-scroll::-webkit-scrollbar-thumb {
background: #12100e;
}
`;
const PAPER = "#ede4c8";
const INK = "#12100e";
const INK_MID = "rgba(18,16,14,0.5)";
const INK_GHOST = "rgba(18,16,14,0.2)";
const RULE_W = "rgba(18,16,14,0.75)";
function TripleRule() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "3px" }}>
<div style={{ height: "3px", background: INK }} />
<div style={{ height: "1px", background: RULE_W }} />
<div style={{ height: "2px", background: INK }} />
</div>
);
}
function DoubleRule() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "3px" }}>
<div style={{ height: "2px", background: RULE_W }} />
<div style={{ height: "1px", background: RULE_W }} />
</div>
);
}
function KampPage() {
const [mounted, setMounted] = useState(false);
const [ctaHovered, setCtaHovered] = useState(false);
useEffect(() => {
if (!document.getElementById("kamp-newspaper-styles")) {
const el = document.createElement("style");
el.id = "kamp-newspaper-styles";
el.textContent = STYLES;
document.head.appendChild(el);
}
document.body.classList.add("kamp-page");
const t = setTimeout(() => setMounted(true), 60);
return () => {
document.body.classList.remove("kamp-page");
clearTimeout(t);
};
}, []);
return (
<div
style={{
height: "100dvh",
background: "#d6cdb0",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='400' height='400' filter='url(%23n)' opacity='0.12'/%3E%3C/svg%3E")`,
backgroundRepeat: "repeat",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem 1rem",
}}
>
{/* Newspaper page */}
<article
className="kamp-scroll"
style={{
width: "100%",
maxWidth: "700px",
height: "100%",
background: PAPER,
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.06'/%3E%3C/svg%3E")`,
border: `1px solid ${INK}`,
boxShadow:
"3px 5px 24px rgba(0,0,0,0.18), 0 1px 3px rgba(0,0,0,0.12)",
opacity: mounted ? 1 : 0,
animation: mounted ? "kampPressIn 0.9s ease both" : undefined,
overflowY: "auto",
}}
>
{/* Outer decorative border inset */}
<div
style={{
margin: "6px",
border: `1px solid ${INK}`,
}}
>
{/* Top flag strip */}
<div
style={{
borderBottom: `1px solid ${INK}`,
padding: "7px 20px 6px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Link
to="/"
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "10px",
letterSpacing: "0.18em",
color: INK,
textDecoration: "none",
borderBottom: `1px solid ${INK}`,
paddingBottom: "1px",
}}
>
Open Mic Night
</Link>
<span
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.18em",
color: INK_MID,
}}
>
· ·
</span>
<span
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.22em",
textTransform: "uppercase",
color: INK_MID,
}}
>
Anno 2026
</span>
</div>
{/* Masthead */}
<div
style={{
padding: "20px 24px 16px",
textAlign: "center",
borderBottom: `1px solid ${INK}`,
}}
>
<h1
style={{
fontFamily: "'UnifrakturMaguntia', cursive",
fontSize: "clamp(2.4rem, 9vw, 4.5rem)",
color: INK,
margin: 0,
lineHeight: 1,
letterSpacing: "0.02em",
}}
>
De Kunstenkamp Gazet
</h1>
</div>
{/* Publication bar */}
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: "6px 20px 5px",
borderBottom: `1px solid ${INK}`,
}}
>
{[
"Vol. CCXXVI",
"Zomerkamp · Juli 2026",
"Prijs: uw aanwezigheid",
].map((t) => (
<span
key={t}
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "10px",
color: INK_MID,
letterSpacing: "0.1em",
}}
>
{t}
</span>
))}
</div>
{/* Main content area */}
<div style={{ padding: "28px 28px 24px" }}>
{/* Triple rule */}
<div style={{ marginBottom: "24px" }}>
<TripleRule />
</div>
{/* Main headline */}
<div style={{ textAlign: "center", marginBottom: "16px" }}>
<h2
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 900,
fontSize: "clamp(2.6rem, 9vw, 4.5rem)",
color: INK,
textTransform: "uppercase",
letterSpacing: "0.06em",
lineHeight: 0.95,
margin: "0 0 14px",
}}
>
Wat Is Waar?
</h2>
<p
style={{
fontFamily: "'Playfair Display', serif",
fontStyle: "italic",
fontSize: "clamp(1rem, 2.5vw, 1.2rem)",
color: INK_MID,
margin: 0,
lineHeight: 1.45,
}}
>
Een krant. Een tijdmachine. De waarheid doorheen de eeuwen.
</p>
</div>
{/* Triple rule */}
<div style={{ marginBottom: "22px" }}>
<TripleRule />
</div>
{/* Editorial body copy */}
<p
style={{
fontFamily: "'EB Garamond', serif",
fontSize: "clamp(1.05rem, 2.2vw, 1.2rem)",
color: INK,
lineHeight: 1.75,
margin: "0 0 10px",
textAlign: "justify",
hyphens: "auto",
}}
>
In de zomer van 1826 en opnieuw in 2026 reizen onze
verslaggevers terug naar de roerige redactiezalen van de
negentiende eeuw. Waar de drukpers ronkt, de rookmachines tieren
en elke kop een mening verbergt, stellen wij de vraag die door
alle eeuwen galmt: <em>wat mogen wij geloven?</em>
</p>
<p
style={{
fontFamily: "'EB Garamond', serif",
fontStyle: "italic",
fontSize: "clamp(0.95rem, 2vw, 1.08rem)",
color: INK_MID,
lineHeight: 1.7,
margin: "0 0 24px",
textAlign: "justify",
hyphens: "auto",
}}
>
Twee waarheden en een leugen. Vier bolhoeden en één typmachine.
Bereid u voor op een week journalistiek, theater, dans en
woordkunst alles gehuld in inkt en papier-maché.
</p>
{/* Ornamental divider */}
<p
style={{
textAlign: "center",
fontFamily: "'EB Garamond', serif",
fontSize: "13px",
color: INK_GHOST,
margin: "0 0 24px",
letterSpacing: "0.4em",
}}
>
· ·
</p>
{/* Details grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
border: `1px solid ${INK}`,
marginBottom: "28px",
}}
>
{[
{ label: "Primo", value: "20 25 Juli 2026" },
{ label: "Secundo", value: "27 Juli 1 Aug 2026" },
{
label: "Leeftijd",
value: "9 18 jaar",
sub: "* geboortejaar dat je 10 wordt",
},
{
label: "Locatie",
value: "Camp de Limauges",
sub: "Ceroux-Mousty · België",
},
].map(({ label, value, sub }, i) => (
<div
key={label}
style={{
padding: "16px 18px",
borderRight: i % 2 === 0 ? `1px solid ${INK}` : undefined,
borderTop: i >= 2 ? `1px solid ${INK}` : undefined,
}}
>
<p
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.3em",
textTransform: "uppercase",
color: INK_MID,
margin: "0 0 6px",
}}
>
{label}
</p>
<p
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 700,
fontSize: "clamp(1rem, 2.5vw, 1.15rem)",
color: INK,
margin: 0,
lineHeight: 1.3,
}}
>
{value}
</p>
{sub && (
<p
style={{
fontFamily: "'EB Garamond', serif",
fontStyle: "italic",
fontSize: "13px",
color: INK_MID,
margin: "4px 0 0",
}}
>
{sub}
</p>
)}
</div>
))}
</div>
{/* Double rule */}
<div style={{ marginBottom: "24px" }}>
<DoubleRule />
</div>
{/* CTA — inverted ink block */}
<div style={{ marginBottom: "4px" }}>
<p
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.35em",
textTransform: "uppercase",
color: INK_MID,
textAlign: "center",
margin: "0 0 10px",
}}
>
Aankondiging
</p>
<a
href={KAMP_URL}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setCtaHovered(true)}
onMouseLeave={() => setCtaHovered(false)}
style={{
display: "block",
background: ctaHovered ? "#2a2418" : INK,
color: PAPER,
textDecoration: "none",
textAlign: "center",
padding: "28px 32px 26px",
outline: `2px solid ${INK}`,
outlineOffset: "4px",
transition: "background 0.2s ease",
}}
>
<span
style={{
display: "block",
fontFamily: "'Playfair Display', serif",
fontWeight: 900,
fontSize: "clamp(1.5rem, 5vw, 2.4rem)",
textTransform: "uppercase",
letterSpacing: "0.1em",
lineHeight: 1,
marginBottom: "10px",
}}
>
Schrijf U In
</span>
<span
style={{
display: "block",
fontFamily: "'Special Elite', cursive",
fontSize: "clamp(0.7rem, 2vw, 0.85rem)",
letterSpacing: "0.25em",
opacity: 0.65,
}}
>
via ejv.be
</span>
</a>
</div>
</div>
{/* Footer */}
<div
style={{
borderTop: `1px solid ${INK}`,
padding: "7px 20px 8px",
textAlign: "center",
}}
>
<p
style={{
fontFamily: "'EB Garamond', serif",
fontStyle: "italic",
fontSize: "12px",
color: INK_GHOST,
margin: 0,
letterSpacing: "0.06em",
}}
>
Kunst · Expressie · Avontuur · Waar is de waarheid?
</p>
</div>
</div>
</article>
</div>
);
}

View File

@@ -117,6 +117,8 @@ interface EditFormProps {
lastName: string; lastName: string;
email: string; email: string;
phone: string | null; phone: string | null;
birthdate: string;
postcode: string;
registrationType: string; registrationType: string;
artForm: string | null; artForm: string | null;
experience: string | null; experience: string | null;
@@ -140,6 +142,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: initialData.lastName, lastName: initialData.lastName,
email: initialData.email, email: initialData.email,
phone: initialData.phone ?? "", phone: initialData.phone ?? "",
birthdate: initialData.birthdate ?? "",
postcode: initialData.postcode ?? "",
registrationType: initialType, registrationType: initialType,
artForm: initialData.artForm ?? "", artForm: initialData.artForm ?? "",
experience: initialData.experience ?? "", experience: initialData.experience ?? "",
@@ -185,6 +189,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
? formData.experience.trim() || undefined ? formData.experience.trim() || undefined
: undefined, : undefined,
isOver16: performer ? formData.isOver16 : false, isOver16: performer ? formData.isOver16 : false,
birthdate: formData.birthdate.trim(),
postcode: formData.postcode.trim(),
guests: performer guests: performer
? [] ? []
: formGuests.map((g) => ({ : formGuests.map((g) => ({
@@ -192,6 +198,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: g.lastName.trim(), lastName: g.lastName.trim(),
email: g.email.trim() || undefined, email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined, phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})), })),
extraQuestions: formData.extraQuestions.trim() || undefined, extraQuestions: formData.extraQuestions.trim() || undefined,
giftAmount, giftAmount,
@@ -393,7 +401,14 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
if (formGuests.length >= 9) return; if (formGuests.length >= 9) return;
setFormGuests((prev) => [ setFormGuests((prev) => [
...prev, ...prev,
{ firstName: "", lastName: "", email: "", phone: "" }, {
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
]); ]);
}} }}
onRemove={(idx) => onRemove={(idx) =>
@@ -569,7 +584,7 @@ function ManageRegistrationPage() {
Jouw inschrijving Jouw inschrijving
</h1> </h1>
<p className="mb-8 text-white/60"> <p className="mb-8 text-white/60">
Open Mic Night vrijdag 18 april 2026 Open Mic Night vrijdag 24 april 2026
</p> </p>
{/* Type badge */} {/* Type badge */}

View File

@@ -26,7 +26,7 @@ function PrivacyPage() {
<p> <p>
We verzamelen alleen de gegevens die je zelf invoert bij de We verzamelen alleen de gegevens die je zelf invoert bij de
registratie: voornaam, achternaam, e-mailadres, telefoonnummer, registratie: voornaam, achternaam, e-mailadres, telefoonnummer,
kunstvorm en ervaring. geboortedatum, postcode, kunstvorm en ervaring.
</p> </p>
</section> </section>
@@ -39,6 +39,12 @@ function PrivacyPage() {
Mic Night, om het programma samen te stellen en om je te Mic Night, om het programma samen te stellen en om je te
informeren over aanvullende details over het evenement. informeren over aanvullende details over het evenement.
</p> </p>
<p className="mt-3">
<strong className="text-white">Geboortedatum en postcode</strong>{" "}
worden gevraagd ter naleving van de rapportageverplichtingen van{" "}
<strong className="text-white">EJV</strong> en{" "}
<strong className="text-white">de Vlaamse Overheid</strong>.
</p>
</section> </section>
<section> <section>

View File

@@ -1,18 +1,72 @@
import type { EmailMessage } from "@kk/api/email-queue";
import { dispatchEmailMessage } from "@kk/api/email-queue";
import { runSendReminders } from "@kk/api/routers/index"; import { runSendReminders } from "@kk/api/routers/index";
import { import {
createStartHandler, createStartHandler,
defaultStreamHandler, defaultStreamHandler,
} from "@tanstack/react-start/server"; } from "@tanstack/react-start/server";
const fetch = createStartHandler(defaultStreamHandler); // ---------------------------------------------------------------------------
// CF Workers globals — not in DOM/ES2022 lib
// ---------------------------------------------------------------------------
type MessageItem<Body> = {
body: Body;
ack(): void;
retry(): void;
};
type MessageBatch<Body> = { messages: Array<MessageItem<Body>> };
type ExecutionContext = { waitUntil(promise: Promise<unknown>): void };
// ---------------------------------------------------------------------------
// Minimal CF Queue binding shape
// ---------------------------------------------------------------------------
type EmailQueue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
type Env = {
EMAIL_QUEUE?: EmailQueue;
};
const startHandler = createStartHandler(defaultStreamHandler);
export default { export default {
fetch, fetch(request: Request, env: Env) {
// Cast required: TanStack Start's BaseContext doesn't know about emailQueue,
// but it threads the value through to route handlers via requestContext.
return startHandler(request, {
context: { emailQueue: env.EMAIL_QUEUE } as Record<string, unknown>,
});
},
async queue(
batch: MessageBatch<EmailMessage>,
_env: Env,
_ctx: ExecutionContext,
) {
for (const msg of batch.messages) {
try {
await dispatchEmailMessage(msg.body);
msg.ack();
} catch (err) {
console.error("Queue dispatch failed:", err);
msg.retry();
}
}
},
async scheduled( async scheduled(
_event: { cron: string; scheduledTime: number }, _event: { cron: string; scheduledTime: number },
_env: Record<string, unknown>, env: Env,
ctx: { waitUntil: (promise: Promise<unknown>) => void }, ctx: ExecutionContext,
) { ) {
ctx.waitUntil(runSendReminders()); ctx.waitUntil(runSendReminders(env.EMAIL_QUEUE));
}, },
}; };

View File

@@ -46,6 +46,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "^0.45.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"libsql": "catalog:", "libsql": "catalog:",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
@@ -67,6 +68,7 @@
"@kk/config": "workspace:*", "@kk/config": "workspace:*",
"@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-router-devtools": "^1.141.1", "@tanstack/react-router-devtools": "^1.141.1",
"@tanstack/router-core": "^1.141.1",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
@@ -95,7 +97,7 @@
"@orpc/zod": "catalog:", "@orpc/zod": "catalog:",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.2",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
@@ -1511,7 +1513,7 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="], "nodemailer": ["nodemailer@8.0.2", "", {}, "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1987,8 +1989,6 @@
"@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=="], "@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=="], "@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=="], "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],

View File

@@ -20,7 +20,7 @@
"@orpc/zod": "catalog:", "@orpc/zod": "catalog:",
"dotenv": "catalog:", "dotenv": "catalog:",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.2",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,17 @@
// ---------------------------------------------------------------------------
// Single source-of-truth for event details used across the API
// ---------------------------------------------------------------------------
export const EVENT = "Open Mic Night — vrijdag 24 april 2026";
export const LOCATION = "Lange Winkelstraat 5, 2000 Antwerpen";
export const OPENS = "maandag 16 maart 2026 om 19:00";
// Registration opens — used for reminder scheduling windows
export const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00");
/**
* Set to `true` when the event has been postponed.
* Allows waitlist email subscriptions even after the original registration
* open date has passed.
*/
export const POSTPONED = true;

View File

@@ -1,13 +1,33 @@
import { auth } from "@kk/auth"; import { auth } from "@kk/auth";
import { env } from "@kk/env/server"; import { env } from "@kk/env/server";
import type { EmailMessage } from "./email-queue";
export async function createContext({ req }: { req: Request }) { // CF Workers runtime Queue type (not the alchemy resource type)
type Queue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
export async function createContext({
req,
emailQueue,
}: {
req: Request;
emailQueue?: Queue;
}) {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: req.headers, headers: req.headers,
}); });
return { return {
session, session,
env, env,
emailQueue,
}; };
} }

View File

@@ -0,0 +1,147 @@
/**
* Email queue types and dispatcher.
*
* All email sends are modelled as a discriminated union so the Cloudflare Queue
* consumer can pattern-match on `msg.type` and call the right send*Email().
*
* The `Queue<EmailMessage>` type used in context.ts / server.ts refers to the
* CF runtime binding type (`import type { Queue } from "@cloudflare/workers-types"`).
*/
import {
sendCancellationEmail,
sendConfirmationEmail,
sendPaymentConfirmationEmail,
sendPaymentReminderEmail,
sendReminder24hEmail,
sendReminderEmail,
sendSubscriptionConfirmationEmail,
sendUpdateEmail,
} from "./email";
import { sendDeductionEmail } from "./lib/drinkkaart-email";
// ---------------------------------------------------------------------------
// Message types — one variant per send*Email function
// ---------------------------------------------------------------------------
export type ConfirmationMessage = {
type: "registrationConfirmation";
to: string;
firstName: string;
managementToken: string;
wantsToPerform: boolean;
artForm?: string | null;
giftAmount?: number;
drinkCardValue?: number;
signupUrl?: string;
};
export type UpdateMessage = {
type: "updateConfirmation";
to: string;
firstName: string;
managementToken: string;
wantsToPerform: boolean;
artForm?: string | null;
giftAmount?: number;
drinkCardValue?: number;
};
export type CancellationMessage = {
type: "cancellation";
to: string;
firstName: string;
};
export type SubscriptionConfirmationMessage = {
type: "subscriptionConfirmation";
to: string;
};
export type Reminder24hMessage = {
type: "reminder24h";
to: string;
firstName?: string | null;
};
export type Reminder1hMessage = {
type: "reminder1h";
to: string;
firstName?: string | null;
};
export type PaymentReminderMessage = {
type: "paymentReminder";
to: string;
firstName: string;
managementToken: string;
drinkCardValue?: number;
giftAmount?: number;
};
export type PaymentConfirmationMessage = {
type: "paymentConfirmation";
to: string;
firstName: string;
managementToken: string;
drinkCardValue?: number;
giftAmount?: number;
};
export type DeductionMessage = {
type: "deduction";
to: string;
firstName: string;
amountCents: number;
newBalanceCents: number;
};
export type EmailMessage =
| ConfirmationMessage
| UpdateMessage
| CancellationMessage
| SubscriptionConfirmationMessage
| Reminder24hMessage
| Reminder1hMessage
| PaymentReminderMessage
| PaymentConfirmationMessage
| DeductionMessage;
// ---------------------------------------------------------------------------
// Consumer-side dispatcher — called once per queue message
// ---------------------------------------------------------------------------
export async function dispatchEmailMessage(msg: EmailMessage): Promise<void> {
switch (msg.type) {
case "registrationConfirmation":
await sendConfirmationEmail(msg);
break;
case "updateConfirmation":
await sendUpdateEmail(msg);
break;
case "cancellation":
await sendCancellationEmail(msg);
break;
case "subscriptionConfirmation":
await sendSubscriptionConfirmationEmail({ to: msg.to });
break;
case "reminder24h":
await sendReminder24hEmail(msg);
break;
case "reminder1h":
await sendReminderEmail(msg);
break;
case "paymentReminder":
await sendPaymentReminderEmail(msg);
break;
case "paymentConfirmation":
await sendPaymentConfirmationEmail(msg);
break;
case "deduction":
await sendDeductionEmail(msg);
break;
default:
// Exhaustiveness check — TypeScript will catch unhandled variants at compile time
msg satisfies never;
}
}

View File

@@ -1,291 +1,187 @@
import { env } from "@kk/env/server"; import { env } from "@kk/env/server";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { EVENT, LOCATION, OPENS } from "./constants";
// Singleton transport — created once per module, reused across all email sends. // ---------------------------------------------------------------------------
// Re-creating it on every call causes EventEmitter listener accumulation in // Transport — singleton so a warm CF isolate reuses the open TCP connection
// long-lived Cloudflare Worker processes, triggering memory leak warnings. // ---------------------------------------------------------------------------
let _transport: nodemailer.Transporter | null | undefined;
let _transport: nodemailer.Transporter | undefined;
function getTransport(): nodemailer.Transporter | null { function getTransport(): nodemailer.Transporter | null {
if (_transport !== undefined) return _transport; if (_transport) return _transport;
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) { if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
_transport = null; // Not cached — re-evaluated on every call so a cold isolate that
// receives vars after module init can still pick them up.
return null; return null;
} }
_transport = nodemailer.createTransport({ _transport = nodemailer.createTransport({
host: env.SMTP_HOST, host: env.SMTP_HOST,
port: env.SMTP_PORT, port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465, secure: env.SMTP_PORT === 465,
auth: { auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
}); });
return _transport; return _transport;
} }
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>"; // ---------------------------------------------------------------------------
const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be"; // Logging — console.log(JSON) is the only thing visible in CF dashboard
// ---------------------------------------------------------------------------
function registrationConfirmationHtml(params: { export function emailLog(
firstName: string; level: "info" | "warn" | "error",
manageUrl: string; event: string,
signupUrl: string; extra?: Record<string, unknown>,
wantsToPerform: boolean; ): void {
artForm?: string | null; console.log(JSON.stringify({ level, event, ...extra }));
giftAmount?: number; }
drinkCardValue?: number;
}) {
const role = params.wantsToPerform
? `Optreden${params.artForm ? `${params.artForm}` : ""}`
: "Toeschouwer";
const giftCents = params.giftAmount ?? 0; // ---------------------------------------------------------------------------
// drinkCardValue is stored in euros (e.g., 5), giftAmount in cents (e.g., 5000) // HTML helpers
const drinkCardCents = (params.drinkCardValue ?? 0) * 100; // ---------------------------------------------------------------------------
const hasPayment = drinkCardCents > 0 || giftCents > 0;
const formatEuro = (cents: number) => /** Primary or secondary CTA button */
`${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; function btn(
href: string,
label: string,
variant: "primary" | "secondary" = "primary",
): string {
const bg = variant === "primary" ? "#fff" : "rgba(255,255,255,0.15)";
const color = variant === "primary" ? "#214e51" : "#fff";
return `<table cellpadding="0" cellspacing="0" style="margin:0 0 16px;">
<tr><td style="border-radius:2px;background:${bg};">
<a href="${href}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:${color};text-decoration:none;">${label}</a>
</td></tr>
</table>`;
}
const paymentSummary = hasPayment /** Info card — single label + value */
? `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:16px 0;"> function card(label: string, value: string): string {
<tr> return `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<td style="padding:20px 24px;"> <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;">${label}</p>
<p style="margin:0;font-size:18px;color:#fff;font-weight:600;">${value}</p>
</td></tr>
</table>`;
}
/** Payment breakdown card — renders only when totalCents > 0 */
function paymentCard(drinkCardCents: number, giftCents: number): string {
const total = drinkCardCents + giftCents;
if (total === 0) return "";
return `<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</p> <p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Betaling</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>` : ""} ${drinkCardCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);">Drinkkaart: <strong style="color:#fff;">${euro(drinkCardCents)}</strong></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>` : ""} ${giftCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);">Vrijwillige gift: <strong style="color:#fff;">${euro(giftCents)}</strong></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(drinkCardCents + giftCents)}</p> <p style="margin:16px 0 0;padding-top:12px;border-top:1px solid rgba(255,255,255,0.1);font-size:18px;color:#fff;font-weight:600;">Totaal: ${euro(total)}</p>
</td> </td></tr>
</tr> </table>`;
</table>` }
: "";
/** Shared text paragraph */
function p(text: string): string {
return `<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">${text}</p>`;
}
/** Muted footnote */
function note(html: string): string {
return `<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);">${html}</p>`;
}
// ---------------------------------------------------------------------------
// Email layout shell
// ---------------------------------------------------------------------------
function emailLayout(heading: string, body: string): string {
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="nl"> <html lang="nl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> <meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Bevestiging inschrijving</title> <title>${heading}</title>
</head> </head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;"> <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;"> <table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr> <tr><td align="center">
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;"> <table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<!-- Header --> <tr><td style="padding:40px 48px 32px;">
<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> <p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Je inschrijving is bevestigd!</h1> <h1 style="margin:12px 0 0;font-size:28px;color:#fff;font-weight:700;">${heading}</h1>
</td> </td></tr>
</tr> <tr><td style="padding:0 48px 32px;">${body}</td></tr>
<!-- Body --> <tr><td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
We hebben je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> in goede orde ontvangen.
</p>
<!-- Registration summary -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 8px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Jouw rol</p>
<p style="margin:0;font-size:18px;color:#ffffff;font-weight:600;">${role}</p>
</td>
</tr>
</table>
${paymentSummary}
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Maak een account aan om je inschrijving te beheren en (voor bezoekers) je Drinkkaart te activeren.
</p>
<!-- Account creation CTA -->
<table cellpadding="0" cellspacing="0" style="margin-bottom:16px;">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<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>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Of kopieer deze link: <span style="color:rgba(255,255,255,0.6);">${params.manageUrl}</span>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;"> <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/> 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. Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p> </p>
</td> </td></tr>
</tr>
</table> </table>
</td> </td></tr>
</tr>
</table> </table>
</body> </body>
</html>`; </html>`;
} }
function updateConfirmationHtml(params: { // ---------------------------------------------------------------------------
firstName: string; // Shared send helper — logs attempt/sent/error, skips when SMTP not configured
manageUrl: string; // ---------------------------------------------------------------------------
wantsToPerform: boolean;
artForm?: string | null; async function send(
giftAmount?: number; type: string,
drinkCardValue?: number; to: string,
}) { subject: string,
const role = params.wantsToPerform html: string,
? `Optreden${params.artForm ? `${params.artForm}` : ""}` ): Promise<void> {
emailLog("info", "email.attempt", { type, to });
const transport = getTransport();
if (!transport) {
emailLog("warn", "email.skipped", {
type,
to,
reason: "smtp_not_configured",
// Which vars are missing — helps diagnose CF env issues
hasHost: !!env.SMTP_HOST,
hasUser: !!env.SMTP_USER,
hasPass: !!env.SMTP_PASS,
});
return;
}
try {
await transport.sendMail({ from: env.SMTP_FROM, to, subject, html });
emailLog("info", "email.sent", { type, to });
} catch (err) {
emailLog("error", "email.error", { type, to, error: String(err) });
throw err;
}
}
// ---------------------------------------------------------------------------
// Euro formatter
// ---------------------------------------------------------------------------
function euro(cents: number): string {
return `${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
}
// ---------------------------------------------------------------------------
// Helpers for registration emails
// ---------------------------------------------------------------------------
function roleLabel(wantsToPerform: boolean, artForm?: string | null): string {
return wantsToPerform
? `Optreden${artForm ? `${artForm}` : ""}`
: "Toeschouwer"; : "Toeschouwer";
const giftCents = params.giftAmount ?? 0;
// drinkCardValue is stored in euros (e.g., 5), giftAmount in cents (e.g., 5000)
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
const hasPayment = drinkCardCents > 0 || giftCents > 0;
const formatEuro = (cents: number) =>
`${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
const paymentSummary = hasPayment
? `<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</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(drinkCardCents + giftCents)}</p>
</td>
</tr>
</table>`
: "";
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<title>Inschrijving bijgewerkt</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Inschrijving bijgewerkt</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> is succesvol bijgewerkt.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 8px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Jouw rol</p>
<p style="margin:0;font-size:18px;color:#ffffff;font-weight:600;">${role}</p>
</td>
</tr>
</table>
${paymentSummary}
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${params.manageUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Bekijk mijn inschrijving
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
} }
function cancellationHtml(params: { firstName: string }) { function manageUrl(token: string): string {
return `<!DOCTYPE html> return `${env.BETTER_AUTH_URL}/manage/${token}`;
<html lang="nl">
<head>
<meta charset="UTF-8" />
<title>Inschrijving geannuleerd</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Inschrijving geannuleerd</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 40px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> is geannuleerd.
</p>
<p style="margin:0;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven via <a href="${baseUrl}/#registration" style="color:#ffffff;">kunstenkamp.be</a>.
</p>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
} }
// ---------------------------------------------------------------------------
// Emails
// ---------------------------------------------------------------------------
export async function sendConfirmationEmail(params: { export async function sendConfirmationEmail(params: {
to: string; to: string;
firstName: string; firstName: string;
@@ -294,32 +190,41 @@ export async function sendConfirmationEmail(params: {
artForm?: string | null; artForm?: string | null;
giftAmount?: number; giftAmount?: number;
drinkCardValue?: number; drinkCardValue?: number;
/** Pre-filled signup URL, e.g. /login?signup=1&email=…&next=/account */
signupUrl?: string; signupUrl?: string;
}) { }) {
const transport = getTransport(); const manage = manageUrl(params.managementToken);
if (!transport) { const signup =
console.warn("SMTP not configured — skipping confirmation email");
return;
}
const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
const signupUrl =
params.signupUrl ?? params.signupUrl ??
`${baseUrl}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`; `${env.BETTER_AUTH_URL}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`;
await transport.sendMail({ const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
from, const giftCents = params.giftAmount ?? 0;
to: params.to,
subject: "Bevestiging inschrijving — Open Mic Night", await send(
html: registrationConfirmationHtml({ "registrationConfirmation",
firstName: params.firstName, params.to,
manageUrl, "Bevestiging inschrijving — Open Mic Night",
signupUrl, emailLayout(
wantsToPerform: params.wantsToPerform, "Je inschrijving is bevestigd!",
artForm: params.artForm, p(`Hoi ${params.firstName},`) +
giftAmount: params.giftAmount, p(
drinkCardValue: params.drinkCardValue, `We hebben je inschrijving voor <strong style="color:#fff;">${EVENT}</strong> in goede orde ontvangen.`,
}), ) +
}); card("Locatie", LOCATION) +
card("Jouw rol", roleLabel(params.wantsToPerform, params.artForm)) +
paymentCard(drinkCardCents, giftCents) +
p(
"Maak een account aan om je inschrijving te beheren en je Drinkkaart te activeren.",
) +
btn(signup, "Account aanmaken") +
p(
"Wil je je gegevens aanpassen of je inschrijving annuleren? De link hieronder is uniek voor jou — deel hem niet.",
) +
btn(manage, "Beheer mijn inschrijving", "secondary") +
note(
`Of kopieer: <span style="color:rgba(255,255,255,0.6);">${manage}</span>`,
),
),
);
} }
export async function sendUpdateEmail(params: { export async function sendUpdateEmail(params: {
@@ -331,219 +236,121 @@ export async function sendUpdateEmail(params: {
giftAmount?: number; giftAmount?: number;
drinkCardValue?: number; drinkCardValue?: number;
}) { }) {
const transport = getTransport(); const manage = manageUrl(params.managementToken);
if (!transport) { const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
console.warn("SMTP not configured — skipping update email"); const giftCents = params.giftAmount ?? 0;
return;
} await send(
const manageUrl = `${baseUrl}/manage/${params.managementToken}`; "updateConfirmation",
await transport.sendMail({ params.to,
from, "Inschrijving bijgewerkt — Open Mic Night",
to: params.to, emailLayout(
subject: "Inschrijving bijgewerkt — Open Mic Night", "Inschrijving bijgewerkt",
html: updateConfirmationHtml({ p(`Hoi ${params.firstName},`) +
firstName: params.firstName, p(
manageUrl, `Je inschrijving voor <strong style="color:#fff;">${EVENT}</strong> is succesvol bijgewerkt.`,
wantsToPerform: params.wantsToPerform, ) +
artForm: params.artForm, card("Locatie", LOCATION) +
giftAmount: params.giftAmount, card("Jouw rol", roleLabel(params.wantsToPerform, params.artForm)) +
drinkCardValue: params.drinkCardValue, paymentCard(drinkCardCents, giftCents) +
}), btn(manage, "Bekijk mijn inschrijving"),
}); ),
);
} }
export async function sendCancellationEmail(params: { export async function sendCancellationEmail(params: {
to: string; to: string;
firstName: string; firstName: string;
}) { }) {
const transport = getTransport(); await send(
if (!transport) { "cancellation",
console.warn("SMTP not configured — skipping cancellation email"); params.to,
return; "Inschrijving geannuleerd — Open Mic Night",
} emailLayout(
await transport.sendMail({ "Inschrijving geannuleerd",
from, p(`Hoi ${params.firstName},`) +
to: params.to, p(
subject: "Inschrijving geannuleerd — Open Mic Night", `Je inschrijving voor <strong style="color:#fff;">${EVENT}</strong> is geannuleerd.`,
html: cancellationHtml({ firstName: params.firstName }), ) +
}); p(
`Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven via <a href="${env.BETTER_AUTH_URL}/#registration" style="color:#fff;">kunstenkamp.be</a>.`,
),
),
);
} }
function reminderHtml(params: { firstName?: string | null }) { export async function sendSubscriptionConfirmationEmail(params: {
to: string;
}) {
await send(
"subscriptionConfirmation",
params.to,
"Herinnering ingepland — Open Mic Night",
emailLayout(
"Je herinnering is ingepland!",
p(
`We sturen een herinnering naar <strong style="color:#fff;">${params.to}</strong> wanneer de inschrijvingen openen.`,
) +
card("Inschrijvingen openen", OPENS) +
p(
"Je ontvangt twee herinneringen: één 24 uur van tevoren en één 1 uur voor de opening.",
) +
p("Wees er snel bij — de plaatsen zijn beperkt!") +
btn(`${env.BETTER_AUTH_URL}/#registration`, "Bekijk de pagina"),
),
);
}
export async function sendReminder24hEmail(params: {
to: string;
firstName?: string | null;
}) {
const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!"; 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> await send(
<html lang="nl"> "reminder24h",
<head> params.to,
<meta charset="UTF-8" /> "Nog 24 uur — inschrijvingen openen morgen!",
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> emailLayout(
<title>Nog 1 uur — inschrijvingen openen straks!</title> "Nog 24 uur!",
</head> p(greeting) +
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;"> p(
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;"> `Morgen openen de inschrijvingen voor <strong style="color:#fff;">${EVENT}</strong>. Zet je wekker!`,
<tr> ) +
<td align="center"> card("Inschrijvingen openen", OPENS) +
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;"> p("Wees er snel bij — de plaatsen zijn beperkt!") +
<!-- Header --> btn(`${env.BETTER_AUTH_URL}/#registration`, "Bekijk de pagina") +
<tr> note(
<td style="padding:40px 48px 32px;"> "Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd op kunstenkamp.be.",
<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: { export async function sendReminderEmail(params: {
to: string; to: string;
firstName?: string | null; firstName?: string | null;
}) { }) {
const transport = getTransport(); const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!";
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: { await send(
firstName: string; "reminder1h",
accountUrl: string; params.to,
manageUrl: string; "Nog 1 uur — inschrijvingen openen straks!",
drinkCardValue?: number; emailLayout(
giftAmount?: number; "Nog 1 uur!",
}) { p(greeting) +
const formatEuro = (cents: number) => p(
`${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; `Over ongeveer <strong style="color:#fff;">1 uur</strong> openen de inschrijvingen voor <strong style="color:#fff;">${EVENT}</strong>.`,
) +
const giftCents = params.giftAmount ?? 0; card("Inschrijvingen openen", OPENS) +
const drinkCardCents = (params.drinkCardValue ?? 0) * 100; p("Wees er snel bij — de plaatsen zijn beperkt!") +
const totalCents = drinkCardCents + giftCents; btn(`${env.BETTER_AUTH_URL}/#registration`, "Schrijf je in") +
const totalStr = totalCents > 0 ? formatEuro(totalCents) : ""; note(
"Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd op kunstenkamp.be.",
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: { export async function sendPaymentReminderEmail(params: {
@@ -553,107 +360,33 @@ export async function sendPaymentReminderEmail(params: {
drinkCardValue?: number; drinkCardValue?: number;
giftAmount?: number; giftAmount?: number;
}) { }) {
const transport = getTransport(); const manage = manageUrl(params.managementToken);
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 drinkCardCents = (params.drinkCardValue ?? 0) * 100;
const totalCents = drinkCardCents + giftCents; const giftCents = params.giftAmount ?? 0;
const total = drinkCardCents + giftCents;
const amountNote =
total > 0
? `Je betaling van <strong style="color:#fff;">${euro(total)}</strong> staat nog open.`
: "Je betaling staat nog open.";
const paymentSummary = await send(
totalCents > 0 "paymentReminder",
? `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:16px 0;"> params.to,
<tr> "Herinnering: betaling open — Open Mic Night",
<td style="padding:20px 24px;"> emailLayout(
<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> "Betaling nog open",
${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>` : ""} p(`Hoi ${params.firstName},`) +
${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(
<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> `Je hebt je ingeschreven voor <strong style="color:#fff;">${EVENT}</strong>, maar we hebben nog geen betaling ontvangen.`,
</td> ) +
</tr> card("Locatie", LOCATION) +
</table>` p(`${amountNote} Log in op je account om te betalen.`) +
: ""; btn(`${env.BETTER_AUTH_URL}/account`, "Betaal nu") +
note(
return `<!DOCTYPE html> `Je inschrijving beheren? <a href="${manage}" style="color:rgba(255,255,255,0.6);">Klik hier</a>`,
<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: { export async function sendPaymentConfirmationEmail(params: {
@@ -663,21 +396,23 @@ export async function sendPaymentConfirmationEmail(params: {
drinkCardValue?: number; drinkCardValue?: number;
giftAmount?: number; giftAmount?: number;
}) { }) {
const transport = getTransport(); const manage = manageUrl(params.managementToken);
if (!transport) { const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
console.warn("SMTP not configured — skipping payment confirmation email"); const giftCents = params.giftAmount ?? 0;
return;
} await send(
const manageUrl = `${baseUrl}/manage/${params.managementToken}`; "paymentConfirmation",
await transport.sendMail({ params.to,
from, "Betaling ontvangen — Open Mic Night",
to: params.to, emailLayout(
subject: "Betaling ontvangen — Open Mic Night", "Betaling ontvangen!",
html: paymentConfirmationHtml({ p(`Hoi ${params.firstName},`) +
firstName: params.firstName, p(
manageUrl, `We hebben je betaling voor <strong style="color:#fff;">${EVENT}</strong> in goede orde ontvangen. Tot dan!`,
drinkCardValue: params.drinkCardValue, ) +
giftAmount: params.giftAmount, card("Locatie", LOCATION) +
}), paymentCard(drinkCardCents, giftCents) +
}); btn(manage, "Beheer mijn inschrijving", "secondary"),
),
);
} }

View File

@@ -1,13 +1,15 @@
import { env } from "@kk/env/server"; import { env } from "@kk/env/server";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { emailLog } from "../email";
// Re-use the same SMTP transport strategy as email.ts. // Re-use the same SMTP transport strategy as email.ts:
let _transport: nodemailer.Transporter | null | undefined; // only cache a successfully-created transporter; never cache null so that a
// cold isolate that receives env vars after module init can still pick them up.
let _transport: nodemailer.Transporter | undefined;
function getTransport(): nodemailer.Transporter | null { function getTransport(): nodemailer.Transporter | null {
if (_transport !== undefined) return _transport; if (_transport) return _transport;
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) { if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
_transport = null;
return null; return null;
} }
_transport = nodemailer.createTransport({ _transport = nodemailer.createTransport({
@@ -22,9 +24,6 @@ function getTransport(): nodemailer.Transporter | null {
return _transport; return _transport;
} }
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be";
function formatEuro(cents: number): string { function formatEuro(cents: number): string {
return new Intl.NumberFormat("nl-BE", { return new Intl.NumberFormat("nl-BE", {
style: "currency", style: "currency",
@@ -116,7 +115,7 @@ function deductionHtml(params: {
/** /**
* Send a deduction notification email to the cardholder. * Send a deduction notification email to the cardholder.
* Fire-and-forget: errors are logged but not re-thrown. * Throws on SMTP error so the queue consumer can retry.
*/ */
export async function sendDeductionEmail(params: { export async function sendDeductionEmail(params: {
to: string; to: string;
@@ -124,9 +123,18 @@ export async function sendDeductionEmail(params: {
amountCents: number; amountCents: number;
newBalanceCents: number; newBalanceCents: number;
}): Promise<void> { }): Promise<void> {
emailLog("info", "email.attempt", { type: "deduction", to: params.to });
const transport = getTransport(); const transport = getTransport();
if (!transport) { if (!transport) {
console.warn("SMTP not configured — skipping deduction email"); emailLog("warn", "email.skipped", {
type: "deduction",
to: params.to,
reason: "smtp_not_configured",
hasHost: !!env.SMTP_HOST,
hasUser: !!env.SMTP_USER,
hasPass: !!env.SMTP_PASS,
});
return; return;
} }
@@ -144,8 +152,9 @@ export async function sendDeductionEmail(params: {
currency: "EUR", currency: "EUR",
}).format(params.amountCents / 100); }).format(params.amountCents / 100);
try {
await transport.sendMail({ await transport.sendMail({
from, from: env.SMTP_FROM,
to: params.to, to: params.to,
subject: `Drinkkaart — ${amountFormatted} afgeschreven`, subject: `Drinkkaart — ${amountFormatted} afgeschreven`,
html: deductionHtml({ html: deductionHtml({
@@ -153,7 +162,16 @@ export async function sendDeductionEmail(params: {
amountCents: params.amountCents, amountCents: params.amountCents,
newBalanceCents: params.newBalanceCents, newBalanceCents: params.newBalanceCents,
dateTime, dateTime,
drinkkaartUrl: `${baseUrl}/drinkkaart`, drinkkaartUrl: `${env.BETTER_AUTH_URL}/drinkkaart`,
}), }),
}); });
emailLog("info", "email.sent", { type: "deduction", to: params.to });
} catch (err) {
emailLog("error", "email.error", {
type: "deduction",
to: params.to,
error: String(err),
});
throw err;
}
} }

View File

@@ -9,6 +9,7 @@ import { user } from "@kk/db/schema/auth";
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { and, desc, eq, gte, lte, sql } from "drizzle-orm"; import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import type { EmailMessage } from "../email-queue";
import { adminProcedure, protectedProcedure } from "../index"; import { adminProcedure, protectedProcedure } from "../index";
import { sendDeductionEmail } from "../lib/drinkkaart-email"; import { sendDeductionEmail } from "../lib/drinkkaart-email";
import { import {
@@ -339,7 +340,7 @@ export const drinkkaartRouter = {
createdAt: now, createdAt: now,
}); });
// Fire-and-forget deduction email // Fire-and-forget deduction email (via queue when available)
const cardUser = await db const cardUser = await db
.select({ email: user.email, name: user.name }) .select({ email: user.email, name: user.name })
.from(user) .from(user)
@@ -348,15 +349,22 @@ export const drinkkaartRouter = {
.then((r) => r[0]); .then((r) => r[0]);
if (cardUser) { if (cardUser) {
sendDeductionEmail({ const deductionMsg: EmailMessage = {
type: "deduction",
to: cardUser.email, to: cardUser.email,
firstName: cardUser.name.split(" ")[0] ?? cardUser.name, firstName: cardUser.name.split(" ")[0] ?? cardUser.name,
amountCents: input.amountCents, amountCents: input.amountCents,
newBalanceCents: balanceAfter, newBalanceCents: balanceAfter,
}).catch((err) => };
if (context.emailQueue) {
await context.emailQueue.send(deductionMsg);
} else {
await sendDeductionEmail(deductionMsg).catch((err) =>
console.error("Failed to send deduction email:", err), console.error("Failed to send deduction email:", err),
); );
} }
}
return { return {
transactionId, transactionId,

View File

@@ -18,30 +18,55 @@ import {
sum, sum,
} from "drizzle-orm"; } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { POSTPONED, REGISTRATION_OPENS_AT } from "../constants";
import { import {
emailLog,
sendCancellationEmail, sendCancellationEmail,
sendConfirmationEmail, sendConfirmationEmail,
sendPaymentReminderEmail, sendPaymentReminderEmail,
sendReminder24hEmail,
sendReminderEmail, sendReminderEmail,
sendSubscriptionConfirmationEmail,
sendUpdateEmail, sendUpdateEmail,
} from "../email"; } from "../email";
import type { EmailMessage } from "../email-queue";
// Minimal CF Queue binding shape (mirrors context.ts)
type EmailQueue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
import { adminProcedure, protectedProcedure, publicProcedure } from "../index"; import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { generateQrSecret } from "../lib/drinkkaart-utils"; import { generateQrSecret } from "../lib/drinkkaart-utils";
import { drinkkaartRouter } from "./drinkkaart"; import { drinkkaartRouter } from "./drinkkaart";
// Registration opens at this date — reminders fire 1 hour before // Reminder windows derived from the canonical registration open date
const REGISTRATION_OPENS_AT = new Date("2026-03-11T10:30:00+01:00"); const REMINDER_1H_WINDOW_START = new Date(
const REMINDER_WINDOW_START = new Date(
REGISTRATION_OPENS_AT.getTime() - 60 * 60 * 1000, REGISTRATION_OPENS_AT.getTime() - 60 * 60 * 1000,
); );
const REMINDER_24H_WINDOW_START = new Date(
REGISTRATION_OPENS_AT.getTime() - 25 * 60 * 60 * 1000,
);
const REMINDER_24H_WINDOW_END = new Date(
REGISTRATION_OPENS_AT.getTime() - 23 * 60 * 60 * 1000,
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared helpers // Shared helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Drink card price in euros: €5 base + €2 per extra guest. */ const MAX_WATCHERS = 70; // max total watcher spots (people, not registrations)
/** Drink card price in euros: €5 per person (primary + guests). */
function drinkCardEuros(guestCount: number): number { function drinkCardEuros(guestCount: number): number {
return 5 + guestCount * 2; return 5 + guestCount * 5;
} }
/** Drink card price in cents for payment processing. */ /** Drink card price in cents for payment processing. */
@@ -237,6 +262,8 @@ const guestSchema = z.object({
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email().optional().or(z.literal("")), email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(), phone: z.string().optional(),
birthdate: z.string().min(1),
postcode: z.string().min(1),
}); });
const coreRegistrationFields = { const coreRegistrationFields = {
@@ -244,6 +271,8 @@ const coreRegistrationFields = {
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email(), email: z.string().email(),
phone: z.string().optional(), phone: z.string().optional(),
birthdate: z.string().min(1),
postcode: z.string().min(1),
registrationType: registrationTypeSchema.default("watcher"), registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(), artForm: z.string().optional(),
experience: z.string().optional(), experience: z.string().optional(),
@@ -278,6 +307,28 @@ export const appRouter = {
healthCheck: publicProcedure.handler(() => "OK"), healthCheck: publicProcedure.handler(() => "OK"),
drinkkaart: drinkkaartRouter, drinkkaart: drinkkaartRouter,
getWatcherCapacity: publicProcedure.handler(async () => {
const rows = await db
.select({ guests: registration.guests })
.from(registration)
.where(
and(
eq(registration.registrationType, "watcher"),
isNull(registration.cancelledAt),
),
);
const takenSpots = rows.reduce((sum, r) => {
const g = r.guests ? (JSON.parse(r.guests) as unknown[]) : [];
return sum + 1 + g.length;
}, 0);
return {
total: MAX_WATCHERS,
taken: takenSpots,
available: Math.max(0, MAX_WATCHERS - takenSpots),
isFull: takenSpots >= MAX_WATCHERS,
};
}),
privateData: protectedProcedure.handler(({ context }) => ({ privateData: protectedProcedure.handler(({ context }) => ({
message: "This is private", message: "This is private",
user: context.session?.user, user: context.session?.user,
@@ -285,17 +336,42 @@ export const appRouter = {
submitRegistration: publicProcedure submitRegistration: publicProcedure
.input(submitRegistrationSchema) .input(submitRegistrationSchema)
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
const managementToken = randomUUID(); const managementToken = randomUUID();
const isPerformer = input.registrationType === "performer"; const isPerformer = input.registrationType === "performer";
const guests = isPerformer ? [] : (input.guests ?? []); const guests = isPerformer ? [] : (input.guests ?? []);
// Enforce max watcher capacity (70 people total, counting guests)
if (!isPerformer) {
const rows = await db
.select({ guests: registration.guests })
.from(registration)
.where(
and(
eq(registration.registrationType, "watcher"),
isNull(registration.cancelledAt),
),
);
const takenSpots = rows.reduce((sum, r) => {
const g = r.guests ? (JSON.parse(r.guests) as unknown[]) : [];
return sum + 1 + g.length;
}, 0);
const newSpots = 1 + guests.length;
if (takenSpots + newSpots > MAX_WATCHERS) {
throw new Error(
`Er zijn helaas niet genoeg plaatsen meer beschikbaar. Nog ${MAX_WATCHERS - takenSpots} ${MAX_WATCHERS - takenSpots === 1 ? "plaats" : "plaatsen"} vrij.`,
);
}
}
await db.insert(registration).values({ await db.insert(registration).values({
id: randomUUID(), id: randomUUID(),
firstName: input.firstName, firstName: input.firstName,
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
birthdate: input.birthdate,
postcode: input.postcode,
registrationType: input.registrationType, registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null, artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null, experience: isPerformer ? input.experience || null : null,
@@ -307,7 +383,8 @@ export const appRouter = {
managementToken, managementToken,
}); });
await sendConfirmationEmail({ const confirmationMsg: EmailMessage = {
type: "registrationConfirmation",
to: input.email, to: input.email,
firstName: input.firstName, firstName: input.firstName,
managementToken, managementToken,
@@ -315,9 +392,18 @@ export const appRouter = {
artForm: input.artForm, artForm: input.artForm,
giftAmount: input.giftAmount, giftAmount: input.giftAmount,
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
}).catch((err) => };
console.error("Failed to send confirmation email:", err),
if (context.emailQueue) {
await context.emailQueue.send(confirmationMsg);
} else {
await sendConfirmationEmail(confirmationMsg).catch((err) =>
emailLog("error", "email.catch", {
type: "confirmation",
error: String(err),
}),
); );
}
return { success: true, managementToken }; return { success: true, managementToken };
}), }),
@@ -340,7 +426,7 @@ export const appRouter = {
updateRegistration: publicProcedure updateRegistration: publicProcedure
.input(updateRegistrationSchema) .input(updateRegistrationSchema)
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
const row = await getActiveRegistration(input.token); const row = await getActiveRegistration(input.token);
const isPerformer = input.registrationType === "performer"; const isPerformer = input.registrationType === "performer";
@@ -383,6 +469,8 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
birthdate: input.birthdate,
postcode: input.postcode,
registrationType: input.registrationType, registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null, artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null, experience: isPerformer ? input.experience || null : null,
@@ -396,7 +484,8 @@ export const appRouter = {
}) })
.where(eq(registration.managementToken, input.token)); .where(eq(registration.managementToken, input.token));
await sendUpdateEmail({ const updateMsg: EmailMessage = {
type: "updateConfirmation",
to: input.email, to: input.email,
firstName: input.firstName, firstName: input.firstName,
managementToken: input.token, managementToken: input.token,
@@ -404,14 +493,25 @@ export const appRouter = {
artForm: input.artForm, artForm: input.artForm,
giftAmount: input.giftAmount, giftAmount: input.giftAmount,
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
}).catch((err) => console.error("Failed to send update email:", err)); };
if (context.emailQueue) {
await context.emailQueue.send(updateMsg);
} else {
await sendUpdateEmail(updateMsg).catch((err) =>
emailLog("error", "email.catch", {
type: "update",
error: String(err),
}),
);
}
return { success: true }; return { success: true };
}), }),
cancelRegistration: publicProcedure cancelRegistration: publicProcedure
.input(z.object({ token: z.string().uuid() })) .input(z.object({ token: z.string().uuid() }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
const row = await getActiveRegistration(input.token); const row = await getActiveRegistration(input.token);
await db await db
@@ -419,12 +519,22 @@ export const appRouter = {
.set({ cancelledAt: new Date() }) .set({ cancelledAt: new Date() })
.where(eq(registration.managementToken, input.token)); .where(eq(registration.managementToken, input.token));
await sendCancellationEmail({ const cancellationMsg: EmailMessage = {
type: "cancellation",
to: row.email, to: row.email,
firstName: row.firstName, firstName: row.firstName,
}).catch((err) => };
console.error("Failed to send cancellation email:", err),
if (context.emailQueue) {
await context.emailQueue.send(cancellationMsg);
} else {
await sendCancellationEmail(cancellationMsg).catch((err) =>
emailLog("error", "email.catch", {
type: "cancellation",
error: String(err),
}),
); );
}
return { success: true }; return { success: true };
}), }),
@@ -553,56 +663,24 @@ export const appRouter = {
const escapeCell = (val: string) => `"${val.replace(/"/g, '""')}"`; const escapeCell = (val: string) => `"${val.replace(/"/g, '""')}"`;
// Main registration headers // Each person (registrant + guests) gets their own row.
// Guest rows reference the registrant via "Registration ID".
const headers = [ const headers = [
"ID", "Row Type", // "registrant" | "guest"
"Registration ID", // UUID of the parent registration (same for registrant + its guests)
"First Name", "First Name",
"Last Name", "Last Name",
"Email", "Email",
"Phone", "Phone",
"Birthdate",
"Postcode",
// Registrant-only fields (blank for guest rows)
"Type", "Type",
"Art Form", "Art Form",
"Experience", "Experience",
"Is Over 16", "Is Over 16",
"Drink Card Value (EUR)", "Drink Card Value (EUR)",
"Gift Amount (cents)", "Gift Amount (cents)",
"Guest Count",
"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", "Payment Status",
"Paid At", "Paid At",
"Extra Questions", "Extra Questions",
@@ -610,46 +688,74 @@ export const appRouter = {
"Created At", "Created At",
]; ];
const MAX_GUESTS = 9; const rows: string[][] = [];
const rows = data.map((r) => { for (const r of data) {
const guests = parseGuestsJson(r.guests); const guests = parseGuestsJson(r.guests) as Array<{
firstName: string;
lastName: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}>;
// Build guest columns (up to 9 guests, 4 fields each) const paymentStatus =
const guestCols: string[] = []; r.paymentStatus === "paid"
for (let i = 0; i < MAX_GUESTS; i++) { ? "Paid"
const g = guests[i]; : r.paymentStatus === "extra_payment_pending"
guestCols.push(g?.firstName ?? ""); ? "Extra Payment Pending"
guestCols.push(g?.lastName ?? ""); : "Pending";
guestCols.push(g?.email ?? "");
guestCols.push(g?.phone ?? "");
}
return [ // Registrant row
rows.push([
"registrant",
r.id, r.id,
r.firstName, r.firstName,
r.lastName, r.lastName,
r.email, r.email,
r.phone || "", r.phone || "",
r.birthdate || "",
r.postcode || "",
r.registrationType ?? "", r.registrationType ?? "",
r.artForm || "", r.artForm || "",
r.experience || "", r.experience || "",
r.isOver16 ? "Yes" : "No", r.isOver16 ? "Yes" : "No",
String(r.drinkCardValue ?? 0), String(r.drinkCardValue ?? 0),
String(r.giftAmount ?? 0), String(r.giftAmount ?? 0),
String(guests.length), paymentStatus,
...guestCols,
r.paymentStatus === "paid"
? "Paid"
: r.paymentStatus === "extra_payment_pending"
? "Extra Payment Pending"
: "Pending",
r.paidAt ? r.paidAt.toISOString() : "", r.paidAt ? r.paidAt.toISOString() : "",
r.extraQuestions || "", r.extraQuestions || "",
r.cancelledAt ? r.cancelledAt.toISOString() : "", r.cancelledAt ? r.cancelledAt.toISOString() : "",
r.createdAt.toISOString(), r.createdAt.toISOString(),
]; ]);
});
// One row per guest — link back to parent registration via Registration ID
for (const g of guests) {
rows.push([
"guest",
r.id,
g.firstName,
g.lastName,
g.email || "",
g.phone || "",
g.birthdate || "",
g.postcode || "",
// Registrant-only fields left blank
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
]);
}
}
const csvContent = [ const csvContent = [
headers.map(escapeCell).join(","), headers.map(escapeCell).join(","),
@@ -796,6 +902,10 @@ export const appRouter = {
return { success: true, message: "Admin toegang goedgekeurd" }; return { success: true, message: "Admin toegang goedgekeurd" };
}), }),
getWaitlistSubscribers: adminProcedure.handler(async () => {
return db.select().from(reminder).orderBy(desc(reminder.createdAt));
}),
rejectAdminRequest: adminProcedure rejectAdminRequest: adminProcedure
.input(z.object({ requestId: z.string() })) .input(z.object({ requestId: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input, context }) => {
@@ -827,17 +937,19 @@ export const appRouter = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Subscribe an email address to receive a reminder 1 hour before registration * Subscribe an email address to receive reminders before registration opens:
* opens. Silently deduplicates — if the email is already subscribed, returns * one 24 hours before and one 1 hour before. Silently deduplicates — if the
* ok:true without error. Returns ok:false if registration is already open. * email is already subscribed, returns ok:true without error. Returns
* ok:false if registration is already open.
*/ */
subscribeReminder: publicProcedure subscribeReminder: publicProcedure
.input(z.object({ email: z.string().email() })) .input(z.object({ email: z.string().email() }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
const now = Date.now(); const now = Date.now();
// Registration is already open — no point subscribing // Registration is already open — no point subscribing, unless the event
if (now >= REGISTRATION_OPENS_AT.getTime()) { // is postponed (in which case we collect waitlist signups instead).
if (now >= REGISTRATION_OPENS_AT.getTime() && !POSTPONED) {
return { ok: false, reason: "already_open" as const }; return { ok: false, reason: "already_open" as const };
} }
@@ -858,6 +970,26 @@ export const appRouter = {
email: input.email, email: input.email,
}); });
const subMsg: EmailMessage = {
type: "subscriptionConfirmation",
to: input.email,
};
if (context.emailQueue) {
await context.emailQueue.send(subMsg);
} else {
// Awaited — CF Workers abandon unawaited promises when the response
// returns. Mail errors are caught so they don't fail the request.
await sendSubscriptionConfirmationEmail({ to: input.email }).catch(
(err) =>
emailLog("error", "email.catch", {
type: "subscription_confirmation",
to: input.email,
error: String(err),
}),
);
}
return { ok: true }; return { ok: true };
}), }),
@@ -867,8 +999,9 @@ export const appRouter = {
* header. Should be called by a Cloudflare Cron Trigger at the top of * header. Should be called by a Cloudflare Cron Trigger at the top of
* every hour, or directly via `POST /api/cron/reminders`. * every hour, or directly via `POST /api/cron/reminders`.
* *
* Only sends when the current time is within the 1-hour reminder window * Fires two waves:
* (between REGISTRATION_OPENS_AT - 1h and REGISTRATION_OPENS_AT). * - 24 hours before: window [REGISTRATION_OPENS_AT - 25h, REGISTRATION_OPENS_AT - 23h)
* - 1 hour before: window [REGISTRATION_OPENS_AT - 1h, REGISTRATION_OPENS_AT)
*/ */
sendReminders: publicProcedure sendReminders: publicProcedure
.input(z.object({ secret: z.string() })) .input(z.object({ secret: z.string() }))
@@ -879,22 +1012,59 @@ export const appRouter = {
} }
const now = Date.now(); const now = Date.now();
const windowStart = REMINDER_WINDOW_START.getTime(); const in24hWindow =
const windowEnd = REGISTRATION_OPENS_AT.getTime(); now >= REMINDER_24H_WINDOW_START.getTime() &&
now < REMINDER_24H_WINDOW_END.getTime();
const in1hWindow =
now >= REMINDER_1H_WINDOW_START.getTime() &&
now < REGISTRATION_OPENS_AT.getTime();
// Only send during the reminder window const activeWindow = in24hWindow ? "24h" : in1hWindow ? "1h" : null;
if (now < windowStart || now >= windowEnd) { emailLog("info", "reminders.cron.tick", { activeWindow });
if (!in24hWindow && !in1hWindow) {
return { sent: 0, skipped: true, reason: "outside_window" as const }; return { sent: 0, skipped: true, reason: "outside_window" as const };
} }
// Fetch all unsent reminders let sent = 0;
const errors: string[] = [];
if (in24hWindow) {
// Fetch reminders where the 24h email has not been sent yet
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sent24hAt));
emailLog("info", "reminders.24h.pending", { count: pending.length });
for (const row of pending) {
try {
await sendReminder24hEmail({ to: row.email });
await db
.update(reminder)
.set({ sent24hAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`24h ${row.email}: ${String(err)}`);
emailLog("error", "email.catch", {
type: "reminder_24h",
to: row.email,
error: String(err),
});
}
}
}
if (in1hWindow) {
// Fetch reminders where the 1h email has not been sent yet
const pending = await db const pending = await db
.select() .select()
.from(reminder) .from(reminder)
.where(isNull(reminder.sentAt)); .where(isNull(reminder.sentAt));
let sent = 0; emailLog("info", "reminders.1h.pending", { count: pending.length });
const errors: string[] = [];
for (const row of pending) { for (const row of pending) {
try { try {
@@ -905,10 +1075,20 @@ export const appRouter = {
.where(eq(reminder.id, row.id)); .where(eq(reminder.id, row.id));
sent++; sent++;
} catch (err) { } catch (err) {
errors.push(`${row.email}: ${String(err)}`); errors.push(`1h ${row.email}: ${String(err)}`);
console.error(`Failed to send reminder to ${row.email}:`, err); emailLog("error", "email.catch", {
type: "reminder_1h",
to: row.email,
error: String(err),
});
} }
} }
}
emailLog("info", "reminders.cron.done", {
sent,
errorCount: errors.length,
});
return { sent, skipped: false, errors }; return { sent, skipped: false, errors };
}), }),
@@ -1024,29 +1204,111 @@ export type AppRouterClient = RouterClient<typeof appRouter>;
* Standalone function that sends pending reminder emails. * Standalone function that sends pending reminder emails.
* Exported so that the Cloudflare Cron HTTP handler can call it directly * Exported so that the Cloudflare Cron HTTP handler can call it directly
* without needing drizzle-orm as a direct dependency of apps/web. * without needing drizzle-orm as a direct dependency of apps/web.
*
* When `emailQueue` is provided, reminder fan-outs mark the DB rows
* optimistically before calling `sendBatch()` — preventing re-enqueue on
* the next cron tick while still letting the queue handle email delivery
* and retries. Payment reminders always run directly since they need
* per-row DB updates tightly coupled to the send.
*/ */
export async function runSendReminders(): Promise<{ export async function runSendReminders(emailQueue?: EmailQueue): Promise<{
sent: number; sent: number;
skipped: boolean; skipped: boolean;
reason?: string; reason?: string;
errors: string[]; errors: string[];
}> { }> {
const now = Date.now(); const now = Date.now();
const windowStart = REMINDER_WINDOW_START.getTime(); const in24hWindow =
const windowEnd = REGISTRATION_OPENS_AT.getTime(); now >= REMINDER_24H_WINDOW_START.getTime() &&
now < REMINDER_24H_WINDOW_END.getTime();
const in1hWindow =
now >= REMINDER_1H_WINDOW_START.getTime() &&
now < REGISTRATION_OPENS_AT.getTime();
if (now < windowStart || now >= windowEnd) { const activeWindow = in24hWindow ? "24h" : in1hWindow ? "1h" : null;
emailLog("info", "reminders.cron.tick", { activeWindow });
if (!in24hWindow && !in1hWindow) {
return { sent: 0, skipped: true, reason: "outside_window", errors: [] }; return { sent: 0, skipped: true, reason: "outside_window", errors: [] };
} }
let sent = 0;
const errors: string[] = [];
if (in24hWindow) {
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sent24hAt));
emailLog("info", "reminders.24h.pending", { count: pending.length });
if (emailQueue && pending.length > 0) {
// Mark rows optimistically before enqueuing — prevents re-enqueue on the
// next cron tick if the queue send succeeds but the consumer hasn't run yet.
for (const row of pending) {
await db
.update(reminder)
.set({ sent24hAt: new Date() })
.where(eq(reminder.id, row.id));
}
await emailQueue.sendBatch(
pending.map((row) => ({
body: {
type: "reminder24h" as const,
to: row.email,
} satisfies EmailMessage,
})),
);
sent += pending.length;
} else {
for (const row of pending) {
try {
await sendReminder24hEmail({ to: row.email });
await db
.update(reminder)
.set({ sent24hAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`24h ${row.email}: ${String(err)}`);
emailLog("error", "email.catch", {
type: "reminder_24h",
to: row.email,
error: String(err),
});
}
}
}
}
if (in1hWindow) {
const pending = await db const pending = await db
.select() .select()
.from(reminder) .from(reminder)
.where(isNull(reminder.sentAt)); .where(isNull(reminder.sentAt));
let sent = 0; emailLog("info", "reminders.1h.pending", { count: pending.length });
const errors: string[] = [];
if (emailQueue && pending.length > 0) {
// Mark rows optimistically before enqueuing — prevents re-enqueue on the
// next cron tick if the queue send succeeds but the consumer hasn't run yet.
for (const row of pending) {
await db
.update(reminder)
.set({ sentAt: new Date() })
.where(eq(reminder.id, row.id));
}
await emailQueue.sendBatch(
pending.map((row) => ({
body: {
type: "reminder1h" as const,
to: row.email,
} satisfies EmailMessage,
})),
);
sent += pending.length;
} else {
for (const row of pending) { for (const row of pending) {
try { try {
await sendReminderEmail({ to: row.email }); await sendReminderEmail({ to: row.email });
@@ -1056,12 +1318,19 @@ export async function runSendReminders(): Promise<{
.where(eq(reminder.id, row.id)); .where(eq(reminder.id, row.id));
sent++; sent++;
} catch (err) { } catch (err) {
errors.push(`${row.email}: ${String(err)}`); errors.push(`1h ${row.email}: ${String(err)}`);
console.error(`Failed to send reminder to ${row.email}:`, err); emailLog("error", "email.catch", {
type: "reminder_1h",
to: row.email,
error: String(err),
});
}
}
} }
} }
// Payment reminders: watchers with pending payment older than 3 days // Payment reminders: watchers with pending payment older than 3 days.
// Always run directly (not via queue) — each row needs a DB update after send.
const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000;
const unpaidWatchers = await db const unpaidWatchers = await db
.select() .select()
@@ -1075,6 +1344,10 @@ export async function runSendReminders(): Promise<{
), ),
); );
emailLog("info", "reminders.payment.pending", {
count: unpaidWatchers.length,
});
for (const reg of unpaidWatchers) { for (const reg of unpaidWatchers) {
if (!reg.managementToken) continue; if (!reg.managementToken) continue;
try { try {
@@ -1092,9 +1365,15 @@ export async function runSendReminders(): Promise<{
sent++; sent++;
} catch (err) { } catch (err) {
errors.push(`payment-reminder ${reg.email}: ${String(err)}`); errors.push(`payment-reminder ${reg.email}: ${String(err)}`);
console.error(`Failed to send payment reminder to ${reg.email}:`, err); emailLog("error", "email.catch", {
type: "payment_reminder",
to: reg.email,
error: String(err),
});
} }
} }
emailLog("info", "reminders.cron.done", { sent, errorCount: errors.length });
return { sent, skipped: false, errors }; return { sent, skipped: false, errors };
} }

BIN
packages/db/local.db Normal file

Binary file not shown.

BIN
packages/db/local.db-shm Normal file

Binary file not shown.

0
packages/db/local.db-wal Normal file
View File

View File

@@ -0,0 +1,101 @@
ALTER TABLE `registration` RENAME COLUMN "wants_to_perform" TO "registration_type";--> statement-breakpoint
CREATE TABLE `drinkkaart` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`balance` integer DEFAULT 0 NOT NULL,
`version` integer DEFAULT 0 NOT NULL,
`qr_secret` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `drinkkaart_user_id_unique` ON `drinkkaart` (`user_id`);--> statement-breakpoint
CREATE TABLE `drinkkaart_topup` (
`id` text PRIMARY KEY NOT NULL,
`drinkkaart_id` text NOT NULL,
`user_id` text NOT NULL,
`amount_cents` integer NOT NULL,
`balance_before` integer NOT NULL,
`balance_after` integer NOT NULL,
`type` text NOT NULL,
`mollie_payment_id` text,
`admin_id` text,
`reason` text,
`paid_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `drinkkaart_topup_mollie_payment_id_unique` ON `drinkkaart_topup` (`mollie_payment_id`);--> statement-breakpoint
CREATE TABLE `drinkkaart_transaction` (
`id` text PRIMARY KEY NOT NULL,
`drinkkaart_id` text NOT NULL,
`user_id` text NOT NULL,
`admin_id` text NOT NULL,
`amount_cents` integer NOT NULL,
`balance_before` integer NOT NULL,
`balance_after` integer NOT NULL,
`type` text NOT NULL,
`reversed_by` text,
`reverses` text,
`note` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `reminder` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`sent_24h_at` integer,
`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`);--> statement-breakpoint
DROP INDEX "admin_request_user_id_unique";--> statement-breakpoint
DROP INDEX "admin_request_userId_idx";--> statement-breakpoint
DROP INDEX "admin_request_status_idx";--> statement-breakpoint
DROP INDEX "account_userId_idx";--> statement-breakpoint
DROP INDEX "session_token_unique";--> statement-breakpoint
DROP INDEX "session_userId_idx";--> statement-breakpoint
DROP INDEX "user_email_unique";--> statement-breakpoint
DROP INDEX "verification_identifier_idx";--> statement-breakpoint
DROP INDEX "drinkkaart_user_id_unique";--> statement-breakpoint
DROP INDEX "drinkkaart_topup_mollie_payment_id_unique";--> statement-breakpoint
DROP INDEX "registration_management_token_unique";--> statement-breakpoint
DROP INDEX "registration_email_idx";--> statement-breakpoint
DROP INDEX "registration_registrationType_idx";--> statement-breakpoint
DROP INDEX "registration_artForm_idx";--> statement-breakpoint
DROP INDEX "registration_createdAt_idx";--> statement-breakpoint
DROP INDEX "registration_managementToken_idx";--> statement-breakpoint
DROP INDEX "registration_paymentStatus_idx";--> statement-breakpoint
DROP INDEX "registration_giftAmount_idx";--> statement-breakpoint
DROP INDEX "registration_molliePaymentId_idx";--> statement-breakpoint
DROP INDEX "reminder_email_idx";--> statement-breakpoint
ALTER TABLE `registration` ALTER COLUMN "registration_type" TO "registration_type" text NOT NULL DEFAULT 'watcher';--> statement-breakpoint
CREATE UNIQUE INDEX `admin_request_user_id_unique` ON `admin_request` (`user_id`);--> statement-breakpoint
CREATE INDEX `admin_request_userId_idx` ON `admin_request` (`user_id`);--> statement-breakpoint
CREATE INDEX `admin_request_status_idx` ON `admin_request` (`status`);--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
CREATE UNIQUE INDEX `registration_management_token_unique` ON `registration` (`management_token`);--> statement-breakpoint
CREATE INDEX `registration_email_idx` ON `registration` (`email`);--> statement-breakpoint
CREATE INDEX `registration_registrationType_idx` ON `registration` (`registration_type`);--> statement-breakpoint
CREATE INDEX `registration_artForm_idx` ON `registration` (`art_form`);--> statement-breakpoint
CREATE INDEX `registration_createdAt_idx` ON `registration` (`created_at`);--> statement-breakpoint
CREATE INDEX `registration_managementToken_idx` ON `registration` (`management_token`);--> statement-breakpoint
CREATE INDEX `registration_paymentStatus_idx` ON `registration` (`payment_status`);--> statement-breakpoint
CREATE INDEX `registration_giftAmount_idx` ON `registration` (`gift_amount`);--> statement-breakpoint
CREATE INDEX `registration_molliePaymentId_idx` ON `registration` (`mollie_payment_id`);--> statement-breakpoint
ALTER TABLE `registration` ADD `is_over_16` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `drink_card_value` integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE `registration` ADD `guests` text;--> statement-breakpoint
ALTER TABLE `registration` ADD `birthdate` text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `postcode` text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `payment_status` text DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `payment_amount` integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE `registration` ADD `gift_amount` integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE `registration` ADD `mollie_payment_id` text;--> statement-breakpoint
ALTER TABLE `registration` ADD `paid_at` integer;--> statement-breakpoint
ALTER TABLE `registration` ADD `drinkkaart_credited_at` integer;--> statement-breakpoint
ALTER TABLE `registration` ADD `payment_reminder_sent_at` integer;

View File

@@ -0,0 +1,2 @@
-- Add sent_24h_at column to track whether the 24-hour-before reminder was sent
ALTER TABLE `reminder` ADD `sent_24h_at` integer;

View File

@@ -0,0 +1,971 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7994e607-3835-43a5-9251-fca48f0aa19a",
"prevId": "d9a4f07e-e6ae-45d0-be82-c919ae7fbe09",
"tables": {
"admin_request": {
"name": "admin_request",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"requested_at": {
"name": "requested_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"reviewed_at": {
"name": "reviewed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reviewed_by": {
"name": "reviewed_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"admin_request_user_id_unique": {
"name": "admin_request_user_id_unique",
"columns": ["user_id"],
"isUnique": true
},
"admin_request_userId_idx": {
"name": "admin_request_userId_idx",
"columns": ["user_id"],
"isUnique": false
},
"admin_request_status_idx": {
"name": "admin_request_status_idx",
"columns": ["status"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": ["token"],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": ["identifier"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"drinkkaart": {
"name": "drinkkaart",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"qr_secret": {
"name": "qr_secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"drinkkaart_user_id_unique": {
"name": "drinkkaart_user_id_unique",
"columns": ["user_id"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"drinkkaart_topup": {
"name": "drinkkaart_topup",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"drinkkaart_id": {
"name": "drinkkaart_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_cents": {
"name": "amount_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_before": {
"name": "balance_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_after": {
"name": "balance_after",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mollie_payment_id": {
"name": "mollie_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"paid_at": {
"name": "paid_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"drinkkaart_topup_mollie_payment_id_unique": {
"name": "drinkkaart_topup_mollie_payment_id_unique",
"columns": ["mollie_payment_id"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"drinkkaart_transaction": {
"name": "drinkkaart_transaction",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"drinkkaart_id": {
"name": "drinkkaart_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_cents": {
"name": "amount_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_before": {
"name": "balance_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_after": {
"name": "balance_after",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reversed_by": {
"name": "reversed_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reverses": {
"name": "reverses",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"registration": {
"name": "registration",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"registration_type": {
"name": "registration_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'watcher'"
},
"art_form": {
"name": "art_form",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"experience": {
"name": "experience",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_over_16": {
"name": "is_over_16",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"drink_card_value": {
"name": "drink_card_value",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"guests": {
"name": "guests",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"birthdate": {
"name": "birthdate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"postcode": {
"name": "postcode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"extra_questions": {
"name": "extra_questions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"management_token": {
"name": "management_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cancelled_at": {
"name": "cancelled_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_status": {
"name": "payment_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"payment_amount": {
"name": "payment_amount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"gift_amount": {
"name": "gift_amount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"mollie_payment_id": {
"name": "mollie_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"paid_at": {
"name": "paid_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"drinkkaart_credited_at": {
"name": "drinkkaart_credited_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_reminder_sent_at": {
"name": "payment_reminder_sent_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"registration_management_token_unique": {
"name": "registration_management_token_unique",
"columns": ["management_token"],
"isUnique": true
},
"registration_email_idx": {
"name": "registration_email_idx",
"columns": ["email"],
"isUnique": false
},
"registration_registrationType_idx": {
"name": "registration_registrationType_idx",
"columns": ["registration_type"],
"isUnique": false
},
"registration_artForm_idx": {
"name": "registration_artForm_idx",
"columns": ["art_form"],
"isUnique": false
},
"registration_createdAt_idx": {
"name": "registration_createdAt_idx",
"columns": ["created_at"],
"isUnique": false
},
"registration_managementToken_idx": {
"name": "registration_managementToken_idx",
"columns": ["management_token"],
"isUnique": false
},
"registration_paymentStatus_idx": {
"name": "registration_paymentStatus_idx",
"columns": ["payment_status"],
"isUnique": false
},
"registration_giftAmount_idx": {
"name": "registration_giftAmount_idx",
"columns": ["gift_amount"],
"isUnique": false
},
"registration_molliePaymentId_idx": {
"name": "registration_molliePaymentId_idx",
"columns": ["mollie_payment_id"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reminder": {
"name": "reminder",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sent_24h_at": {
"name": "sent_24h_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sent_at": {
"name": "sent_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"reminder_email_idx": {
"name": "reminder_email_idx",
"columns": ["email"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"registration\".\"wants_to_perform\"": "\"registration\".\"registration_type\""
}
},
"internal": {
"indexes": {}
}
}

View File

@@ -57,6 +57,13 @@
"when": 1772931300000, "when": 1772931300000,
"tag": "0007_reminder", "tag": "0007_reminder",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1773910007494,
"tag": "0008_melodic_marrow",
"breakpoints": true
} }
] ]
} }

View File

@@ -19,9 +19,11 @@ export const registration = sqliteTable(
.default(false), .default(false),
// Watcher-specific fields // Watcher-specific fields
drinkCardValue: integer("drink_card_value").default(0), drinkCardValue: integer("drink_card_value").default(0),
// Guests: JSON array of {firstName, lastName, email?, phone?} objects // Guests: JSON array of {firstName, lastName, email?, phone?, birthdate, postcode} objects
guests: text("guests"), guests: text("guests"),
// Shared // Shared
birthdate: text("birthdate").notNull().default(""),
postcode: text("postcode").notNull().default(""),
extraQuestions: text("extra_questions"), extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(), managementToken: text("management_token").unique(),
cancelledAt: integer("cancelled_at", { mode: "timestamp_ms" }), cancelledAt: integer("cancelled_at", { mode: "timestamp_ms" }),

View File

@@ -6,6 +6,9 @@ export const reminder = sqliteTable(
{ {
id: text("id").primaryKey(), // UUID id: text("id").primaryKey(), // UUID
email: text("email").notNull(), email: text("email").notNull(),
/** Set when the 24-hours-before reminder has been sent. */
sent24hAt: integer("sent_24h_at", { mode: "timestamp_ms" }),
/** Set when the 1-hour-before reminder has been sent. */
sentAt: integer("sent_at", { mode: "timestamp_ms" }), sentAt: integer("sent_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" }) createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)

View File

@@ -15,13 +15,13 @@ export const env = createEnv({
server: { server: {
DATABASE_URL: z.string().min(1), DATABASE_URL: z.string().min(1),
BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.url(), BETTER_AUTH_URL: z.url().default("https://kunstenkamp.be"),
CORS_ORIGIN: z.url(), CORS_ORIGIN: z.url(),
SMTP_HOST: z.string().min(1).optional(), SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.coerce.number().default(587), SMTP_PORT: z.coerce.number().default(587),
SMTP_USER: z.string().min(1).optional(), SMTP_USER: z.string().min(1).optional(),
SMTP_PASS: z.string().min(1).optional(), SMTP_PASS: z.string().min(1).optional(),
SMTP_FROM: z.string().min(1).optional(), SMTP_FROM: z.string().min(1).default("Kunstenkamp <info@kunstenkamp.be>"),
NODE_ENV: z NODE_ENV: z
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),

View File

@@ -1,5 +1,5 @@
import alchemy from "alchemy"; import alchemy from "alchemy";
import { TanStackStart } from "alchemy/cloudflare"; import { Queue, TanStackStart } from "alchemy/cloudflare";
import { config } from "dotenv"; import { config } from "dotenv";
config({ path: "./.env" }); config({ path: "./.env" });
@@ -16,6 +16,10 @@ function getEnvVar(name: string): string {
return value; return value;
} }
const emailQueue = await Queue("email-queue", {
name: "kk-email-queue",
});
export const web = await TanStackStart("web", { export const web = await TanStackStart("web", {
cwd: "../../apps/web", cwd: "../../apps/web",
bindings: { bindings: {
@@ -34,8 +38,22 @@ export const web = await TanStackStart("web", {
MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"), MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"),
// Cron secret for protected scheduled endpoints // Cron secret for protected scheduled endpoints
CRON_SECRET: getEnvVar("CRON_SECRET"), CRON_SECRET: getEnvVar("CRON_SECRET"),
// Queue binding for async email sends
EMAIL_QUEUE: emailQueue,
}, },
// Fire every hour so the reminder check can run at 09:30 on 2026-03-11 // Queue consumer: the worker's queue() handler processes EmailMessage batches
eventSources: [
{
queue: emailQueue,
settings: {
batchSize: 10,
maxRetries: 3,
retryDelay: 60, // seconds before retrying a failed message
maxWaitTimeMs: 1000,
},
},
],
// Fire every hour so reminder checks can run at 19:00 on 2026-03-15 (24h) and 18:00 on 2026-03-16 (1h)
crons: ["0 * * * *"], crons: ["0 * * * *"],
domains: ["kunstenkamp.be", "www.kunstenkamp.be"], domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
}); });