feat:multiple bezoekers

This commit is contained in:
2026-03-03 10:36:08 +01:00
parent 1210b2e13e
commit f6c2bad9df
12 changed files with 1891 additions and 574 deletions

View File

@@ -40,7 +40,7 @@ export function CookieConsent() {
<strong>We gebruiken cookies</strong> <strong>We gebruiken cookies</strong>
</p> </p>
<p className="mt-1 text-sm text-white/80"> <p className="mt-1 text-sm text-white/80">
We gebruiken analytische cookies om onze website te verbeteren. We gebruiken analytische cookies om onze website te verbeteren.{" "}
<a href="/privacy" className="underline hover:text-white"> <a href="/privacy" className="underline hover:text-white">
Meer informatie Meer informatie
</a> </a>

File diff suppressed because it is too large Load Diff

View File

@@ -9,11 +9,6 @@ const faqQuestions = [
answer: answer:
"Iedereen! Of je nu een doorgewinterde artiest bent of voor het eerst op een podium staat — de open mic night is er voor alle niveaus en ervaringen.", "Iedereen! Of je nu een doorgewinterde artiest bent of voor het eerst op een podium staat — de open mic night is er voor alle niveaus en ervaringen.",
}, },
{
question: "Is er een minimumleeftijd?",
answer:
"Als je wilt optreden, moet je minimaal 15 jaar oud zijn & toestemming hebben van je ouders. Er is geen maximumleeftijd — iedereen is welkom!",
},
{ {
question: "Hoelang mag mijn optreden duren?", question: "Hoelang mag mijn optreden duren?",
answer: answer:

View File

@@ -37,6 +37,9 @@ export const Route = createFileRoute("/admin")({
function AdminPage() { function AdminPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [registrationType, setRegistrationType] = useState<
"performer" | "watcher" | ""
>("");
const [artForm, setArtForm] = useState(""); const [artForm, setArtForm] = useState("");
const [fromDate, setFromDate] = useState(""); const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState(""); const [toDate, setToDate] = useState("");
@@ -49,6 +52,7 @@ function AdminPage() {
orpc.getRegistrations.queryOptions({ orpc.getRegistrations.queryOptions({
input: { input: {
search: search || undefined, search: search || undefined,
registrationType: registrationType || undefined,
artForm: artForm || undefined, artForm: artForm || undefined,
fromDate: fromDate || undefined, fromDate: fromDate || undefined,
toDate: toDate || undefined, toDate: toDate || undefined,
@@ -120,6 +124,29 @@ function AdminPage() {
const adminRequests = adminRequestsQuery.data ?? []; const adminRequests = adminRequestsQuery.data ?? [];
const pendingRequests = adminRequests.filter((r) => r.status === "pending"); const pendingRequests = adminRequests.filter((r) => r.status === "pending");
const performerCount =
stats?.byType.find((t) => t.registrationType === "performer")?.count ?? 0;
const watcherCount =
stats?.byType.find((t) => t.registrationType === "watcher")?.count ?? 0;
// Calculate total attendees including guests
const totalRegistrations = registrationsQuery.data?.data ?? [];
const watcherWithGuests = totalRegistrations.filter(
(r) => r.registrationType === "watcher" && r.guests,
);
const totalGuestCount = watcherWithGuests.reduce((sum, r) => {
try {
const guests = JSON.parse(r.guests as string);
return sum + (Array.isArray(guests) ? guests.length : 0);
} catch {
return sum;
}
}, 0);
const totalWatcherAttendees = watcherCount + totalGuestCount;
const totalDrinkCardValue = totalRegistrations
.filter((r) => r.registrationType === "watcher")
.reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0);
return ( return (
<div className="min-h-screen bg-[#214e51]"> <div className="min-h-screen bg-[#214e51]">
{/* Header */} {/* Header */}
@@ -207,7 +234,7 @@ function AdminPage() {
)} )}
{/* Stats Cards */} {/* Stats Cards */}
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-3"> <div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-4">
<Card className="border-white/10 bg-white/5"> <Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-white/60"> <CardDescription className="text-white/60">
@@ -238,23 +265,61 @@ function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-white/10 bg-white/5"> <Card className="border-amber-400/20 bg-amber-400/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-white/60"> <CardDescription className="text-amber-300/70">
Per kunstvorm Artiesten
</CardDescription> </CardDescription>
<div className="mt-2 space-y-1"> <CardTitle className="font-['Intro',sans-serif] text-4xl text-amber-300">
{stats?.byArtForm.slice(0, 5).map((item) => ( {performerCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{stats?.byArtForm.slice(0, 4).map((item) => (
<div <div
key={item.artForm} key={item.artForm}
className="flex items-center justify-between text-sm" className="flex items-center justify-between text-xs"
> >
<span className="text-white/80">{item.artForm}</span> <span className="text-amber-300/70">
<span className="text-white">{item.count}</span> {item.artForm || "Onbekend"}
</span>
<span className="text-amber-300">{item.count}</span>
</div> </div>
))} ))}
</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-4xl text-teal-300">
{watcherCount}
</CardTitle>
</CardHeader> </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="flex items-center justify-between">
<span className="text-teal-300/70">Drinkkaart</span>
<span className="text-teal-300">{totalDrinkCardValue}</span>
</div>
</div>
</CardContent>
</Card> </Card>
</div> </div>
@@ -266,7 +331,7 @@ function AdminPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-5">
<div> <div>
<label <label
htmlFor="search" htmlFor="search"
@@ -286,6 +351,35 @@ function AdminPage() {
</div> </div>
</div> </div>
<div>
<label
htmlFor="typeFilter"
className="mb-2 block text-sm text-white/60"
>
Type
</label>
<select
id="typeFilter"
value={registrationType}
onChange={(e) =>
setRegistrationType(
e.target.value as "performer" | "watcher" | "",
)
}
className="w-full rounded-md border border-white/20 bg-white/10 px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
<option value="" className="bg-[#214e51]">
Alle types
</option>
<option value="performer" className="bg-[#214e51]">
Artiesten
</option>
<option value="watcher" className="bg-[#214e51]">
Bezoekers
</option>
</select>
</div>
<div> <div>
<label <label
htmlFor="artForm" htmlFor="artForm"
@@ -369,11 +463,20 @@ function AdminPage() {
Telefoon Telefoon
</th> </th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60"> <th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Kunstvorm Type
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Kunstvorm / Drinkkaart
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Gezelschap
</th> </th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60"> <th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Ervaring Ervaring
</th> </th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
16+
</th>
<th className="px-6 py-4 text-left font-medium text-sm text-white/60"> <th className="px-6 py-4 text-left font-medium text-sm text-white/60">
Datum Datum
</th> </th>
@@ -383,7 +486,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? ( {registrationsQuery.isLoading ? (
<tr> <tr>
<td <td
colSpan={6} colSpan={9}
className="px-6 py-8 text-center text-white/60" className="px-6 py-8 text-center text-white/60"
> >
Laden... Laden...
@@ -392,36 +495,83 @@ function AdminPage() {
) : registrations.length === 0 ? ( ) : registrations.length === 0 ? (
<tr> <tr>
<td <td
colSpan={6} colSpan={9}
className="px-6 py-8 text-center text-white/60" className="px-6 py-8 text-center text-white/60"
> >
Geen registraties gevonden Geen registraties gevonden
</td> </td>
</tr> </tr>
) : ( ) : (
registrations.map((reg) => ( registrations.map((reg) => {
<tr const isPerformer = reg.registrationType === "performer";
key={reg.id} return (
className="border-white/5 border-b hover:bg-white/5" <tr
> key={reg.id}
<td className="px-6 py-4 text-white"> className="border-white/5 border-b hover:bg-white/5"
{reg.firstName} {reg.lastName} >
</td> <td className="px-6 py-4 text-white">
<td className="px-6 py-4 text-white/80">{reg.email}</td> {reg.firstName} {reg.lastName}
<td className="px-6 py-4 text-white/80"> </td>
{reg.phone || "-"} <td className="px-6 py-4 text-white/80">
</td> {reg.email}
<td className="px-6 py-4 text-white/80"> </td>
{reg.artForm} <td className="px-6 py-4 text-white/80">
</td> {reg.phone || "-"}
<td className="px-6 py-4 text-white/80"> </td>
{reg.experience || "-"} <td className="px-6 py-4">
</td> <span
<td className="px-6 py-4 text-white/60"> className={`inline-flex items-center rounded-full px-2.5 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
{new Date(reg.createdAt).toLocaleDateString("nl-BE")} >
</td> {isPerformer ? "Artiest" : "Bezoeker"}
</tr> </span>
)) </td>
<td className="px-6 py-4 text-white/80">
{isPerformer
? reg.artForm || "-"
: `${reg.drinkCardValue ?? 5} drinkkaart`}
</td>
<td className="px-6 py-4 text-white/80">
{isPerformer
? "-"
: (() => {
if (!reg.guests) return "-";
try {
const guests = JSON.parse(
reg.guests as string,
);
const count = Array.isArray(guests)
? guests.length
: 0;
return count > 0
? `${count} gast${count === 1 ? "" : "en"}`
: "-";
} catch {
return "-";
}
})()}
</td>
<td className="px-6 py-4 text-white/80">
{isPerformer ? reg.experience || "-" : "-"}
</td>
<td className="px-6 py-4 text-white/80">
{isPerformer ? (
reg.isOver16 ? (
<span className="text-green-400"></span>
) : (
<span className="text-red-400"></span>
)
) : (
"-"
)}
</td>
<td className="px-6 py-4 text-white/60">
{new Date(reg.createdAt).toLocaleDateString(
"nl-BE",
)}
</td>
</tr>
);
})
)} )}
</tbody> </tbody>
</table> </table>

View File

@@ -8,6 +8,31 @@ export const Route = createFileRoute("/manage/$token")({
component: ManageRegistrationPage, component: ManageRegistrationPage,
}); });
interface GuestEntry {
firstName: string;
lastName: string;
email: string;
phone: string;
}
function parseGuests(raw: string | null | undefined): GuestEntry[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((g) => ({
firstName: g.firstName ?? "",
lastName: g.lastName ?? "",
email: g.email ?? "",
phone: g.phone ?? "",
}));
}
} catch {
// ignore
}
return [];
}
function ManageRegistrationPage() { function ManageRegistrationPage() {
const { token } = useParams({ from: "/manage/$token" }); const { token } = useParams({ from: "/manage/$token" });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -18,11 +43,13 @@ function ManageRegistrationPage() {
lastName: "", lastName: "",
email: "", email: "",
phone: "", phone: "",
wantsToPerform: false, registrationType: "watcher" as "performer" | "watcher",
artForm: "", artForm: "",
experience: "", experience: "",
isOver16: false,
extraQuestions: "", extraQuestions: "",
}); });
const [formGuests, setFormGuests] = useState<GuestEntry[]>([]);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
...orpc.getRegistrationByToken.queryOptions({ input: { token } }), ...orpc.getRegistrationByToken.queryOptions({ input: { token } }),
@@ -58,30 +85,41 @@ function ManageRegistrationPage() {
lastName: data.lastName, lastName: data.lastName,
email: data.email, email: data.email,
phone: data.phone || "", phone: data.phone || "",
wantsToPerform: data.wantsToPerform ?? false, registrationType:
(data.registrationType as "performer" | "watcher") ?? "watcher",
artForm: data.artForm || "", artForm: data.artForm || "",
experience: data.experience || "", experience: data.experience || "",
isOver16: data.isOver16 ?? false,
extraQuestions: data.extraQuestions || "", extraQuestions: data.extraQuestions || "",
}); });
setFormGuests(parseGuests(data.guests));
setIsEditing(true); setIsEditing(true);
} }
}; };
const handleSave = (e: React.FormEvent) => { const handleSave = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const isPerformer = formData.registrationType === "performer";
updateMutation.mutate({ updateMutation.mutate({
token, token,
firstName: formData.firstName.trim(), firstName: formData.firstName.trim(),
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,
wantsToPerform: formData.wantsToPerform, registrationType: formData.registrationType,
artForm: formData.wantsToPerform artForm: isPerformer ? formData.artForm.trim() || undefined : undefined,
? formData.artForm.trim() || undefined experience: isPerformer
: undefined,
experience: formData.wantsToPerform
? formData.experience.trim() || undefined ? formData.experience.trim() || undefined
: undefined, : undefined,
isOver16: isPerformer ? formData.isOver16 : false,
guests: isPerformer
? []
: formGuests.map((g) => ({
firstName: g.firstName.trim(),
lastName: g.lastName.trim(),
email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined,
})),
extraQuestions: formData.extraQuestions.trim() || undefined, extraQuestions: formData.extraQuestions.trim() || undefined,
}); });
}; };
@@ -96,6 +134,33 @@ function ManageRegistrationPage() {
} }
}; };
const handleAddGuest = () => {
if (formGuests.length >= 9) return;
setFormGuests((prev) => [
...prev,
{ firstName: "", lastName: "", email: "", phone: "" },
]);
};
const handleRemoveGuest = (index: number) => {
setFormGuests((prev) => prev.filter((_, i) => i !== index));
};
const handleGuestChange = (
index: number,
field: keyof GuestEntry,
value: string,
) => {
setFormGuests((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const inputClasses =
"w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none";
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-[#214e51]"> <div className="min-h-screen bg-[#214e51]">
@@ -133,7 +198,13 @@ function ManageRegistrationPage() {
); );
} }
const isPerformer = data.registrationType === "performer";
const viewGuests = parseGuests(data.guests);
if (isEditing) { if (isEditing) {
const isEditingWatcher = formData.registrationType === "watcher";
const totalDrinkCard = isEditingWatcher ? 5 + formGuests.length * 2 : 0;
return ( return (
<div className="min-h-screen bg-[#214e51]"> <div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16"> <div className="mx-auto max-w-3xl px-6 py-16">
@@ -161,7 +232,7 @@ function ManageRegistrationPage() {
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ ...p, firstName: e.target.value })) setFormData((p) => ({ ...p, firstName: e.target.value }))
} }
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none" className={inputClasses}
/> />
</div> </div>
<div> <div>
@@ -176,7 +247,7 @@ function ManageRegistrationPage() {
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ ...p, lastName: e.target.value })) setFormData((p) => ({ ...p, lastName: e.target.value }))
} }
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none" className={inputClasses}
/> />
</div> </div>
</div> </div>
@@ -193,7 +264,7 @@ function ManageRegistrationPage() {
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ ...p, email: e.target.value })) setFormData((p) => ({ ...p, email: e.target.value }))
} }
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none" className={inputClasses}
/> />
</div> </div>
@@ -208,46 +279,57 @@ function ManageRegistrationPage() {
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ ...p, phone: e.target.value })) setFormData((p) => ({ ...p, phone: e.target.value }))
} }
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none" className={inputClasses}
/> />
</div> </div>
<label {/* Registration type */}
htmlFor="wantsToPerform" <div>
className="flex cursor-pointer items-center gap-4" <p className="mb-3 text-white">Type inschrijving</p>
> <div className="flex gap-4">
<div className="relative flex shrink-0"> <label className="flex cursor-pointer items-center gap-3">
<input <div className="relative flex shrink-0">
id="wantsToPerform" <input
type="checkbox" type="radio"
checked={formData.wantsToPerform} name="registrationType"
onChange={(e) => value="performer"
setFormData((p) => ({ checked={formData.registrationType === "performer"}
...p, onChange={() =>
wantsToPerform: e.target.checked, setFormData((p) => ({
artForm: e.target.checked ? p.artForm : "", ...p,
experience: e.target.checked ? p.experience : "", registrationType: "performer",
})) }))
} }
className="peer sr-only" className="peer sr-only"
/> />
<div className="h-6 w-6 border-2 border-white/50 bg-transparent transition-colors peer-checked:border-white peer-checked:bg-white" /> <div className="h-5 w-5 rounded-full border-2 border-white/50 bg-transparent transition-colors peer-checked:border-amber-400 peer-checked:bg-amber-400" />
<svg </div>
className="pointer-events-none absolute top-0 left-0 h-6 w-6 scale-0 text-[#214e51] transition-transform peer-checked:scale-100" <span className="text-white">Optreden</span>
viewBox="0 0 24 24" </label>
fill="none" <label className="flex cursor-pointer items-center gap-3">
stroke="currentColor" <div className="relative flex shrink-0">
strokeWidth={3} <input
aria-label="Geselecteerd" type="radio"
> name="registrationType"
<polyline points="20 6 9 17 4 12" /> value="watcher"
</svg> checked={formData.registrationType === "watcher"}
onChange={() =>
setFormData((p) => ({
...p,
registrationType: "watcher",
}))
}
className="peer sr-only"
/>
<div className="h-5 w-5 rounded-full border-2 border-white/50 bg-transparent transition-colors peer-checked:border-teal-400 peer-checked:bg-teal-400" />
</div>
<span className="text-white">Kijken</span>
</label>
</div> </div>
<span className="text-white text-xl">Ik wil optreden</span> </div>
</label>
{formData.wantsToPerform && ( {formData.registrationType === "performer" && (
<div className="space-y-6 border border-white/20 p-6"> <div className="space-y-6 border border-amber-400/20 bg-amber-400/5 p-6">
<div> <div>
<label htmlFor="artForm" className="mb-2 block text-white"> <label htmlFor="artForm" className="mb-2 block text-white">
Kunstvorm * Kunstvorm *
@@ -255,16 +337,13 @@ function ManageRegistrationPage() {
<input <input
id="artForm" id="artForm"
type="text" type="text"
required={formData.wantsToPerform} required={formData.registrationType === "performer"}
value={formData.artForm} value={formData.artForm}
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ setFormData((p) => ({ ...p, artForm: e.target.value }))
...p,
artForm: e.target.value,
}))
} }
list="artFormSuggestions" list="artFormSuggestions"
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none" className={inputClasses}
/> />
<datalist id="artFormSuggestions"> <datalist id="artFormSuggestions">
<option value="Muziek" /> <option value="Muziek" />
@@ -290,7 +369,7 @@ function ManageRegistrationPage() {
})) }))
} }
list="experienceSuggestions" list="experienceSuggestions"
className="w-full border-white/30 border-b bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none" className={inputClasses}
/> />
<datalist id="experienceSuggestions"> <datalist id="experienceSuggestions">
<option value="Beginner" /> <option value="Beginner" />
@@ -298,6 +377,187 @@ function ManageRegistrationPage() {
<option value="Professional" /> <option value="Professional" />
</datalist> </datalist>
</div> </div>
<label className="flex cursor-pointer items-center gap-3">
<div className="relative flex shrink-0">
<input
type="checkbox"
checked={formData.isOver16}
onChange={(e) =>
setFormData((p) => ({
...p,
isOver16: e.target.checked,
}))
}
className="peer sr-only"
/>
<div className="h-6 w-6 border-2 border-white/50 bg-transparent transition-colors peer-checked:border-amber-400 peer-checked:bg-amber-400" />
<svg
className="pointer-events-none absolute top-0 left-0 h-6 w-6 scale-0 text-[#214e51] transition-transform peer-checked:scale-100"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
aria-label="Geselecteerd"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<span className="text-white">Ik ben 16 jaar of ouder</span>
</label>
</div>
)}
{/* Guests section for watchers */}
{isEditingWatcher && (
<div className="border border-teal-400/20 bg-teal-400/5 p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-sm text-teal-300/80 uppercase tracking-wider">
Medebezoekers{" "}
<span className="text-white/50 normal-case">
({formGuests.length}/9)
</span>
</p>
<p className="mt-1 text-sm text-white/60">
Drinkkaart totaal: {totalDrinkCard} voor{" "}
{1 + formGuests.length} personen
</p>
</div>
{formGuests.length < 9 && (
<button
type="button"
onClick={handleAddGuest}
className="flex items-center gap-1.5 rounded border border-teal-400/40 bg-teal-400/10 px-3 py-1.5 text-sm text-teal-300 transition-colors hover:bg-teal-400/20"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
Toevoegen
</button>
)}
</div>
{formGuests.length === 0 && (
<p className="text-sm text-white/40">
Geen medebezoekers toegevoegd.
</p>
)}
<div className="flex flex-col gap-4">
{formGuests.map((guest, idx) => (
<div
key={`edit-guest-${
// biome-ignore lint/suspicious/noArrayIndexKey: stable index
idx
}`}
className="border border-teal-400/20 p-4"
>
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-sm text-teal-300">
Medebezoeker {idx + 1}
</span>
<button
type="button"
onClick={() => handleRemoveGuest(idx)}
className="text-red-400/70 text-sm transition-colors hover:text-red-300"
>
Verwijderen
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label
htmlFor={`edit-guest-${idx}-firstName`}
className="mb-1 block text-sm text-white/70"
>
Voornaam *
</label>
<input
id={`edit-guest-${idx}-firstName`}
type="text"
required
value={guest.firstName}
onChange={(e) =>
handleGuestChange(
idx,
"firstName",
e.target.value,
)
}
placeholder="Voornaam"
className={inputClasses}
/>
</div>
<div>
<label
htmlFor={`edit-guest-${idx}-lastName`}
className="mb-1 block text-sm text-white/70"
>
Achternaam *
</label>
<input
id={`edit-guest-${idx}-lastName`}
type="text"
required
value={guest.lastName}
onChange={(e) =>
handleGuestChange(idx, "lastName", e.target.value)
}
placeholder="Achternaam"
className={inputClasses}
/>
</div>
<div>
<label
htmlFor={`edit-guest-${idx}-email`}
className="mb-1 block text-sm text-white/70"
>
E-mail
</label>
<input
id={`edit-guest-${idx}-email`}
type="email"
value={guest.email}
onChange={(e) =>
handleGuestChange(idx, "email", e.target.value)
}
placeholder="optioneel@email.be"
className={inputClasses}
/>
</div>
<div>
<label
htmlFor={`edit-guest-${idx}-phone`}
className="mb-1 block text-sm text-white/70"
>
Telefoon
</label>
<input
id={`edit-guest-${idx}-phone`}
type="tel"
value={guest.phone}
onChange={(e) =>
handleGuestChange(idx, "phone", e.target.value)
}
placeholder="06-12345678"
className={inputClasses}
/>
</div>
</div>
</div>
))}
</div>
</div> </div>
)} )}
@@ -358,6 +618,19 @@ function ManageRegistrationPage() {
Open Mic Night vrijdag 18 april 2026 Open Mic Night vrijdag 18 april 2026
</p> </p>
{/* Type badge */}
<div
className={`mb-6 inline-flex items-center gap-2 rounded-full border px-4 py-1.5 font-semibold text-sm ${isPerformer ? "border-amber-400/40 bg-amber-400/10 text-amber-300" : "border-teal-400/40 bg-teal-400/10 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
{!isPerformer && (
<span className="ml-1 opacity-80">
Drinkkaart {data.drinkCardValue ?? 5}
{viewGuests.length > 0 && ` (${1 + viewGuests.length} personen)`}
</span>
)}
</div>
<div className="space-y-6 rounded-lg border border-white/20 bg-white/5 p-6 md:p-8"> <div className="space-y-6 rounded-lg border border-white/20 bg-white/5 p-6 md:p-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
@@ -378,17 +651,51 @@ function ManageRegistrationPage() {
</div> </div>
</div> </div>
<div className="border-white/10 border-t pt-6"> {isPerformer && (
<p className="mb-2 text-sm text-white/50">Rol</p> <div className="border-white/10 border-t pt-6">
<p className="text-lg text-white"> <p className="mb-2 text-sm text-white/50">Optreden</p>
{data.wantsToPerform <p className="text-lg text-white">
? `Optreden${data.artForm ? `${data.artForm}` : ""}` {data.artForm || ""}
: "Toeschouwer"} {data.experience && (
</p> <span className="ml-2 text-white/60">
{data.wantsToPerform && data.experience && ( ({data.experience})
<p className="mt-1 text-white/60">{data.experience}</p> </span>
)} )}
</div> </p>
<p className="mt-1 text-sm text-white/60">
{data.isOver16 ? "16+ bevestigd" : "Leeftijd niet bevestigd"}
</p>
</div>
)}
{!isPerformer && viewGuests.length > 0 && (
<div className="border-white/10 border-t pt-6">
<p className="mb-3 text-sm text-white/50">
Medebezoekers ({viewGuests.length})
</p>
<div className="flex flex-col gap-3">
{viewGuests.map((g, idx) => (
<div
key={`view-guest-${
// biome-ignore lint/suspicious/noArrayIndexKey: stable index
idx
}`}
className="rounded border border-teal-400/20 bg-teal-400/5 p-3"
>
<p className="text-white">
{g.firstName} {g.lastName}
</p>
{g.email && (
<p className="text-sm text-white/60">{g.email}</p>
)}
{g.phone && (
<p className="text-sm text-white/60">{g.phone}</p>
)}
</div>
))}
</div>
</div>
)}
{data.extraQuestions && ( {data.extraQuestions && (
<div className="border-white/10 border-t pt-6"> <div className="border-white/10 border-t pt-6">

View File

@@ -12,19 +12,34 @@ import {
} from "../email"; } from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index"; import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
const registrationTypeSchema = z.enum(["performer", "watcher"]);
const guestSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
});
const submitRegistrationSchema = z.object({ const submitRegistrationSchema = z.object({
firstName: z.string().min(1), firstName: z.string().min(1),
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(),
wantsToPerform: z.boolean().default(false), registrationType: registrationTypeSchema.default("watcher"),
// Performer-specific
artForm: z.string().optional(), artForm: z.string().optional(),
experience: z.string().optional(), experience: z.string().optional(),
isOver16: z.boolean().optional(),
// Watcher-specific: drinkCardValue is computed server-side, guests are named
guests: z.array(guestSchema).max(9).optional(),
// Shared
extraQuestions: z.string().optional(), extraQuestions: z.string().optional(),
}); });
const getRegistrationsSchema = z.object({ const getRegistrationsSchema = z.object({
search: z.string().optional(), search: z.string().optional(),
registrationType: registrationTypeSchema.optional(),
artForm: z.string().optional(), artForm: z.string().optional(),
fromDate: z.string().datetime().optional(), fromDate: z.string().datetime().optional(),
toDate: z.string().datetime().optional(), toDate: z.string().datetime().optional(),
@@ -48,15 +63,22 @@ export const appRouter = {
.input(submitRegistrationSchema) .input(submitRegistrationSchema)
.handler(async ({ input }) => { .handler(async ({ input }) => {
const managementToken = randomUUID(); const managementToken = randomUUID();
const isPerformer = input.registrationType === "performer";
const guests = isPerformer ? [] : (input.guests ?? []);
// €5 for primary registrant + €2 per extra guest
const drinkCardValue = isPerformer ? 0 : 5 + guests.length * 2;
await db.insert(registration).values({ await db.insert(registration).values({
id: randomUUID(), id: randomUUID(),
firstName: input.firstName, firstName: input.firstName,
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
wantsToPerform: input.wantsToPerform, registrationType: input.registrationType,
artForm: input.artForm || null, artForm: isPerformer ? input.artForm || null : null,
experience: input.experience || null, experience: isPerformer ? input.experience || null : null,
isOver16: isPerformer ? (input.isOver16 ?? false) : false,
drinkCardValue,
guests: guests.length > 0 ? JSON.stringify(guests) : null,
extraQuestions: input.extraQuestions || null, extraQuestions: input.extraQuestions || null,
managementToken, managementToken,
}); });
@@ -65,7 +87,7 @@ export const appRouter = {
to: input.email, to: input.email,
firstName: input.firstName, firstName: input.firstName,
managementToken, managementToken,
wantsToPerform: input.wantsToPerform, wantsToPerform: isPerformer,
artForm: input.artForm, artForm: input.artForm,
}).catch((err) => }).catch((err) =>
console.error("Failed to send confirmation email:", err), console.error("Failed to send confirmation email:", err),
@@ -98,9 +120,11 @@ export const appRouter = {
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(),
wantsToPerform: z.boolean().default(false), registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(), artForm: z.string().optional(),
experience: z.string().optional(), experience: z.string().optional(),
isOver16: z.boolean().optional(),
guests: z.array(guestSchema).max(9).optional(),
extraQuestions: z.string().optional(), extraQuestions: z.string().optional(),
}), }),
) )
@@ -119,6 +143,9 @@ export const appRouter = {
const row = rows[0]; const row = rows[0];
if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd"); if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd");
const isPerformer = input.registrationType === "performer";
const guests = isPerformer ? [] : (input.guests ?? []);
const drinkCardValue = isPerformer ? 0 : 5 + guests.length * 2;
await db await db
.update(registration) .update(registration)
.set({ .set({
@@ -126,9 +153,12 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
wantsToPerform: input.wantsToPerform, registrationType: input.registrationType,
artForm: input.artForm || null, artForm: isPerformer ? input.artForm || null : null,
experience: input.experience || null, experience: isPerformer ? input.experience || null : null,
isOver16: isPerformer ? (input.isOver16 ?? false) : false,
drinkCardValue,
guests: guests.length > 0 ? JSON.stringify(guests) : null,
extraQuestions: input.extraQuestions || null, extraQuestions: input.extraQuestions || null,
}) })
.where(eq(registration.managementToken, input.token)); .where(eq(registration.managementToken, input.token));
@@ -137,7 +167,7 @@ export const appRouter = {
to: input.email, to: input.email,
firstName: input.firstName, firstName: input.firstName,
managementToken: input.token, managementToken: input.token,
wantsToPerform: input.wantsToPerform, wantsToPerform: isPerformer,
artForm: input.artForm, artForm: input.artForm,
}).catch((err) => console.error("Failed to send update email:", err)); }).catch((err) => console.error("Failed to send update email:", err));
@@ -192,6 +222,12 @@ export const appRouter = {
); );
} }
if (input.registrationType) {
conditions.push(
eq(registration.registrationType, input.registrationType),
);
}
if (input.artForm) { if (input.artForm) {
conditions.push(eq(registration.artForm, input.artForm)); conditions.push(eq(registration.artForm, input.artForm));
} }
@@ -235,20 +271,29 @@ export const appRouter = {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const [totalResult, todayResult, artFormResult] = await Promise.all([ const [totalResult, todayResult, artFormResult, typeResult] =
db.select({ count: count() }).from(registration), await Promise.all([
db db.select({ count: count() }).from(registration),
.select({ count: count() }) db
.from(registration) .select({ count: count() })
.where(gte(registration.createdAt, today)), .from(registration)
db .where(gte(registration.createdAt, today)),
.select({ db
artForm: registration.artForm, .select({
count: count(), artForm: registration.artForm,
}) count: count(),
.from(registration) })
.groupBy(registration.artForm), .from(registration)
]); .where(eq(registration.registrationType, "performer"))
.groupBy(registration.artForm),
db
.select({
registrationType: registration.registrationType,
count: count(),
})
.from(registration)
.groupBy(registration.registrationType),
]);
return { return {
total: totalResult[0]?.count ?? 0, total: totalResult[0]?.count ?? 0,
@@ -257,6 +302,10 @@ export const appRouter = {
artForm: r.artForm, artForm: r.artForm,
count: r.count, count: r.count,
})), })),
byType: typeResult.map((r) => ({
registrationType: r.registrationType,
count: r.count,
})),
}; };
}), }),
@@ -272,24 +321,46 @@ export const appRouter = {
"Last Name", "Last Name",
"Email", "Email",
"Phone", "Phone",
"Wants To Perform", "Type",
"Art Form", "Art Form",
"Experience", "Experience",
"Is Over 16",
"Drink Card Value",
"Guest Count",
"Guests",
"Extra Questions", "Extra Questions",
"Created At", "Created At",
]; ];
const rows = data.map((r) => [ const rows = data.map((r) => {
r.id, const guests: Array<{
r.firstName, firstName: string;
r.lastName, lastName: string;
r.email, email?: string;
r.phone || "", phone?: string;
r.wantsToPerform ? "Yes" : "No", }> = r.guests ? JSON.parse(r.guests) : [];
r.artForm || "", const guestSummary = guests
r.experience || "", .map(
r.extraQuestions || "", (g) =>
r.createdAt.toISOString(), `${g.firstName} ${g.lastName}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
]); )
.join(" | ");
return [
r.id,
r.firstName,
r.lastName,
r.email,
r.phone || "",
r.registrationType,
r.artForm || "",
r.experience || "",
r.isOver16 ? "Yes" : "No",
String(r.drinkCardValue ?? 0),
String(guests.length),
guestSummary,
r.extraQuestions || "",
r.createdAt.toISOString(),
];
});
const csvContent = [ const csvContent = [
headers.join(","), headers.join(","),
@@ -321,16 +392,17 @@ export const appRouter = {
.limit(1); .limit(1);
if (existingRequest.length > 0) { if (existingRequest.length > 0) {
if (existingRequest[0].status === "pending") { const existing = existingRequest[0]!;
if (existing.status === "pending") {
return { success: false, message: "Je hebt al een aanvraag openstaan" }; return { success: false, message: "Je hebt al een aanvraag openstaan" };
} }
if (existingRequest[0].status === "approved") { if (existing.status === "approved") {
return { return {
success: false, success: false,
message: "Je aanvraag is al goedgekeurd, log opnieuw in", message: "Je aanvraag is al goedgekeurd, log opnieuw in",
}; };
} }
if (existingRequest[0].status === "rejected") { if (existing.status === "rejected") {
// Allow re-requesting if previously rejected // Allow re-requesting if previously rejected
await db await db
.update(adminRequest) .update(adminRequest)
@@ -387,7 +459,8 @@ export const appRouter = {
throw new Error("Aanvraag niet gevonden"); throw new Error("Aanvraag niet gevonden");
} }
if (request[0].status !== "pending") { const req = request[0]!;
if (req.status !== "pending") {
throw new Error("Deze aanvraag is al behandeld"); throw new Error("Deze aanvraag is al behandeld");
} }
@@ -405,7 +478,7 @@ export const appRouter = {
await db await db
.update(user) .update(user)
.set({ role: "admin" }) .set({ role: "admin" })
.where(eq(user.id, request[0].userId)); .where(eq(user.id, req.userId));
return { success: true, message: "Admin toegang goedgekeurd" }; return { success: true, message: "Admin toegang goedgekeurd" };
}), }),
@@ -423,7 +496,8 @@ export const appRouter = {
throw new Error("Aanvraag niet gevonden"); throw new Error("Aanvraag niet gevonden");
} }
if (request[0].status !== "pending") { const req = request[0]!;
if (req.status !== "pending") {
throw new Error("Deze aanvraag is al behandeld"); throw new Error("Deze aanvraag is al behandeld");
} }

View File

@@ -2,7 +2,7 @@ import dotenv from "dotenv";
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
dotenv.config({ dotenv.config({
path: "../../apps/web/.env", path: "../env/.env",
}); });
export default defineConfig({ export default defineConfig({

View File

@@ -0,0 +1,20 @@
-- Migration: Replace wantsToPerform with registrationType + add isOver16 + drinkCardValue
-- Migrate existing 'wants_to_perform' data to 'registration_type' before dropping
ALTER TABLE `registration` ADD `registration_type` text NOT NULL DEFAULT 'watcher';--> statement-breakpoint
ALTER TABLE `registration` ADD `is_over_16` integer NOT NULL DEFAULT false;--> statement-breakpoint
ALTER TABLE `registration` ADD `drink_card_value` integer DEFAULT 0;--> statement-breakpoint
-- Backfill registration_type from wants_to_perform
UPDATE `registration` SET `registration_type` = 'performer' WHERE `wants_to_perform` = 1;--> statement-breakpoint
UPDATE `registration` SET `registration_type` = 'watcher' WHERE `wants_to_perform` = 0;--> statement-breakpoint
-- Backfill drink_card_value for watchers
UPDATE `registration` SET `drink_card_value` = 5 WHERE `wants_to_perform` = 0;--> statement-breakpoint
-- Create index on registration_type
CREATE INDEX `registration_registrationType_idx` ON `registration` (`registration_type`);--> statement-breakpoint
-- Drop old wants_to_perform column (SQLite requires recreating the table)
-- SQLite doesn't support DROP COLUMN directly in older versions, but Turso/libSQL does
ALTER TABLE `registration` DROP COLUMN `wants_to_perform`;

View File

@@ -0,0 +1,2 @@
-- Migration: Add guests column (JSON text) to registration table
ALTER TABLE `registration` ADD `guests` text;

View File

@@ -15,6 +15,13 @@
"when": 1772480778632, "when": 1772480778632,
"tag": "0001_third_stark_industries", "tag": "0001_third_stark_industries",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1772520000000,
"tag": "0002_registration_type_redesign",
"breakpoints": true
} }
] ]
} }

View File

@@ -9,11 +9,19 @@ export const registration = sqliteTable(
lastName: text("last_name").notNull(), lastName: text("last_name").notNull(),
email: text("email").notNull(), email: text("email").notNull(),
phone: text("phone"), phone: text("phone"),
wantsToPerform: integer("wants_to_perform", { mode: "boolean" }) // registrationType: 'performer' | 'watcher'
.notNull() registrationType: text("registration_type").notNull().default("watcher"),
.default(false), // Performer-specific fields
artForm: text("art_form"), artForm: text("art_form"),
experience: text("experience"), experience: text("experience"),
isOver16: integer("is_over_16", { mode: "boolean" })
.notNull()
.default(false),
// Watcher-specific fields
drinkCardValue: integer("drink_card_value").default(0),
// Guests: JSON array of {firstName, lastName, email?, phone?} objects
guests: text("guests"),
// Shared
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" }),
@@ -23,6 +31,7 @@ export const registration = sqliteTable(
}, },
(table) => [ (table) => [
index("registration_email_idx").on(table.email), index("registration_email_idx").on(table.email),
index("registration_registrationType_idx").on(table.registrationType),
index("registration_artForm_idx").on(table.artForm), index("registration_artForm_idx").on(table.artForm),
index("registration_createdAt_idx").on(table.createdAt), index("registration_createdAt_idx").on(table.createdAt),
index("registration_managementToken_idx").on(table.managementToken), index("registration_managementToken_idx").on(table.managementToken),

View File

@@ -29,7 +29,7 @@ export const web = await TanStackStart("web", {
SMTP_PASS: getEnvVar("SMTP_PASS"), SMTP_PASS: getEnvVar("SMTP_PASS"),
SMTP_FROM: getEnvVar("SMTP_FROM"), SMTP_FROM: getEnvVar("SMTP_FROM"),
}, },
domains: ["kunstenkamp.be"], domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
}); });
console.log(`Web -> ${web.url}`); console.log(`Web -> ${web.url}`);