From 7eabe88d30ad4b6467a14a2704bf2c82b02af874 Mon Sep 17 00:00:00 2001 From: zias Date: Tue, 10 Mar 2026 14:55:03 +0100 Subject: [PATCH] feat: reminder email opt-in 1 hour before registration opens --- .../components/homepage/CountdownBanner.tsx | 203 ++++++++++++++++-- apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/api/cron/reminders.ts | 40 ++++ packages/api/src/email.ts | 96 +++++++++ packages/api/src/routers/index.ts | 144 ++++++++++++- packages/db/local.db | Bin 4096 -> 0 bytes packages/db/local.db-shm | Bin 32768 -> 0 bytes packages/db/local.db-wal | Bin 247232 -> 0 bytes packages/db/src/migrations/0007_reminder.sql | 9 + packages/db/src/migrations/meta/_journal.json | 28 +++ packages/db/src/schema/index.ts | 1 + packages/db/src/schema/reminders.ts | 18 ++ packages/env/src/server.ts | 1 + packages/infra/alchemy.run.ts | 4 + turbo.json | 9 +- 15 files changed, 558 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/routes/api/cron/reminders.ts delete mode 100644 packages/db/local.db delete mode 100644 packages/db/local.db-shm delete mode 100644 packages/db/local.db-wal create mode 100644 packages/db/src/migrations/0007_reminder.sql create mode 100644 packages/db/src/schema/reminders.ts diff --git a/apps/web/src/components/homepage/CountdownBanner.tsx b/apps/web/src/components/homepage/CountdownBanner.tsx index 2152399..230946e 100644 --- a/apps/web/src/components/homepage/CountdownBanner.tsx +++ b/apps/web/src/components/homepage/CountdownBanner.tsx @@ -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 (
-
+
{value}
- + {label}
@@ -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 ( +
+ + + {/* Hairline divider */} +
+ + {status === "subscribed" || status === "already_subscribed" ? ( +
+ {/* Checkmark glyph */} +
+ + + +
+

+ Herinnering ingepland voor {reminderTime} +

+
+ ) : ( +
+

+ Herinnering ontvangen? +

+ + {/* Fused pill input + button */} +
+ 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 */} +
+ + + + {status === "error" && ( +

+ {errorMessage} +

+ )} +
+ )} +
+ ); +} + /** * 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 ( -
+
-

+

Inschrijvingen openen op

-

+

{openDate} om {openTime}

- {/* Countdown boxes */} -
+ {/* Countdown — single row forced via grid, scales down on small screens */} +
- + : - + : - + :
-

+

Kom snel terug! Zodra de inschrijvingen openen kun je je hier registreren als toeschouwer of als artiest.

+ + {/* Email reminder opt-in — always last */} +
); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 1ca7a8a..84f9305 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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, } diff --git a/apps/web/src/routes/api/cron/reminders.ts b/apps/web/src/routes/api/cron/reminders.ts new file mode 100644 index 0000000..4ee4db4 --- /dev/null +++ b/apps/web/src/routes/api/cron/reminders.ts @@ -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, + }, + }, +}); diff --git a/packages/api/src/email.ts b/packages/api/src/email.ts index 997ec0c..2e96546 100644 --- a/packages/api/src/email.ts +++ b/packages/api/src/email.ts @@ -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 ` + + + + + Nog 1 uur — inschrijvingen openen straks! + + + + + + +
+ + + + + + + + + + + + + +
+

Kunstenkamp

+

Nog 1 uur!

+
+

+ ${greeting} +

+

+ Over ongeveer 1 uur openen de inschrijvingen voor Open Mic Night — vrijdag 18 april 2026. +

+ + + + + +
+

Inschrijvingen openen

+

${openDateStr}

+
+

+ Wees er snel bij — de plaatsen zijn beperkt! +

+ + + + + +
+ + Schrijf je in + +
+

+ Of ga naar: ${registrationUrl} +

+
+

+ Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd.
+ Vragen? Mail ons op info@kunstenkamp.be
+ Kunstenkamp — Een initiatief voor en door kunstenaars. +

+
+
+ +`; +} + +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 }), + }); +} diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index 777f895..a672c26 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -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; + +/** + * 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 }; +} diff --git a/packages/db/local.db b/packages/db/local.db deleted file mode 100644 index 0a06b00940a2e489182e153184a104fe6003c831..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WY8i4w)9wf zEq#`X#qHJD=d1TMd%oE{*YESJpRb$EXpd0{Ab21mY0yt2x*!lafp!f=Ze|8Xz>k1C zGlN1POaXUV423|L0`8n73V|>M^4bn_akfUl&c@q80f9gTirNiybJj+nq&*N22vndf dcjhpKK!^f8np8c+#n~AFJN11^b2xlM;1ykHG4KEY diff --git a/packages/db/local.db-wal b/packages/db/local.db-wal deleted file mode 100644 index 460988c829413a6db7a1be7db557d7c291f725b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 247232 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 c00IagfB*srAb diff --git a/packages/db/src/migrations/0007_reminder.sql b/packages/db/src/migrations/0007_reminder.sql new file mode 100644 index 0000000..af94d29 --- /dev/null +++ b/packages/db/src/migrations/0007_reminder.sql @@ -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`); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index fa2d5be..0a3ed89 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index e6373c7..11148ef 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -2,3 +2,4 @@ export * from "./admin-requests"; export * from "./auth"; export * from "./drinkkaart"; export * from "./registrations"; +export * from "./reminders"; diff --git a/packages/db/src/schema/reminders.ts b/packages/db/src/schema/reminders.ts new file mode 100644 index 0000000..90ca18d --- /dev/null +++ b/packages/db/src/schema/reminders.ts @@ -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; diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index 41a3d43..c85881d 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -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, diff --git a/packages/infra/alchemy.run.ts b/packages/infra/alchemy.run.ts index 87bab0c..6611a3b 100644 --- a/packages/infra/alchemy.run.ts +++ b/packages/infra/alchemy.run.ts @@ -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"], }); diff --git a/turbo.json b/turbo.json index d85764a..f32e48c 100644 --- a/turbo.json +++ b/turbo.json @@ -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,