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:
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,55 +62,62 @@ 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>
|
||||
{/* 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">
|
||||
<p className="text-sm text-white/80">
|
||||
<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.
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-3 text-sm">
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-teal-300 underline-offset-2 transition-colors hover:underline"
|
||||
>
|
||||
Inloggen
|
||||
</Link>
|
||||
<span className="text-white/30">·</span>
|
||||
<Link
|
||||
to="/login"
|
||||
search={{ signup: "1" }}
|
||||
className="text-white/60 transition-colors hover:text-white"
|
||||
>
|
||||
Nog geen account? Aanmaken
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!selectedType && <TypeSelector onSelect={setSelectedType} />}
|
||||
{selectedType === "performer" && (
|
||||
<PerformerForm
|
||||
onBack={() => setSelectedType(null)}
|
||||
onSuccess={(token, email, name) =>
|
||||
setSuccessState({ token, email, name })
|
||||
}
|
||||
prefillFirstName={prefillFirstName}
|
||||
prefillLastName={prefillLastName}
|
||||
prefillEmail={prefillEmail}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
{selectedType === "watcher" && (
|
||||
<WatcherForm
|
||||
onBack={() => setSelectedType(null)}
|
||||
prefillFirstName={prefillFirstName}
|
||||
prefillLastName={prefillLastName}
|
||||
prefillEmail={prefillEmail}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
|
||||
{!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">
|
||||
<p className="text-sm text-white/80">
|
||||
<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.
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-3 text-sm">
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-teal-300 underline-offset-2 transition-colors hover:underline"
|
||||
>
|
||||
Inloggen
|
||||
</Link>
|
||||
<span className="text-white/30">·</span>
|
||||
<Link
|
||||
to="/login"
|
||||
search={{ signup: "1" }}
|
||||
className="text-white/60 transition-colors hover:text-white"
|
||||
>
|
||||
Nog geen account? Aanmaken
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!selectedType && <TypeSelector onSelect={setSelectedType} />}
|
||||
{selectedType === "performer" && (
|
||||
<PerformerForm
|
||||
onBack={() => setSelectedType(null)}
|
||||
onSuccess={(token, email, name) =>
|
||||
setSuccessState({ token, email, name })
|
||||
}
|
||||
prefillFirstName={prefillFirstName}
|
||||
prefillLastName={prefillLastName}
|
||||
prefillEmail={prefillEmail}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
{selectedType === "watcher" && (
|
||||
<WatcherForm
|
||||
onBack={() => setSelectedType(null)}
|
||||
prefillFirstName={prefillFirstName}
|
||||
prefillLastName={prefillLastName}
|
||||
prefillEmail={prefillEmail}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user