Compare commits

3 Commits
master ... ezpz

Author SHA1 Message Date
d5fde568da feat(registration): update event date and add required guest fields
Change event date from April 18 to April 24 across all pages and emails.
Add birthdate and postcode as required fields for guest registration.
Update API to support multiple registrations per user. Enhance admin
panel with expandable guest details view.
2026-03-07 15:49:56 +01:00
dcf21a80e2 feat:add birthdate and postcode 2026-03-07 02:46:14 +01:00
ac466a7f0e feat:switch back to lemonsqueezy 2026-03-07 02:28:03 +01:00
24 changed files with 1078 additions and 538 deletions

View File

@@ -36,6 +36,7 @@ export default function EventRegistrationForm() {
token={successState.token} token={successState.token}
email={successState.email} email={successState.email}
name={successState.name} name={successState.name}
isLoggedIn={isLoggedIn}
onReset={() => { onReset={() => {
setSuccessState(null); setSuccessState(null);
setSelectedType(null); setSelectedType(null);

View File

@@ -83,7 +83,7 @@ export default function Hero() {
{/* Bottom Right - Dark Teal with date - above mic */} {/* Bottom Right - Dark Teal with date - above mic */}
<div className="relative flex flex-1 items-center justify-end bg-[#214e51]"> <div className="relative flex flex-1 items-center justify-end bg-[#214e51]">
<p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(2rem,5vw,6rem)] text-white uppercase leading-[1.1]"> <p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(2rem,5vw,6rem)] text-white uppercase leading-[1.1]">
VRIJDAG 18 VRIJDAG 24
<br /> <br />
april april
</p> </p>
@@ -153,7 +153,7 @@ export default function Hero() {
<p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight"> <p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight">
VRIJDAG VRIJDAG
<br /> <br />
18 april 24 april
</p> </p>
</div> </div>
</div> </div>

View File

@@ -204,6 +204,51 @@ export function GuestList({
)} )}
</div> </div>
<div className="flex flex-col gap-2">
<label
htmlFor={`guest-${idx}-birthdate`}
className="text-white/80"
>
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id={`guest-${idx}-birthdate`}
value={guest.birthdate}
onChange={(e) => onChange(idx, "birthdate", e.target.value)}
autoComplete="off"
className={inputCls(!!errors[idx]?.birthdate)}
/>
{errors[idx]?.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].birthdate}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label
htmlFor={`guest-${idx}-postcode`}
className="text-white/80"
>
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id={`guest-${idx}-postcode`}
value={guest.postcode}
onChange={(e) => onChange(idx, "postcode", e.target.value)}
placeholder="1234 AB"
autoComplete="off"
className={inputCls(!!errors[idx]?.postcode)}
/>
{errors[idx]?.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].postcode}
</span>
)}
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor={`guest-${idx}-email`} className="text-white/80"> <label htmlFor={`guest-${idx}-email`} className="text-white/80">
E-mail E-mail

View File

@@ -15,6 +15,8 @@ interface PerformerErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
postcode?: string;
birthdate?: string;
artForm?: string; artForm?: string;
isOver16?: string; isOver16?: string;
} }
@@ -41,6 +43,8 @@ export function PerformerForm({
lastName: prefillLastName, lastName: prefillLastName,
email: prefillEmail, email: prefillEmail,
phone: "", phone: "",
postcode: "",
birthdate: "",
artForm: "", artForm: "",
experience: "", experience: "",
isOver16: false, isOver16: false,
@@ -71,6 +75,8 @@ export function PerformerForm({
lastName: validateTextField(data.lastName, true, "Achternaam"), lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email), email: validateEmail(data.email),
phone: validatePhone(data.phone), phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined, artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined,
isOver16: !data.isOver16 isOver16: !data.isOver16
? "Je moet 16 jaar of ouder zijn om op te treden" ? "Je moet 16 jaar of ouder zijn om op te treden"
@@ -82,6 +88,8 @@ export function PerformerForm({
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
postcode: true,
birthdate: true,
artForm: true, artForm: true,
isOver16: true, isOver16: true,
}); });
@@ -110,6 +118,14 @@ export function PerformerForm({
), ),
email: validateEmail(name === "email" ? value : data.email), email: validateEmail(name === "email" ? value : data.email),
phone: validatePhone(name === "phone" ? value : data.phone), phone: validatePhone(name === "phone" ? value : data.phone),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
artForm: artForm:
name === "artForm" && !value.trim() name === "artForm" && !value.trim()
? "Kunstvorm is verplicht" ? "Kunstvorm is verplicht"
@@ -129,6 +145,14 @@ export function PerformerForm({
lastName: validateTextField(value, true, "Achternaam"), lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value), email: validateEmail(value),
phone: validatePhone(value), phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
artForm: !value.trim() ? "Kunstvorm is verplicht" : undefined, artForm: !value.trim() ? "Kunstvorm is verplicht" : undefined,
}; };
setErrors((prev) => ({ ...prev, [name]: errMap[name] })); setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
@@ -145,6 +169,8 @@ export function PerformerForm({
lastName: data.lastName.trim(), lastName: data.lastName.trim(),
email: data.email.trim(), email: data.email.trim(),
phone: data.phone.trim() || undefined, phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "performer", registrationType: "performer",
artForm: data.artForm.trim() || undefined, artForm: data.artForm.trim() || undefined,
experience: data.experience.trim() || undefined, experience: data.experience.trim() || undefined,
@@ -306,6 +332,54 @@ export function PerformerForm({
</div> </div>
</div> </div>
{/* Postcode + Birthdate row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="p-postcode" className="text-white text-xl">
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id="p-postcode"
name="postcode"
value={data.postcode}
onChange={handleChange}
onBlur={handleBlur}
placeholder="1234 AB"
autoComplete="postal-code"
aria-required="true"
aria-invalid={touched.postcode && !!errors.postcode}
className={inputCls(!!touched.postcode && !!errors.postcode)}
/>
{touched.postcode && errors.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors.postcode}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="p-birthdate" className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id="p-birthdate"
name="birthdate"
value={data.birthdate}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="bday"
aria-invalid={touched.birthdate && !!errors.birthdate}
className={`${inputCls(!!touched.birthdate && !!errors.birthdate)} [color-scheme:dark]`}
/>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
</div>
{/* Performer-specific fields */} {/* Performer-specific fields */}
<div className="border border-amber-400/20 bg-amber-400/5 p-6"> <div className="border border-amber-400/20 bg-amber-400/5 p-6">
<p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider"> <p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider">

View File

@@ -5,9 +5,16 @@ interface Props {
email?: string; email?: string;
name?: string; name?: string;
onReset: () => void; onReset: () => void;
isLoggedIn?: boolean;
} }
export function SuccessScreen({ token, email, name, onReset }: Props) { export function SuccessScreen({
token,
email,
name,
onReset,
isLoggedIn,
}: Props) {
const manageUrl = const manageUrl =
typeof window !== "undefined" typeof window !== "undefined"
? `${window.location.origin}/manage/${token}` ? `${window.location.origin}/manage/${token}`
@@ -97,8 +104,8 @@ export function SuccessScreen({ token, email, name, onReset }: Props) {
</button> </button>
</div> </div>
{/* Account creation prompt */} {/* Account creation prompt — hidden when already logged in */}
{!drinkkaartPromptDismissed && ( {!isLoggedIn && !drinkkaartPromptDismissed && (
<div className="mt-8 rounded-xl border border-teal-400/30 bg-teal-400/10 p-6"> <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"> <h3 className="mb-1 font-['Intro',sans-serif] text-lg text-white">
Maak een gratis account aan Maak een gratis account aan

View File

@@ -21,6 +21,8 @@ interface WatcherErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
postcode?: string;
birthdate?: string;
} }
interface Props { interface Props {
@@ -251,6 +253,8 @@ export function WatcherForm({
lastName: prefillLastName, lastName: prefillLastName,
email: prefillEmail, email: prefillEmail,
phone: "", phone: "",
postcode: "",
birthdate: "",
extraQuestions: "", extraQuestions: "",
}); });
const [errors, setErrors] = useState<WatcherErrors>({}); const [errors, setErrors] = useState<WatcherErrors>({});
@@ -306,6 +310,8 @@ export function WatcherForm({
lastName: validateTextField(data.lastName, true, "Achternaam"), lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email), email: validateEmail(data.email),
phone: validatePhone(data.phone), phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
}; };
setErrors(fieldErrs); setErrors(fieldErrs);
setTouched({ setTouched({
@@ -313,6 +319,8 @@ export function WatcherForm({
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
postcode: true,
birthdate: true,
}); });
const { errors: gErrs, valid: gValid } = validateGuests(guests); const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs); setGuestErrors(gErrs);
@@ -330,6 +338,14 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"), lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value), email: validateEmail(value),
phone: validatePhone(value), phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
}; };
setErrors((prev) => ({ ...prev, [name]: errMap[name] })); setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
} }
@@ -345,6 +361,14 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"), lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value), email: validateEmail(value),
phone: validatePhone(value), phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
}; };
setErrors((prev) => ({ ...prev, [name]: errMap[name] })); setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
} }
@@ -365,7 +389,14 @@ export function WatcherForm({
if (guests.length >= 9) return; if (guests.length >= 9) return;
setGuests((prev) => [ setGuests((prev) => [
...prev, ...prev,
{ firstName: "", lastName: "", email: "", phone: "" }, {
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
]); ]);
setGuestErrors((prev) => [...prev, {}]); setGuestErrors((prev) => [...prev, {}]);
} }
@@ -386,12 +417,16 @@ export function WatcherForm({
lastName: data.lastName.trim(), lastName: data.lastName.trim(),
email: data.email.trim(), email: data.email.trim(),
phone: data.phone.trim() || undefined, phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "watcher", registrationType: "watcher",
guests: guests.map((g) => ({ guests: guests.map((g) => ({
firstName: g.firstName.trim(), firstName: g.firstName.trim(),
lastName: g.lastName.trim(), lastName: g.lastName.trim(),
email: g.email.trim() || undefined, email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined, phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})), })),
extraQuestions: data.extraQuestions.trim() || undefined, extraQuestions: data.extraQuestions.trim() || undefined,
giftAmount, giftAmount,
@@ -585,6 +620,54 @@ export function WatcherForm({
</div> </div>
</div> </div>
{/* Postcode + Birthdate row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor="w-postcode" className="text-white text-xl">
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id="w-postcode"
name="postcode"
value={data.postcode}
onChange={handleChange}
onBlur={handleBlur}
placeholder="1234 AB"
autoComplete="postal-code"
aria-required="true"
aria-invalid={touched.postcode && !!errors.postcode}
className={inputCls(!!touched.postcode && !!errors.postcode)}
/>
{touched.postcode && errors.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors.postcode}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="w-birthdate" className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id="w-birthdate"
name="birthdate"
value={data.birthdate}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="bday"
aria-invalid={touched.birthdate && !!errors.birthdate}
className={`${inputCls(!!touched.birthdate && !!errors.birthdate)} [color-scheme:dark]`}
/>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
</div>
{/* Guests */} {/* Guests */}
<GuestList <GuestList
guests={guests} guests={guests}

View File

@@ -28,6 +28,8 @@ export interface GuestEntry {
lastName: string; lastName: string;
email: string; email: string;
phone: string; phone: string;
birthdate: string;
postcode: string;
} }
export interface GuestErrors { export interface GuestErrors {
@@ -35,6 +37,8 @@ export interface GuestErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
} }
/** /**
@@ -51,6 +55,8 @@ export function parseGuests(raw: string | null | undefined): GuestEntry[] {
lastName: g.lastName ?? "", lastName: g.lastName ?? "",
email: g.email ?? "", email: g.email ?? "",
phone: g.phone ?? "", phone: g.phone ?? "",
birthdate: g.birthdate ?? "",
postcode: g.postcode ?? "",
})); }));
} catch { } catch {
return []; return [];
@@ -102,6 +108,8 @@ export function validateGuests(guests: GuestEntry[]): {
g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, "")) g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, ""))
? "Voer een geldig telefoonnummer in" ? "Voer een geldig telefoonnummer in"
: undefined, : undefined,
birthdate: !g.birthdate.trim() ? "Geboortedatum is verplicht" : undefined,
postcode: !g.postcode.trim() ? "Postcode is verplicht" : undefined,
})); }));
const valid = !errors.some((e) => Object.values(e).some(Boolean)); const valid = !errors.some((e) => Object.values(e).some(Boolean));
return { errors, valid }; return { errors, valid };

View File

@@ -19,7 +19,7 @@ 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'
import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart' import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart'
import { Route as ApiWebhookMollieRouteImport } from './routes/api/webhook/mollie' import { Route as ApiWebhookLemonsqueezyRouteImport } from './routes/api/webhook/lemonsqueezy'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
@@ -73,9 +73,9 @@ const AdminDrinkkaartRoute = AdminDrinkkaartRouteImport.update({
path: '/admin/drinkkaart', path: '/admin/drinkkaart',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiWebhookMollieRoute = ApiWebhookMollieRouteImport.update({ const ApiWebhookLemonsqueezyRoute = ApiWebhookLemonsqueezyRouteImport.update({
id: '/api/webhook/mollie', id: '/api/webhook/lemonsqueezy',
path: '/api/webhook/mollie', path: '/api/webhook/lemonsqueezy',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
@@ -102,7 +102,7 @@ export interface FileRoutesByFullPath {
'/admin/': typeof AdminIndexRoute '/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute '/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -117,7 +117,7 @@ export interface FileRoutesByTo {
'/admin': typeof AdminIndexRoute '/admin': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute '/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -133,7 +133,7 @@ export interface FileRoutesById {
'/admin/': typeof AdminIndexRoute '/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute '/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -150,7 +150,7 @@ export interface FileRouteTypes {
| '/admin/' | '/admin/'
| '/api/auth/$' | '/api/auth/$'
| '/api/rpc/$' | '/api/rpc/$'
| '/api/webhook/mollie' | '/api/webhook/lemonsqueezy'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -165,7 +165,7 @@ export interface FileRouteTypes {
| '/admin' | '/admin'
| '/api/auth/$' | '/api/auth/$'
| '/api/rpc/$' | '/api/rpc/$'
| '/api/webhook/mollie' | '/api/webhook/lemonsqueezy'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -180,7 +180,7 @@ export interface FileRouteTypes {
| '/admin/' | '/admin/'
| '/api/auth/$' | '/api/auth/$'
| '/api/rpc/$' | '/api/rpc/$'
| '/api/webhook/mollie' | '/api/webhook/lemonsqueezy'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -196,7 +196,7 @@ export interface RootRouteChildren {
AdminIndexRoute: typeof AdminIndexRoute AdminIndexRoute: typeof AdminIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute
ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute ApiWebhookLemonsqueezyRoute: typeof ApiWebhookLemonsqueezyRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -271,11 +271,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminDrinkkaartRouteImport preLoaderRoute: typeof AdminDrinkkaartRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/webhook/mollie': { '/api/webhook/lemonsqueezy': {
id: '/api/webhook/mollie' id: '/api/webhook/lemonsqueezy'
path: '/api/webhook/mollie' path: '/api/webhook/lemonsqueezy'
fullPath: '/api/webhook/mollie' fullPath: '/api/webhook/lemonsqueezy'
preLoaderRoute: typeof ApiWebhookMollieRouteImport preLoaderRoute: typeof ApiWebhookLemonsqueezyRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/rpc/$': { '/api/rpc/$': {
@@ -308,7 +308,7 @@ const rootRouteChildren: RootRouteChildren = {
AdminIndexRoute: AdminIndexRoute, AdminIndexRoute: AdminIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute,
ApiWebhookMollieRoute: ApiWebhookMollieRoute, ApiWebhookLemonsqueezyRoute: ApiWebhookLemonsqueezyRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -17,7 +17,7 @@ import appCss from "../index.css?url";
const siteUrl = "https://kunstenkamp.be"; const siteUrl = "https://kunstenkamp.be";
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord"; const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord";
const siteDescription = const siteDescription =
"Doe mee met de Open Mic Night op 18 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional."; "Doe mee met de Open Mic Night op 24 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional.";
const eventImage = `${siteUrl}/assets/og-image.jpg`; const eventImage = `${siteUrl}/assets/og-image.jpg`;
export interface RouterAppContext { export interface RouterAppContext {

View File

@@ -90,7 +90,7 @@ function AccountPage() {
orpc.drinkkaart.getMyDrinkkaart.queryOptions(), orpc.drinkkaart.getMyDrinkkaart.queryOptions(),
); );
const registrationQuery = useQuery(orpc.getMyRegistration.queryOptions()); const registrationQuery = useQuery(orpc.getMyRegistrations.queryOptions());
// Handle topup=success redirect (from Lemon Squeezy returning to /account) // Handle topup=success redirect (from Lemon Squeezy returning to /account)
useEffect(() => { useEffect(() => {
@@ -128,7 +128,7 @@ function AccountPage() {
| { name?: string; email?: string } | { name?: string; email?: string }
| undefined; | undefined;
const registration = registrationQuery.data; const registrations = registrationQuery.data ?? [];
const drinkkaart = drinkkaartQuery.data; const drinkkaart = drinkkaartQuery.data;
const isLoading = const isLoading =
@@ -170,112 +170,122 @@ function AccountPage() {
Mijn Inschrijving Mijn Inschrijving
</h2> </h2>
{registration ? ( {registrations.length > 0 ? (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6"> <div className="flex flex-col gap-4">
{/* Type badge */} {registrations.map((registration) => (
<div className="mb-4 flex items-center justify-between"> <div
<div className="flex items-center gap-2"> key={registration.id}
{registration.registrationType === "performer" ? ( className="rounded-2xl border border-white/10 bg-white/5 p-6"
<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" /> {/* Type badge */}
Artiest <div className="mb-4 flex items-center justify-between">
</span> <div className="flex items-center gap-2">
) : ( {registration.registrationType === "performer" ? (
<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"> <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">
<Users className="h-3.5 w-3.5" /> <Music className="h-3.5 w-3.5" />
Bezoeker Artiest
</span> </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>
{(registration.registrationType !== "performer" ||
(registration.giftAmount ?? 0) > 0) && (
<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>
<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>
) : ( ) : (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6"> <div className="rounded-2xl border border-white/10 bg-white/5 p-6">

View File

@@ -13,7 +13,7 @@ import {
Users, Users,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -44,6 +44,19 @@ function AdminPage() {
const pageSize = 20; const pageSize = 20;
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandedGuests, setExpandedGuests] = useState<Set<string>>(new Set());
const toggleGuests = (id: string) => {
setExpandedGuests((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
// Get current session to check user role // Get current session to check user role
const sessionQuery = useQuery({ const sessionQuery = useQuery({
@@ -278,6 +291,25 @@ function AdminPage() {
return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`; return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`;
}; };
const parseGuestsJson = (
raw: string | null | undefined,
): Array<{
firstName: string;
lastName: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}> => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw as string);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
// Check if user is admin // Check if user is admin
const user = sessionQuery.data?.data?.user as const user = sessionQuery.data?.data?.user as
| { role?: string; name?: string } | { role?: string; name?: string }
@@ -486,7 +518,6 @@ function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Stats Cards */} {/* Stats Cards */}
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-5 lg:gap-6"> <div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-5 lg:gap-6">
<Card className="border-white/10 bg-white/5"> <Card className="border-white/10 bg-white/5">
@@ -581,79 +612,6 @@ function AdminPage() {
<CardDescription className="text-pink-300/70 text-xs sm:text-sm"> <CardDescription className="text-pink-300/70 text-xs sm:text-sm">
Vrijwillige Gifts Vrijwillige Gifts
</CardDescription> </CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-white sm:text-3xl lg:text-4xl">
{stats?.today ?? 0}
</CardTitle>
</CardHeader>
<CardContent>
<span className="text-white/40 text-xs sm:text-sm">
Nieuwe registraties vandaag
</span>
</CardContent>
</Card>
<Card className="border-amber-400/20 bg-amber-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-amber-300/70">
Artiesten
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-amber-300 sm:text-3xl lg:text-4xl">
{performerCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{stats?.byArtForm.slice(0, 2).map((item) => (
<div
key={item.artForm}
className="flex items-center justify-between text-xs"
>
<span className="truncate text-amber-300/70">
{item.artForm || "Onbekend"}
</span>
<span className="text-amber-300">{item.count}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-teal-400/20 bg-teal-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-teal-300/70">
Bezoekers
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-teal-300 sm:text-3xl lg:text-4xl">
{watcherCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-xs">
{totalGuestCount > 0 && (
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Inclusief gasten</span>
<span className="text-teal-300">+{totalGuestCount}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Totaal aanwezig</span>
<span className="font-semibold text-teal-300">
{totalWatcherAttendees}
</span>
</div>
<div className="hidden sm:flex sm:items-center sm:justify-between">
<span className="text-teal-300/70">Drinkkaart</span>
<span className="text-teal-300">{totalDrinkCardValue}</span>
</div>
</div>
</CardContent>
</Card>
<Card className="border-pink-400/20 bg-pink-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-pink-300/70">
Vrijwillige Gifts
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-2xl text-pink-300 sm:text-3xl lg:text-4xl"> <CardTitle className="font-['Intro',sans-serif] text-2xl text-pink-300 sm:text-3xl lg:text-4xl">
{Math.round(totalGiftRevenue / 100)} {Math.round(totalGiftRevenue / 100)}
</CardTitle> </CardTitle>
@@ -665,7 +623,6 @@ function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Filters */} {/* Filters */}
<Card className="mb-6 border-white/10 bg-white/5"> <Card className="mb-6 border-white/10 bg-white/5">
<CardHeader className="px-4 py-4 sm:px-6 sm:py-6"> <CardHeader className="px-4 py-4 sm:px-6 sm:py-6">
@@ -773,7 +730,6 @@ function AdminPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Export Button */} {/* Export Button */}
<div className="mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/60"> <p className="text-sm text-white/60">
@@ -788,7 +744,6 @@ function AdminPage() {
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"} {exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
</Button> </Button>
</div> </div>
{/* Registrations Table / Cards */} {/* Registrations Table / Cards */}
<Card className="border-white/10 bg-white/5"> <Card className="border-white/10 bg-white/5">
<CardContent className="p-0"> <CardContent className="p-0">
@@ -833,6 +788,21 @@ function AdminPage() {
<th className={thClass} onClick={() => handleSort("datum")}> <th className={thClass} onClick={() => handleSort("datum")}>
Datum <SortIcon col="datum" /> Datum <SortIcon col="datum" />
</th> </th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Postcode
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Geboortedatum
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Ervaring
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
16+
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Opmerkingen
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider"> <th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Link Link
</th> </th>
@@ -842,7 +812,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? ( {registrationsQuery.isLoading ? (
<tr> <tr>
<td <td
colSpan={10} colSpan={15}
className="px-4 py-8 text-center text-white/60" className="px-4 py-8 text-center text-white/60"
> >
Laden... Laden...
@@ -851,7 +821,7 @@ function AdminPage() {
) : sortedRegistrations.length === 0 ? ( ) : sortedRegistrations.length === 0 ? (
<tr> <tr>
<td <td
colSpan={10} colSpan={15}
className="px-4 py-8 text-center text-white/60" className="px-4 py-8 text-center text-white/60"
> >
Geen registraties gevonden Geen registraties gevonden
@@ -861,15 +831,9 @@ function AdminPage() {
sortedRegistrations.map((reg) => { sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer"; const isPerformer = reg.registrationType === "performer";
const guestCount = (() => { const guests = parseGuestsJson(reg.guests);
if (!reg.guests) return 0; const guestCount = guests.length;
try { const isGuestsExpanded = expandedGuests.has(reg.id);
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer const detailLabel = isPerformer
? reg.artForm || "-" ? reg.artForm || "-"
@@ -891,93 +855,190 @@ function AdminPage() {
})(); })();
return ( return (
<tr <Fragment key={reg.id}>
key={reg.id} <tr
className="border-white/5 border-b hover:bg-white/5" key={reg.id}
> className="border-white/5 border-b hover:bg-white/5"
<td className="px-4 py-3 font-medium text-white"> >
{reg.firstName} {reg.lastName} <td className="px-4 py-3 font-medium text-white">
</td> {reg.firstName} {reg.lastName}
<td className="px-4 py-3 text-sm text-white/70"> </td>
{reg.email} <td className="px-4 py-3 text-sm text-white/70">
</td> {reg.email}
<td className="px-4 py-3 text-sm text-white/60"> </td>
{reg.phone || "-"} <td className="px-4 py-3 text-sm text-white/60">
</td> {reg.phone || "-"}
<td className="px-4 py-3"> </td>
<span <td className="px-4 py-3">
className={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`} <span
> className={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
</td>
<td className="px-4 py-3 text-sm text-white/70">
{detailLabel}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{guestCount > 0
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
: "-"}
</td>
<td className="px-4 py-3 text-pink-300/70 text-sm">
{formatCents(reg.giftAmount)}
</td>
<td className="px-4 py-3">
{isPerformer ? (
<span className="text-sm text-white/30">-</span>
) : reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
<Check className="h-3.5 w-3.5" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
</span>
) : (
<span className="text-sm text-yellow-400">
Open
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
{dateLabel}
</td>
<td className="px-4 py-3">
{reg.managementToken ? (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
> >
{copiedId === reg.id ? ( {isPerformer ? "Artiest" : "Bezoeker"}
<ClipboardCheck className="h-4 w-4 text-green-400" /> </span>
</td>
<td className="px-4 py-3 text-sm text-white/70">
{detailLabel}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{guestCount > 0 ? (
<button
type="button"
onClick={() => toggleGuests(reg.id)}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
{guestCount} gast
{guestCount === 1 ? "" : "en"}
{isGuestsExpanded ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</button>
) : (
"-"
)}
</td>
<td className="px-4 py-3 text-pink-300/70 text-sm">
{formatCents(reg.giftAmount)}
</td>
<td className="px-4 py-3">
{isPerformer ? (
<span className="text-sm text-white/30">-</span>
) : reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
<Check className="h-3.5 w-3.5" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
</span>
) : (
<span className="text-sm text-yellow-400">
Open
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
{dateLabel}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{reg.postcode || "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70 tabular-nums">
{reg.birthdate || "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{isPerformer ? reg.experience || "-" : "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{isPerformer ? (
reg.isOver16 ? (
<span className="text-green-400">Ja</span>
) : ( ) : (
<Clipboard className="h-4 w-4" /> <span className="text-red-400">Nee</span>
)} )
</button> ) : (
) : ( "-"
<span className="text-sm text-white/20"></span> )}
)} </td>
</td> <td className="max-w-[200px] px-4 py-3 text-sm text-white/60">
</tr> <span
className="block truncate"
title={reg.extraQuestions ?? undefined}
>
{reg.extraQuestions || "-"}
</span>
</td>
<td className="px-4 py-3">
{reg.managementToken ? (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
) : (
<span className="text-sm text-white/20"></span>
)}
</td>
</tr>
{isGuestsExpanded &&
guests.map((guest, gi) => (
<tr
key={`${reg.id}-guest-${gi}`}
className="border-white/5 border-b bg-white/[0.02]"
>
<td className="py-2 pr-4 pl-8 text-sm text-white/60 italic">
{guest.firstName} {guest.lastName}
</td>
<td className="px-4 py-2 text-sm text-white/50">
{guest.email || "-"}
</td>
<td className="px-4 py-2 text-sm text-white/50">
{guest.phone || "-"}
</td>
<td className="px-4 py-2">
<span className="inline-flex items-center rounded-full bg-white/5 px-2 py-0.5 font-semibold text-white/40 text-xs">
Gast
</span>
</td>
<td className="px-4 py-2 text-sm text-white/50">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/50">
{guest.postcode || "-"}
</td>
<td className="px-4 py-2 text-sm text-white/50 tabular-nums">
{guest.birthdate || "-"}
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
</tr>
))}
</Fragment>
); );
}) })
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Mobile Cards */}
<div className="lg:hidden"> <div className="lg:hidden">
{registrationsQuery.isLoading ? ( {registrationsQuery.isLoading ? (
<div className="px-4 py-8 text-center text-white/60"> <div className="px-4 py-8 text-center text-white/60">
@@ -992,15 +1053,9 @@ function AdminPage() {
{sortedRegistrations.map((reg) => { {sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer"; const isPerformer = reg.registrationType === "performer";
const guestCount = (() => { const guests = parseGuestsJson(reg.guests);
if (!reg.guests) return 0; const guestCount = guests.length;
try { const isGuestsExpanded = expandedGuests.has(reg.id);
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer const detailLabel = isPerformer
? reg.artForm || "-" ? reg.artForm || "-"
@@ -1022,88 +1077,171 @@ function AdminPage() {
})(); })();
return ( return (
<div key={reg.id} className="p-4 hover:bg-white/5"> <div key={reg.id} className="hover:bg-white/5">
<div className="flex items-start justify-between gap-3"> <div className="p-4">
<div className="min-w-0 flex-1"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2"> <div className="min-w-0 flex-1">
<span className="truncate font-medium text-white"> <div className="flex items-center gap-2">
{reg.firstName} {reg.lastName} <span className="truncate font-medium text-white">
</span> {reg.firstName} {reg.lastName}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-white/60 text-xs">
<span className="truncate">{reg.email}</span>
{reg.phone && (
<span className="shrink-0">
{reg.phone}
</span>
)}
</div>
</div> </div>
<div className="mt-1 flex items-center gap-2 text-white/60 text-xs"> <div className="flex items-center gap-2">
<span className="truncate">{reg.email}</span> <span
{reg.phone && ( className={`shrink-0 rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
<span className="shrink-0"> {reg.phone}</span> >
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
{reg.managementToken && (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="shrink-0 rounded p-1.5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<span <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
className={`shrink-0 rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`} <div className="text-white/70">
> <span className="text-white/40">Details:</span>{" "}
{isPerformer ? "Artiest" : "Bezoeker"} {detailLabel}
</span> </div>
{reg.managementToken && ( {guestCount > 0 && (
<button <button
type="button" type="button"
title="Kopieer beheerlink" onClick={() => toggleGuests(reg.id)}
onClick={() => className="inline-flex items-center gap-1 text-white/70 transition-colors hover:text-white"
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="shrink-0 rounded p-1.5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
> >
{copiedId === reg.id ? ( <span className="text-white/40">Gasten:</span>{" "}
<ClipboardCheck className="h-4 w-4 text-green-400" /> {guestCount}
{isGuestsExpanded ? (
<ChevronUp className="h-3 w-3" />
) : ( ) : (
<Clipboard className="h-4 w-4" /> <ChevronDown className="h-3 w-3" />
)} )}
</button> </button>
)} )}
{(reg.giftAmount ?? 0) > 0 && (
<div className="text-pink-300">
<span className="text-white/40">Gift:</span>{" "}
{formatCents(reg.giftAmount)}
</div>
)}
{!isPerformer && (
<div>
<span className="text-white/40">Betaling:</span>{" "}
{reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400">
<Check className="h-3 w-3" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400">
<span className="h-1 w-1 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(
0,
)}
)
</span>
) : (
<span className="text-yellow-400">Open</span>
)}
</div>
)}
{reg.postcode && (
<div className="text-white/70">
<span className="text-white/40">Postcode:</span>{" "}
{reg.postcode}
</div>
)}
{reg.birthdate && (
<div className="text-white/70">
<span className="text-white/40">
Geboortedatum:
</span>{" "}
{reg.birthdate}
</div>
)}
{isPerformer && reg.experience && (
<div className="text-white/70">
<span className="text-white/40">Ervaring:</span>{" "}
{reg.experience}
</div>
)}
{isPerformer && (
<div className="text-white/70">
<span className="text-white/40">16+:</span>{" "}
{reg.isOver16 ? (
<span className="text-green-400">Ja</span>
) : (
<span className="text-red-400">Nee</span>
)}
</div>
)}
{reg.extraQuestions && (
<div className="w-full text-white/60">
<span className="text-white/40">
Opmerkingen:
</span>{" "}
{reg.extraQuestions}
</div>
)}
<div className="text-white/50">{dateLabel}</div>
</div> </div>
</div> </div>
{isGuestsExpanded && guestCount > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs"> <div className="border-white/5 border-t bg-white/[0.02] px-4 pt-2 pb-3">
<div className="text-white/70"> <div className="mb-1 text-white/30 text-xs">
<span className="text-white/40">Details:</span>{" "} Gasten
{detailLabel} </div>
<div className="flex flex-col gap-2">
{guests.map((guest, gi) => (
<div
key={`${reg.id}-guest-${gi}`}
className="rounded border border-white/10 bg-white/5 px-3 py-2 text-xs"
>
<div className="font-medium text-white/80">
{guest.firstName} {guest.lastName}
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-white/50">
{guest.email && <span>{guest.email}</span>}
{guest.phone && <span>{guest.phone}</span>}
{guest.birthdate && (
<span>{guest.birthdate}</span>
)}
{guest.postcode && (
<span>{guest.postcode}</span>
)}
</div>
</div>
))}
</div>
</div> </div>
{guestCount > 0 && ( )}
<div className="text-white/70">
<span className="text-white/40">Gasten:</span>{" "}
{guestCount}
</div>
)}
{(reg.giftAmount ?? 0) > 0 && (
<div className="text-pink-300">
<span className="text-white/40">Gift:</span>{" "}
{formatCents(reg.giftAmount)}
</div>
)}
{!isPerformer && (
<div>
<span className="text-white/40">Betaling:</span>{" "}
{reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400">
<Check className="h-3 w-3" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400">
<span className="h-1 w-1 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
</span>
) : (
<span className="text-yellow-400">Open</span>
)}
</div>
)}
<div className="text-white/50">{dateLabel}</div>
</div>
</div> </div>
); );
})} })}
@@ -1112,8 +1250,6 @@ function AdminPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && ( {pagination && pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2 sm:mt-6"> <div className="mt-4 flex items-center justify-center gap-2 sm:mt-6">
<Button <Button

View File

@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto"; import { createHmac, randomUUID } from "node:crypto";
import { creditRegistrationToAccount } from "@kk/api/routers/index"; 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";
@@ -7,106 +7,101 @@ 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";
// Mollie payment object (relevant fields only) // LemonSqueezy webhook payload types (order_created event)
interface MolliePayment { interface LemonSqueezyOrderCreatedPayload {
id: string; meta: {
status: string; event_name: string;
amount: { value: string; currency: string }; custom_data?: {
customerId?: string; type?: string;
metadata?: { registration_token?: string;
registration_token?: string; drinkkaartId?: string;
type?: string; userId?: string;
drinkkaartId?: string; };
userId?: string; };
data: {
id: string;
attributes: {
status: string;
customer_id: number;
total: number; // amount in cents
};
}; };
} }
async function fetchMolliePayment(paymentId: string): Promise<MolliePayment> { function verifyWebhookSignature(
const response = await fetch( payload: string,
`https://api.mollie.com/v2/payments/${paymentId}`, signature: string,
{ secret: string,
headers: { ): boolean {
Authorization: `Bearer ${env.MOLLIE_API_KEY}`, const hmac = createHmac("sha256", secret);
}, hmac.update(payload);
}, const digest = hmac.digest("hex");
); return signature === digest;
if (!response.ok) {
throw new Error(
`Failed to fetch Mollie payment ${paymentId}: ${response.status}`,
);
}
return response.json() as Promise<MolliePayment>;
} }
async function handleWebhook({ request }: { request: Request }) { async function handleWebhook({ request }: { request: Request }) {
if (!env.MOLLIE_API_KEY) { if (!env.LEMON_SQUEEZY_WEBHOOK_SECRET) {
console.error("MOLLIE_API_KEY not configured"); console.error("LEMON_SQUEEZY_WEBHOOK_SECRET not configured");
return new Response("Payment provider not configured", { status: 500 }); return new Response("Payment provider not configured", { status: 500 });
} }
// Mollie sends application/x-www-form-urlencoded with a single "id" field const payload = await request.text();
let paymentId: string | null = null; const signature = request.headers.get("X-Signature");
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
if (
!verifyWebhookSignature(
payload,
signature,
env.LEMON_SQUEEZY_WEBHOOK_SECRET,
)
) {
return new Response("Invalid signature", { status: 401 });
}
let event: LemonSqueezyOrderCreatedPayload;
try { try {
const body = await request.text(); event = JSON.parse(payload) as LemonSqueezyOrderCreatedPayload;
const params = new URLSearchParams(body);
paymentId = params.get("id");
} catch { } catch {
return new Response("Invalid request body", { status: 400 }); return new Response("Invalid JSON", { status: 400 });
} }
if (!paymentId) { // Only handle order_created events
return new Response("Missing payment id", { status: 400 }); if (event.meta.event_name !== "order_created") {
return new Response("Event ignored", { status: 200 });
} }
// Fetch-to-verify: retrieve the actual payment from Mollie to confirm its const orderId = event.data.id;
// status. A malicious webhook cannot fake a paid status this way. const customerId = String(event.data.attributes.customer_id);
let payment: MolliePayment; const amountCents = event.data.attributes.total;
try { const customData = event.meta.custom_data;
payment = await fetchMolliePayment(paymentId);
} catch (err) {
console.error("Failed to fetch Mollie payment:", err);
return new Response("Failed to fetch payment", { status: 500 });
}
// Only process paid payments
if (payment.status !== "paid") {
return new Response("Payment status ignored", { status: 200 });
}
const metadata = payment.metadata;
try { try {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Branch: Drinkkaart top-up // Branch: Drinkkaart top-up
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
if (metadata?.type === "drinkkaart_topup") { if (customData?.type === "drinkkaart_topup") {
const { drinkkaartId, userId } = metadata; const { drinkkaartId, userId } = customData;
if (!drinkkaartId || !userId) { if (!drinkkaartId || !userId) {
console.error( console.error(
"Missing drinkkaartId or userId in drinkkaart_topup payment metadata", "Missing drinkkaartId or userId in drinkkaart_topup custom_data",
); );
return new Response("Missing drinkkaart data", { status: 400 }); return new Response("Missing drinkkaart data", { status: 400 });
} }
// Amount in cents — Mollie returns e.g. "10.00"; parse to integer cents
const amountCents = Math.round(
Number.parseFloat(payment.amount.value) * 100,
);
// Idempotency: skip if already processed // Idempotency: skip if already processed
const existing = await db const existing = await db
.select({ id: drinkkaartTopup.id }) .select({ id: drinkkaartTopup.id })
.from(drinkkaartTopup) .from(drinkkaartTopup)
.where(eq(drinkkaartTopup.molliePaymentId, payment.id)) .where(eq(drinkkaartTopup.lemonsqueezyOrderId, orderId))
.limit(1) .limit(1)
.then((r) => r[0]); .then((r) => r[0]);
if (existing) { if (existing) {
console.log( console.log(`Drinkkaart topup already processed for order ${orderId}`);
`Drinkkaart topup already processed for payment ${payment.id}`,
);
return new Response("OK", { status: 200 }); return new Response("OK", { status: 200 });
} }
@@ -141,7 +136,7 @@ async function handleWebhook({ request }: { request: Request }) {
); );
if (result.rowsAffected === 0) { if (result.rowsAffected === 0) {
// Return 500 so Mollie retries; idempotency check prevents double-credit // Return 500 so LemonSqueezy retries; idempotency check prevents double-credit
console.error( console.error(
`Drinkkaart optimistic lock conflict for ${drinkkaartId}`, `Drinkkaart optimistic lock conflict for ${drinkkaartId}`,
); );
@@ -156,14 +151,14 @@ async function handleWebhook({ request }: { request: Request }) {
balanceBefore, balanceBefore,
balanceAfter, balanceAfter,
type: "payment", type: "payment",
molliePaymentId: payment.id, lemonsqueezyOrderId: orderId,
adminId: null, adminId: null,
reason: null, reason: null,
paidAt: new Date(), paidAt: new Date(),
}); });
console.log( console.log(
`Drinkkaart topup successful: drinkkaart=${drinkkaartId}, amount=${amountCents}c, payment=${payment.id}`, `Drinkkaart topup successful: drinkkaart=${drinkkaartId}, amount=${amountCents}c, order=${orderId}`,
); );
return new Response("OK", { status: 200 }); return new Response("OK", { status: 200 });
@@ -172,9 +167,9 @@ async function handleWebhook({ request }: { request: Request }) {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Branch: Registration payment // Branch: Registration payment
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const registrationToken = metadata?.registration_token; const registrationToken = customData?.registration_token;
if (!registrationToken) { if (!registrationToken) {
console.error("No registration token in payment metadata"); console.error("No registration token in order custom_data");
return new Response("Missing registration token", { status: 400 }); return new Response("Missing registration token", { status: 400 });
} }
@@ -197,13 +192,14 @@ async function handleWebhook({ request }: { request: Request }) {
.set({ .set({
paymentStatus: "paid", paymentStatus: "paid",
paymentAmount: 0, paymentAmount: 0,
molliePaymentId: payment.id, lemonsqueezyOrderId: orderId,
lemonsqueezyCustomerId: customerId,
paidAt: new Date(), paidAt: new Date(),
}) })
.where(eq(registration.managementToken, registrationToken)); .where(eq(registration.managementToken, registrationToken));
console.log( console.log(
`Payment successful for registration ${registrationToken}, payment ${payment.id}`, `Payment successful for registration ${registrationToken}, order ${orderId}`,
); );
// If this is a watcher with a drink card value, try to credit their // If this is a watcher with a drink card value, try to credit their
@@ -254,7 +250,7 @@ async function handleWebhook({ request }: { request: Request }) {
} }
} }
export const Route = createFileRoute("/api/webhook/mollie")({ export const Route = createFileRoute("/api/webhook/lemonsqueezy")({
server: { server: {
handlers: { handlers: {
POST: handleWebhook, POST: handleWebhook,

View File

@@ -38,7 +38,7 @@ function ContactPage() {
<section className="rounded-lg bg-white/5 p-6"> <section className="rounded-lg bg-white/5 p-6">
<h3 className="mb-3 text-white text-xl">Open Mic Night</h3> <h3 className="mb-3 text-white text-xl">Open Mic Night</h3>
<p>Vrijdag 18 april 2026</p> <p>Vrijdag 24 april 2026</p>
<p>Aanvang: 19:00 uur</p> <p>Aanvang: 19:00 uur</p>
<p className="mt-2 text-white/60"> <p className="mt-2 text-white/60">
Locatie wordt later bekendgemaakt aan geregistreerde deelnemers. Locatie wordt later bekendgemaakt aan geregistreerde deelnemers.

View File

@@ -117,6 +117,8 @@ interface EditFormProps {
lastName: string; lastName: string;
email: string; email: string;
phone: string | null; phone: string | null;
postcode: string | null;
birthdate: string | null;
registrationType: string; registrationType: string;
artForm: string | null; artForm: string | null;
experience: string | null; experience: string | null;
@@ -140,6 +142,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: initialData.lastName, lastName: initialData.lastName,
email: initialData.email, email: initialData.email,
phone: initialData.phone ?? "", phone: initialData.phone ?? "",
postcode: initialData.postcode ?? "",
birthdate: initialData.birthdate ?? "",
registrationType: initialType, registrationType: initialType,
artForm: initialData.artForm ?? "", artForm: initialData.artForm ?? "",
experience: initialData.experience ?? "", experience: initialData.experience ?? "",
@@ -179,6 +183,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: formData.lastName.trim(), lastName: formData.lastName.trim(),
email: formData.email.trim(), email: formData.email.trim(),
phone: formData.phone.trim() || undefined, phone: formData.phone.trim() || undefined,
postcode: formData.postcode.trim() || "",
birthdate: formData.birthdate || "",
registrationType: formData.registrationType, registrationType: formData.registrationType,
artForm: performer ? formData.artForm.trim() || undefined : undefined, artForm: performer ? formData.artForm.trim() || undefined : undefined,
experience: performer experience: performer
@@ -192,6 +198,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: g.lastName.trim(), lastName: g.lastName.trim(),
email: g.email.trim() || undefined, email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined, phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})), })),
extraQuestions: formData.extraQuestions.trim() || undefined, extraQuestions: formData.extraQuestions.trim() || undefined,
giftAmount, giftAmount,
@@ -271,6 +279,42 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
/> />
</div> </div>
{/* Postcode + Birthdate */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label htmlFor="postcode" className="mb-2 block text-white">
Postcode *
</label>
<input
id="postcode"
type="text"
required
value={formData.postcode}
onChange={(e) =>
setFormData((p) => ({ ...p, postcode: e.target.value }))
}
autoComplete="postal-code"
className={inputCls(false)}
/>
</div>
<div>
<label htmlFor="birthdate" className="mb-2 block text-white">
Geboortedatum *
</label>
<input
id="birthdate"
type="date"
required
value={formData.birthdate}
onChange={(e) =>
setFormData((p) => ({ ...p, birthdate: e.target.value }))
}
autoComplete="bday"
className={`${inputCls(false)} [color-scheme:dark]`}
/>
</div>
</div>
{/* Registration type toggle */} {/* Registration type toggle */}
<div> <div>
<p className="mb-3 text-white">Type inschrijving</p> <p className="mb-3 text-white">Type inschrijving</p>
@@ -393,7 +437,14 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
if (formGuests.length >= 9) return; if (formGuests.length >= 9) return;
setFormGuests((prev) => [ setFormGuests((prev) => [
...prev, ...prev,
{ firstName: "", lastName: "", email: "", phone: "" }, {
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
]); ]);
}} }}
onRemove={(idx) => onRemove={(idx) =>
@@ -569,7 +620,7 @@ function ManageRegistrationPage() {
Jouw inschrijving Jouw inschrijving
</h1> </h1>
<p className="mb-8 text-white/60"> <p className="mb-8 text-white/60">
Open Mic Night vrijdag 18 april 2026 Open Mic Night vrijdag 24 april 2026
</p> </p>
{/* Type badge */} {/* Type badge */}
@@ -585,18 +636,19 @@ function ManageRegistrationPage() {
)} )}
</div> </div>
{/* Payment status - shown for everyone with pending/extra payment or gift */} {/* Payment status - not shown for performers without a gift */}
{(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && ( {(!isPerformer || (data.giftAmount ?? 0) > 0) &&
<div className="mb-6"> (data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
{data.paymentStatus === "paid" ? ( <div className="mb-6">
<PaidBadge /> {data.paymentStatus === "paid" ? (
) : data.paymentStatus === "extra_payment_pending" ? ( <PaidBadge />
<ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} /> ) : data.paymentStatus === "extra_payment_pending" ? (
) : ( <ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} />
<PendingBadge /> ) : (
)} <PendingBadge />
</div> )}
)} </div>
)}
{/* Gift display */} {/* Gift display */}
{(data.giftAmount ?? 0) > 0 && ( {(data.giftAmount ?? 0) > 0 && (
@@ -626,6 +678,14 @@ function ManageRegistrationPage() {
<p className="text-sm text-white/50">Telefoon</p> <p className="text-sm text-white/50">Telefoon</p>
<p className="text-lg text-white">{data.phone || "—"}</p> <p className="text-lg text-white">{data.phone || "—"}</p>
</div> </div>
<div>
<p className="text-sm text-white/50">Postcode</p>
<p className="text-lg text-white">{data.postcode || "—"}</p>
</div>
<div>
<p className="text-sm text-white/50">Geboortedatum</p>
<p className="text-lg text-white">{data.birthdate || "—"}</p>
</div>
</div> </div>
{isPerformer && ( {isPerformer && (
@@ -660,6 +720,16 @@ function ManageRegistrationPage() {
<p className="text-white"> <p className="text-white">
{g.firstName} {g.lastName} {g.firstName} {g.lastName}
</p> </p>
{g.birthdate && (
<p className="text-sm text-white/60">
Geboortedatum: {g.birthdate}
</p>
)}
{g.postcode && (
<p className="text-sm text-white/60">
Postcode: {g.postcode}
</p>
)}
{g.email && ( {g.email && (
<p className="text-sm text-white/60">{g.email}</p> <p className="text-sm text-white/60">{g.email}</p>
)} )}

View File

@@ -86,7 +86,7 @@ function registrationConfirmationHtml(params: {
Hoi ${params.firstName}, Hoi ${params.firstName},
</p> </p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;"> <p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
We hebben je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> in goede orde ontvangen. We hebben je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 24 april 2026</strong> in goede orde ontvangen.
</p> </p>
<!-- Registration summary --> <!-- Registration summary -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;"> <table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">

View File

@@ -134,49 +134,82 @@ export const drinkkaartRouter = {
.handler(async ({ input, context }) => { .handler(async ({ input, context }) => {
const { env: serverEnv, session } = context; const { env: serverEnv, session } = context;
if (!serverEnv.MOLLIE_API_KEY) { if (
!serverEnv.LEMON_SQUEEZY_API_KEY ||
!serverEnv.LEMON_SQUEEZY_STORE_ID ||
!serverEnv.LEMON_SQUEEZY_VARIANT_ID
) {
throw new ORPCError("INTERNAL_SERVER_ERROR", { throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Mollie is niet geconfigureerd", message: "LemonSqueezy is niet geconfigureerd",
}); });
} }
const card = await getOrCreateDrinkkaart(session.user.id); const card = await getOrCreateDrinkkaart(session.user.id);
// Mollie amounts must be formatted as "10.00" (string, 2 decimal places) const response = await fetch(
const amountValue = (input.amountCents / 100).toFixed(2); "https://api.lemonsqueezy.com/v1/checkouts",
{
const response = await fetch("https://api.mollie.com/v2/payments", { method: "POST",
method: "POST", headers: {
headers: { Accept: "application/vnd.api+json",
"Content-Type": "application/json", "Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${serverEnv.MOLLIE_API_KEY}`, Authorization: `Bearer ${serverEnv.LEMON_SQUEEZY_API_KEY}`,
},
body: JSON.stringify({
amount: { value: amountValue, currency: "EUR" },
description: `Drinkkaart Opladen — ${formatCents(input.amountCents)}`,
redirectUrl: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
webhookUrl: `${serverEnv.BETTER_AUTH_URL}/api/webhook/mollie`,
locale: "nl_NL",
metadata: {
type: "drinkkaart_topup",
drinkkaartId: card.id,
userId: session.user.id,
}, },
}), body: JSON.stringify({
}); data: {
type: "checkouts",
attributes: {
custom_price: input.amountCents,
product_options: {
name: "Drinkkaart Opladen",
description: `Opwaardering van ${formatCents(input.amountCents)}`,
redirect_url: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
},
checkout_data: {
email: session.user.email,
name: session.user.name,
custom: {
type: "drinkkaart_topup",
drinkkaartId: card.id,
userId: session.user.id,
},
},
checkout_options: {
embed: false,
locale: "nl",
},
},
relationships: {
store: {
data: {
type: "stores",
id: serverEnv.LEMON_SQUEEZY_STORE_ID,
},
},
variant: {
data: {
type: "variants",
id: serverEnv.LEMON_SQUEEZY_VARIANT_ID,
},
},
},
},
}),
},
);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
console.error("Mollie Drinkkaart checkout error:", errorData); console.error("LemonSqueezy Drinkkaart checkout error:", errorData);
throw new ORPCError("INTERNAL_SERVER_ERROR", { throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Kon checkout niet aanmaken", message: "Kon checkout niet aanmaken",
}); });
} }
const data = (await response.json()) as { const data = (await response.json()) as {
_links?: { checkout?: { href?: string } }; data?: { attributes?: { url?: string } };
}; };
const checkoutUrl = data._links?.checkout?.href; const checkoutUrl = data.data?.attributes?.url;
if (!checkoutUrl) { if (!checkoutUrl) {
throw new ORPCError("INTERNAL_SERVER_ERROR", { throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Geen checkout URL ontvangen", message: "Geen checkout URL ontvangen",

View File

@@ -36,6 +36,8 @@ function parseGuestsJson(raw: string | null): Array<{
lastName: string; lastName: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
}> { }> {
if (!raw) return []; if (!raw) return [];
try { try {
@@ -50,7 +52,7 @@ function parseGuestsJson(raw: string | null): Array<{
* Credits a watcher's drinkCardValue to their drinkkaart account. * Credits a watcher's drinkCardValue to their drinkkaart account.
* *
* Safe to call multiple times — the `drinkkaartCreditedAt IS NULL` guard and the * Safe to call multiple times — the `drinkkaartCreditedAt IS NULL` guard and the
* `mollie_payment_id` UNIQUE constraint together prevent double-crediting even * `lemonsqueezy_order_id` UNIQUE constraint together prevent double-crediting even
* if called concurrently (webhook + signup racing each other). * if called concurrently (webhook + signup racing each other).
* *
* Returns a status string describing the outcome. * Returns a status string describing the outcome.
@@ -160,11 +162,11 @@ export async function creditRegistrationToAccount(
return { credited: false, amountCents: 0, status: "already_credited" }; return { credited: false, amountCents: 0, status: "already_credited" };
} }
// Record the topup. Re-use the registration's molliePaymentId so the // Record the topup. Re-use the registration's lemonsqueezyOrderId so the
// topup row is clearly linked to the original payment. We prefix with // topup row is clearly linked to the original payment. We prefix with
// "reg_" to distinguish from direct drinkkaart top-up orders if needed. // "reg_" to distinguish from direct drinkkaart top-up orders if needed.
const topupPaymentId = reg.molliePaymentId const topupPaymentId = reg.lemonsqueezyOrderId
? `reg_${reg.molliePaymentId}` ? `reg_${reg.lemonsqueezyOrderId}`
: null; : null;
await db.insert(drinkkaartTopup).values({ await db.insert(drinkkaartTopup).values({
@@ -175,7 +177,7 @@ export async function creditRegistrationToAccount(
balanceBefore, balanceBefore,
balanceAfter, balanceAfter,
type: "payment", type: "payment",
molliePaymentId: topupPaymentId, lemonsqueezyOrderId: topupPaymentId,
adminId: null, adminId: null,
reason: "Drinkkaart bij registratie", reason: "Drinkkaart bij registratie",
paidAt: reg.paidAt ?? new Date(), paidAt: reg.paidAt ?? new Date(),
@@ -218,6 +220,8 @@ const guestSchema = z.object({
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email().optional().or(z.literal("")), email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(), phone: z.string().optional(),
birthdate: z.string().min(1),
postcode: z.string().min(1),
}); });
const coreRegistrationFields = { const coreRegistrationFields = {
@@ -225,6 +229,8 @@ const coreRegistrationFields = {
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email(), email: z.string().email(),
phone: z.string().optional(), phone: z.string().optional(),
postcode: z.string().min(1),
birthdate: z.string().min(1),
registrationType: registrationTypeSchema.default("watcher"), registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(), artForm: z.string().optional(),
experience: z.string().optional(), experience: z.string().optional(),
@@ -277,6 +283,8 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType, registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null, artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null, experience: isPerformer ? input.experience || null : null,
@@ -364,6 +372,8 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType, registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null, artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null, experience: isPerformer ? input.experience || null : null,
@@ -515,6 +525,8 @@ export const appRouter = {
"Last Name", "Last Name",
"Email", "Email",
"Phone", "Phone",
"Postcode",
"Birthdate",
"Type", "Type",
"Art Form", "Art Form",
"Experience", "Experience",
@@ -534,7 +546,7 @@ export const appRouter = {
const guestSummary = guests const guestSummary = guests
.map( .map(
(g) => (g) =>
`${g.firstName} ${g.lastName}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`, `${g.firstName} ${g.lastName}${g.birthdate ? ` (${g.birthdate})` : ""}${g.postcode ? ` [${g.postcode}]` : ""}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
) )
.join(" | "); .join(" | ");
return [ return [
@@ -543,6 +555,8 @@ export const appRouter = {
r.lastName, r.lastName,
r.email, r.email,
r.phone || "", r.phone || "",
r.postcode || "",
r.birthdate || "",
r.registrationType, r.registrationType,
r.artForm || "", r.artForm || "",
r.experience || "", r.experience || "",
@@ -583,7 +597,7 @@ export const appRouter = {
return result; return result;
}), }),
getMyRegistration: protectedProcedure.handler(async ({ context }) => { getMyRegistrations: protectedProcedure.handler(async ({ context }) => {
const email = context.session.user.email; const email = context.session.user.email;
const rows = await db const rows = await db
@@ -592,16 +606,12 @@ export const appRouter = {
.where( .where(
and(eq(registration.email, email), isNull(registration.cancelledAt)), and(eq(registration.email, email), isNull(registration.cancelledAt)),
) )
.orderBy(desc(registration.createdAt)) .orderBy(desc(registration.createdAt));
.limit(1);
const row = rows[0]; return rows.map((row) => ({
if (!row) return null;
return {
...row, ...row,
guests: parseGuestsJson(row.guests), guests: parseGuestsJson(row.guests),
}; }));
}), }),
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -741,8 +751,12 @@ export const appRouter = {
}), }),
) )
.handler(async ({ input }) => { .handler(async ({ input }) => {
if (!env.MOLLIE_API_KEY) { if (
throw new Error("Mollie is niet geconfigureerd"); !env.LEMON_SQUEEZY_API_KEY ||
!env.LEMON_SQUEEZY_STORE_ID ||
!env.LEMON_SQUEEZY_VARIANT_ID
) {
throw new Error("LemonSqueezy is niet geconfigureerd");
} }
const rows = await db const rows = await db
@@ -795,37 +809,66 @@ export const appRouter = {
const redirectUrl = const redirectUrl =
input.redirectUrl ?? `${env.CORS_ORIGIN}/manage/${input.token}`; input.redirectUrl ?? `${env.CORS_ORIGIN}/manage/${input.token}`;
// Mollie amounts must be formatted as "10.00" (string, 2 decimal places) const response = await fetch(
const amountValue = (amountInCents / 100).toFixed(2); "https://api.lemonsqueezy.com/v1/checkouts",
{
const webhookUrl = `${env.CORS_ORIGIN}/api/webhook/mollie`; method: "POST",
headers: {
const response = await fetch("https://api.mollie.com/v2/payments", { Accept: "application/vnd.api+json",
method: "POST", "Content-Type": "application/vnd.api+json",
headers: { Authorization: `Bearer ${env.LEMON_SQUEEZY_API_KEY}`,
"Content-Type": "application/json", },
Authorization: `Bearer ${env.MOLLIE_API_KEY}`, body: JSON.stringify({
data: {
type: "checkouts",
attributes: {
custom_price: amountInCents,
product_options: {
name: "Kunstenkamp Evenement",
description: productDescription,
redirect_url: redirectUrl,
},
checkout_data: {
email: row.email,
name: `${row.firstName} ${row.lastName}`,
custom: {
registration_token: input.token,
},
},
checkout_options: {
embed: false,
locale: "nl",
},
},
relationships: {
store: {
data: {
type: "stores",
id: env.LEMON_SQUEEZY_STORE_ID,
},
},
variant: {
data: {
type: "variants",
id: env.LEMON_SQUEEZY_VARIANT_ID,
},
},
},
},
}),
}, },
body: JSON.stringify({ );
amount: { value: amountValue, currency: "EUR" },
description: `Kunstenkamp Evenement — ${productDescription}`,
redirectUrl,
webhookUrl,
locale: "nl_NL",
metadata: { registration_token: input.token },
}),
});
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
console.error("Mollie checkout error:", errorData); console.error("LemonSqueezy checkout error:", errorData);
throw new Error("Kon checkout niet aanmaken"); throw new Error("Kon checkout niet aanmaken");
} }
const checkoutData = (await response.json()) as { const checkoutData = (await response.json()) as {
_links?: { checkout?: { href?: string } }; data?: { attributes?: { url?: string } };
}; };
const checkoutUrl = checkoutData._links?.checkout?.href; const checkoutUrl = checkoutData.data?.attributes?.url;
if (!checkoutUrl) throw new Error("Geen checkout URL ontvangen"); if (!checkoutUrl) throw new Error("Geen checkout URL ontvangen");

View File

@@ -0,0 +1,15 @@
-- Migrate from Mollie back to LemonSqueezy
-- Renames payment provider columns in registration and drinkkaart_topup tables,
-- and restores the lemonsqueezy_customer_id column on registration.
-- registration table:
-- mollie_payment_id -> lemonsqueezy_order_id
-- (re-add) lemonsqueezy_customer_id
ALTER TABLE registration RENAME COLUMN mollie_payment_id TO lemonsqueezy_order_id;
ALTER TABLE registration ADD COLUMN lemonsqueezy_customer_id text;
-- drinkkaart_topup table:
-- mollie_payment_id -> lemonsqueezy_order_id
ALTER TABLE drinkkaart_topup RENAME COLUMN mollie_payment_id TO lemonsqueezy_order_id;

View File

@@ -0,0 +1,2 @@
ALTER TABLE registration ADD COLUMN postcode TEXT;
ALTER TABLE registration ADD COLUMN birthdate TEXT;

View File

@@ -29,6 +29,13 @@
"when": 1772530000000, "when": 1772530000000,
"tag": "0003_add_guests", "tag": "0003_add_guests",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1741388400000,
"tag": "0008_add_postcode_birthdate",
"breakpoints": true
} }
] ]
} }

View File

@@ -46,7 +46,7 @@ export const drinkkaartTopup = sqliteTable("drinkkaart_topup", {
balanceBefore: integer("balance_before").notNull(), balanceBefore: integer("balance_before").notNull(),
balanceAfter: integer("balance_after").notNull(), balanceAfter: integer("balance_after").notNull(),
type: text("type", { enum: ["payment", "admin_credit"] }).notNull(), type: text("type", { enum: ["payment", "admin_credit"] }).notNull(),
molliePaymentId: text("mollie_payment_id").unique(), // nullable; only for type="payment" lemonsqueezyOrderId: text("lemonsqueezy_order_id").unique(), // nullable; only for type="payment"
adminId: text("admin_id"), // nullable; only for type="admin_credit" adminId: text("admin_id"), // nullable; only for type="admin_credit"
reason: text("reason"), reason: text("reason"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }).notNull(), paidAt: integer("paid_at", { mode: "timestamp_ms" }).notNull(),

View File

@@ -21,6 +21,9 @@ export const registration = sqliteTable(
drinkCardValue: integer("drink_card_value").default(0), drinkCardValue: integer("drink_card_value").default(0),
// Guests: JSON array of {firstName, lastName, email?, phone?} objects // Guests: JSON array of {firstName, lastName, email?, phone?} objects
guests: text("guests"), guests: text("guests"),
// Contact / demographic
postcode: text("postcode"),
birthdate: text("birthdate"),
// Shared // Shared
extraQuestions: text("extra_questions"), extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(), managementToken: text("management_token").unique(),
@@ -29,7 +32,8 @@ export const registration = sqliteTable(
paymentStatus: text("payment_status").notNull().default("pending"), paymentStatus: text("payment_status").notNull().default("pending"),
paymentAmount: integer("payment_amount").default(0), paymentAmount: integer("payment_amount").default(0),
giftAmount: integer("gift_amount").default(0), giftAmount: integer("gift_amount").default(0),
molliePaymentId: text("mollie_payment_id"), lemonsqueezyOrderId: text("lemonsqueezy_order_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. // 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 // Null means not yet credited (either unpaid, account doesn't exist yet, or
@@ -49,6 +53,6 @@ export const registration = sqliteTable(
index("registration_managementToken_idx").on(table.managementToken), index("registration_managementToken_idx").on(table.managementToken),
index("registration_paymentStatus_idx").on(table.paymentStatus), index("registration_paymentStatus_idx").on(table.paymentStatus),
index("registration_giftAmount_idx").on(table.giftAmount), index("registration_giftAmount_idx").on(table.giftAmount),
index("registration_molliePaymentId_idx").on(table.molliePaymentId), index("registration_lemonsqueezyOrderId_idx").on(table.lemonsqueezyOrderId),
], ],
); );

View File

@@ -25,7 +25,10 @@ export const env = createEnv({
NODE_ENV: z NODE_ENV: z
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),
MOLLIE_API_KEY: z.string().min(1).optional(), LEMON_SQUEEZY_API_KEY: z.string().min(1).optional(),
LEMON_SQUEEZY_STORE_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_VARIANT_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_WEBHOOK_SECRET: z.string().min(1).optional(),
}, },
runtimeEnv: process.env, runtimeEnv: process.env,
emptyStringAsUndefined: true, emptyStringAsUndefined: true,

View File

@@ -30,8 +30,11 @@ export const web = await TanStackStart("web", {
SMTP_USER: getEnvVar("SMTP_USER"), SMTP_USER: getEnvVar("SMTP_USER"),
SMTP_PASS: getEnvVar("SMTP_PASS"), SMTP_PASS: getEnvVar("SMTP_PASS"),
SMTP_FROM: getEnvVar("SMTP_FROM"), SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (Mollie) // Payments (LemonSqueezy)
MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"), LEMON_SQUEEZY_API_KEY: getEnvVar("LEMON_SQUEEZY_API_KEY"),
LEMON_SQUEEZY_STORE_ID: getEnvVar("LEMON_SQUEEZY_STORE_ID"),
LEMON_SQUEEZY_VARIANT_ID: getEnvVar("LEMON_SQUEEZY_VARIANT_ID"),
LEMON_SQUEEZY_WEBHOOK_SECRET: getEnvVar("LEMON_SQUEEZY_WEBHOOK_SECRET"),
}, },
domains: ["kunstenkamp.be", "www.kunstenkamp.be"], domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
}); });