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 { Link, useNavigate } from "@tanstack/react-router";
import { LogOut } from "lucide-react"; import { LogOut, User } from "lucide-react";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
interface HeaderProps { interface HeaderProps {
userName?: string; userName?: string;
isAdmin?: boolean; 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 navigate = useNavigate();
const handleSignOut = async () => { const handleSignOut = async () => {
@@ -15,25 +26,29 @@ export function Header({ userName, isAdmin }: HeaderProps) {
navigate({ to: "/" }); navigate({ to: "/" });
}; };
if (!userName) return null;
return ( return (
<header className="border-white/10 border-b bg-[#214e51]/95 backdrop-blur-sm"> <header
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-3"> className={`border-white/10 border-b bg-[#214e51]/95 backdrop-blur-sm transition-transform duration-300 ${
<nav className="flex items-center gap-6"> 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 <Link
to="/" 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 Kunstenkamp
</Link> </Link>
{!isGuest && (
<>
<Link <Link
to="/drinkkaart" to="/account"
search={{ topup: undefined }}
className="text-sm text-white/70 transition-colors hover:text-white" className="text-sm text-white/70 transition-colors hover:text-white"
activeProps={{ className: "text-white font-medium" }} activeProps={{ className: "text-white font-medium" }}
> >
Drinkkaart <span className="hidden sm:inline">Mijn Account</span>
<User className="h-4 w-4 sm:hidden" />
</Link> </Link>
{isAdmin && ( {isAdmin && (
<Link <Link
@@ -44,19 +59,45 @@ export function Header({ userName, isAdmin }: HeaderProps) {
Admin Admin
</Link> </Link>
)} )}
</>
)}
</nav> </nav>
<div className="flex items-center gap-3"> {isGuest ? (
<span className="text-sm text-white/60">{userName}</span> <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 <button
type="button" type="button"
onClick={handleSignOut} 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" /> <LogOut className="h-4 w-4" />
Uitloggen <span className="hidden text-xs sm:inline">Uitloggen</span>
</button> </button>
</div> </div>
)}
</div> </div>
</header> </header>
); );

View File

@@ -1,18 +1,43 @@
"use client"; "use client";
import { useRouterState } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { Header } from "./Header"; 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() { export function SiteHeader() {
const { data: session } = authClient.useSession(); 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 }; 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 { useState } from "react";
import { PerformerForm } from "@/components/registration/PerformerForm"; import { PerformerForm } from "@/components/registration/PerformerForm";
import { SuccessScreen } from "@/components/registration/SuccessScreen"; import { SuccessScreen } from "@/components/registration/SuccessScreen";
import { TypeSelector } from "@/components/registration/TypeSelector"; import { TypeSelector } from "@/components/registration/TypeSelector";
import { WatcherForm } from "@/components/registration/WatcherForm"; import { WatcherForm } from "@/components/registration/WatcherForm";
import { authClient } from "@/lib/auth-client";
type RegistrationType = "performer" | "watcher"; type RegistrationType = "performer" | "watcher";
interface SuccessState {
token: string;
email: string;
name: string;
}
export default function EventRegistrationForm() { export default function EventRegistrationForm() {
const { data: session } = authClient.useSession();
const [selectedType, setSelectedType] = useState<RegistrationType | null>( const [selectedType, setSelectedType] = useState<RegistrationType | null>(
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 ( return (
<SuccessScreen <SuccessScreen
token={successToken} token={successState.token}
email={successState.email}
name={successState.name}
onReset={() => { onReset={() => {
setSuccessToken(null); setSuccessState(null);
setSelectedType(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"> <h2 className="mb-2 font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Schrijf je nu in! Schrijf je nu in!
</h2> </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. Doe je mee of kom je kijken? Kies je rol en vul het formulier in.
</p> </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 && <TypeSelector onSelect={setSelectedType} />}
{selectedType === "performer" && ( {selectedType === "performer" && (
<PerformerForm <PerformerForm
onBack={() => setSelectedType(null)} onBack={() => setSelectedType(null)}
onSuccess={setSuccessToken} onSuccess={(token, email, name) =>
setSuccessState({ token, email, name })
}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/> />
)} )}
{selectedType === "watcher" && ( {selectedType === "watcher" && (
<WatcherForm onBack={() => setSelectedType(null)} /> <WatcherForm
onBack={() => setSelectedType(null)}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/>
)} )}
</div> </div>
</section> </section>

View File

@@ -31,21 +31,21 @@ export default function Footer() {
Waar creativiteit tot leven komt Waar creativiteit tot leven komt
</p> </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 <a
href="/privacy" href="/privacy"
className="link-hover transition-colors hover:text-white" className="link-hover transition-colors hover:text-white"
> >
Privacy Beleid Privacy Beleid
</a> </a>
<span className="text-white/40">|</span> <span className="hidden text-white/40 md:inline">|</span>
<a <a
href="/terms" href="/terms"
className="link-hover transition-colors hover:text-white" className="link-hover transition-colors hover:text-white"
> >
Algemene Voorwaarden Algemene Voorwaarden
</a> </a>
<span className="text-white/40">|</span> <span className="hidden text-white/40 md:inline">|</span>
<a <a
href="/contact" href="/contact"
className="link-hover transition-colors hover:text-white" className="link-hover transition-colors hover:text-white"
@@ -54,7 +54,7 @@ export default function Footer() {
</a> </a>
{!isLoading && isAdmin && ( {!isLoading && isAdmin && (
<> <>
<span className="text-white/40">|</span> <span className="hidden text-white/40 md:inline">|</span>
<Link <Link
to="/admin" to="/admin"
className="link-hover transition-colors hover:text-white" className="link-hover transition-colors hover:text-white"

View File

@@ -88,9 +88,15 @@ export default function Info() {
</div> </div>
{/* Main Title */} {/* 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 Ongedesemd
<span className="block text-5xl md:text-7xl lg:text-8xl"> <span
className="block"
style={{ fontSize: "clamp(1rem, 6vw + 1rem, 6rem)" }}
>
Brood?! Brood?!
</span> </span>
</h2> </h2>
@@ -110,8 +116,16 @@ export default function Info() {
</div> </div>
{/* Content Section */} {/* Content Section */}
<div className="w-full px-12 py-16"> <div className="relative w-full px-12 py-16">
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col justify-center gap-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 */} {/* 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="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" /> <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 { interface Props {
onBack: () => void; 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({ const [data, setData] = useState({
firstName: "", firstName: prefillFirstName,
lastName: "", lastName: prefillLastName,
email: "", email: prefillEmail,
phone: "", phone: "",
artForm: "", artForm: "",
experience: "", experience: "",
@@ -42,7 +53,12 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
const submitMutation = useMutation({ const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(), ...orpc.submitRegistration.mutationOptions(),
onSuccess: (result) => { 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) => { onError: (error) => {
toast.error(`Er is iets misgegaan: ${error.message}`); toast.error(`Er is iets misgegaan: ${error.message}`);
@@ -249,14 +265,15 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
id="p-email" id="p-email"
name="email" name="email"
value={data.email} value={data.email}
onChange={handleChange} onChange={isLoggedIn ? undefined : handleChange}
onBlur={handleBlur} onBlur={isLoggedIn ? undefined : handleBlur}
readOnly={isLoggedIn}
placeholder="jouw@email.be" placeholder="jouw@email.be"
autoComplete="email" autoComplete="email"
inputMode="email" inputMode="email"
aria-required="true" aria-required="true"
aria-invalid={touched.email && !!errors.email} 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 && ( {touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert"> <span className="text-red-300 text-sm" role="alert">

View File

@@ -2,10 +2,12 @@ import { useState } from "react";
interface Props { interface Props {
token: string; token: string;
email?: string;
name?: string;
onReset: () => void; onReset: () => void;
} }
export function SuccessScreen({ token, onReset }: Props) { export function SuccessScreen({ token, email, name, onReset }: Props) {
const manageUrl = const manageUrl =
typeof window !== "undefined" typeof window !== "undefined"
? `${window.location.origin}/manage/${token}` ? `${window.location.origin}/manage/${token}`
@@ -33,6 +35,11 @@ export function SuccessScreen({ token, onReset }: Props) {
setDrinkkaartPromptDismissed(true); 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 ( return (
<section <section
id="registration" id="registration"
@@ -90,27 +97,46 @@ export function SuccessScreen({ token, onReset }: Props) {
</button> </button>
</div> </div>
{/* Drinkkaart account prompt */} {/* Account creation prompt */}
{!drinkkaartPromptDismissed && ( {!drinkkaartPromptDismissed && (
<div className="mt-8 rounded-lg border border-white/20 bg-white/10 p-6"> <div className="mt-8 rounded-xl border border-teal-400/30 bg-teal-400/10 p-6">
<h3 className="mb-2 font-['Intro',sans-serif] text-lg text-white"> <h3 className="mb-1 font-['Intro',sans-serif] text-lg text-white">
Wil je een Drinkkaart? Maak een gratis account aan
</h3> </h3>
<p className="mb-4 text-sm text-white/70"> <p className="mb-4 text-sm text-white/70">
Maak een gratis account aan om je digitale Drinkkaart te Met een account zie je je inschrijving, activeer je je
activeren en saldo op te laden vóór het evenement. Drinkkaart en laad je saldo op vóór het evenement.
</p> </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 <a
href="/login" 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:bg-gray-100" 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 Account aanmaken
{name && (
<span className="ml-1.5 font-normal text-xs opacity-60">
als {name}
</span>
)}
</a> </a>
<button <button
type="button" type="button"
onClick={handleDismissPrompt} 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 Overslaan
</button> </button>

View File

@@ -1,6 +1,7 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { import {
calculateDrinkCard, calculateDrinkCard,
type GuestEntry, type GuestEntry,
@@ -24,13 +25,231 @@ interface WatcherErrors {
interface Props { interface Props {
onBack: () => void; 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({ const [data, setData] = useState({
firstName: "", firstName: prefillFirstName,
lastName: "", lastName: prefillLastName,
email: "", email: prefillEmail,
phone: "", phone: "",
extraQuestions: "", extraQuestions: "",
}); });
@@ -40,16 +259,37 @@ export function WatcherForm({ onBack }: Props) {
const [guestErrors, setGuestErrors] = useState<GuestErrors[]>([]); const [guestErrors, setGuestErrors] = useState<GuestErrors[]>([]);
const [giftAmount, setGiftAmount] = useState(0); 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({ const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(), ...orpc.submitRegistration.mutationOptions(),
onSuccess: async (result) => { onSuccess: async (result) => {
if (!result.managementToken) return; if (!result.managementToken) return;
// Redirect to Lemon Squeezy checkout immediately after registration
try { try {
const redirectUrl = isLoggedIn
? `${window.location.origin}/account?topup=success`
: undefined;
const checkout = await client.getCheckoutUrl({ const checkout = await client.getCheckoutUrl({
token: result.managementToken, token: result.managementToken,
redirectUrl,
}); });
if (isLoggedIn) {
// Already logged in — skip the account-creation modal, go straight to checkout
window.location.href = checkout.checkoutUrl; 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) { } catch (error) {
console.error("Checkout error:", error); console.error("Checkout error:", error);
toast.error("Er is iets misgegaan bij het aanmaken van de betaling"); toast.error("Er is iets misgegaan bij het aanmaken van de betaling");
@@ -162,6 +402,19 @@ export function WatcherForm({ onBack }: Props) {
return ( return (
<div> <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 */} {/* Back + type header */}
<div className="mb-8 flex items-center gap-4"> <div className="mb-8 flex items-center gap-4">
<button <button
@@ -291,14 +544,15 @@ export function WatcherForm({ onBack }: Props) {
id="w-email" id="w-email"
name="email" name="email"
value={data.email} value={data.email}
onChange={handleChange} onChange={isLoggedIn ? undefined : handleChange}
onBlur={handleBlur} onBlur={isLoggedIn ? undefined : handleBlur}
readOnly={isLoggedIn}
placeholder="jouw@email.be" placeholder="jouw@email.be"
autoComplete="email" autoComplete="email"
inputMode="email" inputMode="email"
aria-required="true" aria-required="true"
aria-invalid={touched.email && !!errors.email} 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 && ( {touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert"> <span className="text-red-300 text-sm" role="alert">

View File

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

View File

@@ -109,6 +109,32 @@ html {
border-radius: 3px; 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 ───────────────────────────────────────────────── /* ─── Text Selection Colors ─────────────────────────────────────────────────
Each section gets a ::selection style that harmonizes with its background. Each section gets a ::selection style that harmonizes with its background.
The goal: selection feels native to each section, not a browser default. 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 LoginRouteImport } from './routes/login'
import { Route as DrinkkaartRouteImport } from './routes/drinkkaart' import { Route as DrinkkaartRouteImport } from './routes/drinkkaart'
import { Route as ContactRouteImport } from './routes/contact' import { Route as ContactRouteImport } from './routes/contact'
import { Route as AccountRouteImport } from './routes/account'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as AdminIndexRouteImport } from './routes/admin/index' import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as ManageTokenRouteImport } from './routes/manage.$token' import { Route as ManageTokenRouteImport } from './routes/manage.$token'
@@ -47,6 +48,11 @@ const ContactRoute = ContactRouteImport.update({
path: '/contact', path: '/contact',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AccountRoute = AccountRouteImport.update({
id: '/account',
path: '/account',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -85,6 +91,7 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute '/drinkkaart': typeof DrinkkaartRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -99,6 +106,7 @@ export interface FileRoutesByFullPath {
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute '/drinkkaart': typeof DrinkkaartRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -114,6 +122,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute '/drinkkaart': typeof DrinkkaartRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -130,6 +139,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/account'
| '/contact' | '/contact'
| '/drinkkaart' | '/drinkkaart'
| '/login' | '/login'
@@ -144,6 +154,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/account'
| '/contact' | '/contact'
| '/drinkkaart' | '/drinkkaart'
| '/login' | '/login'
@@ -158,6 +169,7 @@ export interface FileRouteTypes {
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/account'
| '/contact' | '/contact'
| '/drinkkaart' | '/drinkkaart'
| '/login' | '/login'
@@ -173,6 +185,7 @@ export interface FileRouteTypes {
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AccountRoute: typeof AccountRoute
ContactRoute: typeof ContactRoute ContactRoute: typeof ContactRoute
DrinkkaartRoute: typeof DrinkkaartRoute DrinkkaartRoute: typeof DrinkkaartRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
@@ -223,6 +236,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ContactRouteImport preLoaderRoute: typeof ContactRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/account': {
id: '/account'
path: '/account'
fullPath: '/account'
preLoaderRoute: typeof AccountRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@@ -277,6 +297,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AccountRoute: AccountRoute,
ContactRoute: ContactRoute, ContactRoute: ContactRoute,
DrinkkaartRoute: DrinkkaartRoute, DrinkkaartRoute: DrinkkaartRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,

View File

@@ -1,5 +1,4 @@
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { import {
createRootRouteWithContext, createRootRouteWithContext,
@@ -159,7 +158,7 @@ function RootDocument() {
<head> <head>
<HeadContent /> <HeadContent />
</head> </head>
<body> <body className="flex min-h-screen flex-col bg-[#214e51]">
<a <a
href="#main-content" 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" 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 Ga naar hoofdinhoud
</a> </a>
<SiteHeader /> <SiteHeader />
<div id="main-content"> <main id="main-content" className="flex-1">
<Outlet /> <Outlet />
</div> </main>
<Toaster /> <Toaster />
<CookieConsent /> <CookieConsent />
<TanStackRouterDevtools position="bottom-left" /> <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 ( return (
<div className="min-h-screen bg-[#214e51]"> <div>
{/* Header */} {/* Header */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-6 py-5"> <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"> <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 { useMutation, useQuery } from "@tanstack/react-query";
import { import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
createFileRoute,
Link,
redirect,
useNavigate,
} from "@tanstack/react-router";
import { import {
Check, Check,
ChevronDown, ChevronDown,
@@ -34,16 +29,6 @@ import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/admin/")({ export const Route = createFileRoute("/admin/")({
component: AdminPage, 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() { function AdminPage() {
@@ -60,6 +45,12 @@ function AdminPage() {
const [copiedId, setCopiedId] = useState<string | null>(null); 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 handleCopyManageUrl = (token: string, id: string) => {
const url = `${window.location.origin}/manage/${token}`; const url = `${window.location.origin}/manage/${token}`;
navigator.clipboard.writeText(url).then(() => { 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 () => { const handleSignOut = async () => {
await authClient.signOut(); await authClient.signOut();
navigate({ to: "/" }); navigate({ to: "/" });
@@ -163,6 +164,10 @@ function AdminPage() {
rejectRequestMutation.mutate({ requestId }); rejectRequestMutation.mutate({ requestId });
}; };
const handleRequestAdmin = () => {
requestAdminMutation.mutate(undefined);
};
const stats = statsQuery.data; const stats = statsQuery.data;
const registrations = registrationsQuery.data?.data ?? []; const registrations = registrationsQuery.data?.data ?? [];
const pagination = registrationsQuery.data?.pagination; const pagination = registrationsQuery.data?.pagination;
@@ -273,8 +278,119 @@ function AdminPage() {
return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`; 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 ( 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 */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-8 py-6"> <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"> <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 { createHmac, randomUUID } from "node:crypto";
import { creditRegistrationToAccount } from "@kk/api/routers/index";
import { db } from "@kk/db"; import { db } from "@kk/db";
import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema"; import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { env } from "@kk/env/server"; import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
@@ -176,9 +178,21 @@ async function handleWebhook({ request }: { request: Request }) {
const orderId = event.data.id; const orderId = event.data.id;
const customerId = String(event.data.attributes.customer_id); const customerId = String(event.data.attributes.customer_id);
// Update registration in database. // Fetch the registration row first so we can use its email + drinkCardValue.
// Covers both "pending" (initial payment) and "extra_payment_pending" const regRow = await db
// (delta payment after adding guests to an already-paid registration). .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 await db
.update(registration) .update(registration)
.set({ .set({
@@ -194,6 +208,50 @@ async function handleWebhook({ request }: { request: Request }) {
`Payment successful for registration ${registrationToken}, order ${orderId}`, `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 }); return new Response("OK", { status: 200 });
} catch (error) { } catch (error) {
console.error("Webhook processing error:", error); console.error("Webhook processing error:", error);

View File

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

View File

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

View File

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

View File

@@ -163,7 +163,7 @@ export const drinkkaartRouter = {
product_options: { product_options: {
name: "Drinkkaart Opladen", name: "Drinkkaart Opladen",
description: `Drinkkaart top-up — ${formatCents(input.amountCents)}`, 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: { checkout_data: {
email: session.user.email, email: session.user.email,

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { db } from "@kk/db"; import { db } from "@kk/db";
import { adminRequest, registration } from "@kk/db/schema"; import { adminRequest, registration } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth"; import { user } from "@kk/db/schema/auth";
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
import { env } from "@kk/env/server"; import { env } from "@kk/env/server";
import type { RouterClient } from "@orpc/server"; import type { RouterClient } from "@orpc/server";
import { and, count, desc, eq, gte, isNull, like, lte, sum } from "drizzle-orm"; import { and, count, desc, eq, gte, isNull, like, lte, sum } from "drizzle-orm";
@@ -12,6 +13,7 @@ import {
sendUpdateEmail, sendUpdateEmail,
} from "../email"; } from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index"; import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { generateQrSecret } from "../lib/drinkkaart-utils";
import { drinkkaartRouter } from "./drinkkaart"; 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. */ /** Fetches a single registration by management token, throws if not found or cancelled. */
async function getActiveRegistration(token: string) { async function getActiveRegistration(token: string) {
const rows = await db 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 // Admin access requests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -555,7 +735,12 @@ export const appRouter = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
getCheckoutUrl: publicProcedure getCheckoutUrl: publicProcedure
.input(z.object({ token: z.string().uuid() })) .input(
z.object({
token: z.string().uuid(),
redirectUrl: z.string().optional(),
}),
)
.handler(async ({ input }) => { .handler(async ({ input }) => {
if ( if (
!env.LEMON_SQUEEZY_API_KEY || !env.LEMON_SQUEEZY_API_KEY ||
@@ -629,7 +814,9 @@ export const appRouter = {
product_options: { product_options: {
name: "Kunstenkamp Evenement", name: "Kunstenkamp Evenement",
description: productDescription, description: productDescription,
redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`, redirect_url:
input.redirectUrl ??
`${env.CORS_ORIGIN}/manage/${input.token}`,
}, },
checkout_data: { checkout_data: {
email: row.email, 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"), lemonsqueezyOrderId: text("lemonsqueezy_order_id"),
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"), lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }), 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" }) createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(), .notNull(),