Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d5fde568da
|
|||
|
dcf21a80e2
|
|||
|
ac466a7f0e
|
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
15
packages/db/src/migrations/0007_lemonsqueezy_migration.sql
Normal file
15
packages/db/src/migrations/0007_lemonsqueezy_migration.sql
Normal 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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE registration ADD COLUMN postcode TEXT;
|
||||||
|
ALTER TABLE registration ADD COLUMN birthdate TEXT;
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
5
packages/env/src/server.ts
vendored
5
packages/env/src/server.ts
vendored
@@ -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,
|
||||||
|
|||||||
@@ -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"],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user