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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user