feat: postponed
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user