Compare commits

...

2 Commits

Author SHA1 Message Date
7eabe88d30 feat: reminder email opt-in 1 hour before registration opens 2026-03-10 15:48:41 +01:00
d180a33d6e feat: hoe inschrijven 2026-03-10 14:31:36 +01:00
18 changed files with 697 additions and 74 deletions

View File

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

View File

@@ -47,6 +47,19 @@ export default function EventRegistrationForm() {
);
}
if (!isOpen) {
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">
<CountdownBanner />
</div>
</section>
);
}
return (
<section
id="registration"
@@ -63,10 +76,6 @@ export default function EventRegistrationForm() {
Doe je mee of kom je kijken? Kies je rol en vul het formulier in.
</p>
{!isOpen ? (
<CountdownBanner />
) : (
<>
{/* Login nudge — shown only to guests */}
{!isLoggedIn && (
<div className="mb-10 flex flex-col gap-3 rounded-lg border border-[#52979b]/40 bg-[#52979b]/10 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
@@ -74,8 +83,8 @@ export default function EventRegistrationForm() {
<span className="mr-1 font-semibold text-teal-300">
Al een account?
</span>
Log in om je gegevens automatisch in te vullen en je
Drinkkaart direct te activeren na registratie.
Log in om je gegevens automatisch in te vullen en je Drinkkaart
direct te activeren na registratie.
</p>
<div className="flex shrink-0 items-center gap-3 text-sm">
<Link
@@ -117,8 +126,6 @@ export default function EventRegistrationForm() {
isLoggedIn={isLoggedIn}
/>
)}
</>
)}
</div>
</section>
);

View File

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

View File

@@ -21,6 +21,7 @@ import { Route as ManageTokenRouteImport } from './routes/manage.$token'
import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart'
import { Route as ApiWebhookMollieRouteImport } from './routes/api/webhook/mollie'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
import { Route as ApiCronRemindersRouteImport } from './routes/api/cron/reminders'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
const TermsRoute = TermsRouteImport.update({
@@ -83,6 +84,11 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
path: '/api/rpc/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiCronRemindersRoute = ApiCronRemindersRouteImport.update({
id: '/api/cron/reminders',
path: '/api/cron/reminders',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
@@ -101,6 +107,7 @@ export interface FileRoutesByFullPath {
'/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
@@ -116,6 +123,7 @@ export interface FileRoutesByTo {
'/manage/$token': typeof ManageTokenRoute
'/admin': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
@@ -132,6 +140,7 @@ export interface FileRoutesById {
'/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
@@ -149,6 +158,7 @@ export interface FileRouteTypes {
| '/manage/$token'
| '/admin/'
| '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/mollie'
fileRoutesByTo: FileRoutesByTo
@@ -164,6 +174,7 @@ export interface FileRouteTypes {
| '/manage/$token'
| '/admin'
| '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/mollie'
id:
@@ -179,6 +190,7 @@ export interface FileRouteTypes {
| '/manage/$token'
| '/admin/'
| '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/mollie'
fileRoutesById: FileRoutesById
@@ -195,6 +207,7 @@ export interface RootRouteChildren {
ManageTokenRoute: typeof ManageTokenRoute
AdminIndexRoute: typeof AdminIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiCronRemindersRoute: typeof ApiCronRemindersRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute
}
@@ -285,6 +298,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiRpcSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/cron/reminders': {
id: '/api/cron/reminders'
path: '/api/cron/reminders'
fullPath: '/api/cron/reminders'
preLoaderRoute: typeof ApiCronRemindersRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
@@ -307,6 +327,7 @@ const rootRouteChildren: RootRouteChildren = {
ManageTokenRoute: ManageTokenRoute,
AdminIndexRoute: AdminIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiCronRemindersRoute: ApiCronRemindersRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
ApiWebhookMollieRoute: ApiWebhookMollieRoute,
}

View File

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

View File

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

View File

@@ -348,3 +348,99 @@ export async function sendCancellationEmail(params: {
html: cancellationHtml({ firstName: params.firstName }),
});
}
function reminderHtml(params: { firstName?: string | null }) {
const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!";
// Registration opens at 2026-03-16T19:00:00+01:00
const openDateStr = "maandag 16 maart 2026 om 19:00";
const registrationUrl = `${baseUrl}/#registration`;
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nog 1 uur — inschrijvingen openen straks!</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<!-- Header -->
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Nog 1 uur!</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
${greeting}
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Over ongeveer <strong style="color:#ffffff;">1 uur</strong> openen de inschrijvingen voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong>.
</p>
<!-- Time box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 8px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Inschrijvingen openen</p>
<p style="margin:0;font-size:18px;color:#ffffff;font-weight:600;">${openDateStr}</p>
</td>
</tr>
</table>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Wees er snel bij — de plaatsen zijn beperkt!
</p>
<!-- CTA button -->
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${registrationUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Schrijf je in
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Of ga naar: <span style="color:rgba(255,255,255,0.6);">${registrationUrl}</span>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd.<br/>
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export async function sendReminderEmail(params: {
to: string;
firstName?: string | null;
}) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping reminder email");
return;
}
await transport.sendMail({
from,
to: params.to,
subject: "Nog 1 uur — inschrijvingen openen straks!",
html: reminderHtml({ firstName: params.firstName }),
});
}

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import { adminRequest, registration } from "@kk/db/schema";
import { adminRequest, registration, reminder } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
import { env } from "@kk/env/server";
@@ -10,12 +10,19 @@ import { z } from "zod";
import {
sendCancellationEmail,
sendConfirmationEmail,
sendReminderEmail,
sendUpdateEmail,
} from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { generateQrSecret } from "../lib/drinkkaart-utils";
import { drinkkaartRouter } from "./drinkkaart";
// Registration opens at this date — reminders fire 1 hour before
const REGISTRATION_OPENS_AT = new Date("2026-03-10T17:00:00+01:00");
const REMINDER_WINDOW_START = new Date(
REGISTRATION_OPENS_AT.getTime() - 60 * 60 * 1000,
);
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
@@ -729,6 +736,97 @@ export const appRouter = {
return { success: true, message: "Admin toegang geweigerd" };
}),
// ---------------------------------------------------------------------------
// Reminder opt-in
// ---------------------------------------------------------------------------
/**
* Subscribe an email address to receive a reminder 1 hour before registration
* opens. Silently deduplicates — if the email is already subscribed, returns
* ok:true without error. Returns ok:false if registration is already open.
*/
subscribeReminder: publicProcedure
.input(z.object({ email: z.string().email() }))
.handler(async ({ input }) => {
const now = Date.now();
// Registration is already open — no point subscribing
if (now >= REGISTRATION_OPENS_AT.getTime()) {
return { ok: false, reason: "already_open" as const };
}
// Check for an existing subscription (silent dedup)
const existing = await db
.select({ id: reminder.id })
.from(reminder)
.where(eq(reminder.email, input.email))
.limit(1)
.then((rows) => rows[0]);
if (existing) {
return { ok: true };
}
await db.insert(reminder).values({
id: randomUUID(),
email: input.email,
});
return { ok: true };
}),
/**
* Send pending reminder emails to all opted-in addresses.
* Protected by a shared secret (`CRON_SECRET`) passed as an Authorization
* header. Should be called by a Cloudflare Cron Trigger at the top of
* every hour, or directly via `POST /api/cron/reminders`.
*
* Only sends when the current time is within the 1-hour reminder window
* (between REGISTRATION_OPENS_AT - 1h and REGISTRATION_OPENS_AT).
*/
sendReminders: publicProcedure
.input(z.object({ secret: z.string() }))
.handler(async ({ input }) => {
// Validate the shared secret
if (!env.CRON_SECRET || input.secret !== env.CRON_SECRET) {
throw new Error("Unauthorized");
}
const now = Date.now();
const windowStart = REMINDER_WINDOW_START.getTime();
const windowEnd = REGISTRATION_OPENS_AT.getTime();
// Only send during the reminder window
if (now < windowStart || now >= windowEnd) {
return { sent: 0, skipped: true, reason: "outside_window" as const };
}
// Fetch all unsent reminders
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sentAt));
let sent = 0;
const errors: string[] = [];
for (const row of pending) {
try {
await sendReminderEmail({ to: row.email });
await db
.update(reminder)
.set({ sentAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`${row.email}: ${String(err)}`);
console.error(`Failed to send reminder to ${row.email}:`, err);
}
}
return { sent, skipped: false, errors };
}),
// ---------------------------------------------------------------------------
// Checkout
// ---------------------------------------------------------------------------
@@ -835,3 +933,47 @@ export const appRouter = {
export type AppRouter = typeof appRouter;
export type AppRouterClient = RouterClient<typeof appRouter>;
/**
* Standalone function that sends pending reminder emails.
* Exported so that the Cloudflare Cron HTTP handler can call it directly
* without needing drizzle-orm as a direct dependency of apps/web.
*/
export async function runSendReminders(): Promise<{
sent: number;
skipped: boolean;
reason?: string;
errors: string[];
}> {
const now = Date.now();
const windowStart = REMINDER_WINDOW_START.getTime();
const windowEnd = REGISTRATION_OPENS_AT.getTime();
if (now < windowStart || now >= windowEnd) {
return { sent: 0, skipped: true, reason: "outside_window", errors: [] };
}
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sentAt));
let sent = 0;
const errors: string[] = [];
for (const row of pending) {
try {
await sendReminderEmail({ to: row.email });
await db
.update(reminder)
.set({ sentAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`${row.email}: ${String(err)}`);
console.error(`Failed to send reminder to ${row.email}:`, err);
}
}
return { sent, skipped: false, errors };
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -29,6 +29,34 @@
"when": 1772530000000,
"tag": "0003_add_guests",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1772536320000,
"tag": "0004_drinkkaart",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1772596240000,
"tag": "0005_registration_drinkkaart_credit",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1772672880000,
"tag": "0006_mollie_migration",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1772931300000,
"tag": "0007_reminder",
"breakpoints": true
}
]
}

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ export const env = createEnv({
.enum(["development", "production", "test"])
.default("development"),
MOLLIE_API_KEY: z.string().min(1).optional(),
CRON_SECRET: z.string().min(1).optional(),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,

View File

@@ -32,7 +32,11 @@ export const web = await TanStackStart("web", {
SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (Mollie)
MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"),
// Cron secret for protected scheduled endpoints
CRON_SECRET: getEnvVar("CRON_SECRET"),
},
// Fire every hour so the reminder check can run at 18:00 on 2026-03-16
crons: ["0 * * * *"],
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
});

View File

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