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 0a06b00..0000000 Binary files a/packages/db/local.db and /dev/null differ diff --git a/packages/db/local.db-shm b/packages/db/local.db-shm deleted file mode 100644 index 5984cc6..0000000 Binary files a/packages/db/local.db-shm and /dev/null differ diff --git a/packages/db/local.db-wal b/packages/db/local.db-wal deleted file mode 100644 index 460988c..0000000 Binary files a/packages/db/local.db-wal and /dev/null differ 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,