Compare commits

...

4 Commits

Author SHA1 Message Date
845624dfd3 feat(admin): improve export to put guests in their own row 2026-03-19 10:31:12 +01:00
ee0e9e68a9 feat(admin): add birthdate & postcode 2026-03-19 10:28:07 +01:00
f113770348 feat(registration): add birthdate & postcode 2026-03-19 10:15:45 +01:00
19d784b2e5 feat(drinkkaart): implement QR scan and balance deduction interface
Convert /drinkkaart route from a redirect to a full admin scanning interface.
Add flow: scan QR → resolve user → select amount → confirm deduction → success.
Refactor QrScanner to use Html5Qrcode API for better control and cleanup.
Update event date/time to April 24, 2026 at 19:30.

BREAKING CHANGE: /drinkkaart now requires admin role and provides scanner UI
instead of redirecting to /account.
2026-03-15 14:47:36 +01:00
19 changed files with 2355 additions and 116 deletions

View File

@@ -5,14 +5,16 @@ import { Input } from "@/components/ui/input";
interface QrScannerProps { interface QrScannerProps {
onScan: (token: string) => void; onScan: (token: string) => void;
onCancel: () => void; onCancel: () => void;
/** When true the scanner takes up more vertical space (mobile full-screen mode) */
fullHeight?: boolean;
} }
export function QrScanner({ onScan, onCancel }: QrScannerProps) { export function QrScanner({ onScan, onCancel, fullHeight }: QrScannerProps) {
const scannerDivId = "qr-reader-container"; const videoContainerId = "qr-video-container";
const scannerRef = useRef<unknown>(null); const scannerRef = useRef<unknown>(null);
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
const [manualToken, setManualToken] = useState(""); const [manualToken, setManualToken] = useState("");
// Keep a stable ref to onScan so the effect never re-runs due to identity changes // Keep a stable ref so the effect never re-runs due to callback identity changes
const onScanRef = useRef(onScan); const onScanRef = useRef(onScan);
useEffect(() => { useEffect(() => {
onScanRef.current = onScan; onScanRef.current = onScan;
@@ -22,32 +24,34 @@ export function QrScanner({ onScan, onCancel }: QrScannerProps) {
let stopped = false; let stopped = false;
import("html5-qrcode") import("html5-qrcode")
.then(({ Html5QrcodeScanner }) => { .then(({ Html5Qrcode }) => {
if (stopped) return; if (stopped) return;
const scanner = new Html5QrcodeScanner( const scanner = new Html5Qrcode(videoContainerId);
scannerDivId,
{
fps: 10,
qrbox: { width: 250, height: 250 },
},
false,
);
scannerRef.current = scanner; scannerRef.current = scanner;
scanner.render( scanner
(decodedText: string) => { .start(
scanner.clear().catch(console.error); { facingMode: "environment" },
onScanRef.current(decodedText); { fps: 15, qrbox: { width: 240, height: 240 } },
}, (decodedText: string) => {
(error: string) => { // Stop immediately so we don't fire multiple times
// Suppress routine "not found" scan errors scanner
if (!error.includes("No QR code found")) { .stop()
console.debug("QR scan error:", error); .catch(console.error)
.finally(() => {
if (!stopped) onScanRef.current(decodedText);
});
},
// Per-frame error (QR not found) — suppress
() => {},
)
.catch((err: unknown) => {
if (!stopped) {
console.error("Camera start failed:", err);
setHasError(true);
} }
}, });
);
}) })
.catch((err) => { .catch((err) => {
console.error("Failed to load html5-qrcode:", err); console.error("Failed to load html5-qrcode:", err);
@@ -57,27 +61,40 @@ export function QrScanner({ onScan, onCancel }: QrScannerProps) {
return () => { return () => {
stopped = true; stopped = true;
if (scannerRef.current) { if (scannerRef.current) {
(scannerRef.current as { clear: () => Promise<void> }) (
.clear() scannerRef.current as {
.catch(console.error); stop: () => Promise<void>;
clear: () => Promise<void>;
}
)
.stop()
.catch(() => {})
.finally(() => {
(scannerRef.current as { clear: () => Promise<void> })
?.clear?.()
.catch(() => {});
});
} }
}; };
}, []); // empty deps — runs once on mount, cleans up on unmount }, []); // runs once on mount
const handleManualSubmit = () => { const handleManualSubmit = () => {
const token = manualToken.trim(); const token = manualToken.trim();
if (token) { if (token) onScan(token);
onScan(token);
}
}; };
const containerHeight = fullHeight ? "h-64 sm:h-80" : "h-52";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{!hasError ? ( {!hasError ? (
<div id={scannerDivId} className="overflow-hidden rounded-xl" /> <div
id={videoContainerId}
className={`w-full overflow-hidden rounded-xl bg-black ${containerHeight} [&_#qr-shaded-region]:!border-white/30 [&>video]:h-full [&>video]:w-full [&>video]:object-cover`}
/>
) : ( ) : (
<div className="rounded-xl border border-white/10 bg-white/5 p-4 text-center"> <div className="rounded-xl border border-white/10 bg-white/5 p-6 text-center">
<p className="mb-2 text-sm text-white/60"> <p className="text-sm text-white/60">
Camera niet beschikbaar. Plak het QR-token hieronder. Camera niet beschikbaar. Plak het QR-token hieronder.
</p> </p>
</div> </div>

View File

@@ -245,6 +245,169 @@ export function GuestList({
</span> </span>
)} )}
</div> </div>
{/* Birthdate */}
<div className="flex flex-col gap-2 md:col-span-2">
<p className="text-white/80">
Geboortedatum <span className="text-red-300">*</span>
</p>
<fieldset className="m-0 flex gap-2 border-0 p-0">
<legend className="sr-only">
Geboortedatum medebezoeker {idx + 1}
</legend>
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`guest-${idx}-birthdateDay`}
className="sr-only"
>
Dag
</label>
<select
id={`guest-${idx}-birthdateDay`}
value={guest.birthdate?.split("-")[2] ?? ""}
onChange={(e) => {
const parts = (guest.birthdate || "--").split("-");
onChange(
idx,
"birthdate",
`${parts[0] || ""}-${parts[1] || ""}-${e.target.value.padStart(2, "0")}`,
);
}}
aria-label="Dag"
className={`border-b bg-transparent pb-2 text-sm text-white transition-colors focus:outline-none ${errors[idx]?.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
DD
</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option
key={d}
value={String(d).padStart(2, "0")}
className="bg-[#214e51]"
>
{String(d).padStart(2, "0")}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`guest-${idx}-birthdateMonth`}
className="sr-only"
>
Maand
</label>
<select
id={`guest-${idx}-birthdateMonth`}
value={guest.birthdate?.split("-")[1] ?? ""}
onChange={(e) => {
const parts = (guest.birthdate || "--").split("-");
onChange(
idx,
"birthdate",
`${parts[0] || ""}-${e.target.value.padStart(2, "0")}-${parts[2] || ""}`,
);
}}
aria-label="Maand"
className={`border-b bg-transparent pb-2 text-sm text-white transition-colors focus:outline-none ${errors[idx]?.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
MM
</option>
{[
"Jan",
"Feb",
"Mrt",
"Apr",
"Mei",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dec",
].map((m, i) => (
<option
key={m}
value={String(i + 1).padStart(2, "0")}
className="bg-[#214e51]"
>
{m}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`guest-${idx}-birthdateYear`}
className="sr-only"
>
Jaar
</label>
<select
id={`guest-${idx}-birthdateYear`}
value={guest.birthdate?.split("-")[0] ?? ""}
onChange={(e) => {
const parts = (guest.birthdate || "--").split("-");
onChange(
idx,
"birthdate",
`${e.target.value}-${parts[1] || ""}-${parts[2] || ""}`,
);
}}
aria-label="Jaar"
className={`border-b bg-transparent pb-2 text-sm text-white transition-colors focus:outline-none ${errors[idx]?.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
JJJJ
</option>
{Array.from(
{ length: 100 },
(_, i) => new Date().getFullYear() - i,
).map((y) => (
<option
key={y}
value={String(y)}
className="bg-[#214e51]"
>
{y}
</option>
))}
</select>
</div>
</fieldset>
{errors[idx]?.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].birthdate}
</span>
)}
</div>
{/* Postcode */}
<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="bv. 9000"
autoComplete="off"
inputMode="numeric"
className={inputCls(!!errors[idx]?.postcode)}
/>
{errors[idx]?.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].postcode}
</span>
)}
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -4,8 +4,10 @@ import { toast } from "sonner";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { import {
inputCls, inputCls,
validateBirthdate,
validateEmail, validateEmail,
validatePhone, validatePhone,
validatePostcode,
validateTextField, validateTextField,
} from "@/lib/registration"; } from "@/lib/registration";
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
@@ -16,6 +18,8 @@ interface PerformerErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
artForm?: string; artForm?: string;
isOver16?: string; isOver16?: string;
} }
@@ -34,6 +38,10 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: "", lastName: "",
email: "", email: "",
phone: "", phone: "",
birthdateDay: "",
birthdateMonth: "",
birthdateYear: "",
postcode: "",
artForm: "", artForm: "",
experience: "", experience: "",
isOver16: false, isOver16: false,
@@ -64,12 +72,21 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
}, },
}); });
function getBirthdate(): string {
const { birthdateYear, birthdateMonth, birthdateDay } = data;
if (!birthdateYear || !birthdateMonth || !birthdateDay) return "";
return `${birthdateYear}-${birthdateMonth.padStart(2, "0")}-${birthdateDay.padStart(2, "0")}`;
}
function validate(): boolean { function validate(): boolean {
const birthdate = getBirthdate();
const errs: PerformerErrors = { const errs: PerformerErrors = {
firstName: validateTextField(data.firstName, true, "Voornaam"), firstName: validateTextField(data.firstName, true, "Voornaam"),
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),
birthdate: validateBirthdate(birthdate),
postcode: validatePostcode(data.postcode),
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"
@@ -81,6 +98,8 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
birthdate: true,
postcode: true,
artForm: true, artForm: true,
isOver16: true, isOver16: true,
}); });
@@ -144,6 +163,8 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
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,
birthdate: getBirthdate(),
postcode: data.postcode.trim(),
registrationType: "performer", registrationType: "performer",
artForm: data.artForm.trim() || undefined, artForm: data.artForm.trim() || undefined,
experience: data.experience.trim() || undefined, experience: data.experience.trim() || undefined,
@@ -305,6 +326,149 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
</div> </div>
</div> </div>
{/* Birthdate + Postcode row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<p className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</p>
<fieldset className="m-0 flex gap-2 border-0 p-0">
<legend className="sr-only">Geboortedatum</legend>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="p-birthdateDay" className="sr-only">
Dag
</label>
<select
id="p-birthdateDay"
name="birthdateDay"
value={data.birthdateDay}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateDay: e.target.value,
}))
}
aria-label="Dag"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
DD
</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={String(d)} className="bg-[#214e51]">
{String(d).padStart(2, "0")}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="p-birthdateMonth" className="sr-only">
Maand
</label>
<select
id="p-birthdateMonth"
name="birthdateMonth"
value={data.birthdateMonth}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateMonth: e.target.value,
}))
}
aria-label="Maand"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
MM
</option>
{[
"Jan",
"Feb",
"Mrt",
"Apr",
"Mei",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dec",
].map((m, i) => (
<option
key={m}
value={String(i + 1)}
className="bg-[#214e51]"
>
{m}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="p-birthdateYear" className="sr-only">
Jaar
</label>
<select
id="p-birthdateYear"
name="birthdateYear"
value={data.birthdateYear}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateYear: e.target.value,
}))
}
aria-label="Jaar"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
JJJJ
</option>
{Array.from(
{ length: 100 },
(_, i) => new Date().getFullYear() - i,
).map((y) => (
<option key={y} value={String(y)} className="bg-[#214e51]">
{y}
</option>
))}
</select>
</div>
</fieldset>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
<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="bv. 9000"
autoComplete="postal-code"
inputMode="numeric"
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>
{/* Performer-specific fields */} {/* Performer-specific fields */}
<div className="border border-amber-400/20 bg-amber-400/5 p-6"> <div className="border border-amber-400/20 bg-amber-400/5 p-6">
<p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider"> <p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider">

View File

@@ -7,9 +7,11 @@ import {
type GuestEntry, type GuestEntry,
type GuestErrors, type GuestErrors,
inputCls, inputCls,
validateBirthdate,
validateEmail, validateEmail,
validateGuests, validateGuests,
validatePhone, validatePhone,
validatePostcode,
validateTextField, validateTextField,
} from "@/lib/registration"; } from "@/lib/registration";
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
@@ -21,6 +23,8 @@ interface WatcherErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
} }
interface Props { interface Props {
@@ -39,6 +43,10 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
lastName: "", lastName: "",
email: "", email: "",
phone: "", phone: "",
birthdateDay: "",
birthdateMonth: "",
birthdateYear: "",
postcode: "",
extraQuestions: "", extraQuestions: "",
}); });
const [errors, setErrors] = useState<WatcherErrors>({}); const [errors, setErrors] = useState<WatcherErrors>({});
@@ -69,12 +77,21 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
}, },
}); });
function getBirthdate(): string {
const { birthdateYear, birthdateMonth, birthdateDay } = data;
if (!birthdateYear || !birthdateMonth || !birthdateDay) return "";
return `${birthdateYear}-${birthdateMonth.padStart(2, "0")}-${birthdateDay.padStart(2, "0")}`;
}
function validate(): boolean { function validate(): boolean {
const birthdate = getBirthdate();
const fieldErrs: WatcherErrors = { const fieldErrs: WatcherErrors = {
firstName: validateTextField(data.firstName, true, "Voornaam"), firstName: validateTextField(data.firstName, true, "Voornaam"),
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),
birthdate: validateBirthdate(birthdate),
postcode: validatePostcode(data.postcode),
}; };
setErrors(fieldErrs); setErrors(fieldErrs);
setTouched({ setTouched({
@@ -82,6 +99,8 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
birthdate: true,
postcode: true,
}); });
const { errors: gErrs, valid: gValid } = validateGuests(guests); const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs); setGuestErrors(gErrs);
@@ -134,7 +153,14 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
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, {}]);
} }
@@ -155,12 +181,16 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
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,
birthdate: getBirthdate(),
postcode: data.postcode.trim(),
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,
@@ -341,6 +371,146 @@ export function WatcherForm({ onBack, onSuccess }: Props) {
</div> </div>
</div> </div>
{/* Birthdate + Postcode row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<p className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</p>
<fieldset className="m-0 flex gap-2 border-0 p-0">
<legend className="sr-only">Geboortedatum</legend>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="w-birthdateDay" className="sr-only">
Dag
</label>
<select
id="w-birthdateDay"
value={data.birthdateDay}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateDay: e.target.value,
}))
}
aria-label="Dag"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
DD
</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={String(d)} className="bg-[#214e51]">
{String(d).padStart(2, "0")}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="w-birthdateMonth" className="sr-only">
Maand
</label>
<select
id="w-birthdateMonth"
value={data.birthdateMonth}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateMonth: e.target.value,
}))
}
aria-label="Maand"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
MM
</option>
{[
"Jan",
"Feb",
"Mrt",
"Apr",
"Mei",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dec",
].map((m, i) => (
<option
key={m}
value={String(i + 1)}
className="bg-[#214e51]"
>
{m}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="w-birthdateYear" className="sr-only">
Jaar
</label>
<select
id="w-birthdateYear"
value={data.birthdateYear}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateYear: e.target.value,
}))
}
aria-label="Jaar"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
JJJJ
</option>
{Array.from(
{ length: 100 },
(_, i) => new Date().getFullYear() - i,
).map((y) => (
<option key={y} value={String(y)} className="bg-[#214e51]">
{y}
</option>
))}
</select>
</div>
</fieldset>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
<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="bv. 9000"
autoComplete="postal-code"
inputMode="numeric"
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>
{/* Guests */} {/* Guests */}
<GuestList <GuestList
guests={guests} guests={guests}

View File

@@ -29,6 +29,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 {
@@ -36,6 +38,8 @@ export interface GuestErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
} }
/** /**
@@ -52,6 +56,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 [];
@@ -87,6 +93,22 @@ export function validatePhone(value: string): string | undefined {
return undefined; return undefined;
} }
export function validateBirthdate(value: string): string | undefined {
if (!value.trim()) return "Geboortedatum is verplicht";
// Expect YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
return "Voer een geldige geboortedatum in";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Voer een geldige geboortedatum in";
if (date > new Date()) return "Geboortedatum mag niet in de toekomst liggen";
return undefined;
}
export function validatePostcode(value: string): string | undefined {
if (!value.trim()) return "Postcode is verplicht";
return undefined;
}
/** Validates all guests and returns errors array + overall validity flag. */ /** Validates all guests and returns errors array + overall validity flag. */
export function validateGuests(guests: GuestEntry[]): { export function validateGuests(guests: GuestEntry[]): {
errors: GuestErrors[]; errors: GuestErrors[];
@@ -103,6 +125,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: validateBirthdate(g.birthdate),
postcode: validatePostcode(g.postcode),
})); }));
const valid = !errors.some((e) => Object.values(e).some(Boolean)); const valid = !errors.some((e) => Object.values(e).some(Boolean));
return { errors, valid }; return { errors, valid };

View File

@@ -128,7 +128,7 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
name: "Kunstenkamp Open Mic Night", name: "Kunstenkamp Open Mic Night",
description: description:
"Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom om zijn of haar talent te tonen.", "Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom om zijn of haar talent te tonen.",
startDate: "2026-04-18T19:00:00+02:00", startDate: "2026-04-24T19:30:00+02:00",
eventStatus: "https://schema.org/EventScheduled", eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode", eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
organizer: { organizer: {

View File

@@ -30,6 +30,8 @@ type Guest = {
lastName: string; lastName: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
}; };
function parseGuests(raw: unknown): Guest[] { function parseGuests(raw: unknown): Guest[] {
@@ -664,6 +666,10 @@ function AdminPage() {
Email <SortIcon col="email" /> Email <SortIcon col="email" />
</th> </th>
<th className={`${thCls} hidden xl:table-cell`}>Telefoon</th> <th className={`${thCls} hidden xl:table-cell`}>Telefoon</th>
<th className={`${thCls} hidden xl:table-cell`}>
Geboortedatum
</th>
<th className={`${thCls} hidden xl:table-cell`}>Postcode</th>
<th className={thCls} onClick={() => handleSort("type")}> <th className={thCls} onClick={() => handleSort("type")}>
Type <SortIcon col="type" /> Type <SortIcon col="type" />
</th> </th>
@@ -690,7 +696,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? ( {registrationsQuery.isLoading ? (
<tr> <tr>
<td <td
colSpan={12} colSpan={14}
className="px-4 py-12 text-center font-mono text-sm text-white/30" className="px-4 py-12 text-center font-mono text-sm text-white/30"
> >
laden... laden...
@@ -699,7 +705,7 @@ function AdminPage() {
) : sortedRegistrations.length === 0 ? ( ) : sortedRegistrations.length === 0 ? (
<tr> <tr>
<td <td
colSpan={12} colSpan={14}
className="px-4 py-12 text-center font-mono text-sm text-white/30" className="px-4 py-12 text-center font-mono text-sm text-white/30"
> >
Geen registraties gevonden Geen registraties gevonden
@@ -757,6 +763,12 @@ function AdminPage() {
<td className="hidden px-3 py-3 font-mono text-white/45 text-xs xl:table-cell"> <td className="hidden px-3 py-3 font-mono text-white/45 text-xs xl:table-cell">
{reg.phone || "—"} {reg.phone || "—"}
</td> </td>
<td className="hidden px-3 py-3 font-mono text-white/45 text-xs xl:table-cell">
{reg.birthdate || "—"}
</td>
<td className="hidden px-3 py-3 font-mono text-white/45 text-xs xl:table-cell">
{reg.postcode || "—"}
</td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<span <span
className={`rounded-full px-2 py-0.5 font-mono font-semibold text-[10px] uppercase tracking-wide ${isPerformer ? "bg-amber-400/12 text-amber-300" : "bg-teal-400/12 text-teal-300"}`} className={`rounded-full px-2 py-0.5 font-mono font-semibold text-[10px] uppercase tracking-wide ${isPerformer ? "bg-amber-400/12 text-amber-300" : "bg-teal-400/12 text-teal-300"}`}
@@ -833,7 +845,7 @@ function AdminPage() {
key={`${reg.id}-detail`} key={`${reg.id}-detail`}
className="border-white/5 border-b bg-white/5" className="border-white/5 border-b bg-white/5"
> >
<td colSpan={12} className="px-6 pt-3 pb-5"> <td colSpan={14} className="px-6 pt-3 pb-5">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{/* Guests */} {/* Guests */}
{guestCount > 0 && ( {guestCount > 0 && (
@@ -864,6 +876,16 @@ function AdminPage() {
{g.phone} {g.phone}
</span> </span>
)} )}
{g.birthdate && (
<span className="font-mono text-white/35 text-xs">
{g.birthdate}
</span>
)}
{g.postcode && (
<span className="font-mono text-white/35 text-xs">
{g.postcode}
</span>
)}
{!g.email && !g.phone && ( {!g.email && !g.phone && (
<span className="font-mono text-white/25 text-xs italic"> <span className="font-mono text-white/25 text-xs italic">
geen contactgegevens geen contactgegevens
@@ -875,7 +897,7 @@ function AdminPage() {
</div> </div>
)} )}
{/* Notes */} {/* Registrant birthdate + postcode */}
{hasNotes && ( {hasNotes && (
<div> <div>
<p className="mb-2 font-mono text-[10px] text-white/40 uppercase tracking-widest"> <p className="mb-2 font-mono text-[10px] text-white/40 uppercase tracking-widest">
@@ -980,6 +1002,12 @@ function AdminPage() {
{reg.phone} {reg.phone}
</p> </p>
)} )}
{reg.birthdate && (
<p className="font-mono text-white/30 text-xs">
{reg.birthdate}
{reg.postcode && ` · ${reg.postcode}`}
</p>
)}
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-1.5"> <div className="flex shrink-0 flex-col items-end gap-1.5">
<span <span
@@ -1067,6 +1095,12 @@ function AdminPage() {
{reg.phone} {reg.phone}
</p> </p>
)} )}
{reg.birthdate && (
<p className="font-mono text-white/30 text-xs">
{reg.birthdate}
{reg.postcode && ` · ${reg.postcode}`}
</p>
)}
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-1.5"> <div className="flex shrink-0 flex-col items-end gap-1.5">
<span <span
@@ -1157,6 +1191,13 @@ function AdminPage() {
{g.phone} {g.phone}
</p> </p>
)} )}
{(g.birthdate || g.postcode) && (
<p className="font-mono text-white/30 text-xs">
{g.birthdate}
{g.birthdate && g.postcode && " · "}
{g.postcode}
</p>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -39,7 +39,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 24 april 2026</p> <p>Vrijdag 24 april 2026</p>
<p>Aanvang: 19:00 uur</p> <p>Aanvang: 19:30 uur</p>
<p className="mt-2 text-white/60"> <p className="mt-2 text-white/60">
Lange Winkelstraat 5, 2000 Antwerpen Lange Winkelstraat 5, 2000 Antwerpen
</p> </p>

View File

@@ -1,21 +1,575 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { DEDUCTION_PRESETS_CENTS, formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
// /drinkkaart redirects to /account, preserving ?topup=success for
// backward-compatibility with the Lemon Squeezy webhook return URL.
export const Route = createFileRoute("/drinkkaart")({ export const Route = createFileRoute("/drinkkaart")({
validateSearch: (search: Record<string, unknown>) => ({ beforeLoad: async () => {
topup: search.topup as string | undefined,
}),
beforeLoad: async ({ search }) => {
const session = await authClient.getSession(); const session = await authClient.getSession();
if (!session.data?.user) { if (!session.data?.user) {
throw redirect({ to: "/login" }); throw redirect({ to: "/login" });
} }
throw redirect({ const user = session.data.user as { role?: string };
to: "/account", if (user.role !== "admin") {
search: search.topup ? { topup: search.topup } : {}, throw redirect({ to: "/" });
}); }
}, },
component: () => null, component: DrinkkaartScanPage,
}); });
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ScanState =
| { step: "scanning" }
| { step: "resolving" }
| {
step: "resolved";
drinkkaartId: string;
userId: string;
userName: string;
userEmail: string;
balance: number;
}
| {
step: "confirming";
drinkkaartId: string;
userName: string;
userEmail: string;
balance: number;
amountCents: number;
}
| {
step: "success";
userName: string;
amountCents: number;
balanceAfter: number;
}
| { step: "error"; message: string };
// ---------------------------------------------------------------------------
// ScanningView — full-screen camera with manual fallback
// ---------------------------------------------------------------------------
function ScanningView({ onScan }: { onScan: (token: string) => void }) {
const videoContainerId = "qr-video-container";
const scannerRef = useRef<unknown>(null);
const [hasError, setHasError] = useState(false);
const [manualToken, setManualToken] = useState("");
const [showManual, setShowManual] = useState(false);
const onScanRef = useRef(onScan);
useEffect(() => {
onScanRef.current = onScan;
}, [onScan]);
useEffect(() => {
if (hasError) return;
let stopped = false;
import("html5-qrcode")
.then(({ Html5Qrcode }) => {
if (stopped) return;
const scanner = new Html5Qrcode(videoContainerId);
scannerRef.current = scanner;
scanner
.start(
{ facingMode: "environment" },
{ fps: 15, qrbox: { width: 240, height: 240 } },
(decodedText: string) => {
scanner
.stop()
.catch(console.error)
.finally(() => {
if (!stopped) onScanRef.current(decodedText);
});
},
() => {},
)
.catch((err: unknown) => {
if (!stopped) {
console.error("Camera start failed:", err);
setHasError(true);
setShowManual(true);
}
});
})
.catch((err) => {
console.error("Failed to load html5-qrcode:", err);
setHasError(true);
setShowManual(true);
});
return () => {
stopped = true;
const s = scannerRef.current as {
stop: () => Promise<void>;
clear: () => Promise<void>;
} | null;
s?.stop()
.catch(() => {})
.finally(() => s?.clear().catch(() => {}));
};
}, [hasError]);
const handleManualSubmit = () => {
const token = manualToken.trim();
if (token) onScan(token);
};
return (
<div className="flex flex-1 flex-col">
{/* Camera — fills all available space above the hint */}
{!hasError && (
<div
id={videoContainerId}
className="[&_#qr-shaded-region]:!border-white/40 flex-1 overflow-hidden bg-black [&>video]:h-full [&>video]:w-full [&>video]:object-cover"
/>
)}
{hasError && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-white/5 p-6 text-center">
<p className="text-white/60">Camera niet beschikbaar.</p>
</div>
</div>
)}
{/* Bottom controls */}
<div className="shrink-0 space-y-3 px-4 pt-4 pb-8">
{showManual ? (
<div className="flex gap-2">
<Input
autoFocus
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
placeholder="Plak QR token..."
className="border-white/20 bg-white/10 text-white placeholder:text-white/30"
onKeyDown={(e) => e.key === "Enter" && handleManualSubmit()}
/>
<Button
onClick={handleManualSubmit}
disabled={!manualToken.trim()}
className="shrink-0 bg-white text-[#214e51] hover:bg-white/90"
>
OK
</Button>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-sm text-white/40">
Richt de camera op de QR-code
</p>
<button
type="button"
onClick={() => setShowManual(true)}
className="text-sm text-white/30 hover:text-white/60"
>
Handmatig
</button>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
function DrinkkaartScanPage() {
const [scanState, setScanState] = useState<ScanState>({ step: "scanning" });
const [customCents, setCustomCents] = useState("");
const [useCustom, setUseCustom] = useState(false);
const [selectedCents, setSelectedCents] = useState<number>(
DEDUCTION_PRESETS_CENTS[1],
);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-return to scanner 1.5s after success
useEffect(() => {
if (scanState.step === "success") {
successTimerRef.current = setTimeout(() => {
setScanState({ step: "scanning" });
setCustomCents("");
setUseCustom(false);
setSelectedCents(DEDUCTION_PRESETS_CENTS[1]);
}, 1500);
}
return () => {
if (successTimerRef.current) clearTimeout(successTimerRef.current);
};
}, [scanState.step]);
// --- Mutations ---
const resolveQrMutation = useMutation(
orpc.drinkkaart.resolveQrToken.mutationOptions(),
);
const deductMutation = useMutation(
orpc.drinkkaart.deductBalance.mutationOptions(),
);
// --- Handlers ---
const handleScan = useCallback(
(token: string) => {
setScanState({ step: "resolving" });
resolveQrMutation.mutate(
{ token },
{
onSuccess: (data) => {
setScanState({
step: "resolved",
drinkkaartId: data.drinkkaartId,
userId: data.userId,
userName: data.userName,
userEmail: data.userEmail,
balance: data.balance,
});
},
onError: (err: Error) => {
setScanState({
step: "error",
message: err.message ?? "Ongeldig QR-token",
});
},
},
);
},
[resolveQrMutation],
);
const handlePresetSelect = (cents: number) => {
setSelectedCents(cents);
setUseCustom(false);
};
const getAmountCents = () => {
if (useCustom) {
return Math.round((Number.parseFloat(customCents || "0") || 0) * 100);
}
return selectedCents;
};
const handleDeductTap = () => {
if (scanState.step !== "resolved") return;
const amountCents = getAmountCents();
if (!amountCents || amountCents < 1 || amountCents > scanState.balance)
return;
setScanState({
step: "confirming",
drinkkaartId: scanState.drinkkaartId,
userName: scanState.userName,
userEmail: scanState.userEmail,
balance: scanState.balance,
amountCents,
});
};
const handleConfirm = () => {
if (scanState.step !== "confirming") return;
deductMutation.mutate(
{
drinkkaartId: scanState.drinkkaartId,
amountCents: scanState.amountCents,
},
{
onSuccess: (data) => {
toast.success(`${formatCents(scanState.amountCents)} afgeschreven`);
setScanState({
step: "success",
userName: scanState.userName,
amountCents: scanState.amountCents,
balanceAfter: data.balanceAfter,
});
deductMutation.reset();
},
onError: (err: Error) => {
setScanState({
step: "error",
message: err.message ?? "Fout bij afschrijving",
});
deductMutation.reset();
},
},
);
};
const restartScanner = () => {
setScanState({ step: "scanning" });
setCustomCents("");
setUseCustom(false);
setSelectedCents(DEDUCTION_PRESETS_CENTS[1]);
deductMutation.reset();
};
// Determine the display amount for the deduct button (only in resolved step)
const resolvedAmountCents =
scanState.step === "resolved" ? getAmountCents() : 0;
const resolvedBalance = scanState.step === "resolved" ? scanState.balance : 0;
const deductIsValid =
scanState.step === "resolved" &&
Number.isInteger(resolvedAmountCents) &&
resolvedAmountCents >= 1 &&
resolvedAmountCents <= resolvedBalance;
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="flex h-dvh flex-col bg-[#214e51]">
{/* ------------------------------------------------------------------ */}
{/* Header */}
{/* ------------------------------------------------------------------ */}
<header className="flex shrink-0 items-center justify-between border-white/10 border-b px-4 py-3">
<h1 className="font-['Intro',sans-serif] text-lg text-white">
Drinkkaart
</h1>
{/* Only show the admin link when not mid-flow */}
{(scanState.step === "scanning" || scanState.step === "error") && (
<a
href="/admin/drinkkaart"
className="rounded-lg px-3 py-1.5 text-sm text-white/50 hover:bg-white/10 hover:text-white"
>
Beheer
</a>
)}
{scanState.step === "resolved" && (
<button
type="button"
onClick={restartScanner}
className="rounded-lg px-3 py-1.5 text-sm text-white/50 hover:bg-white/10 hover:text-white"
>
Annuleer
</button>
)}
{scanState.step === "confirming" && (
<button
type="button"
onClick={() => {
// Go back to resolved state
const s = scanState;
setScanState({
step: "resolved",
drinkkaartId: s.drinkkaartId,
userId: "",
userName: s.userName,
userEmail: s.userEmail,
balance: s.balance,
});
}}
className="rounded-lg px-3 py-1.5 text-sm text-white/50 hover:bg-white/10 hover:text-white"
>
Terug
</button>
)}
</header>
{/* ------------------------------------------------------------------ */}
{/* Main content area */}
{/* ------------------------------------------------------------------ */}
<main className="flex flex-1 flex-col overflow-hidden">
{/* ---- SCANNING ---- */}
{scanState.step === "scanning" && <ScanningView onScan={handleScan} />}
{/* ---- RESOLVING ---- */}
{scanState.step === "resolving" && (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-white/20 border-t-white" />
<p className="text-white/60">Controleren...</p>
</div>
)}
{/* ---- RESOLVED: show person + deduct controls ---- */}
{scanState.step === "resolved" && (
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Person card */}
<div className="mx-4 mt-4 rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-lg text-white">
{scanState.userName}
</p>
<p className="truncate text-sm text-white/50">
{scanState.userEmail}
</p>
</div>
<div className="ml-4 shrink-0 text-right">
<p className="text-white/40 text-xs">Saldo</p>
<p className="font-['Intro',sans-serif] text-3xl text-white">
{formatCents(scanState.balance)}
</p>
</div>
</div>
</div>
{/* Amount selection */}
<div className="mx-4 mt-4 space-y-4">
<p className="text-sm text-white/60">Bedrag kiezen</p>
{/* Preset grid */}
<div className="grid grid-cols-4 gap-2">
{DEDUCTION_PRESETS_CENTS.map((cents) => (
<button
key={cents}
type="button"
onClick={() => handlePresetSelect(cents)}
className={`rounded-xl border py-4 font-semibold text-sm transition-colors ${
!useCustom && selectedCents === cents
? "border-white bg-white text-[#214e51]"
: "border-white/20 text-white hover:border-white/50 active:bg-white/10"
}`}
>
{formatCents(cents)}
</button>
))}
</div>
{/* Custom amount */}
<div className="relative">
<span className="absolute top-1/2 left-3.5 -translate-y-1/2 text-white/50">
</span>
<Input
type="number"
min="0.01"
step="0.01"
placeholder="Eigen bedrag"
value={customCents}
onChange={(e) => {
setCustomCents(e.target.value);
setUseCustom(true);
}}
onFocus={() => setUseCustom(true)}
className="border-white/20 bg-white/10 py-4 pl-8 text-base text-white placeholder:text-white/30"
/>
</div>
{/* Insufficient balance warning */}
{resolvedAmountCents > resolvedBalance && (
<p className="text-red-400 text-sm">
Onvoldoende saldo ({formatCents(resolvedBalance)})
</p>
)}
</div>
{/* Deduct button — sticky at bottom */}
<div className="mt-auto px-4 pt-4 pb-8">
<Button
onClick={handleDeductTap}
disabled={!deductIsValid}
className="h-14 w-full bg-white font-semibold text-[#214e51] text-lg hover:bg-white/90 disabled:opacity-40"
>
{deductIsValid
? `Afschrijven — ${formatCents(resolvedAmountCents)}`
: "Kies een bedrag"}
</Button>
</div>
</div>
)}
{/* ---- CONFIRMING ---- */}
{scanState.step === "confirming" && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-6">
{/* Summary card */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 text-center">
<p className="text-sm text-white/60">Afschrijving voor</p>
<p className="mt-1 font-semibold text-white text-xl">
{scanState.userName}
</p>
<p className="mt-4 font-['Intro',sans-serif] text-5xl text-white">
{formatCents(scanState.amountCents)}
</p>
<p className="mt-3 text-sm text-white/50">
Nieuw saldo:{" "}
<span className="text-white">
{formatCents(scanState.balance - scanState.amountCents)}
</span>
</p>
</div>
{deductMutation.isError && (
<p className="text-center text-red-400 text-sm">
{(deductMutation.error as Error)?.message ??
"Fout bij afschrijving"}
</p>
)}
{/* Confirm button */}
<Button
onClick={handleConfirm}
disabled={deductMutation.isPending}
className="h-14 w-full bg-white font-semibold text-[#214e51] text-lg hover:bg-white/90 disabled:opacity-50"
>
{deductMutation.isPending ? "Verwerken..." : "Bevestigen"}
</Button>
</div>
</div>
)}
{/* ---- SUCCESS ---- */}
{scanState.step === "success" && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-4 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-400/20">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className="h-8 w-8 text-green-400"
aria-label="Betaling geslaagd"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div>
<p className="font-semibold text-green-300 text-xl">
{formatCents(scanState.amountCents)} afgeschreven
</p>
<p className="mt-1 text-sm text-white/60">
{scanState.userName} nieuw saldo:{" "}
<strong className="text-white">
{formatCents(scanState.balanceAfter)}
</strong>
</p>
</div>
<p className="text-white/30 text-xs">
Volgende scan wordt gestart
</p>
</div>
</div>
)}
{/* ---- ERROR ---- */}
{scanState.step === "error" && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-5">
<div className="rounded-2xl border border-red-400/20 bg-red-400/5 p-5 text-center">
<p className="font-semibold text-lg text-red-300">Fout</p>
<p className="mt-2 text-sm text-white/60">
{scanState.message}
</p>
</div>
<Button
onClick={restartScanner}
className="h-14 w-full bg-white font-semibold text-[#214e51] text-lg hover:bg-white/90"
>
Opnieuw scannen
</Button>
</div>
</div>
)}
</main>
</div>
);
}

View File

@@ -117,6 +117,8 @@ interface EditFormProps {
lastName: string; lastName: string;
email: string; email: string;
phone: string | null; phone: string | null;
birthdate: string;
postcode: string;
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 ?? "",
birthdate: initialData.birthdate ?? "",
postcode: initialData.postcode ?? "",
registrationType: initialType, registrationType: initialType,
artForm: initialData.artForm ?? "", artForm: initialData.artForm ?? "",
experience: initialData.experience ?? "", experience: initialData.experience ?? "",
@@ -185,6 +189,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
? formData.experience.trim() || undefined ? formData.experience.trim() || undefined
: undefined, : undefined,
isOver16: performer ? formData.isOver16 : false, isOver16: performer ? formData.isOver16 : false,
birthdate: formData.birthdate.trim(),
postcode: formData.postcode.trim(),
guests: performer guests: performer
? [] ? []
: formGuests.map((g) => ({ : formGuests.map((g) => ({
@@ -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,
@@ -393,7 +401,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) =>

View File

@@ -26,7 +26,7 @@ function PrivacyPage() {
<p> <p>
We verzamelen alleen de gegevens die je zelf invoert bij de We verzamelen alleen de gegevens die je zelf invoert bij de
registratie: voornaam, achternaam, e-mailadres, telefoonnummer, registratie: voornaam, achternaam, e-mailadres, telefoonnummer,
kunstvorm en ervaring. geboortedatum, postcode, kunstvorm en ervaring.
</p> </p>
</section> </section>
@@ -39,6 +39,12 @@ function PrivacyPage() {
Mic Night, om het programma samen te stellen en om je te Mic Night, om het programma samen te stellen en om je te
informeren over aanvullende details over het evenement. informeren over aanvullende details over het evenement.
</p> </p>
<p className="mt-3">
<strong className="text-white">Geboortedatum en postcode</strong>{" "}
worden gevraagd ter naleving van de rapportageverplichtingen van{" "}
<strong className="text-white">EJV</strong> en{" "}
<strong className="text-white">de Vlaamse Overheid</strong>.
</p>
</section> </section>
<section> <section>

View File

@@ -262,6 +262,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 = {
@@ -269,6 +271,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(),
birthdate: z.string().min(1),
postcode: 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(),
@@ -366,6 +370,8 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
birthdate: input.birthdate,
postcode: input.postcode,
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,
@@ -463,6 +469,8 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
birthdate: input.birthdate,
postcode: input.postcode,
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,
@@ -655,56 +663,24 @@ export const appRouter = {
const escapeCell = (val: string) => `"${val.replace(/"/g, '""')}"`; const escapeCell = (val: string) => `"${val.replace(/"/g, '""')}"`;
// Main registration headers // Each person (registrant + guests) gets their own row.
// Guest rows reference the registrant via "Registration ID".
const headers = [ const headers = [
"ID", "Row Type", // "registrant" | "guest"
"Registration ID", // UUID of the parent registration (same for registrant + its guests)
"First Name", "First Name",
"Last Name", "Last Name",
"Email", "Email",
"Phone", "Phone",
"Birthdate",
"Postcode",
// Registrant-only fields (blank for guest rows)
"Type", "Type",
"Art Form", "Art Form",
"Experience", "Experience",
"Is Over 16", "Is Over 16",
"Drink Card Value (EUR)", "Drink Card Value (EUR)",
"Gift Amount (cents)", "Gift Amount (cents)",
"Guest Count",
"Guest 1 First Name",
"Guest 1 Last Name",
"Guest 1 Email",
"Guest 1 Phone",
"Guest 2 First Name",
"Guest 2 Last Name",
"Guest 2 Email",
"Guest 2 Phone",
"Guest 3 First Name",
"Guest 3 Last Name",
"Guest 3 Email",
"Guest 3 Phone",
"Guest 4 First Name",
"Guest 4 Last Name",
"Guest 4 Email",
"Guest 4 Phone",
"Guest 5 First Name",
"Guest 5 Last Name",
"Guest 5 Email",
"Guest 5 Phone",
"Guest 6 First Name",
"Guest 6 Last Name",
"Guest 6 Email",
"Guest 6 Phone",
"Guest 7 First Name",
"Guest 7 Last Name",
"Guest 7 Email",
"Guest 7 Phone",
"Guest 8 First Name",
"Guest 8 Last Name",
"Guest 8 Email",
"Guest 8 Phone",
"Guest 9 First Name",
"Guest 9 Last Name",
"Guest 9 Email",
"Guest 9 Phone",
"Payment Status", "Payment Status",
"Paid At", "Paid At",
"Extra Questions", "Extra Questions",
@@ -712,46 +688,74 @@ export const appRouter = {
"Created At", "Created At",
]; ];
const MAX_GUESTS = 9; const rows: string[][] = [];
const rows = data.map((r) => { for (const r of data) {
const guests = parseGuestsJson(r.guests); const guests = parseGuestsJson(r.guests) as Array<{
firstName: string;
lastName: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}>;
// Build guest columns (up to 9 guests, 4 fields each) const paymentStatus =
const guestCols: string[] = []; r.paymentStatus === "paid"
for (let i = 0; i < MAX_GUESTS; i++) { ? "Paid"
const g = guests[i]; : r.paymentStatus === "extra_payment_pending"
guestCols.push(g?.firstName ?? ""); ? "Extra Payment Pending"
guestCols.push(g?.lastName ?? ""); : "Pending";
guestCols.push(g?.email ?? "");
guestCols.push(g?.phone ?? "");
}
return [ // Registrant row
rows.push([
"registrant",
r.id, r.id,
r.firstName, r.firstName,
r.lastName, r.lastName,
r.email, r.email,
r.phone || "", r.phone || "",
r.birthdate || "",
r.postcode || "",
r.registrationType ?? "", r.registrationType ?? "",
r.artForm || "", r.artForm || "",
r.experience || "", r.experience || "",
r.isOver16 ? "Yes" : "No", r.isOver16 ? "Yes" : "No",
String(r.drinkCardValue ?? 0), String(r.drinkCardValue ?? 0),
String(r.giftAmount ?? 0), String(r.giftAmount ?? 0),
String(guests.length), paymentStatus,
...guestCols,
r.paymentStatus === "paid"
? "Paid"
: r.paymentStatus === "extra_payment_pending"
? "Extra Payment Pending"
: "Pending",
r.paidAt ? r.paidAt.toISOString() : "", r.paidAt ? r.paidAt.toISOString() : "",
r.extraQuestions || "", r.extraQuestions || "",
r.cancelledAt ? r.cancelledAt.toISOString() : "", r.cancelledAt ? r.cancelledAt.toISOString() : "",
r.createdAt.toISOString(), r.createdAt.toISOString(),
]; ]);
});
// One row per guest — link back to parent registration via Registration ID
for (const g of guests) {
rows.push([
"guest",
r.id,
g.firstName,
g.lastName,
g.email || "",
g.phone || "",
g.birthdate || "",
g.postcode || "",
// Registrant-only fields left blank
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
]);
}
}
const csvContent = [ const csvContent = [
headers.map(escapeCell).join(","), headers.map(escapeCell).join(","),

BIN
packages/db/local.db Normal file

Binary file not shown.

BIN
packages/db/local.db-shm Normal file

Binary file not shown.

0
packages/db/local.db-wal Normal file
View File

View File

@@ -0,0 +1,101 @@
ALTER TABLE `registration` RENAME COLUMN "wants_to_perform" TO "registration_type";--> statement-breakpoint
CREATE TABLE `drinkkaart` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`balance` integer DEFAULT 0 NOT NULL,
`version` integer DEFAULT 0 NOT NULL,
`qr_secret` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `drinkkaart_user_id_unique` ON `drinkkaart` (`user_id`);--> statement-breakpoint
CREATE TABLE `drinkkaart_topup` (
`id` text PRIMARY KEY NOT NULL,
`drinkkaart_id` text NOT NULL,
`user_id` text NOT NULL,
`amount_cents` integer NOT NULL,
`balance_before` integer NOT NULL,
`balance_after` integer NOT NULL,
`type` text NOT NULL,
`mollie_payment_id` text,
`admin_id` text,
`reason` text,
`paid_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `drinkkaart_topup_mollie_payment_id_unique` ON `drinkkaart_topup` (`mollie_payment_id`);--> statement-breakpoint
CREATE TABLE `drinkkaart_transaction` (
`id` text PRIMARY KEY NOT NULL,
`drinkkaart_id` text NOT NULL,
`user_id` text NOT NULL,
`admin_id` text NOT NULL,
`amount_cents` integer NOT NULL,
`balance_before` integer NOT NULL,
`balance_after` integer NOT NULL,
`type` text NOT NULL,
`reversed_by` text,
`reverses` text,
`note` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `reminder` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`sent_24h_at` integer,
`sent_at` integer,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `reminder_email_idx` ON `reminder` (`email`);--> statement-breakpoint
DROP INDEX "admin_request_user_id_unique";--> statement-breakpoint
DROP INDEX "admin_request_userId_idx";--> statement-breakpoint
DROP INDEX "admin_request_status_idx";--> statement-breakpoint
DROP INDEX "account_userId_idx";--> statement-breakpoint
DROP INDEX "session_token_unique";--> statement-breakpoint
DROP INDEX "session_userId_idx";--> statement-breakpoint
DROP INDEX "user_email_unique";--> statement-breakpoint
DROP INDEX "verification_identifier_idx";--> statement-breakpoint
DROP INDEX "drinkkaart_user_id_unique";--> statement-breakpoint
DROP INDEX "drinkkaart_topup_mollie_payment_id_unique";--> statement-breakpoint
DROP INDEX "registration_management_token_unique";--> statement-breakpoint
DROP INDEX "registration_email_idx";--> statement-breakpoint
DROP INDEX "registration_registrationType_idx";--> statement-breakpoint
DROP INDEX "registration_artForm_idx";--> statement-breakpoint
DROP INDEX "registration_createdAt_idx";--> statement-breakpoint
DROP INDEX "registration_managementToken_idx";--> statement-breakpoint
DROP INDEX "registration_paymentStatus_idx";--> statement-breakpoint
DROP INDEX "registration_giftAmount_idx";--> statement-breakpoint
DROP INDEX "registration_molliePaymentId_idx";--> statement-breakpoint
DROP INDEX "reminder_email_idx";--> statement-breakpoint
ALTER TABLE `registration` ALTER COLUMN "registration_type" TO "registration_type" text NOT NULL DEFAULT 'watcher';--> statement-breakpoint
CREATE UNIQUE INDEX `admin_request_user_id_unique` ON `admin_request` (`user_id`);--> statement-breakpoint
CREATE INDEX `admin_request_userId_idx` ON `admin_request` (`user_id`);--> statement-breakpoint
CREATE INDEX `admin_request_status_idx` ON `admin_request` (`status`);--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
CREATE UNIQUE INDEX `registration_management_token_unique` ON `registration` (`management_token`);--> statement-breakpoint
CREATE INDEX `registration_email_idx` ON `registration` (`email`);--> statement-breakpoint
CREATE INDEX `registration_registrationType_idx` ON `registration` (`registration_type`);--> statement-breakpoint
CREATE INDEX `registration_artForm_idx` ON `registration` (`art_form`);--> statement-breakpoint
CREATE INDEX `registration_createdAt_idx` ON `registration` (`created_at`);--> statement-breakpoint
CREATE INDEX `registration_managementToken_idx` ON `registration` (`management_token`);--> statement-breakpoint
CREATE INDEX `registration_paymentStatus_idx` ON `registration` (`payment_status`);--> statement-breakpoint
CREATE INDEX `registration_giftAmount_idx` ON `registration` (`gift_amount`);--> statement-breakpoint
CREATE INDEX `registration_molliePaymentId_idx` ON `registration` (`mollie_payment_id`);--> statement-breakpoint
ALTER TABLE `registration` ADD `is_over_16` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `drink_card_value` integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE `registration` ADD `guests` text;--> statement-breakpoint
ALTER TABLE `registration` ADD `birthdate` text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `postcode` text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `payment_status` text DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `payment_amount` integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE `registration` ADD `gift_amount` integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE `registration` ADD `mollie_payment_id` text;--> statement-breakpoint
ALTER TABLE `registration` ADD `paid_at` integer;--> statement-breakpoint
ALTER TABLE `registration` ADD `drinkkaart_credited_at` integer;--> statement-breakpoint
ALTER TABLE `registration` ADD `payment_reminder_sent_at` integer;

View File

@@ -0,0 +1,971 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7994e607-3835-43a5-9251-fca48f0aa19a",
"prevId": "d9a4f07e-e6ae-45d0-be82-c919ae7fbe09",
"tables": {
"admin_request": {
"name": "admin_request",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"requested_at": {
"name": "requested_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"reviewed_at": {
"name": "reviewed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reviewed_by": {
"name": "reviewed_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"admin_request_user_id_unique": {
"name": "admin_request_user_id_unique",
"columns": ["user_id"],
"isUnique": true
},
"admin_request_userId_idx": {
"name": "admin_request_userId_idx",
"columns": ["user_id"],
"isUnique": false
},
"admin_request_status_idx": {
"name": "admin_request_status_idx",
"columns": ["status"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": ["token"],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": ["identifier"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"drinkkaart": {
"name": "drinkkaart",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"qr_secret": {
"name": "qr_secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"drinkkaart_user_id_unique": {
"name": "drinkkaart_user_id_unique",
"columns": ["user_id"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"drinkkaart_topup": {
"name": "drinkkaart_topup",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"drinkkaart_id": {
"name": "drinkkaart_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_cents": {
"name": "amount_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_before": {
"name": "balance_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_after": {
"name": "balance_after",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mollie_payment_id": {
"name": "mollie_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"paid_at": {
"name": "paid_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"drinkkaart_topup_mollie_payment_id_unique": {
"name": "drinkkaart_topup_mollie_payment_id_unique",
"columns": ["mollie_payment_id"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"drinkkaart_transaction": {
"name": "drinkkaart_transaction",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"drinkkaart_id": {
"name": "drinkkaart_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_cents": {
"name": "amount_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_before": {
"name": "balance_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_after": {
"name": "balance_after",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reversed_by": {
"name": "reversed_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reverses": {
"name": "reverses",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"registration": {
"name": "registration",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"registration_type": {
"name": "registration_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'watcher'"
},
"art_form": {
"name": "art_form",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"experience": {
"name": "experience",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_over_16": {
"name": "is_over_16",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"drink_card_value": {
"name": "drink_card_value",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"guests": {
"name": "guests",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"birthdate": {
"name": "birthdate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"postcode": {
"name": "postcode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"extra_questions": {
"name": "extra_questions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"management_token": {
"name": "management_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cancelled_at": {
"name": "cancelled_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_status": {
"name": "payment_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"payment_amount": {
"name": "payment_amount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"gift_amount": {
"name": "gift_amount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"mollie_payment_id": {
"name": "mollie_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"paid_at": {
"name": "paid_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"drinkkaart_credited_at": {
"name": "drinkkaart_credited_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_reminder_sent_at": {
"name": "payment_reminder_sent_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"registration_management_token_unique": {
"name": "registration_management_token_unique",
"columns": ["management_token"],
"isUnique": true
},
"registration_email_idx": {
"name": "registration_email_idx",
"columns": ["email"],
"isUnique": false
},
"registration_registrationType_idx": {
"name": "registration_registrationType_idx",
"columns": ["registration_type"],
"isUnique": false
},
"registration_artForm_idx": {
"name": "registration_artForm_idx",
"columns": ["art_form"],
"isUnique": false
},
"registration_createdAt_idx": {
"name": "registration_createdAt_idx",
"columns": ["created_at"],
"isUnique": false
},
"registration_managementToken_idx": {
"name": "registration_managementToken_idx",
"columns": ["management_token"],
"isUnique": false
},
"registration_paymentStatus_idx": {
"name": "registration_paymentStatus_idx",
"columns": ["payment_status"],
"isUnique": false
},
"registration_giftAmount_idx": {
"name": "registration_giftAmount_idx",
"columns": ["gift_amount"],
"isUnique": false
},
"registration_molliePaymentId_idx": {
"name": "registration_molliePaymentId_idx",
"columns": ["mollie_payment_id"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reminder": {
"name": "reminder",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sent_24h_at": {
"name": "sent_24h_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sent_at": {
"name": "sent_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"reminder_email_idx": {
"name": "reminder_email_idx",
"columns": ["email"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"registration\".\"wants_to_perform\"": "\"registration\".\"registration_type\""
}
},
"internal": {
"indexes": {}
}
}

View File

@@ -57,6 +57,13 @@
"when": 1772931300000, "when": 1772931300000,
"tag": "0007_reminder", "tag": "0007_reminder",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1773910007494,
"tag": "0008_melodic_marrow",
"breakpoints": true
} }
] ]
} }

View File

@@ -19,9 +19,11 @@ export const registration = sqliteTable(
.default(false), .default(false),
// Watcher-specific fields // Watcher-specific fields
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?, birthdate, postcode} objects
guests: text("guests"), guests: text("guests"),
// Shared // Shared
birthdate: text("birthdate").notNull().default(""),
postcode: text("postcode").notNull().default(""),
extraQuestions: text("extra_questions"), extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(), managementToken: text("management_token").unique(),
cancelledAt: integer("cancelled_at", { mode: "timestamp_ms" }), cancelledAt: integer("cancelled_at", { mode: "timestamp_ms" }),