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
This commit is contained in:
@@ -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",
|
||||
|
||||
119
apps/web/src/components/homepage/CountdownBanner.tsx
Normal file
119
apps/web/src/components/homepage/CountdownBanner.tsx
Normal file
@@ -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 (
|
||||
<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">
|
||||
{value}
|
||||
</div>
|
||||
<span className="font-['Intro',sans-serif] text-white/50 text-xs uppercase tracking-widest">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-8 py-4 text-center">
|
||||
<div>
|
||||
<p className="font-['Intro',sans-serif] text-lg text-white/70 md:text-xl">
|
||||
Inschrijvingen openen op
|
||||
</p>
|
||||
<p className="mt-1 font-['Intro',sans-serif] text-white text-xl capitalize md:text-2xl">
|
||||
{openDate} om {openTime}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Countdown boxes */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 md:gap-6">
|
||||
<UnitBox value={String(days)} label="dagen" />
|
||||
<span className="mb-6 font-['Intro',sans-serif] text-4xl text-white/40">
|
||||
:
|
||||
</span>
|
||||
<UnitBox value={pad(hours)} label="uren" />
|
||||
<span className="mb-6 font-['Intro',sans-serif] text-4xl text-white/40">
|
||||
:
|
||||
</span>
|
||||
<UnitBox value={pad(minutes)} label="minuten" />
|
||||
<span className="mb-6 font-['Intro',sans-serif] text-4xl text-white/40">
|
||||
:
|
||||
</span>
|
||||
<UnitBox value={pad(seconds)} label="seconden" />
|
||||
</div>
|
||||
|
||||
<p className="max-w-md text-sm text-white/50">
|
||||
Kom snel terug! Zodra de inschrijvingen openen kun je je hier
|
||||
registreren als toeschouwer of als artiest.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<RegistrationType | null>(
|
||||
null,
|
||||
);
|
||||
@@ -59,6 +62,11 @@ export default function EventRegistrationForm() {
|
||||
<p className="mb-8 max-w-3xl text-lg text-white/80 md:text-xl">
|
||||
Doe je mee of kom je kijken? Kies je rol en vul het formulier in.
|
||||
</p>
|
||||
|
||||
{!isOpen ? (
|
||||
<CountdownBanner />
|
||||
) : (
|
||||
<>
|
||||
{/* Login nudge — shown only to guests */}
|
||||
{!isLoggedIn && (
|
||||
<div className="mb-10 flex flex-col gap-3 rounded-lg border border-[#52979b]/40 bg-[#52979b]/10 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -66,8 +74,8 @@ export default function EventRegistrationForm() {
|
||||
<span className="mr-1 font-semibold text-teal-300">
|
||||
Al een account?
|
||||
</span>
|
||||
Log in om je gegevens automatisch in te vullen en je Drinkkaart
|
||||
direct te activeren na registratie.
|
||||
Log in om je gegevens automatisch in te vullen en je
|
||||
Drinkkaart direct te activeren na registratie.
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-3 text-sm">
|
||||
<Link
|
||||
@@ -109,6 +117,8 @@ export default function EventRegistrationForm() {
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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<string | undefined>();
|
||||
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;
|
||||
|
||||
5
apps/web/src/lib/opening.ts
Normal file
5
apps/web/src/lib/opening.ts
Normal file
@@ -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");
|
||||
45
apps/web/src/lib/useRegistrationOpen.ts
Normal file
45
apps/web/src/lib/useRegistrationOpen.ts
Normal file
@@ -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<RegistrationOpenState>(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;
|
||||
}
|
||||
@@ -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
|
||||
</Link>
|
||||
</div>
|
||||
) : isSignup && !isOpen ? (
|
||||
/* Signup is closed until registration opens */
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 p-5 text-center">
|
||||
<p className="mb-1 font-['Intro',sans-serif] text-white">
|
||||
Registratie nog niet open
|
||||
</p>
|
||||
<p className="text-sm text-white/60">
|
||||
Accounts aanmaken kan vanaf{" "}
|
||||
<span className="text-teal-300">
|
||||
{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",
|
||||
})}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSignup(false)}
|
||||
className="text-sm text-white/60 hover:text-white"
|
||||
>
|
||||
Al een account? Log in
|
||||
</button>
|
||||
<Link to="/" className="text-sm text-white/60 hover:text-white">
|
||||
← Terug naar website
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isSignup && (
|
||||
@@ -271,6 +311,8 @@ function LoginPage() {
|
||||
: "Inloggen"}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
{/* Only show signup toggle when registration is open */}
|
||||
{isOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSignup(!isSignup)}
|
||||
@@ -280,6 +322,18 @@ function LoginPage() {
|
||||
? "Al een account? Log in"
|
||||
: "Nog geen account? Registreer"}
|
||||
</button>
|
||||
)}
|
||||
{!isOpen && isSignup === false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSignup(true)}
|
||||
className="cursor-not-allowed text-sm text-white/30"
|
||||
disabled
|
||||
title="Registratie opent op 16 maart om 19:00"
|
||||
>
|
||||
Nog geen account? (opent 16 maart)
|
||||
</button>
|
||||
)}
|
||||
<Link to="/" className="text-sm text-white/60 hover:text-white">
|
||||
← Terug naar website
|
||||
</Link>
|
||||
|
||||
6
bun.lock
6
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=="],
|
||||
|
||||
Reference in New Issue
Block a user