feat:UX and fix drinkkaart payment logic

This commit is contained in:
2026-03-04 14:22:42 +01:00
parent 79869a9a21
commit f9d79c3879
26 changed files with 1432 additions and 283 deletions

View File

@@ -1,13 +1,24 @@
import { Link, useNavigate } from "@tanstack/react-router";
import { LogOut } from "lucide-react";
import { LogOut, User } from "lucide-react";
import { authClient } from "@/lib/auth-client";
interface HeaderProps {
userName?: string;
isAdmin?: boolean;
/** When true, the user is not authenticated — show login/signup CTAs */
isGuest?: boolean;
isVisible?: boolean;
/** When true, uses fixed positioning (for homepage scroll behavior) */
isHomepage?: boolean;
}
export function Header({ userName, isAdmin }: HeaderProps) {
export function Header({
userName,
isAdmin,
isGuest,
isVisible,
isHomepage,
}: HeaderProps) {
const navigate = useNavigate();
const handleSignOut = async () => {
@@ -15,25 +26,29 @@ export function Header({ userName, isAdmin }: HeaderProps) {
navigate({ to: "/" });
};
if (!userName) return null;
return (
<header className="border-white/10 border-b bg-[#214e51]/95 backdrop-blur-sm">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-3">
<nav className="flex items-center gap-6">
<header
className={`border-white/10 border-b bg-[#214e51]/95 backdrop-blur-sm transition-transform duration-300 ${
isVisible ? "translate-y-0" : "-translate-y-full"
} ${isHomepage ? "fixed" : "sticky"} top-0 right-0 left-0 z-50`}
>
<div className="mx-auto flex max-w-8xl items-center justify-between px-3 py-2 sm:px-6 sm:py-3">
<nav className="flex items-center gap-3 sm:gap-6">
<Link
to="/"
className="font-['Intro',sans-serif] text-lg text-white/80 transition-opacity hover:opacity-100"
className="font-['Intro',sans-serif] text-base text-white/80 transition-opacity hover:opacity-100 sm:text-lg"
>
Kunstenkamp
</Link>
{!isGuest && (
<>
<Link
to="/drinkkaart"
search={{ topup: undefined }}
to="/account"
className="text-sm text-white/70 transition-colors hover:text-white"
activeProps={{ className: "text-white font-medium" }}
>
Drinkkaart
<span className="hidden sm:inline">Mijn Account</span>
<User className="h-4 w-4 sm:hidden" />
</Link>
{isAdmin && (
<Link
@@ -44,19 +59,45 @@ export function Header({ userName, isAdmin }: HeaderProps) {
Admin
</Link>
)}
</>
)}
</nav>
<div className="flex items-center gap-3">
<span className="text-sm text-white/60">{userName}</span>
{isGuest ? (
<div className="flex items-center gap-2 sm:gap-3">
<Link
to="/login"
className="text-sm text-white/70 transition-colors hover:text-white"
>
<span className="hidden sm:inline">Inloggen</span>
<span className="sm:hidden">Login</span>
</Link>
<span className="hidden text-white/20 sm:inline">·</span>
<Link
to="/login"
search={{ signup: "1" }}
className="rounded bg-white/10 px-2 py-1 text-white/80 text-xs transition-colors hover:bg-white/20 hover:text-white sm:px-3 sm:py-1.5 sm:text-sm"
>
<span className="hidden sm:inline">Account aanmaken</span>
<span className="sm:hidden">Registreer</span>
</Link>
</div>
) : (
<div className="flex items-center gap-2 sm:gap-3">
<span className="max-w-[80px] truncate text-white/60 text-xs sm:max-w-none sm:text-sm">
{userName}
</span>
<button
type="button"
onClick={handleSignOut}
className="flex items-center gap-1.5 rounded px-2 py-1 text-sm text-white/50 transition-colors hover:bg-white/10 hover:text-white"
className="flex items-center gap-1 rounded px-1.5 py-1 text-white/50 transition-colors hover:bg-white/10 hover:text-white sm:px-2"
title="Uitloggen"
>
<LogOut className="h-3.5 w-3.5" />
Uitloggen
<LogOut className="h-4 w-4" />
<span className="hidden text-xs sm:inline">Uitloggen</span>
</button>
</div>
)}
</div>
</header>
);

View File

@@ -1,18 +1,43 @@
"use client";
import { useRouterState } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { authClient } from "@/lib/auth-client";
import { Header } from "./Header";
/**
* Client-side header wrapper.
* Reads the current session and renders <Header> only when the user is logged in.
* Rendered in __root.tsx so it appears on every page.
*/
export function SiteHeader() {
const { data: session } = authClient.useSession();
const routerState = useRouterState();
const isHomepage = routerState.location.pathname === "/";
if (!session?.user) return null;
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!isHomepage) {
setIsVisible(true);
return;
}
const handleScroll = () => {
setIsVisible(window.scrollY > 50);
};
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [isHomepage]);
if (!session?.user) {
return <Header isGuest isVisible={isVisible} isHomepage={isHomepage} />;
}
const user = session.user as { name: string; role?: string };
return <Header userName={user.name} isAdmin={user.role === "admin"} />;
return (
<Header
userName={user.name}
isAdmin={user.role === "admin"}
isVisible={isVisible}
isHomepage={isHomepage}
/>
);
}

View File

@@ -1,23 +1,43 @@
import { Link } from "@tanstack/react-router";
import { useState } from "react";
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";
type RegistrationType = "performer" | "watcher";
interface SuccessState {
token: string;
email: string;
name: string;
}
export default function EventRegistrationForm() {
const { data: session } = authClient.useSession();
const [selectedType, setSelectedType] = useState<RegistrationType | null>(
null,
);
const [successToken, setSuccessToken] = useState<string | null>(null);
const [successState, setSuccessState] = useState<SuccessState | null>(null);
if (successToken) {
const isLoggedIn = !!session?.user;
const user = session?.user as { name?: string; email?: string } | undefined;
// Split "Jan De Smet" → firstName="Jan", lastName="De Smet"
const nameParts = (user?.name ?? "").trim().split(/\s+/);
const prefillFirstName = nameParts[0] ?? "";
const prefillLastName = nameParts.slice(1).join(" ");
const prefillEmail = user?.email ?? "";
if (successState) {
return (
<SuccessScreen
token={successToken}
token={successState.token}
email={successState.email}
name={successState.name}
onReset={() => {
setSuccessToken(null);
setSuccessState(null);
setSelectedType(null);
}}
/>
@@ -33,21 +53,62 @@ export default function EventRegistrationForm() {
<h2 className="mb-2 font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Schrijf je nu in!
</h2>
<p className="mb-12 max-w-3xl text-lg text-white/80 md:text-xl">
<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={setSuccessToken}
onSuccess={(token, email, name) =>
setSuccessState({ token, email, name })
}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/>
)}
{selectedType === "watcher" && (
<WatcherForm onBack={() => setSelectedType(null)} />
<WatcherForm
onBack={() => setSelectedType(null)}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/>
)}
</div>
</section>

View File

@@ -31,21 +31,21 @@ export default function Footer() {
Waar creativiteit tot leven komt
</p>
<div className="flex items-center justify-center gap-8 text-sm text-white/70">
<div className="flex flex-col items-center justify-center gap-2 text-sm text-white/70 md:flex-row md:gap-8">
<a
href="/privacy"
className="link-hover transition-colors hover:text-white"
>
Privacy Beleid
</a>
<span className="text-white/40">|</span>
<span className="hidden text-white/40 md:inline">|</span>
<a
href="/terms"
className="link-hover transition-colors hover:text-white"
>
Algemene Voorwaarden
</a>
<span className="text-white/40">|</span>
<span className="hidden text-white/40 md:inline">|</span>
<a
href="/contact"
className="link-hover transition-colors hover:text-white"
@@ -54,7 +54,7 @@ export default function Footer() {
</a>
{!isLoading && isAdmin && (
<>
<span className="text-white/40">|</span>
<span className="hidden text-white/40 md:inline">|</span>
<Link
to="/admin"
className="link-hover transition-colors hover:text-white"

View File

@@ -88,9 +88,15 @@ export default function Info() {
</div>
{/* Main Title */}
<h2 className="font-['Intro',sans-serif] font-black text-6xl text-white uppercase tracking-tight md:text-8xl lg:text-9xl">
<h2
className="font-['Intro',sans-serif] font-black text-white uppercase tracking-tight"
style={{ fontSize: "clamp(1rem, 8vw + 1rem, 8rem)" }}
>
Ongedesemd
<span className="block text-5xl md:text-7xl lg:text-8xl">
<span
className="block"
style={{ fontSize: "clamp(1rem, 6vw + 1rem, 6rem)" }}
>
Brood?!
</span>
</h2>
@@ -110,8 +116,16 @@ export default function Info() {
</div>
{/* Content Section */}
<div className="w-full px-12 py-16">
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col justify-center gap-16">
<div className="relative w-full px-12 py-16">
{/* Background texture */}
<div
className="pointer-events-none absolute inset-0 opacity-10"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}}
/>
<div className="relative mx-auto flex w-full max-w-6xl flex-1 flex-col justify-center gap-16">
{/* Ongedesemd Brood Explanation - Full Width Special Treatment */}
<div className="relative flex flex-col gap-6 rounded-2xl border-2 border-white/20 bg-white/5 p-8 md:p-12">
<div className="absolute top-0 -left-4 h-full w-1 bg-gradient-to-b from-white/0 via-white/60 to-white/0" />

View File

@@ -21,14 +21,25 @@ interface PerformerErrors {
interface Props {
onBack: () => void;
onSuccess: (token: string) => void;
onSuccess: (token: string, email: string, name: string) => void;
prefillFirstName?: string;
prefillLastName?: string;
prefillEmail?: string;
isLoggedIn?: boolean;
}
export function PerformerForm({ onBack, onSuccess }: Props) {
export function PerformerForm({
onBack,
onSuccess,
prefillFirstName = "",
prefillLastName = "",
prefillEmail = "",
isLoggedIn = false,
}: Props) {
const [data, setData] = useState({
firstName: "",
lastName: "",
email: "",
firstName: prefillFirstName,
lastName: prefillLastName,
email: prefillEmail,
phone: "",
artForm: "",
experience: "",
@@ -42,7 +53,12 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(),
onSuccess: (result) => {
if (result.managementToken) onSuccess(result.managementToken);
if (result.managementToken)
onSuccess(
result.managementToken,
data.email.trim(),
`${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
);
},
onError: (error) => {
toast.error(`Er is iets misgegaan: ${error.message}`);
@@ -249,14 +265,15 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
id="p-email"
name="email"
value={data.email}
onChange={handleChange}
onBlur={handleBlur}
onChange={isLoggedIn ? undefined : handleChange}
onBlur={isLoggedIn ? undefined : handleBlur}
readOnly={isLoggedIn}
placeholder="jouw@email.be"
autoComplete="email"
inputMode="email"
aria-required="true"
aria-invalid={touched.email && !!errors.email}
className={inputCls(!!touched.email && !!errors.email)}
className={`${inputCls(!!touched.email && !!errors.email)}${isLoggedIn ? "cursor-not-allowed opacity-60" : ""}`}
/>
{touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert">

View File

@@ -2,10 +2,12 @@ import { useState } from "react";
interface Props {
token: string;
email?: string;
name?: string;
onReset: () => void;
}
export function SuccessScreen({ token, onReset }: Props) {
export function SuccessScreen({ token, email, name, onReset }: Props) {
const manageUrl =
typeof window !== "undefined"
? `${window.location.origin}/manage/${token}`
@@ -33,6 +35,11 @@ export function SuccessScreen({ token, onReset }: Props) {
setDrinkkaartPromptDismissed(true);
};
// Build signup URL with pre-filled email query param so /login can pre-fill
const signupUrl = email
? `/login?signup=1&email=${encodeURIComponent(email)}`
: "/login?signup=1";
return (
<section
id="registration"
@@ -90,27 +97,46 @@ export function SuccessScreen({ token, onReset }: Props) {
</button>
</div>
{/* Drinkkaart account prompt */}
{/* Account creation prompt */}
{!drinkkaartPromptDismissed && (
<div className="mt-8 rounded-lg border border-white/20 bg-white/10 p-6">
<h3 className="mb-2 font-['Intro',sans-serif] text-lg text-white">
Wil je een Drinkkaart?
<div className="mt-8 rounded-xl border border-teal-400/30 bg-teal-400/10 p-6">
<h3 className="mb-1 font-['Intro',sans-serif] text-lg text-white">
Maak een gratis account aan
</h3>
<p className="mb-4 text-sm text-white/70">
Maak een gratis account aan om je digitale Drinkkaart te
activeren en saldo op te laden vóór het evenement.
Met een account zie je je inschrijving, activeer je je
Drinkkaart en laad je saldo op vóór het evenement.
</p>
<div className="flex flex-wrap gap-3">
<ul className="mb-5 space-y-1.5 text-sm text-white/70">
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Beheer je inschrijving op één plek
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Digitale Drinkkaart met QR-code
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Saldo opladen vóór het evenement
</li>
</ul>
<div className="flex flex-wrap items-center gap-3">
<a
href="/login"
className="inline-flex items-center rounded-lg bg-white px-5 py-2.5 font-['Intro',sans-serif] text-[#214e51] text-sm transition-all hover:bg-gray-100"
href={signupUrl}
className="inline-flex items-center rounded-lg bg-white px-5 py-2.5 font-['Intro',sans-serif] text-[#214e51] text-sm transition-all hover:scale-105 hover:bg-gray-100"
>
Account aanmaken
{name && (
<span className="ml-1.5 font-normal text-xs opacity-60">
als {name}
</span>
)}
</a>
<button
type="button"
onClick={handleDismissPrompt}
className="text-sm text-white/50 underline underline-offset-2 transition-colors hover:text-white/80"
className="text-sm text-white/40 underline underline-offset-2 transition-colors hover:text-white/70"
>
Overslaan
</button>

View File

@@ -1,6 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import {
calculateDrinkCard,
type GuestEntry,
@@ -24,13 +25,231 @@ interface WatcherErrors {
interface Props {
onBack: () => void;
prefillFirstName?: string;
prefillLastName?: string;
prefillEmail?: string;
isLoggedIn?: boolean;
}
export function WatcherForm({ onBack }: Props) {
// ── Account creation modal shown after successful registration ─────────────
interface AccountModalProps {
prefillName: string;
prefillEmail: string;
onDone: () => void;
}
function AccountModal({
prefillName,
prefillEmail,
onDone,
}: AccountModalProps) {
const [name, setName] = useState(prefillName);
const [email] = useState(prefillEmail); // email is fixed (tied to registration)
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState<string | undefined>();
const signupMutation = useMutation({
mutationFn: async () => {
if (password.length < 8)
throw new Error("Wachtwoord moet minstens 8 tekens zijn");
if (password !== confirmPassword)
throw new Error("Wachtwoorden komen niet overeen");
const result = await authClient.signUp.email({ email, password, name });
if (result.error) throw new Error(result.error.message);
return result.data;
},
onSuccess: () => {
toast.success(
"Account aangemaakt! Je wordt nu doorgestuurd naar betaling.",
);
onDone();
},
onError: (error) => {
toast.error(`Account aanmaken mislukt: ${error.message}`);
},
});
const handlePasswordChange = (val: string) => {
setPassword(val);
if (passwordError) setPasswordError(undefined);
};
const handleConfirmChange = (val: string) => {
setConfirmPassword(val);
if (passwordError) setPasswordError(undefined);
};
const handleSignup = (e: React.FormEvent) => {
e.preventDefault();
if (password.length < 8) {
setPasswordError("Wachtwoord moet minstens 8 tekens zijn");
return;
}
if (password !== confirmPassword) {
setPasswordError("Wachtwoorden komen niet overeen");
return;
}
signupMutation.mutate();
};
// Receive the checkout URL from parent by exposing a setter via ref pattern
// (simpler: the parent renders the modal and passes a prop)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-[#1a3d40] p-8 shadow-2xl">
{/* Header */}
<div className="mb-6 flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-teal-400/20 text-teal-300 text-xl">
</div>
<div>
<h2 className="font-['Intro',sans-serif] text-2xl text-white">
Inschrijving gelukt!
</h2>
<p className="mt-1 text-sm text-white/60">
Maak een gratis account aan om je Drinkkaart bij te houden.
</p>
</div>
</div>
{/* Value prop */}
<div className="mb-6 rounded-lg border border-teal-400/20 bg-teal-400/10 p-4">
<ul className="space-y-1.5 text-sm text-white/80">
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Zie je drinkkaart-saldo en QR-code
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Laad je kaart op vóór het evenement
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Beheer je inschrijving op één plek
</li>
</ul>
</div>
{/* Signup form */}
<form onSubmit={handleSignup} className="space-y-4">
{/* Name */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-name"
>
Naam
</label>
<input
id="modal-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none"
/>
</div>
{/* Email (read-only) */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-email"
>
Email
</label>
<input
id="modal-email"
type="email"
value={email}
readOnly
className="w-full cursor-not-allowed rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white/60"
/>
</div>
{/* Password */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-password"
>
Wachtwoord
</label>
<input
id="modal-password"
type="password"
placeholder="Minstens 8 tekens"
value={password}
onChange={(e) => handlePasswordChange(e.target.value)}
required
minLength={8}
className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none"
/>
</div>
{/* Confirm password */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-confirm"
>
Bevestig wachtwoord
</label>
<input
id="modal-confirm"
type="password"
placeholder="Herhaal je wachtwoord"
value={confirmPassword}
onChange={(e) => handleConfirmChange(e.target.value)}
required
className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none"
/>
</div>
{passwordError && (
<p className="text-red-300 text-sm">{passwordError}</p>
)}
<button
type="submit"
disabled={signupMutation.isPending}
className="w-full rounded-lg bg-white py-3 font-['Intro',sans-serif] text-[#214e51] transition-all hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{signupMutation.isPending
? "Bezig..."
: "Account aanmaken & betalen"}
</button>
</form>
{/* Skip */}
<button
type="button"
onClick={onDone}
className="mt-4 w-full text-center text-sm text-white/40 transition-colors hover:text-white/70"
>
Overslaan, verder naar betaling
</button>
</div>
</div>
);
}
// ── Main watcher form ──────────────────────────────────────────────────────
export function WatcherForm({
onBack,
prefillFirstName = "",
prefillLastName = "",
prefillEmail = "",
isLoggedIn = false,
}: Props) {
const [data, setData] = useState({
firstName: "",
lastName: "",
email: "",
firstName: prefillFirstName,
lastName: prefillLastName,
email: prefillEmail,
phone: "",
extraQuestions: "",
});
@@ -40,16 +259,37 @@ export function WatcherForm({ onBack }: Props) {
const [guestErrors, setGuestErrors] = useState<GuestErrors[]>([]);
const [giftAmount, setGiftAmount] = useState(0);
// Modal state: shown after successful registration while we fetch checkout URL
const [modalState, setModalState] = useState<{
prefillName: string;
prefillEmail: string;
checkoutUrl: string;
} | null>(null);
const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(),
onSuccess: async (result) => {
if (!result.managementToken) return;
// Redirect to Lemon Squeezy checkout immediately after registration
try {
const redirectUrl = isLoggedIn
? `${window.location.origin}/account?topup=success`
: undefined;
const checkout = await client.getCheckoutUrl({
token: result.managementToken,
redirectUrl,
});
if (isLoggedIn) {
// Already logged in — skip the account-creation modal, go straight to checkout
window.location.href = checkout.checkoutUrl;
} else {
// Show the account-creation modal before redirecting to checkout
setModalState({
prefillName:
`${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
prefillEmail: data.email.trim(),
checkoutUrl: checkout.checkoutUrl,
});
}
} catch (error) {
console.error("Checkout error:", error);
toast.error("Er is iets misgegaan bij het aanmaken van de betaling");
@@ -162,6 +402,19 @@ export function WatcherForm({ onBack }: Props) {
return (
<div>
{/* Account-creation interstitial modal */}
{modalState && (
<AccountModal
prefillName={modalState.prefillName}
prefillEmail={modalState.prefillEmail}
onDone={() => {
const url = modalState.checkoutUrl;
setModalState(null);
window.location.href = url;
}}
/>
)}
{/* Back + type header */}
<div className="mb-8 flex items-center gap-4">
<button
@@ -291,14 +544,15 @@ export function WatcherForm({ onBack }: Props) {
id="w-email"
name="email"
value={data.email}
onChange={handleChange}
onBlur={handleBlur}
onChange={isLoggedIn ? undefined : handleChange}
onBlur={isLoggedIn ? undefined : handleBlur}
readOnly={isLoggedIn}
placeholder="jouw@email.be"
autoComplete="email"
inputMode="email"
aria-required="true"
aria-invalid={touched.email && !!errors.email}
className={inputCls(!!touched.email && !!errors.email)}
className={`${inputCls(!!touched.email && !!errors.email)}${isLoggedIn ? "cursor-not-allowed opacity-60" : ""}`}
/>
{touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert">

View File

@@ -25,9 +25,9 @@ const Toaster = ({ ...props }: ToasterProps) => {
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--normal-bg": "hsl(var(--popover))",
"--normal-text": "hsl(var(--popover-foreground))",
"--normal-border": "hsl(var(--border))",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}

View File

@@ -109,6 +109,32 @@ html {
border-radius: 3px;
}
/* ─── Theme Colors (CSS Variables for shadcn/ui) ─────────────────────────────
These variables are used by shadcn/ui components and the toast system.
─────────────────────────────────────────────────────────────────────────── */
:root {
--background: 0 0% 100%;
--foreground: 0 0% 10%;
--card: 0 0% 100%;
--card-foreground: 0 0% 10%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 10%;
--primary: 340 70% 49%;
--primary-foreground: 0 0% 100%;
--secondary: 183 40% 23%;
--secondary-foreground: 0 0% 100%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 340 70% 49%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 340 70% 49%;
--radius: 0rem;
}
/* ─── Text Selection Colors ─────────────────────────────────────────────────
Each section gets a ::selection style that harmonizes with its background.
The goal: selection feels native to each section, not a browser default.

View File

@@ -14,6 +14,7 @@ import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DrinkkaartRouteImport } from './routes/drinkkaart'
import { Route as ContactRouteImport } from './routes/contact'
import { Route as AccountRouteImport } from './routes/account'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as ManageTokenRouteImport } from './routes/manage.$token'
@@ -47,6 +48,11 @@ const ContactRoute = ContactRouteImport.update({
path: '/contact',
getParentRoute: () => rootRouteImport,
} as any)
const AccountRoute = AccountRouteImport.update({
id: '/account',
path: '/account',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -85,6 +91,7 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/login': typeof LoginRoute
@@ -99,6 +106,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/login': typeof LoginRoute
@@ -114,6 +122,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/login': typeof LoginRoute
@@ -130,6 +139,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/account'
| '/contact'
| '/drinkkaart'
| '/login'
@@ -144,6 +154,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/account'
| '/contact'
| '/drinkkaart'
| '/login'
@@ -158,6 +169,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/account'
| '/contact'
| '/drinkkaart'
| '/login'
@@ -173,6 +185,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AccountRoute: typeof AccountRoute
ContactRoute: typeof ContactRoute
DrinkkaartRoute: typeof DrinkkaartRoute
LoginRoute: typeof LoginRoute
@@ -223,6 +236,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ContactRouteImport
parentRoute: typeof rootRouteImport
}
'/account': {
id: '/account'
path: '/account'
fullPath: '/account'
preLoaderRoute: typeof AccountRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -277,6 +297,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AccountRoute: AccountRoute,
ContactRoute: ContactRoute,
DrinkkaartRoute: DrinkkaartRoute,
LoginRoute: LoginRoute,

View File

@@ -1,5 +1,4 @@
import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import {
createRootRouteWithContext,
@@ -159,7 +158,7 @@ function RootDocument() {
<head>
<HeadContent />
</head>
<body>
<body className="flex min-h-screen flex-col bg-[#214e51]">
<a
href="#main-content"
className="fixed top-0 left-0 z-[100] -translate-y-full bg-[#d82560] px-4 py-2 text-white transition-transform focus:translate-y-0"
@@ -167,9 +166,9 @@ function RootDocument() {
Ga naar hoofdinhoud
</a>
<SiteHeader />
<div id="main-content">
<main id="main-content" className="flex-1">
<Outlet />
</div>
</main>
<Toaster />
<CookieConsent />
<TanStackRouterDevtools position="bottom-left" />

View File

@@ -0,0 +1,392 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import {
Calendar,
CreditCard,
Music,
QrCode,
Users,
Wallet,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { QrCodeDisplay } from "@/components/drinkkaart/QrCodeDisplay";
import { TopUpHistory } from "@/components/drinkkaart/TopUpHistory";
import { TopUpModal } from "@/components/drinkkaart/TopUpModal";
import { TransactionHistory } from "@/components/drinkkaart/TransactionHistory";
import { authClient } from "@/lib/auth-client";
import { formatCents } from "@/lib/drinkkaart";
import { client, orpc } from "@/utils/orpc";
interface AccountSearch {
topup?: string;
welkom?: string;
}
export const Route = createFileRoute("/account")({
validateSearch: (search: Record<string, unknown>): AccountSearch => ({
topup: typeof search.topup === "string" ? search.topup : undefined,
welkom: typeof search.welkom === "string" ? search.welkom : undefined,
}),
component: AccountPage,
});
function PaymentBadge({ status }: { status: string | null }) {
if (status === "paid") {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/20 px-2 py-0.5 font-medium text-green-300 text-xs">
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
Betaald
</span>
);
}
if (status === "extra_payment_pending") {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-500/20 px-2 py-0.5 font-medium text-xs text-yellow-300">
Extra betaling verwacht
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-500/20 px-2 py-0.5 font-medium text-orange-300 text-xs">
In behandeling
</span>
);
}
function AccountPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const search = Route.useSearch();
const [showQr, setShowQr] = useState(false);
const [showTopUp, setShowTopUp] = useState(false);
const sessionQuery = useQuery({
queryKey: ["session"],
queryFn: () => authClient.getSession(),
});
// Client-side auth check: redirect to login if not authenticated
useEffect(() => {
if (!sessionQuery.isLoading && !sessionQuery.data?.data?.user) {
navigate({ to: "/login" });
}
}, [sessionQuery.isLoading, sessionQuery.data, navigate]);
const drinkkaartQuery = useQuery(
orpc.drinkkaart.getMyDrinkkaart.queryOptions(),
);
const registrationQuery = useQuery(orpc.getMyRegistration.queryOptions());
// Handle topup=success redirect (from Lemon Squeezy returning to /account)
useEffect(() => {
if (search.topup === "success") {
toast.success("Oplading geslaagd! Je saldo is bijgewerkt.");
queryClient.invalidateQueries({
queryKey: orpc.drinkkaart.getMyDrinkkaart.key(),
});
const url = new URL(window.location.href);
url.searchParams.delete("topup");
window.history.replaceState({}, "", url.toString());
}
}, [search.topup, queryClient]);
// Silent background call to claim any pending registration fee credit.
// This catches cases where: webhook didn't fire/failed, or user signed up after paying.
// No UI feedback needed — if there's nothing to claim, it does nothing.
useEffect(() => {
client.claimRegistrationCredit().catch((err) => {
console.error("claimRegistrationCredit failed:", err);
});
}, []);
// Welcome toast after fresh signup
useEffect(() => {
if (search.welkom === "1") {
toast.success("Welkom! Je account is aangemaakt.");
const url = new URL(window.location.href);
url.searchParams.delete("welkom");
window.history.replaceState({}, "", url.toString());
}
}, [search.welkom]);
const user = sessionQuery.data?.data?.user as
| { name?: string; email?: string }
| undefined;
const registration = registrationQuery.data;
const drinkkaart = drinkkaartQuery.data;
const isLoading =
sessionQuery.isLoading ||
drinkkaartQuery.isLoading ||
registrationQuery.isLoading;
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#214e51]">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
);
}
return (
<div>
<main className="mx-auto max-w-5xl px-4 py-12">
{/* Page header */}
<div className="mb-8">
<h1 className="mb-1 font-['Intro',sans-serif] text-4xl text-white">
Mijn Account
</h1>
<p className="text-white/50">
{user?.name && (
<span className="mr-2 font-medium text-white/70">
{user.name}
</span>
)}
{user?.email}
</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* ── Left column: Registration ── */}
<section>
<h2 className="mb-3 flex items-center gap-2 font-['Intro',sans-serif] text-lg text-white/80">
<Calendar className="h-4 w-4 shrink-0 text-white/40" />
Mijn Inschrijving
</h2>
{registration ? (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
{/* Type badge */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
{registration.registrationType === "performer" ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/20 px-3 py-1 font-medium text-amber-300 text-sm">
<Music className="h-3.5 w-3.5" />
Artiest
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-teal-500/20 px-3 py-1 font-medium text-sm text-teal-300">
<Users className="h-3.5 w-3.5" />
Bezoeker
</span>
)}
</div>
<PaymentBadge status={registration.paymentStatus} />
</div>
{/* Name */}
<p className="mb-1 font-medium text-lg text-white">
{registration.firstName} {registration.lastName}
</p>
{/* Art form (performer only) */}
{registration.registrationType === "performer" &&
registration.artForm && (
<p className="mb-1 text-sm text-white/60">
Kunstvorm:{" "}
<span className="text-white/80">
{registration.artForm}
</span>
</p>
)}
{/* Guests (watcher only) */}
{registration.registrationType === "watcher" &&
registration.guests.length > 0 && (
<p className="mb-1 text-sm text-white/60">
{registration.guests.length + 1} personen (jij +{" "}
{registration.guests.length} gast
{registration.guests.length > 1 ? "en" : ""})
</p>
)}
{/* Drink card value */}
{registration.registrationType === "watcher" &&
(registration.drinkCardValue ?? 0) > 0 && (
<p className="mb-1 text-sm text-white/60">
Drinkkaart:{" "}
<span className="text-white/80">
{registration.drinkCardValue}
</span>
</p>
)}
{/* Gift */}
{(registration.giftAmount ?? 0) > 0 && (
<p className="mb-1 text-sm text-white/60">
Gift:{" "}
<span className="text-white/80">
{(registration.giftAmount ?? 0) / 100}
</span>
</p>
)}
{/* Date */}
<p className="mt-3 text-white/40 text-xs">
Ingeschreven op{" "}
{new Date(registration.createdAt).toLocaleDateString(
"nl-BE",
{
day: "numeric",
month: "long",
year: "numeric",
},
)}
</p>
{/* Action */}
{registration.managementToken && (
<div className="mt-5">
<Link
to="/manage/$token"
params={{ token: registration.managementToken }}
className="inline-flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm text-white transition-colors hover:bg-white/20"
>
Beheer inschrijving
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</Link>
</div>
)}
</div>
) : (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
<p className="mb-3 text-sm text-white/60">
We vonden geen actieve inschrijving voor dit e-mailadres.
</p>
<a
href="/#registration"
className="inline-flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm text-white transition-colors hover:bg-white/20"
>
Inschrijven voor het evenement
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</a>
</div>
)}
</section>
{/* ── Right column: Drinkkaart ── */}
<section>
<h2 className="mb-3 flex items-center gap-2 font-['Intro',sans-serif] text-lg text-white/80">
<CreditCard className="h-4 w-4 shrink-0 text-white/40" />
Mijn Drinkkaart
</h2>
{/* Balance card */}
<div className="mb-4 rounded-2xl border border-white/10 bg-white/5 p-6">
<p className="mb-1 text-sm text-white/50">Huidig saldo</p>
<p className="mb-4 font-['Intro',sans-serif] text-5xl text-white">
{drinkkaart ? formatCents(drinkkaart.balance) : "—"}
</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setShowQr((v) => !v)}
className="inline-flex items-center gap-2 rounded-lg border border-white/30 bg-transparent px-4 py-2 text-sm text-white transition-colors hover:bg-white/10"
>
<QrCode className="h-4 w-4" />
{showQr ? "Verberg QR-code" : "Toon QR-code"}
</button>
<button
type="button"
onClick={() => setShowTopUp(true)}
className="inline-flex items-center gap-2 rounded-lg bg-white px-4 py-2 font-semibold text-[#214e51] text-sm transition-colors hover:bg-white/90"
>
<Wallet className="h-4 w-4" />
Opladen
</button>
</div>
</div>
{/* QR code */}
{showQr && (
<div className="mb-4 flex justify-center rounded-2xl border border-white/10 bg-white/5 p-6">
<QrCodeDisplay />
</div>
)}
{/* Top-up history */}
<section className="mb-6">
<h3 className="mb-2 font-medium text-sm text-white/50 uppercase tracking-wider">
Opladingen
</h3>
{drinkkaart ? (
<TopUpHistory
topups={
drinkkaart.topups as Parameters<
typeof TopUpHistory
>[0]["topups"]
}
/>
) : (
<p className="text-sm text-white/40">Laden...</p>
)}
</section>
{/* Transaction history */}
<section>
<h3 className="mb-2 font-medium text-sm text-white/50 uppercase tracking-wider">
Transacties
</h3>
{drinkkaart ? (
<TransactionHistory
transactions={
drinkkaart.transactions as Parameters<
typeof TransactionHistory
>[0]["transactions"]
}
/>
) : (
<p className="text-sm text-white/40">Laden...</p>
)}
</section>
</section>
</div>
</main>
{showTopUp && <TopUpModal onClose={() => setShowTopUp(false)} />}
</div>
);
}

View File

@@ -98,7 +98,7 @@ function AdminDrinkkaartPage() {
};
return (
<div className="min-h-screen bg-[#214e51]">
<div>
{/* Header */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-6 py-5">
<div className="mx-auto flex max-w-2xl items-center gap-4">

View File

@@ -1,10 +1,5 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
createFileRoute,
Link,
redirect,
useNavigate,
} from "@tanstack/react-router";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import {
Check,
ChevronDown,
@@ -34,16 +29,6 @@ import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/admin/")({
component: AdminPage,
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data?.user) {
throw redirect({ to: "/login" });
}
const user = session.data.user as { role?: string };
if (user.role !== "admin") {
throw redirect({ to: "/login" });
}
},
});
function AdminPage() {
@@ -60,6 +45,12 @@ function AdminPage() {
const [copiedId, setCopiedId] = useState<string | null>(null);
// Get current session to check user role
const sessionQuery = useQuery({
queryKey: ["session"],
queryFn: () => authClient.getSession(),
});
const handleCopyManageUrl = (token: string, id: string) => {
const url = `${window.location.origin}/manage/${token}`;
navigator.clipboard.writeText(url).then(() => {
@@ -146,6 +137,16 @@ function AdminPage() {
},
});
const requestAdminMutation = useMutation({
...orpc.requestAdminAccess.mutationOptions(),
onSuccess: () => {
toast.success("Admin toegang aangevraagd!");
},
onError: (error) => {
toast.error(`Aanvraag mislukt: ${error.message}`);
},
});
const handleSignOut = async () => {
await authClient.signOut();
navigate({ to: "/" });
@@ -163,6 +164,10 @@ function AdminPage() {
rejectRequestMutation.mutate({ requestId });
};
const handleRequestAdmin = () => {
requestAdminMutation.mutate(undefined);
};
const stats = statsQuery.data;
const registrations = registrationsQuery.data?.data ?? [];
const pagination = registrationsQuery.data?.pagination;
@@ -273,8 +278,119 @@ function AdminPage() {
return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`;
};
// Check if user is admin
const user = sessionQuery.data?.data?.user as
| { role?: string; name?: string }
| undefined;
const isAdmin = user?.role === "admin";
const isAuthenticated = !!sessionQuery.data?.data?.user;
// Show loading state while checking session
if (sessionQuery.isLoading) {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<p className="text-white/60">Laden...</p>
</div>
);
}
// Show login prompt for non-authenticated users
if (!isAuthenticated) {
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Inloggen Vereist
</CardTitle>
<CardDescription className="text-white/60">
Je moet ingelogd zijn om admin toegang aan te vragen
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Button
onClick={() => navigate({ to: "/login" })}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
Inloggen
</Button>
<Link
to="/"
className="block text-center text-sm text-white/40 hover:text-white"
>
Terug naar website
</Link>
</div>
</CardContent>
</Card>
</div>
);
}
// Show request admin UI for non-admin users
if (!isAdmin) {
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Admin Toegang Vereist
</CardTitle>
<CardDescription className="text-white/60">
Je hebt geen admin rechten voor dit dashboard
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="rounded-lg border border-white/10 bg-white/5 p-4">
<p className="mb-3 text-center text-sm text-white/60">
Admin-toegang aanvragen?
</p>
<Button
onClick={handleRequestAdmin}
disabled={requestAdminMutation.isPending}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
{requestAdminMutation.isPending
? "Bezig..."
: "Vraag Admin Toegang Aan"}
</Button>
</div>
<Button
onClick={() => navigate({ to: "/account" })}
variant="outline"
className="w-full border-white/20 bg-transparent text-white hover:bg-white/10"
>
Ga naar mijn account
</Button>
<Button
onClick={handleSignOut}
variant="outline"
className="w-full border-white/20 bg-transparent text-white/60 hover:bg-white/10 hover:text-white"
>
<LogOut className="mr-2 h-4 w-4" />
Uitloggen
</Button>
<Link
to="/"
className="block text-center text-sm text-white/40 hover:text-white"
>
Terug naar website
</Link>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div>
{/* Header */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-8 py-6">
<div className="mx-auto flex max-w-7xl items-center justify-between">

View File

@@ -1,6 +1,8 @@
import { createHmac, randomUUID } from "node:crypto";
import { creditRegistrationToAccount } from "@kk/api/routers/index";
import { db } from "@kk/db";
import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router";
import { and, eq } from "drizzle-orm";
@@ -176,9 +178,21 @@ async function handleWebhook({ request }: { request: Request }) {
const orderId = event.data.id;
const customerId = String(event.data.attributes.customer_id);
// Update registration in database.
// Covers both "pending" (initial payment) and "extra_payment_pending"
// (delta payment after adding guests to an already-paid registration).
// Fetch the registration row first so we can use its email + drinkCardValue.
const regRow = await db
.select()
.from(registration)
.where(eq(registration.managementToken, registrationToken))
.limit(1)
.then((r) => r[0]);
if (!regRow) {
console.error(`Registration not found for token ${registrationToken}`);
return new Response("Registration not found", { status: 404 });
}
// Mark the registration as paid. Covers both "pending" (initial payment) and
// "extra_payment_pending" (delta after adding guests).
await db
.update(registration)
.set({
@@ -194,6 +208,50 @@ async function handleWebhook({ request }: { request: Request }) {
`Payment successful for registration ${registrationToken}, order ${orderId}`,
);
// If this is a watcher with a drink card value, try to credit their drinkkaart
// immediately — but only if they already have an account.
if (
regRow.registrationType === "watcher" &&
(regRow.drinkCardValue ?? 0) > 0
) {
// Look up user account by email
const accountUser = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, regRow.email))
.limit(1)
.then((r) => r[0]);
if (accountUser) {
const creditResult = await creditRegistrationToAccount(
regRow.email,
accountUser.id,
).catch((err) => {
console.error(
`Failed to credit drinkkaart for ${regRow.email} after registration payment:`,
err,
);
return null;
});
if (creditResult?.credited) {
console.log(
`Drinkkaart credited ${creditResult.amountCents}c for user ${accountUser.id} (registration ${registrationToken})`,
);
} else if (creditResult) {
console.log(
`Drinkkaart credit skipped for ${regRow.email}: ${creditResult.status}`,
);
}
} else {
// No account yet — credit will be applied when the user signs up via
// claimRegistrationCredit.
console.log(
`No account for ${regRow.email} — drinkkaart credit deferred until signup`,
);
}
}
return new Response("OK", { status: 200 });
} catch (error) {
console.error("Webhook processing error:", error);

View File

@@ -6,8 +6,8 @@ export const Route = createFileRoute("/contact")({
function ContactPage() {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
<div>
<div className="mx-auto max-w-3xl px-6 py-12">
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"

View File

@@ -1,138 +1,21 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { QrCode, Wallet } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { QrCodeDisplay } from "@/components/drinkkaart/QrCodeDisplay";
import { TopUpHistory } from "@/components/drinkkaart/TopUpHistory";
import { TopUpModal } from "@/components/drinkkaart/TopUpModal";
import { TransactionHistory } from "@/components/drinkkaart/TransactionHistory";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
// /drinkkaart redirects to /account, preserving ?topup=success for
// backward-compatibility with the Lemon Squeezy webhook return URL.
export const Route = createFileRoute("/drinkkaart")({
validateSearch: (search: Record<string, unknown>) => ({
topup: search.topup as string | undefined,
}),
component: DrinkkaartPage,
beforeLoad: async () => {
beforeLoad: async ({ search }) => {
const session = await authClient.getSession();
if (!session.data?.user) {
throw redirect({ to: "/login" });
}
throw redirect({
to: "/account",
search: search.topup ? { topup: search.topup } : {},
});
},
component: () => null,
});
function DrinkkaartPage() {
const queryClient = useQueryClient();
const search = Route.useSearch();
const [showQr, setShowQr] = useState(false);
const [showTopUp, setShowTopUp] = useState(false);
const { data, isLoading } = useQuery(
orpc.drinkkaart.getMyDrinkkaart.queryOptions(),
);
// Handle topup=success redirect
useEffect(() => {
if (search.topup === "success") {
toast.success("Oplading geslaagd! Je saldo is bijgewerkt.");
queryClient.invalidateQueries({
queryKey: orpc.drinkkaart.getMyDrinkkaart.key(),
});
// Remove query param without full navigation
const url = new URL(window.location.href);
url.searchParams.delete("topup");
window.history.replaceState({}, "", url.toString());
}
}, [search.topup, queryClient]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#214e51]">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
);
}
return (
<div className="min-h-screen bg-[#214e51]">
<main className="mx-auto max-w-lg px-4 py-10">
<h1 className="mb-6 font-['Intro',sans-serif] text-3xl text-white">
Mijn Drinkkaart
</h1>
{/* Balance card */}
<div className="mb-6 rounded-2xl border border-white/10 bg-white/5 p-6">
<p className="mb-1 text-sm text-white/50">Huidig saldo</p>
<p className="mb-4 font-['Intro',sans-serif] text-5xl text-white">
{data ? formatCents(data.balance) : "—"}
</p>
<div className="flex flex-wrap gap-3">
<Button
onClick={() => setShowQr((v) => !v)}
variant="outline"
className="border-white/30 bg-transparent text-white hover:bg-white/10"
>
<QrCode className="mr-2 h-4 w-4" />
{showQr ? "Verberg QR-code" : "Toon QR-code"}
</Button>
<Button
onClick={() => setShowTopUp(true)}
className="bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
<Wallet className="mr-2 h-4 w-4" />
Opladen
</Button>
</div>
</div>
{/* QR code */}
{showQr && (
<div className="mb-6 flex justify-center rounded-2xl border border-white/10 bg-white/5 p-6">
<QrCodeDisplay />
</div>
)}
{/* Top-up history */}
<section className="mb-8">
<h2 className="mb-3 font-['Intro',sans-serif] text-lg text-white/80">
Opladingen
</h2>
{data ? (
<TopUpHistory
topups={
data.topups as Parameters<typeof TopUpHistory>[0]["topups"]
}
/>
) : (
<p className="text-sm text-white/40">Laden...</p>
)}
</section>
{/* Transaction history */}
<section>
<h2 className="mb-3 font-['Intro',sans-serif] text-lg text-white/80">
Transacties
</h2>
{data ? (
<TransactionHistory
transactions={
data.transactions as Parameters<
typeof TransactionHistory
>[0]["transactions"]
}
/>
) : (
<p className="text-sm text-white/40">Laden...</p>
)}
</section>
</main>
{showTopUp && <TopUpModal onClose={() => setShowTopUp(false)} />}
</div>
);
}

View File

@@ -15,13 +15,24 @@ import { authClient } from "@/lib/auth-client";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/login")({
validateSearch: (search: Record<string, unknown>) => {
const signup =
search.signup === "1" || search.signup === "true" ? "1" : undefined;
const email = typeof search.email === "string" ? search.email : undefined;
// Only return defined keys so redirects without search still typecheck
return {
...(signup !== undefined && { signup }),
...(email !== undefined && { email }),
} as { signup?: "1"; email?: string };
},
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const [isSignup, setIsSignup] = useState(false);
const [email, setEmail] = useState("");
const search = Route.useSearch();
const [isSignup, setIsSignup] = useState(() => search.signup === "1");
const [email, setEmail] = useState(() => search.email ?? "");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
@@ -49,13 +60,12 @@ function LoginPage() {
},
onSuccess: () => {
toast.success("Succesvol ingelogd!");
// Check role and redirect
authClient.getSession().then((session) => {
const user = session.data?.user as { role?: string } | undefined;
if (user?.role === "admin") {
navigate({ to: "/admin" });
} else {
navigate({ to: "/" });
navigate({ to: "/account" });
}
});
},
@@ -85,11 +95,7 @@ function LoginPage() {
return result.data;
},
onSuccess: () => {
toast.success("Account aangemaakt! Wacht op goedkeuring van een admin.");
setIsSignup(false);
setName("");
setEmail("");
setPassword("");
navigate({ to: "/account", search: { welkom: "1" } });
},
onError: (error) => {
toast.error(`Registratie mislukt: ${error.message}`);
@@ -119,13 +125,15 @@ function LoginPage() {
requestAdminMutation.mutate(undefined);
};
// If already logged in as admin, redirect
// If already logged in, redirect to appropriate page
if (sessionQuery.data?.data?.user) {
const user = sessionQuery.data.data.user as { role?: string };
if (user.role === "admin") {
navigate({ to: "/admin" });
return null;
} else {
navigate({ to: "/account" });
}
return null;
}
const isLoggedIn = !!sessionQuery.data?.data?.user;
@@ -134,51 +142,64 @@ function LoginPage() {
| undefined;
return (
<div className="flex min-h-screen items-center justify-center bg-[#214e51] px-4">
<Card className="w-full max-w-md border-white/10 bg-white/5">
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
{isLoggedIn
? "Admin Toegang"
? `Welkom, ${user?.name}`
: isSignup
? "Account Aanmaken"
: "Inloggen"}
</CardTitle>
<CardDescription className="text-white/60">
{isLoggedIn
? `Welkom, ${user?.name}`
? "Je bent al ingelogd"
: isSignup
? "Maak een account aan om toegang te krijgen"
: "Log in om toegang te krijgen tot het admin dashboard"}
? "Maak een gratis account aan voor je Drinkkaart en inschrijving"
: "Log in om je account te bekijken"}
</CardDescription>
</CardHeader>
<CardContent>
{isLoggedIn ? (
<div className="space-y-4">
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-4">
<p className="text-center text-yellow-200">
Je bent ingelogd maar hebt geen admin toegang.
{/* Primary CTA: go to account */}
<Button
onClick={() => navigate({ to: "/account" })}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
Ga naar mijn account
</Button>
{/* Secondary: request admin access */}
<div className="rounded-lg border border-white/10 bg-white/5 p-4">
<p className="mb-3 text-center text-sm text-white/60">
Admin-toegang aanvragen?
</p>
</div>
<Button
onClick={handleRequestAdmin}
disabled={requestAdminMutation.isPending}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
variant="outline"
className="w-full border-white/20 bg-transparent text-white hover:bg-white/10"
>
{requestAdminMutation.isPending
? "Bezig..."
: "Vraag Admin Toegang Aan"}
</Button>
</div>
<Button
onClick={() => authClient.signOut()}
onClick={() =>
authClient.signOut().then(() => navigate({ to: "/" }))
}
variant="outline"
className="w-full border-white/20 bg-transparent text-white hover:bg-white/10"
className="w-full border-white/20 bg-transparent text-white/60 hover:bg-white/10 hover:text-white"
>
Uitloggen
</Button>
<Link
to="/"
className="block text-center text-sm text-white/60 hover:text-white"
className="block text-center text-sm text-white/40 hover:text-white"
>
Terug naar website
</Link>

View File

@@ -22,8 +22,8 @@ export const Route = createFileRoute("/manage/$token")({
function PageShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">{children}</div>
<div>
<div className="mx-auto max-w-5xl px-6 py-12">{children}</div>
</div>
);
}
@@ -31,10 +31,10 @@ function PageShell({ children }: { children: React.ReactNode }) {
function BackLink() {
return (
<Link
to="/"
to="/account"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"
>
Terug naar home
Terug naar account
</Link>
);
}
@@ -427,7 +427,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
onChange={(e) =>
setFormData((p) => ({ ...p, extraQuestions: e.target.value }))
}
className="w-full resize-none bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:outline-none"
className="w-full border border-white/10 resize-none bg-transparent p-2 text-lg text-white placeholder:text-white/40 focus:outline-none"
/>
</div>

View File

@@ -6,8 +6,8 @@ export const Route = createFileRoute("/privacy")({
function PrivacyPage() {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
<div>
<div className="mx-auto max-w-3xl px-6 py-12">
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"

View File

@@ -6,8 +6,8 @@ export const Route = createFileRoute("/terms")({
function TermsPage() {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
<div>
<div className="mx-auto max-w-3xl px-6 py-12">
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"

View File

@@ -163,7 +163,7 @@ export const drinkkaartRouter = {
product_options: {
name: "Drinkkaart Opladen",
description: `Drinkkaart top-up — ${formatCents(input.amountCents)}`,
redirect_url: `${serverEnv.BETTER_AUTH_URL}/drinkkaart?topup=success`,
redirect_url: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
},
checkout_data: {
email: session.user.email,

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import { adminRequest, registration } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
import { env } from "@kk/env/server";
import type { RouterClient } from "@orpc/server";
import { and, count, desc, eq, gte, isNull, like, lte, sum } from "drizzle-orm";
@@ -12,6 +13,7 @@ import {
sendUpdateEmail,
} from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { generateQrSecret } from "../lib/drinkkaart-utils";
import { drinkkaartRouter } from "./drinkkaart";
// ---------------------------------------------------------------------------
@@ -44,6 +46,151 @@ function parseGuestsJson(raw: string | null): Array<{
}
}
/**
* Credits a watcher's drinkCardValue to their drinkkaart account.
*
* Safe to call multiple times — the `drinkkaartCreditedAt IS NULL` guard and the
* `lemonsqueezy_order_id` UNIQUE constraint together prevent double-crediting even
* if called concurrently (webhook + signup racing each other).
*
* Returns a status string describing the outcome.
*/
export async function creditRegistrationToAccount(
email: string,
userId: string,
): Promise<{
credited: boolean;
amountCents: number;
status:
| "credited"
| "already_credited"
| "no_paid_registration"
| "zero_value";
}> {
// Find the most recent paid watcher registration for this email that hasn't
// been credited yet and has a non-zero drink card value.
const rows = await db
.select()
.from(registration)
.where(
and(
eq(registration.email, email),
eq(registration.registrationType, "watcher"),
eq(registration.paymentStatus, "paid"),
isNull(registration.cancelledAt),
isNull(registration.drinkkaartCreditedAt),
),
)
.orderBy(desc(registration.createdAt))
.limit(1);
const reg = rows[0];
if (!reg) {
// Either no paid registration exists, or it was already credited.
// Distinguish between the two for better logging.
const credited = await db
.select({ id: registration.id })
.from(registration)
.where(
and(
eq(registration.email, email),
eq(registration.registrationType, "watcher"),
eq(registration.paymentStatus, "paid"),
isNull(registration.cancelledAt),
),
)
.limit(1)
.then((r) => r[0]);
return {
credited: false,
amountCents: 0,
status: credited ? "already_credited" : "no_paid_registration",
};
}
const drinkCardValueEuros = reg.drinkCardValue ?? 0;
if (drinkCardValueEuros === 0) {
return { credited: false, amountCents: 0, status: "zero_value" };
}
const amountCents = drinkCardValueEuros * 100;
// Get or create the drinkkaart for this user.
let card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.userId, userId))
.limit(1)
.then((r) => r[0]);
if (!card) {
const now = new Date();
card = {
id: randomUUID(),
userId,
balance: 0,
version: 0,
qrSecret: generateQrSecret(),
createdAt: now,
updatedAt: now,
};
await db.insert(drinkkaart).values(card);
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + amountCents;
// Optimistic-lock update — if concurrent, the second caller will see
// drinkkaartCreditedAt IS NOT NULL and return "already_credited" above.
const updateResult = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: new Date(),
})
.where(
and(eq(drinkkaart.id, card.id), eq(drinkkaart.version, card.version)),
);
if (updateResult.rowsAffected === 0) {
// Lost the optimistic lock race — the other caller will handle it.
return { credited: false, amountCents: 0, status: "already_credited" };
}
// Record the topup. Re-use the registration's lemonsqueezyOrderId so the
// topup row is clearly linked to the original payment. We prefix with
// "reg_" to distinguish from direct drinkkaart top-up orders if needed.
const topupOrderId = reg.lemonsqueezyOrderId
? `reg_${reg.lemonsqueezyOrderId}`
: null;
await db.insert(drinkkaartTopup).values({
id: randomUUID(),
drinkkaartId: card.id,
userId,
amountCents,
balanceBefore,
balanceAfter,
type: "payment",
lemonsqueezyOrderId: topupOrderId,
lemonsqueezyCustomerId: reg.lemonsqueezyCustomerId ?? null,
adminId: null,
reason: "Drinkkaart bij registratie",
paidAt: reg.paidAt ?? new Date(),
});
// Mark the registration as credited — prevents any future double-credit.
await db
.update(registration)
.set({ drinkkaartCreditedAt: new Date() })
.where(eq(registration.id, reg.id));
return { credited: true, amountCents, status: "credited" };
}
/** Fetches a single registration by management token, throws if not found or cancelled. */
async function getActiveRegistration(token: string) {
const rows = await db
@@ -425,6 +572,39 @@ export const appRouter = {
};
}),
// ---------------------------------------------------------------------------
// User account
// ---------------------------------------------------------------------------
claimRegistrationCredit: protectedProcedure.handler(async ({ context }) => {
const result = await creditRegistrationToAccount(
context.session.user.email,
context.session.user.id,
);
return result;
}),
getMyRegistration: protectedProcedure.handler(async ({ context }) => {
const email = context.session.user.email;
const rows = await db
.select()
.from(registration)
.where(
and(eq(registration.email, email), isNull(registration.cancelledAt)),
)
.orderBy(desc(registration.createdAt))
.limit(1);
const row = rows[0];
if (!row) return null;
return {
...row,
guests: parseGuestsJson(row.guests),
};
}),
// ---------------------------------------------------------------------------
// Admin access requests
// ---------------------------------------------------------------------------
@@ -555,7 +735,12 @@ export const appRouter = {
// ---------------------------------------------------------------------------
getCheckoutUrl: publicProcedure
.input(z.object({ token: z.string().uuid() }))
.input(
z.object({
token: z.string().uuid(),
redirectUrl: z.string().optional(),
}),
)
.handler(async ({ input }) => {
if (
!env.LEMON_SQUEEZY_API_KEY ||
@@ -629,7 +814,9 @@ export const appRouter = {
product_options: {
name: "Kunstenkamp Evenement",
description: productDescription,
redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`,
redirect_url:
input.redirectUrl ??
`${env.CORS_ORIGIN}/manage/${input.token}`,
},
checkout_data: {
email: row.email,

View File

@@ -0,0 +1,2 @@
-- Migration: Track when a watcher's registration fee has been credited to their drinkkaart
ALTER TABLE `registration` ADD COLUMN `drinkkaart_credited_at` integer;

View File

@@ -32,6 +32,12 @@ export const registration = sqliteTable(
lemonsqueezyOrderId: text("lemonsqueezy_order_id"),
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }),
// Set when the drinkCardValue has been credited to the user's drinkkaart.
// Null means not yet credited (either unpaid, account doesn't exist yet, or
// the registration is a performer). Used to prevent double-crediting.
drinkkaartCreditedAt: integer("drinkkaart_credited_at", {
mode: "timestamp_ms",
}),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),