Compare commits

..

7 Commits

Author SHA1 Message Date
9502fec01f chore: fmt 2026-04-20 21:35:20 +02:00
ee77644d83 fix: deps 2026-04-20 21:34:58 +02:00
130cb9186d feat: postponed 2026-04-20 21:30:00 +02:00
d3a4554b0d fix: links 2026-04-06 18:05:29 +02:00
71b85d6874 chore: fmt 2026-04-01 14:42:24 +02:00
7b3d5461ef feat: kamp page 2026-04-01 14:18:07 +02:00
56942275b8 feat: add homepage selector 2026-03-29 16:28:12 +02:00
12 changed files with 934 additions and 30 deletions

View File

@@ -34,6 +34,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "catalog:",
"drizzle-orm": "^0.45.1",
"html5-qrcode": "^2.3.8",
"libsql": "catalog:",
"lucide-react": "^0.525.0",

View File

@@ -8,11 +8,14 @@ import { Header } from "./Header";
export function SiteHeader() {
const { data: session } = authClient.useSession();
const routerState = useRouterState();
const isHomepage = routerState.location.pathname === "/";
const pathname = routerState.location.pathname;
const isHomepage = pathname === "/";
const isKamp = pathname === "/kamp";
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (isKamp) return;
if (!isHomepage) {
setIsVisible(true);
return;
@@ -25,7 +28,9 @@ export function SiteHeader() {
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [isHomepage]);
}, [isHomepage, isKamp]);
if (isKamp) return null;
if (!session?.user) {
return <Header isGuest isVisible={isVisible} isHomepage={isHomepage} />;

View File

@@ -5,8 +5,169 @@ 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 text-white/50 uppercase tracking-widest">
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 text-white/70 tracking-wide">
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] font-semibold text-white/90 text-xs tracking-wide 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 +214,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 +224,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

@@ -13,6 +13,7 @@ import { Route as TermsRouteImport } from './routes/terms'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as LoginRouteImport } from './routes/login'
import { Route as KampRouteImport } from './routes/kamp'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as DrinkkaartRouteImport } from './routes/drinkkaart'
import { Route as ContactRouteImport } from './routes/contact'
@@ -46,6 +47,11 @@ const LoginRoute = LoginRouteImport.update({
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const KampRoute = KampRouteImport.update({
id: '/kamp',
path: '/kamp',
getParentRoute: () => rootRouteImport,
} as any)
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
id: '/forgot-password',
path: '/forgot-password',
@@ -113,6 +119,7 @@ export interface FileRoutesByFullPath {
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/kamp': typeof KampRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
@@ -131,6 +138,7 @@ export interface FileRoutesByTo {
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/kamp': typeof KampRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
@@ -150,6 +158,7 @@ export interface FileRoutesById {
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/kamp': typeof KampRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
@@ -170,6 +179,7 @@ export interface FileRouteTypes {
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/kamp'
| '/login'
| '/privacy'
| '/reset-password'
@@ -188,6 +198,7 @@ export interface FileRouteTypes {
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/kamp'
| '/login'
| '/privacy'
| '/reset-password'
@@ -206,6 +217,7 @@ export interface FileRouteTypes {
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/kamp'
| '/login'
| '/privacy'
| '/reset-password'
@@ -225,6 +237,7 @@ export interface RootRouteChildren {
ContactRoute: typeof ContactRoute
DrinkkaartRoute: typeof DrinkkaartRoute
ForgotPasswordRoute: typeof ForgotPasswordRoute
KampRoute: typeof KampRoute
LoginRoute: typeof LoginRoute
PrivacyRoute: typeof PrivacyRoute
ResetPasswordRoute: typeof ResetPasswordRoute
@@ -268,6 +281,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/kamp': {
id: '/kamp'
path: '/kamp'
fullPath: '/kamp'
preLoaderRoute: typeof KampRouteImport
parentRoute: typeof rootRouteImport
}
'/forgot-password': {
id: '/forgot-password'
path: '/forgot-password'
@@ -361,6 +381,7 @@ const rootRouteChildren: RootRouteChildren = {
ContactRoute: ContactRoute,
DrinkkaartRoute: DrinkkaartRoute,
ForgotPasswordRoute: ForgotPasswordRoute,
KampRoute: KampRoute,
LoginRoute: LoginRoute,
PrivacyRoute: PrivacyRoute,
ResetPasswordRoute: ResetPasswordRoute,

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,55 @@ 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-white/8 border-b 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-white/5 border-b last:border-0"
>
<td className="py-2 pr-8 font-mono text-white/70 text-xs">
{row.email}
</td>
<td className="py-2 font-mono text-white/40 text-xs">
{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

@@ -1,24 +1,172 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import EventRegistrationForm from "@/components/homepage/EventRegistrationForm";
import Footer from "@/components/homepage/Footer";
import Hero from "@/components/homepage/Hero";
import HoeInschrijven from "@/components/homepage/HoeInschrijven";
import Info from "@/components/homepage/Info";
const KAMP_BANNER_KEY = "kk_kamp_banner_dismissed";
export const Route = createFileRoute("/")({
component: HomePage,
});
function HomePage() {
function KampBanner({ onDismiss }: { onDismiss: () => void }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!document.getElementById("kamp-banner-font")) {
const el = document.createElement("link");
el.id = "kamp-banner-font";
el.rel = "stylesheet";
el.href =
"https://fonts.googleapis.com/css2?family=Special+Elite&display=swap";
document.head.appendChild(el);
}
const t = setTimeout(() => setVisible(true), 600);
return () => clearTimeout(t);
}, []);
const handleDismiss = () => {
setVisible(false);
setTimeout(onDismiss, 300);
};
return (
<div className="relative">
<main className="relative">
<Hero />
<Info />
<HoeInschrijven />
<EventRegistrationForm />
<Footer />
</main>
</div>
<header
style={{
position: "fixed",
bottom: "1.25rem",
left: "50%",
transform: visible
? "translateX(-50%) translateY(0)"
: "translateX(-50%) translateY(120%)",
transition: "transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)",
zIndex: 50,
width: "min(calc(100vw - 2rem), 480px)",
}}
>
<div
style={{
background: "#ede4c8",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.06'/%3E%3C/svg%3E")`,
border: "1px solid #12100e",
outline: "3px solid rgba(18,16,14,0.1)",
outlineOffset: "3px",
borderRadius: 0,
padding: "12px 14px 14px",
boxShadow: "3px 5px 20px rgba(0,0,0,0.25)",
}}
>
{/* Top double rule */}
<div style={{ marginBottom: "10px" }}>
<div
style={{
height: "2px",
background: "#12100e",
marginBottom: "3px",
}}
/>
<div style={{ height: "1px", background: "rgba(18,16,14,0.35)" }} />
</div>
<div style={{ display: "flex", alignItems: "flex-start", gap: "10px" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<p
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "8px",
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "rgba(18,16,14,0.45)",
marginBottom: "6px",
}}
>
Aankondiging · Zomerkamp 2026
</p>
<p
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 700,
fontSize: "14px",
color: "#12100e",
lineHeight: 1.35,
marginBottom: "12px",
}}
>
Inschrijvingen voor de zomerkampen zijn open!
</p>
<Link
to="/kamp"
style={{
display: "inline-block",
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.22em",
textTransform: "uppercase",
textDecoration: "none",
color: "#ede4c8",
background: "#12100e",
border: "1px solid rgba(18,16,14,0.4)",
outline: "2px solid rgba(18,16,14,0.08)",
outlineOffset: "3px",
padding: "7px 12px 6px",
}}
>
Schrijf je in
</Link>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Sluit deze melding"
style={{
background: "none",
border: "none",
color: "rgba(18,16,14,0.3)",
cursor: "pointer",
padding: "2px",
flexShrink: 0,
fontSize: "18px",
lineHeight: 1,
fontFamily: "'Special Elite', cursive",
}}
>
×
</button>
</div>
</div>
</header>
);
}
function HomePage() {
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
if (!localStorage.getItem(KAMP_BANNER_KEY)) {
setShowBanner(true);
}
}, []);
const dismissBanner = () => {
localStorage.setItem(KAMP_BANNER_KEY, "1");
setShowBanner(false);
};
return (
<>
<div className="relative">
<main className="relative">
<Hero />
<Info />
<HoeInschrijven />
<EventRegistrationForm />
<Footer />
</main>
</div>
{showBanner && <KampBanner onDismiss={dismissBanner} />}
</>
);
}

View File

@@ -0,0 +1,473 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
const KAMP_URL = "https://ejv.be/jong/kampen/kunstenkamp/";
export const Route = createFileRoute("/kamp")({
component: KampPage,
});
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=UnifrakturMaguntia&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400;1,700&family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Special+Elite&display=swap');
@keyframes kampPressIn {
from { opacity: 0; }
to { opacity: 1; }
}
body.kamp-page {
background-color: #d6cdb0 !important;
overflow: hidden;
}
body.kamp-page ::selection {
background: #12100e;
color: #ede4c8;
}
.kamp-scroll::-webkit-scrollbar {
width: 6px;
}
.kamp-scroll::-webkit-scrollbar-track {
background: #c9c0a4;
}
.kamp-scroll::-webkit-scrollbar-thumb {
background: #12100e;
}
`;
const PAPER = "#ede4c8";
const INK = "#12100e";
const INK_MID = "rgba(18,16,14,0.5)";
const INK_GHOST = "rgba(18,16,14,0.2)";
const RULE_W = "rgba(18,16,14,0.75)";
function TripleRule() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "3px" }}>
<div style={{ height: "3px", background: INK }} />
<div style={{ height: "1px", background: RULE_W }} />
<div style={{ height: "2px", background: INK }} />
</div>
);
}
function DoubleRule() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "3px" }}>
<div style={{ height: "2px", background: RULE_W }} />
<div style={{ height: "1px", background: RULE_W }} />
</div>
);
}
function KampPage() {
const [mounted, setMounted] = useState(false);
const [ctaHovered, setCtaHovered] = useState(false);
useEffect(() => {
if (!document.getElementById("kamp-newspaper-styles")) {
const el = document.createElement("style");
el.id = "kamp-newspaper-styles";
el.textContent = STYLES;
document.head.appendChild(el);
}
document.body.classList.add("kamp-page");
const t = setTimeout(() => setMounted(true), 60);
return () => {
document.body.classList.remove("kamp-page");
clearTimeout(t);
};
}, []);
return (
<div
style={{
height: "100dvh",
background: "#d6cdb0",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='400' height='400' filter='url(%23n)' opacity='0.12'/%3E%3C/svg%3E")`,
backgroundRepeat: "repeat",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem 1rem",
}}
>
{/* Newspaper page */}
<article
className="kamp-scroll"
style={{
width: "100%",
maxWidth: "700px",
height: "100%",
background: PAPER,
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.06'/%3E%3C/svg%3E")`,
border: `1px solid ${INK}`,
boxShadow:
"3px 5px 24px rgba(0,0,0,0.18), 0 1px 3px rgba(0,0,0,0.12)",
opacity: mounted ? 1 : 0,
animation: mounted ? "kampPressIn 0.9s ease both" : undefined,
overflowY: "auto",
}}
>
{/* Outer decorative border inset */}
<div
style={{
margin: "6px",
border: `1px solid ${INK}`,
}}
>
{/* Top flag strip */}
<div
style={{
borderBottom: `1px solid ${INK}`,
padding: "7px 20px 6px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Link
to="/"
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "10px",
letterSpacing: "0.18em",
color: INK,
textDecoration: "none",
borderBottom: `1px solid ${INK}`,
paddingBottom: "1px",
}}
>
Open Mic Night
</Link>
<span
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.18em",
color: INK_MID,
}}
>
· ·
</span>
<span
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.22em",
textTransform: "uppercase",
color: INK_MID,
}}
>
Anno 2026
</span>
</div>
{/* Masthead */}
<div
style={{
padding: "20px 24px 16px",
textAlign: "center",
borderBottom: `1px solid ${INK}`,
}}
>
<h1
style={{
fontFamily: "'UnifrakturMaguntia', cursive",
fontSize: "clamp(2.4rem, 9vw, 4.5rem)",
color: INK,
margin: 0,
lineHeight: 1,
letterSpacing: "0.02em",
}}
>
De Kunstenkamp Gazet
</h1>
</div>
{/* Publication bar */}
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: "6px 20px 5px",
borderBottom: `1px solid ${INK}`,
}}
>
{[
"Vol. CCXXVI",
"Zomerkamp · Juli 2026",
"Prijs: uw aanwezigheid",
].map((t) => (
<span
key={t}
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "10px",
color: INK_MID,
letterSpacing: "0.1em",
}}
>
{t}
</span>
))}
</div>
{/* Main content area */}
<div style={{ padding: "28px 28px 24px" }}>
{/* Triple rule */}
<div style={{ marginBottom: "24px" }}>
<TripleRule />
</div>
{/* Main headline */}
<div style={{ textAlign: "center", marginBottom: "16px" }}>
<h2
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 900,
fontSize: "clamp(2.6rem, 9vw, 4.5rem)",
color: INK,
textTransform: "uppercase",
letterSpacing: "0.06em",
lineHeight: 0.95,
margin: "0 0 14px",
}}
>
Wat Is Waar?
</h2>
<p
style={{
fontFamily: "'Playfair Display', serif",
fontStyle: "italic",
fontSize: "clamp(1rem, 2.5vw, 1.2rem)",
color: INK_MID,
margin: 0,
lineHeight: 1.45,
}}
>
Een krant. Een tijdmachine. De waarheid doorheen de eeuwen.
</p>
</div>
{/* Triple rule */}
<div style={{ marginBottom: "22px" }}>
<TripleRule />
</div>
{/* Editorial body copy */}
<p
style={{
fontFamily: "'EB Garamond', serif",
fontSize: "clamp(1.05rem, 2.2vw, 1.2rem)",
color: INK,
lineHeight: 1.75,
margin: "0 0 10px",
textAlign: "justify",
hyphens: "auto",
}}
>
In de zomer van 1826 en opnieuw in 2026 reizen onze
verslaggevers terug naar de roerige redactiezalen van de
negentiende eeuw. Waar de drukpers ronkt, de rookmachines tieren
en elke kop een mening verbergt, stellen wij de vraag die door
alle eeuwen galmt: <em>wat mogen wij geloven?</em>
</p>
<p
style={{
fontFamily: "'EB Garamond', serif",
fontStyle: "italic",
fontSize: "clamp(0.95rem, 2vw, 1.08rem)",
color: INK_MID,
lineHeight: 1.7,
margin: "0 0 24px",
textAlign: "justify",
hyphens: "auto",
}}
>
Twee waarheden en een leugen. Vier bolhoeden en één typmachine.
Bereid u voor op een week journalistiek, theater, dans en
woordkunst alles gehuld in inkt en papier-maché.
</p>
{/* Ornamental divider */}
<p
style={{
textAlign: "center",
fontFamily: "'EB Garamond', serif",
fontSize: "13px",
color: INK_GHOST,
margin: "0 0 24px",
letterSpacing: "0.4em",
}}
>
· ·
</p>
{/* Details grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
border: `1px solid ${INK}`,
marginBottom: "28px",
}}
>
{[
{ label: "Primo", value: "20 25 Juli 2026" },
{ label: "Secundo", value: "27 Juli 1 Aug 2026" },
{
label: "Leeftijd",
value: "9 18 jaar",
sub: "* geboortejaar dat je 10 wordt",
},
{
label: "Locatie",
value: "Camp de Limauges",
sub: "Ceroux-Mousty · België",
},
].map(({ label, value, sub }, i) => (
<div
key={label}
style={{
padding: "16px 18px",
borderRight: i % 2 === 0 ? `1px solid ${INK}` : undefined,
borderTop: i >= 2 ? `1px solid ${INK}` : undefined,
}}
>
<p
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.3em",
textTransform: "uppercase",
color: INK_MID,
margin: "0 0 6px",
}}
>
{label}
</p>
<p
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 700,
fontSize: "clamp(1rem, 2.5vw, 1.15rem)",
color: INK,
margin: 0,
lineHeight: 1.3,
}}
>
{value}
</p>
{sub && (
<p
style={{
fontFamily: "'EB Garamond', serif",
fontStyle: "italic",
fontSize: "13px",
color: INK_MID,
margin: "4px 0 0",
}}
>
{sub}
</p>
)}
</div>
))}
</div>
{/* Double rule */}
<div style={{ marginBottom: "24px" }}>
<DoubleRule />
</div>
{/* CTA — inverted ink block */}
<div style={{ marginBottom: "4px" }}>
<p
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.35em",
textTransform: "uppercase",
color: INK_MID,
textAlign: "center",
margin: "0 0 10px",
}}
>
Aankondiging
</p>
<a
href={KAMP_URL}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setCtaHovered(true)}
onMouseLeave={() => setCtaHovered(false)}
style={{
display: "block",
background: ctaHovered ? "#2a2418" : INK,
color: PAPER,
textDecoration: "none",
textAlign: "center",
padding: "28px 32px 26px",
outline: `2px solid ${INK}`,
outlineOffset: "4px",
transition: "background 0.2s ease",
}}
>
<span
style={{
display: "block",
fontFamily: "'Playfair Display', serif",
fontWeight: 900,
fontSize: "clamp(1.5rem, 5vw, 2.4rem)",
textTransform: "uppercase",
letterSpacing: "0.1em",
lineHeight: 1,
marginBottom: "10px",
}}
>
Schrijf U In
</span>
<span
style={{
display: "block",
fontFamily: "'Special Elite', cursive",
fontSize: "clamp(0.7rem, 2vw, 0.85rem)",
letterSpacing: "0.25em",
opacity: 0.65,
}}
>
via ejv.be
</span>
</a>
</div>
</div>
{/* Footer */}
<div
style={{
borderTop: `1px solid ${INK}`,
padding: "7px 20px 8px",
textAlign: "center",
}}
>
<p
style={{
fontFamily: "'EB Garamond', serif",
fontStyle: "italic",
fontSize: "12px",
color: INK_GHOST,
margin: 0,
letterSpacing: "0.06em",
}}
>
Kunst · Expressie · Avontuur · Waar is de waarheid?
</p>
</div>
</div>
</article>
</div>
);
}

View File

@@ -46,6 +46,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "catalog:",
"drizzle-orm": "^0.45.1",
"html5-qrcode": "^2.3.8",
"libsql": "catalog:",
"lucide-react": "^0.525.0",

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,10 @@ 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 +947,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 };
}