feat: postponed

This commit is contained in:
2026-04-20 21:30:00 +02:00
parent d3a4554b0d
commit 130cb9186d
6 changed files with 225 additions and 17 deletions

View File

@@ -5,8 +5,128 @@ import { CountdownBanner } from "@/components/homepage/CountdownBanner";
import { PerformerForm } from "@/components/registration/PerformerForm";
import { TypeSelector } from "@/components/registration/TypeSelector";
import { WatcherForm } from "@/components/registration/WatcherForm";
import { POSTPONED } from "@/lib/opening";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { orpc } from "@/utils/orpc";
import { client, orpc } from "@/utils/orpc";
function PostponedBanner() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "subscribed" | "error"
>("idle");
const [errorMessage, setErrorMessage] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || status === "loading") return;
setStatus("loading");
try {
await client.subscribeReminder({ email });
setStatus("subscribed");
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : "Er ging iets mis.");
setStatus("error");
}
}
return (
<div className="flex flex-col items-center gap-8 py-4 text-center">
{/* Postponed notice */}
<div className="flex flex-col items-center gap-3">
<div
className="inline-block px-4 py-1 font-['Intro',sans-serif] text-xs uppercase tracking-widest"
style={{ background: "rgba(255,255,255,0.12)", color: "rgba(255,255,255,0.7)" }}
>
Aankondiging
</div>
<h2 className="font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Dit evenement is uitgesteld
</h2>
<p className="max-w-lg text-base text-white/70 md:text-lg">
De Open Mic Night van 24 april gaat helaas niet door op de geplande datum. We werken aan een nieuwe datum en laten je zo snel mogelijk weten wanneer het evenement plaatsvindt.
</p>
</div>
{/* Hairline divider */}
<div
className="h-px w-24"
style={{ background: "linear-gradient(to right, transparent, rgba(255,255,255,0.2), transparent)" }}
/>
{/* Waitlist signup */}
<div className="flex w-full max-w-md flex-col items-center gap-4">
<p className="font-['Intro',sans-serif] text-sm uppercase tracking-widest text-white/50">
Schrijf je in op de wachtlijst
</p>
<p className="text-sm text-white/60">
Ontvang een e-mail zodra de nieuwe datum bekend is.
</p>
{status === "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" }}
>
<style>{`
@keyframes reminderCheck {
from { opacity: 0; transform: scale(0.7); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
<div
className="flex h-10 w-10 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="16" height="16" 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 text-white/70">
Je staat op de wachtlijst! We houden je op de hoogte.
</p>
</div>
) : (
<form
onSubmit={handleSubmit}
className="flex w-full 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-3 text-sm text-white outline-none placeholder:text-white/30 disabled:opacity-50"
style={{ fontFamily: "'Intro', sans-serif", fontSize: "0.85rem" }}
/>
<div style={{ width: "1px", background: "rgba(255,255,255,0.15)", flexShrink: 0 }} />
<button
type="submit"
disabled={status === "loading" || !email}
className="shrink-0 px-5 py-3 font-['Intro',sans-serif] text-xs font-semibold tracking-wide text-white/90 transition-all disabled:opacity-40"
style={{ background: "transparent", cursor: status === "loading" || !email ? "not-allowed" : "pointer", whiteSpace: "nowrap" }}
>
{status === "loading" ? "…" : "Inschrijven"}
</button>
</form>
)}
{status === "error" && (
<p className="font-['Intro',sans-serif] text-xs" style={{ color: "rgba(255,140,140,0.8)" }}>
{errorMessage}
</p>
)}
</div>
</div>
);
}
function fireConfetti() {
const colors = ["#d82560", "#52979b", "#d09035", "#214e51", "#ffffff"];
@@ -53,7 +173,7 @@ export default function EventRegistrationForm() {
const { data: capacity } = useQuery(orpc.getWatcherCapacity.queryOptions());
useEffect(() => {
if (isOpen && !confettiFired.current) {
if (isOpen && !confettiFired.current && !POSTPONED) {
confettiFired.current = true;
fireConfetti();
}
@@ -63,6 +183,19 @@ export default function EventRegistrationForm() {
null,
);
if (POSTPONED) {
return (
<section
id="registration"
className="relative z-30 w-full bg-[#214e51]/96 px-6 py-16 md:px-12"
>
<div className="mx-auto w-full max-w-6xl">
<PostponedBanner />
</div>
</section>
);
}
if (!isOpen) {
return (
<section

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { POSTPONED } from "@/lib/opening";
export default function Hero() {
const [micVisible, setMicVisible] = useState(false);
@@ -82,11 +83,17 @@ export default function Hero() {
{/* Bottom Right - Dark Teal with date - above mic */}
<div className="relative flex flex-1 items-center justify-end bg-[#214e51]">
<p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(2rem,5vw,6rem)] text-white uppercase leading-[1.1]">
VRIJDAG 24
<br />
april
</p>
{POSTPONED ? (
<p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(1.5rem,4vw,4.5rem)] text-white uppercase leading-[1.1]">
UITGESTELD
</p>
) : (
<p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(2rem,5vw,6rem)] text-white uppercase leading-[1.1]">
VRIJDAG 24
<br />
april
</p>
)}
</div>
</div>
@@ -97,7 +104,7 @@ export default function Hero() {
type="button"
className="bg-black px-[40px] py-[10px] font-['Intro',sans-serif] font-normal text-[30px] text-white transition-all hover:scale-105 hover:bg-gray-900"
>
Registreer je nu!
{POSTPONED ? "Schrijf je in op de wachtlijst" : "Registreer je nu!"}
</button>
</div>
</div>
@@ -150,11 +157,17 @@ export default function Hero() {
</p>
</div>
<div className="flex flex-1 flex-col items-end justify-center bg-[#214e51] p-4">
<p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight">
VRIJDAG
<br />
24 april
</p>
{POSTPONED ? (
<p className="text-right font-['Intro',sans-serif] font-normal text-[6vw] text-white uppercase leading-tight">
UITGESTELD
</p>
) : (
<p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight">
VRIJDAG
<br />
24 april
</p>
)}
</div>
</div>
</div>
@@ -166,7 +179,7 @@ export default function Hero() {
type="button"
className="w-full bg-black py-3 font-['Intro',sans-serif] font-normal text-lg text-white transition-all hover:scale-105 hover:bg-gray-900"
>
Registreer je nu!
{POSTPONED ? "Schrijf je in op de wachtlijst" : "Registreer je nu!"}
</button>
</div>
</div>

View File

@@ -10,3 +10,9 @@ export const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00");
* Set to `true` to enforce the countdown until REGISTRATION_OPENS_AT.
*/
export const COUNTDOWN_ENABLED = true;
/**
* Set to `true` when the event has been postponed.
* Hides the registration form and shows a waitlist signup instead.
*/
export const POSTPONED = true;

View File

@@ -181,6 +181,7 @@ function AdminPage() {
}),
);
const adminRequestsQuery = useQuery(orpc.getAdminRequests.queryOptions());
const waitlistQuery = useQuery(orpc.getWaitlistSubscribers.queryOptions());
const exportMutation = useMutation({
...orpc.exportRegistrations.mutationOptions(),
@@ -553,6 +554,46 @@ function AdminPage() {
/>
</div>
{/* ── Wachtlijst ── */}
{waitlistQuery.data && waitlistQuery.data.length > 0 && (
<div className="rounded-xl border border-white/8 bg-white/3 p-4">
<div className="mb-3 flex items-center gap-2">
<p className="font-mono text-[10px] text-white/40 uppercase tracking-widest">
Wachtlijst
</p>
<span className="rounded-full bg-white/10 px-2 py-0.5 font-mono text-[10px] text-white/60">
{waitlistQuery.data.length}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/8 text-left">
<th className="pb-2 font-mono text-[10px] text-white/30 uppercase tracking-widest">E-mail</th>
<th className="pb-2 font-mono text-[10px] text-white/30 uppercase tracking-widest">Ingeschreven op</th>
</tr>
</thead>
<tbody>
{waitlistQuery.data.map((row) => (
<tr key={row.id} className="border-b border-white/5 last:border-0">
<td className="py-2 pr-8 font-mono text-xs text-white/70">{row.email}</td>
<td className="py-2 font-mono text-xs text-white/40">
{new Date(row.createdAt).toLocaleString("nl-BE", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ── Filters + export row ── */}
<div className="rounded-xl border border-white/8 bg-white/3 p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-6">

View File

@@ -8,3 +8,10 @@ export const OPENS = "maandag 16 maart 2026 om 19:00";
// Registration opens — used for reminder scheduling windows
export const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00");
/**
* Set to `true` when the event has been postponed.
* Allows waitlist email subscriptions even after the original registration
* open date has passed.
*/
export const POSTPONED = true;

View File

@@ -18,7 +18,7 @@ import {
sum,
} from "drizzle-orm";
import { z } from "zod";
import { REGISTRATION_OPENS_AT } from "../constants";
import { POSTPONED, REGISTRATION_OPENS_AT } from "../constants";
import {
emailLog,
sendCancellationEmail,
@@ -902,6 +902,13 @@ export const appRouter = {
return { success: true, message: "Admin toegang goedgekeurd" };
}),
getWaitlistSubscribers: adminProcedure.handler(async () => {
return db
.select()
.from(reminder)
.orderBy(desc(reminder.createdAt));
}),
rejectAdminRequest: adminProcedure
.input(z.object({ requestId: z.string() }))
.handler(async ({ input, context }) => {
@@ -943,8 +950,9 @@ export const appRouter = {
.handler(async ({ input, context }) => {
const now = Date.now();
// Registration is already open — no point subscribing
if (now >= REGISTRATION_OPENS_AT.getTime()) {
// Registration is already open — no point subscribing, unless the event
// is postponed (in which case we collect waitlist signups instead).
if (now >= REGISTRATION_OPENS_AT.getTime() && !POSTPONED) {
return { ok: false, reason: "already_open" as const };
}