From cf47f25a4da8400e26f7f3cbeb99edd18c7d6cdb Mon Sep 17 00:00:00 2001 From: zias Date: Tue, 10 Mar 2026 13:18:30 +0100 Subject: [PATCH] feat: gate registration behind 16 March 2026 19:00 opening date - Add REGISTRATION_OPENS_AT const in lib/opening.ts as single source of truth - Add useRegistrationOpen hook with live 1s countdown ticker - Add CountdownBanner component (DD/HH/MM/SS) with canvas-confetti on open - EventRegistrationForm shows countdown instead of form while closed - Login page hides/disables signup while closed; login always available - WatcherForm AccountModal guards signUp call as defence-in-depth Closes #2 --- apps/web/package.json | 2 + .../components/homepage/CountdownBanner.tsx | 119 ++++++++++++++++++ .../homepage/EventRegistrationForm.tsx | 108 ++++++++-------- .../components/registration/WatcherForm.tsx | 6 + apps/web/src/lib/opening.ts | 5 + apps/web/src/lib/useRegistrationOpen.ts | 45 +++++++ apps/web/src/routes/login.tsx | 72 +++++++++-- bun.lock | 6 + 8 files changed, 305 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/components/homepage/CountdownBanner.tsx create mode 100644 apps/web/src/lib/opening.ts create mode 100644 apps/web/src/lib/useRegistrationOpen.ts diff --git a/apps/web/package.json b/apps/web/package.json index b57df46..599def1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,6 +30,7 @@ "@tanstack/react-start": "^1.141.1", "@tanstack/router-plugin": "^1.141.1", "better-auth": "catalog:", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "catalog:", @@ -56,6 +57,7 @@ "@tanstack/react-router-devtools": "^1.141.1", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", + "@types/canvas-confetti": "^1.9.0", "@types/qrcode": "^1.5.6", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", diff --git a/apps/web/src/components/homepage/CountdownBanner.tsx b/apps/web/src/components/homepage/CountdownBanner.tsx new file mode 100644 index 0000000..2152399 --- /dev/null +++ b/apps/web/src/components/homepage/CountdownBanner.tsx @@ -0,0 +1,119 @@ +import confetti from "canvas-confetti"; +import { useEffect, useRef } from "react"; +import { REGISTRATION_OPENS_AT } from "@/lib/opening"; +import { useRegistrationOpen } from "@/lib/useRegistrationOpen"; + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +interface UnitBoxProps { + value: string; + label: string; +} + +function UnitBox({ value, label }: UnitBoxProps) { + return ( +
+
+ {value} +
+ + {label} + +
+ ); +} + +function fireConfetti() { + const colors = ["#d82560", "#52979b", "#d09035", "#214e51", "#ffffff"]; + + // Three bursts from different origins + confetti({ + particleCount: 120, + spread: 80, + origin: { x: 0.3, y: 0.6 }, + colors, + }); + setTimeout(() => { + confetti({ + particleCount: 120, + spread: 80, + origin: { x: 0.7, y: 0.6 }, + colors, + }); + }, 200); + setTimeout(() => { + confetti({ + particleCount: 80, + spread: 100, + origin: { x: 0.5, y: 0.5 }, + colors, + }); + }, 400); +} + +/** + * Shown in place of the registration form while registration is not yet open. + * Fires confetti the moment the gate opens (without a page reload). + */ +export function CountdownBanner() { + const { isOpen, days, hours, minutes, seconds } = useRegistrationOpen(); + const confettiFired = useRef(false); + + useEffect(() => { + if (isOpen && !confettiFired.current) { + confettiFired.current = true; + fireConfetti(); + } + }, [isOpen]); + + // Once open the parent component will unmount this — but render nothing just in case + if (isOpen) return null; + + const openDate = REGISTRATION_OPENS_AT.toLocaleDateString("nl-BE", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + }); + const openTime = REGISTRATION_OPENS_AT.toLocaleTimeString("nl-BE", { + hour: "2-digit", + minute: "2-digit", + }); + + return ( +
+
+

+ Inschrijvingen openen op +

+

+ {openDate} om {openTime} +

+
+ + {/* Countdown boxes */} +
+ + + : + + + + : + + + + : + + +
+ +

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

+
+ ); +} diff --git a/apps/web/src/components/homepage/EventRegistrationForm.tsx b/apps/web/src/components/homepage/EventRegistrationForm.tsx index 1b45dd3..b996dc9 100644 --- a/apps/web/src/components/homepage/EventRegistrationForm.tsx +++ b/apps/web/src/components/homepage/EventRegistrationForm.tsx @@ -1,10 +1,12 @@ import { Link } from "@tanstack/react-router"; import { useState } from "react"; +import { CountdownBanner } from "@/components/homepage/CountdownBanner"; import { PerformerForm } from "@/components/registration/PerformerForm"; import { SuccessScreen } from "@/components/registration/SuccessScreen"; import { TypeSelector } from "@/components/registration/TypeSelector"; import { WatcherForm } from "@/components/registration/WatcherForm"; import { authClient } from "@/lib/auth-client"; +import { useRegistrationOpen } from "@/lib/useRegistrationOpen"; type RegistrationType = "performer" | "watcher"; @@ -16,6 +18,7 @@ interface SuccessState { export default function EventRegistrationForm() { const { data: session } = authClient.useSession(); + const { isOpen } = useRegistrationOpen(); const [selectedType, setSelectedType] = useState( null, ); @@ -59,55 +62,62 @@ export default function EventRegistrationForm() {

Doe je mee of kom je kijken? Kies je rol en vul het formulier in.

- {/* Login nudge — shown only to guests */} - {!isLoggedIn && ( -
-

- - Al een account? - - Log in om je gegevens automatisch in te vullen en je Drinkkaart - direct te activeren na registratie. -

-
- - Inloggen - - · - - Nog geen account? Aanmaken - -
-
- )} - {!selectedType && } - {selectedType === "performer" && ( - setSelectedType(null)} - onSuccess={(token, email, name) => - setSuccessState({ token, email, name }) - } - prefillFirstName={prefillFirstName} - prefillLastName={prefillLastName} - prefillEmail={prefillEmail} - isLoggedIn={isLoggedIn} - /> - )} - {selectedType === "watcher" && ( - setSelectedType(null)} - prefillFirstName={prefillFirstName} - prefillLastName={prefillLastName} - prefillEmail={prefillEmail} - isLoggedIn={isLoggedIn} - /> + + {!isOpen ? ( + + ) : ( + <> + {/* Login nudge — shown only to guests */} + {!isLoggedIn && ( +
+

+ + Al een account? + + Log in om je gegevens automatisch in te vullen en je + Drinkkaart direct te activeren na registratie. +

+
+ + Inloggen + + · + + Nog geen account? Aanmaken + +
+
+ )} + {!selectedType && } + {selectedType === "performer" && ( + setSelectedType(null)} + onSuccess={(token, email, name) => + setSuccessState({ token, email, name }) + } + prefillFirstName={prefillFirstName} + prefillLastName={prefillLastName} + prefillEmail={prefillEmail} + isLoggedIn={isLoggedIn} + /> + )} + {selectedType === "watcher" && ( + setSelectedType(null)} + prefillFirstName={prefillFirstName} + prefillLastName={prefillLastName} + prefillEmail={prefillEmail} + isLoggedIn={isLoggedIn} + /> + )} + )} diff --git a/apps/web/src/components/registration/WatcherForm.tsx b/apps/web/src/components/registration/WatcherForm.tsx index 60e9639..254c786 100644 --- a/apps/web/src/components/registration/WatcherForm.tsx +++ b/apps/web/src/components/registration/WatcherForm.tsx @@ -12,6 +12,7 @@ import { validatePhone, validateTextField, } from "@/lib/registration"; +import { useRegistrationOpen } from "@/lib/useRegistrationOpen"; import { client, orpc } from "@/utils/orpc"; import { GiftSelector } from "./GiftSelector"; import { GuestList } from "./GuestList"; @@ -49,6 +50,7 @@ function AccountModal({ const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordError, setPasswordError] = useState(); + const { isOpen } = useRegistrationOpen(); const signupMutation = useMutation({ mutationFn: async () => { @@ -83,6 +85,10 @@ function AccountModal({ const handleSignup = (e: React.FormEvent) => { e.preventDefault(); + if (!isOpen) { + toast.error("Registratie is nog niet open"); + return; + } if (password.length < 8) { setPasswordError("Wachtwoord moet minstens 8 tekens zijn"); return; diff --git a/apps/web/src/lib/opening.ts b/apps/web/src/lib/opening.ts new file mode 100644 index 0000000..55efe73 --- /dev/null +++ b/apps/web/src/lib/opening.ts @@ -0,0 +1,5 @@ +/** + * Single source-of-truth for when registration opens. + * Change this date to reschedule — all gating logic imports from here. + */ +export const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00"); diff --git a/apps/web/src/lib/useRegistrationOpen.ts b/apps/web/src/lib/useRegistrationOpen.ts new file mode 100644 index 0000000..6f7791e --- /dev/null +++ b/apps/web/src/lib/useRegistrationOpen.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; +import { REGISTRATION_OPENS_AT } from "./opening"; + +interface RegistrationOpenState { + isOpen: boolean; + days: number; + hours: number; + minutes: number; + seconds: number; +} + +function compute(): RegistrationOpenState { + const diff = REGISTRATION_OPENS_AT.getTime() - Date.now(); + if (diff <= 0) { + return { isOpen: true, days: 0, hours: 0, minutes: 0, seconds: 0 }; + } + const totalSeconds = Math.floor(diff / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return { isOpen: false, days, hours, minutes, seconds }; +} + +/** + * Returns live countdown state to {@link REGISTRATION_OPENS_AT}. + * Ticks every second; clears the interval once registration is open. + */ +export function useRegistrationOpen(): RegistrationOpenState { + const [state, setState] = useState(compute); + + useEffect(() => { + if (state.isOpen) return; + + const id = setInterval(() => { + const next = compute(); + setState(next); + if (next.isOpen) clearInterval(id); + }, 1000); + + return () => clearInterval(id); + }, [state.isOpen]); + + return state; +} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 7b0a2a4..f89b675 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -12,6 +12,8 @@ import { } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; +import { REGISTRATION_OPENS_AT } from "@/lib/opening"; +import { useRegistrationOpen } from "@/lib/useRegistrationOpen"; import { orpc } from "@/utils/orpc"; export const Route = createFileRoute("/login")({ @@ -31,6 +33,7 @@ export const Route = createFileRoute("/login")({ function LoginPage() { const navigate = useNavigate(); const search = Route.useSearch(); + const { isOpen } = useRegistrationOpen(); const [isSignup, setIsSignup] = useState(() => search.signup === "1"); const [email, setEmail] = useState(() => search.email ?? ""); const [password, setPassword] = useState(""); @@ -204,6 +207,43 @@ function LoginPage() { ← Terug naar website + ) : isSignup && !isOpen ? ( + /* Signup is closed until registration opens */ +
+
+

+ Registratie nog niet open +

+

+ Accounts aanmaken kan vanaf{" "} + + {REGISTRATION_OPENS_AT.toLocaleDateString("nl-BE", { + weekday: "long", + day: "numeric", + month: "long", + })}{" "} + om{" "} + {REGISTRATION_OPENS_AT.toLocaleTimeString("nl-BE", { + hour: "2-digit", + minute: "2-digit", + })} + + . +

+
+
+ + + ← Terug naar website + +
+
) : (
{isSignup && ( @@ -271,15 +311,29 @@ function LoginPage() { : "Inloggen"}
- + {/* Only show signup toggle when registration is open */} + {isOpen && ( + + )} + {!isOpen && isSignup === false && ( + + )} ← Terug naar website diff --git a/bun.lock b/bun.lock index 35b55dc..df32358 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "@tanstack/react-start": "^1.141.1", "@tanstack/router-plugin": "^1.141.1", "better-auth": "catalog:", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "catalog:", @@ -68,6 +69,7 @@ "@tanstack/react-router-devtools": "^1.141.1", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", + "@types/canvas-confetti": "^1.9.0", "@types/qrcode": "^1.5.6", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", @@ -909,6 +911,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="], + "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -1027,6 +1031,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + "canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],