feat: reminder email opt-in 1 hour before registration opens
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { REGISTRATION_OPENS_AT } from "@/lib/opening";
|
import { REGISTRATION_OPENS_AT } from "@/lib/opening";
|
||||||
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
|
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
|
||||||
|
import { client } from "@/utils/orpc";
|
||||||
|
|
||||||
function pad(n: number): string {
|
function pad(n: number): string {
|
||||||
return String(n).padStart(2, "0");
|
return String(n).padStart(2, "0");
|
||||||
@@ -15,10 +16,10 @@ interface UnitBoxProps {
|
|||||||
function UnitBox({ value, label }: UnitBoxProps) {
|
function UnitBox({ value, label }: UnitBoxProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-1">
|
<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}
|
{value}
|
||||||
</div>
|
</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}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +54,181 @@ function fireConfetti() {
|
|||||||
}, 400);
|
}, 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.
|
* Shown in place of the registration form while registration is not yet open.
|
||||||
* Fires confetti the moment the gate opens (without a page reload).
|
* Fires confetti the moment the gate opens (without a page reload).
|
||||||
@@ -83,37 +259,40 @@ export function CountdownBanner() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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>
|
<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
|
Inschrijvingen openen op
|
||||||
</p>
|
</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}
|
{openDate} om {openTime}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Countdown boxes */}
|
{/* Countdown — single row forced via grid, scales down on small screens */}
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3 md:gap-6">
|
<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" />
|
<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>
|
</span>
|
||||||
<UnitBox value={pad(hours)} label="uren" />
|
<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>
|
</span>
|
||||||
<UnitBox value={pad(minutes)} label="minuten" />
|
<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>
|
</span>
|
||||||
<UnitBox value={pad(seconds)} label="seconden" />
|
<UnitBox value={pad(seconds)} label="seconden" />
|
||||||
</div>
|
</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
|
Kom snel terug! Zodra de inschrijvingen openen kun je je hier
|
||||||
registreren als toeschouwer of als artiest.
|
registreren als toeschouwer of als artiest.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Email reminder opt-in — always last */}
|
||||||
|
<ReminderForm />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Route as ManageTokenRouteImport } from './routes/manage.$token'
|
|||||||
import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart'
|
import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart'
|
||||||
import { Route as ApiWebhookMollieRouteImport } from './routes/api/webhook/mollie'
|
import { Route as ApiWebhookMollieRouteImport } from './routes/api/webhook/mollie'
|
||||||
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
|
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
|
||||||
|
import { Route as ApiCronRemindersRouteImport } from './routes/api/cron/reminders'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
|
|
||||||
const TermsRoute = TermsRouteImport.update({
|
const TermsRoute = TermsRouteImport.update({
|
||||||
@@ -83,6 +84,11 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
|||||||
path: '/api/rpc/$',
|
path: '/api/rpc/$',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiCronRemindersRoute = ApiCronRemindersRouteImport.update({
|
||||||
|
id: '/api/cron/reminders',
|
||||||
|
path: '/api/cron/reminders',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||||
id: '/api/auth/$',
|
id: '/api/auth/$',
|
||||||
path: '/api/auth/$',
|
path: '/api/auth/$',
|
||||||
@@ -101,6 +107,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/manage/$token': typeof ManageTokenRoute
|
'/manage/$token': typeof ManageTokenRoute
|
||||||
'/admin/': typeof AdminIndexRoute
|
'/admin/': typeof AdminIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/api/cron/reminders': typeof ApiCronRemindersRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
|
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
|
||||||
}
|
}
|
||||||
@@ -116,6 +123,7 @@ export interface FileRoutesByTo {
|
|||||||
'/manage/$token': typeof ManageTokenRoute
|
'/manage/$token': typeof ManageTokenRoute
|
||||||
'/admin': typeof AdminIndexRoute
|
'/admin': typeof AdminIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/api/cron/reminders': typeof ApiCronRemindersRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
|
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
|
||||||
}
|
}
|
||||||
@@ -132,6 +140,7 @@ export interface FileRoutesById {
|
|||||||
'/manage/$token': typeof ManageTokenRoute
|
'/manage/$token': typeof ManageTokenRoute
|
||||||
'/admin/': typeof AdminIndexRoute
|
'/admin/': typeof AdminIndexRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/api/cron/reminders': typeof ApiCronRemindersRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
|
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
|
||||||
}
|
}
|
||||||
@@ -149,6 +158,7 @@ export interface FileRouteTypes {
|
|||||||
| '/manage/$token'
|
| '/manage/$token'
|
||||||
| '/admin/'
|
| '/admin/'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
|
| '/api/cron/reminders'
|
||||||
| '/api/rpc/$'
|
| '/api/rpc/$'
|
||||||
| '/api/webhook/mollie'
|
| '/api/webhook/mollie'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -164,6 +174,7 @@ export interface FileRouteTypes {
|
|||||||
| '/manage/$token'
|
| '/manage/$token'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
|
| '/api/cron/reminders'
|
||||||
| '/api/rpc/$'
|
| '/api/rpc/$'
|
||||||
| '/api/webhook/mollie'
|
| '/api/webhook/mollie'
|
||||||
id:
|
id:
|
||||||
@@ -179,6 +190,7 @@ export interface FileRouteTypes {
|
|||||||
| '/manage/$token'
|
| '/manage/$token'
|
||||||
| '/admin/'
|
| '/admin/'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
|
| '/api/cron/reminders'
|
||||||
| '/api/rpc/$'
|
| '/api/rpc/$'
|
||||||
| '/api/webhook/mollie'
|
| '/api/webhook/mollie'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -195,6 +207,7 @@ export interface RootRouteChildren {
|
|||||||
ManageTokenRoute: typeof ManageTokenRoute
|
ManageTokenRoute: typeof ManageTokenRoute
|
||||||
AdminIndexRoute: typeof AdminIndexRoute
|
AdminIndexRoute: typeof AdminIndexRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
|
ApiCronRemindersRoute: typeof ApiCronRemindersRoute
|
||||||
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
||||||
ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute
|
ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute
|
||||||
}
|
}
|
||||||
@@ -285,6 +298,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiRpcSplatRouteImport
|
preLoaderRoute: typeof ApiRpcSplatRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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/$': {
|
'/api/auth/$': {
|
||||||
id: '/api/auth/$'
|
id: '/api/auth/$'
|
||||||
path: '/api/auth/$'
|
path: '/api/auth/$'
|
||||||
@@ -307,6 +327,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ManageTokenRoute: ManageTokenRoute,
|
ManageTokenRoute: ManageTokenRoute,
|
||||||
AdminIndexRoute: AdminIndexRoute,
|
AdminIndexRoute: AdminIndexRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
|
ApiCronRemindersRoute: ApiCronRemindersRoute,
|
||||||
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
||||||
ApiWebhookMollieRoute: ApiWebhookMollieRoute,
|
ApiWebhookMollieRoute: ApiWebhookMollieRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
40
apps/web/src/routes/api/cron/reminders.ts
Normal file
40
apps/web/src/routes/api/cron/reminders.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -348,3 +348,99 @@ export async function sendCancellationEmail(params: {
|
|||||||
html: cancellationHtml({ firstName: params.firstName }),
|
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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { db } from "@kk/db";
|
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 { user } from "@kk/db/schema/auth";
|
||||||
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
|
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
|
||||||
import { env } from "@kk/env/server";
|
import { env } from "@kk/env/server";
|
||||||
@@ -10,12 +10,19 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
sendCancellationEmail,
|
sendCancellationEmail,
|
||||||
sendConfirmationEmail,
|
sendConfirmationEmail,
|
||||||
|
sendReminderEmail,
|
||||||
sendUpdateEmail,
|
sendUpdateEmail,
|
||||||
} from "../email";
|
} from "../email";
|
||||||
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
|
||||||
|
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
|
// Shared helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -729,6 +736,97 @@ export const appRouter = {
|
|||||||
return { success: true, message: "Admin toegang geweigerd" };
|
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
|
// Checkout
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -835,3 +933,47 @@ export const appRouter = {
|
|||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
export type AppRouterClient = RouterClient<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.
9
packages/db/src/migrations/0007_reminder.sql
Normal file
9
packages/db/src/migrations/0007_reminder.sql
Normal 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`);
|
||||||
@@ -29,6 +29,34 @@
|
|||||||
"when": 1772530000000,
|
"when": 1772530000000,
|
||||||
"tag": "0003_add_guests",
|
"tag": "0003_add_guests",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from "./admin-requests";
|
|||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
export * from "./drinkkaart";
|
export * from "./drinkkaart";
|
||||||
export * from "./registrations";
|
export * from "./registrations";
|
||||||
|
export * from "./reminders";
|
||||||
|
|||||||
18
packages/db/src/schema/reminders.ts
Normal file
18
packages/db/src/schema/reminders.ts
Normal 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;
|
||||||
1
packages/env/src/server.ts
vendored
1
packages/env/src/server.ts
vendored
@@ -26,6 +26,7 @@ export const env = createEnv({
|
|||||||
.enum(["development", "production", "test"])
|
.enum(["development", "production", "test"])
|
||||||
.default("development"),
|
.default("development"),
|
||||||
MOLLIE_API_KEY: z.string().min(1).optional(),
|
MOLLIE_API_KEY: z.string().min(1).optional(),
|
||||||
|
CRON_SECRET: z.string().min(1).optional(),
|
||||||
},
|
},
|
||||||
runtimeEnv: process.env,
|
runtimeEnv: process.env,
|
||||||
emptyStringAsUndefined: true,
|
emptyStringAsUndefined: true,
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ export const web = await TanStackStart("web", {
|
|||||||
SMTP_FROM: getEnvVar("SMTP_FROM"),
|
SMTP_FROM: getEnvVar("SMTP_FROM"),
|
||||||
// Payments (Mollie)
|
// Payments (Mollie)
|
||||||
MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"),
|
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"],
|
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,17 @@
|
|||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"db:push": {
|
"db:push": {
|
||||||
"cache": false
|
"cache": false,
|
||||||
|
"interactive": true
|
||||||
},
|
},
|
||||||
"db:generate": {
|
"db:generate": {
|
||||||
"cache": false
|
"cache": false,
|
||||||
|
"interactive": true
|
||||||
},
|
},
|
||||||
"db:migrate": {
|
"db:migrate": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true,
|
||||||
|
"interactive": true
|
||||||
},
|
},
|
||||||
"db:studio": {
|
"db:studio": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user