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>
</p>
<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">
Meer informatie
</a>

File diff suppressed because it is too large Load Diff

View File

@@ -9,11 +9,6 @@ const faqQuestions = [
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.",
},
{
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?",
answer:

View File

@@ -37,6 +37,9 @@ export const Route = createFileRoute("/admin")({
function AdminPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [registrationType, setRegistrationType] = useState<
"performer" | "watcher" | ""
>("");
const [artForm, setArtForm] = useState("");
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
@@ -49,6 +52,7 @@ function AdminPage() {
orpc.getRegistrations.queryOptions({
input: {
search: search || undefined,
registrationType: registrationType || undefined,
artForm: artForm || undefined,
fromDate: fromDate || undefined,
toDate: toDate || undefined,
@@ -120,6 +124,29 @@ function AdminPage() {
const adminRequests = adminRequestsQuery.data ?? [];
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 (
<div className="min-h-screen bg-[#214e51]">
{/* Header */}
@@ -207,7 +234,7 @@ function AdminPage() {
)}
{/* 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">
<CardHeader className="pb-2">
<CardDescription className="text-white/60">
@@ -238,23 +265,61 @@ function AdminPage() {
</CardContent>
</Card>
<Card className="border-white/10 bg-white/5">
<Card className="border-amber-400/20 bg-amber-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-white/60">
Per kunstvorm
<CardDescription className="text-amber-300/70">
Artiesten
</CardDescription>
<div className="mt-2 space-y-1">
{stats?.byArtForm.slice(0, 5).map((item) => (
<CardTitle className="font-['Intro',sans-serif] text-4xl text-amber-300">
{performerCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{stats?.byArtForm.slice(0, 4).map((item) => (
<div
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-white">{item.count}</span>
<span className="text-amber-300/70">
{item.artForm || "Onbekend"}
</span>
<span className="text-amber-300">{item.count}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-teal-400/20 bg-teal-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-teal-300/70">
Bezoekers
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-teal-300">
{watcherCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-xs">
{totalGuestCount > 0 && (
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Inclusief gasten</span>
<span className="text-teal-300">+{totalGuestCount}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Totaal aanwezig</span>
<span className="font-semibold text-teal-300">
{totalWatcherAttendees}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Drinkkaart</span>
<span className="text-teal-300">{totalDrinkCardValue}</span>
</div>
</div>
</CardContent>
</Card>
</div>
@@ -266,7 +331,7 @@ function AdminPage() {
</CardTitle>
</CardHeader>
<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>
<label
htmlFor="search"
@@ -286,6 +351,35 @@ function AdminPage() {
</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>
<label
htmlFor="artForm"
@@ -369,11 +463,20 @@ function AdminPage() {
Telefoon
</th>
<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 className="px-6 py-4 text-left font-medium text-sm text-white/60">
Ervaring
</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">
Datum
</th>
@@ -383,7 +486,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? (
<tr>
<td
colSpan={6}
colSpan={9}
className="px-6 py-8 text-center text-white/60"
>
Laden...
@@ -392,14 +495,16 @@ function AdminPage() {
) : registrations.length === 0 ? (
<tr>
<td
colSpan={6}
colSpan={9}
className="px-6 py-8 text-center text-white/60"
>
Geen registraties gevonden
</td>
</tr>
) : (
registrations.map((reg) => (
registrations.map((reg) => {
const isPerformer = reg.registrationType === "performer";
return (
<tr
key={reg.id}
className="border-white/5 border-b hover:bg-white/5"
@@ -407,21 +512,66 @@ function AdminPage() {
<td className="px-6 py-4 text-white">
{reg.firstName} {reg.lastName}
</td>
<td className="px-6 py-4 text-white/80">{reg.email}</td>
<td className="px-6 py-4 text-white/80">
{reg.email}
</td>
<td className="px-6 py-4 text-white/80">
{reg.phone || "-"}
</td>
<td className="px-6 py-4 text-white/80">
{reg.artForm}
<td className="px-6 py-4">
<span
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"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
</td>
<td className="px-6 py-4 text-white/80">
{reg.experience || "-"}
{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")}
{new Date(reg.createdAt).toLocaleDateString(
"nl-BE",
)}
</td>
</tr>
))
);
})
)}
</tbody>
</table>

View File

@@ -8,6 +8,31 @@ export const Route = createFileRoute("/manage/$token")({
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() {
const { token } = useParams({ from: "/manage/$token" });
const queryClient = useQueryClient();
@@ -18,11 +43,13 @@ function ManageRegistrationPage() {
lastName: "",
email: "",
phone: "",
wantsToPerform: false,
registrationType: "watcher" as "performer" | "watcher",
artForm: "",
experience: "",
isOver16: false,
extraQuestions: "",
});
const [formGuests, setFormGuests] = useState<GuestEntry[]>([]);
const { data, isLoading, error } = useQuery({
...orpc.getRegistrationByToken.queryOptions({ input: { token } }),
@@ -58,30 +85,41 @@ function ManageRegistrationPage() {
lastName: data.lastName,
email: data.email,
phone: data.phone || "",
wantsToPerform: data.wantsToPerform ?? false,
registrationType:
(data.registrationType as "performer" | "watcher") ?? "watcher",
artForm: data.artForm || "",
experience: data.experience || "",
isOver16: data.isOver16 ?? false,
extraQuestions: data.extraQuestions || "",
});
setFormGuests(parseGuests(data.guests));
setIsEditing(true);
}
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
const isPerformer = formData.registrationType === "performer";
updateMutation.mutate({
token,
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
email: formData.email.trim(),
phone: formData.phone.trim() || undefined,
wantsToPerform: formData.wantsToPerform,
artForm: formData.wantsToPerform
? formData.artForm.trim() || undefined
: undefined,
experience: formData.wantsToPerform
registrationType: formData.registrationType,
artForm: isPerformer ? formData.artForm.trim() || undefined : undefined,
experience: isPerformer
? formData.experience.trim() || 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,
});
};
@@ -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) {
return (
<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) {
const isEditingWatcher = formData.registrationType === "watcher";
const totalDrinkCard = isEditingWatcher ? 5 + formGuests.length * 2 : 0;
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
@@ -161,7 +232,7 @@ function ManageRegistrationPage() {
onChange={(e) =>
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>
@@ -176,7 +247,7 @@ function ManageRegistrationPage() {
onChange={(e) =>
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>
@@ -193,7 +264,7 @@ function ManageRegistrationPage() {
onChange={(e) =>
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>
@@ -208,46 +279,57 @@ function ManageRegistrationPage() {
onChange={(e) =>
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>
<label
htmlFor="wantsToPerform"
className="flex cursor-pointer items-center gap-4"
>
{/* Registration type */}
<div>
<p className="mb-3 text-white">Type inschrijving</p>
<div className="flex gap-4">
<label className="flex cursor-pointer items-center gap-3">
<div className="relative flex shrink-0">
<input
id="wantsToPerform"
type="checkbox"
checked={formData.wantsToPerform}
onChange={(e) =>
type="radio"
name="registrationType"
value="performer"
checked={formData.registrationType === "performer"}
onChange={() =>
setFormData((p) => ({
...p,
wantsToPerform: e.target.checked,
artForm: e.target.checked ? p.artForm : "",
experience: e.target.checked ? p.experience : "",
registrationType: "performer",
}))
}
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" />
<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 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" />
</div>
<span className="text-white text-xl">Ik wil optreden</span>
<span className="text-white">Optreden</span>
</label>
<label className="flex cursor-pointer items-center gap-3">
<div className="relative flex shrink-0">
<input
type="radio"
name="registrationType"
value="watcher"
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>
{formData.wantsToPerform && (
<div className="space-y-6 border border-white/20 p-6">
{formData.registrationType === "performer" && (
<div className="space-y-6 border border-amber-400/20 bg-amber-400/5 p-6">
<div>
<label htmlFor="artForm" className="mb-2 block text-white">
Kunstvorm *
@@ -255,16 +337,13 @@ function ManageRegistrationPage() {
<input
id="artForm"
type="text"
required={formData.wantsToPerform}
required={formData.registrationType === "performer"}
value={formData.artForm}
onChange={(e) =>
setFormData((p) => ({
...p,
artForm: e.target.value,
}))
setFormData((p) => ({ ...p, artForm: e.target.value }))
}
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">
<option value="Muziek" />
@@ -290,7 +369,7 @@ function ManageRegistrationPage() {
}))
}
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">
<option value="Beginner" />
@@ -298,6 +377,187 @@ function ManageRegistrationPage() {
<option value="Professional" />
</datalist>
</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>
)}
@@ -358,6 +618,19 @@ function ManageRegistrationPage() {
Open Mic Night vrijdag 18 april 2026
</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="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
@@ -378,17 +651,51 @@ function ManageRegistrationPage() {
</div>
</div>
{isPerformer && (
<div className="border-white/10 border-t pt-6">
<p className="mb-2 text-sm text-white/50">Rol</p>
<p className="mb-2 text-sm text-white/50">Optreden</p>
<p className="text-lg text-white">
{data.wantsToPerform
? `Optreden${data.artForm ? `${data.artForm}` : ""}`
: "Toeschouwer"}
{data.artForm || "—"}
{data.experience && (
<span className="ml-2 text-white/60">
({data.experience})
</span>
)}
</p>
{data.wantsToPerform && data.experience && (
<p className="mt-1 text-white/60">{data.experience}</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 && (
<div className="border-white/10 border-t pt-6">

View File

@@ -12,19 +12,34 @@ import {
} from "../email";
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({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
wantsToPerform: z.boolean().default(false),
registrationType: registrationTypeSchema.default("watcher"),
// Performer-specific
artForm: 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(),
});
const getRegistrationsSchema = z.object({
search: z.string().optional(),
registrationType: registrationTypeSchema.optional(),
artForm: z.string().optional(),
fromDate: z.string().datetime().optional(),
toDate: z.string().datetime().optional(),
@@ -48,15 +63,22 @@ export const appRouter = {
.input(submitRegistrationSchema)
.handler(async ({ input }) => {
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({
id: randomUUID(),
firstName: input.firstName,
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
wantsToPerform: input.wantsToPerform,
artForm: input.artForm || null,
experience: input.experience || null,
registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : 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,
managementToken,
});
@@ -65,7 +87,7 @@ export const appRouter = {
to: input.email,
firstName: input.firstName,
managementToken,
wantsToPerform: input.wantsToPerform,
wantsToPerform: isPerformer,
artForm: input.artForm,
}).catch((err) =>
console.error("Failed to send confirmation email:", err),
@@ -98,9 +120,11 @@ export const appRouter = {
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
wantsToPerform: z.boolean().default(false),
registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(),
experience: z.string().optional(),
isOver16: z.boolean().optional(),
guests: z.array(guestSchema).max(9).optional(),
extraQuestions: z.string().optional(),
}),
)
@@ -119,6 +143,9 @@ export const appRouter = {
const row = rows[0];
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
.update(registration)
.set({
@@ -126,9 +153,12 @@ export const appRouter = {
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
wantsToPerform: input.wantsToPerform,
artForm: input.artForm || null,
experience: input.experience || null,
registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : 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,
})
.where(eq(registration.managementToken, input.token));
@@ -137,7 +167,7 @@ export const appRouter = {
to: input.email,
firstName: input.firstName,
managementToken: input.token,
wantsToPerform: input.wantsToPerform,
wantsToPerform: isPerformer,
artForm: input.artForm,
}).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) {
conditions.push(eq(registration.artForm, input.artForm));
}
@@ -235,7 +271,8 @@ export const appRouter = {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [totalResult, todayResult, artFormResult] = await Promise.all([
const [totalResult, todayResult, artFormResult, typeResult] =
await Promise.all([
db.select({ count: count() }).from(registration),
db
.select({ count: count() })
@@ -247,7 +284,15 @@ export const appRouter = {
count: count(),
})
.from(registration)
.where(eq(registration.registrationType, "performer"))
.groupBy(registration.artForm),
db
.select({
registrationType: registration.registrationType,
count: count(),
})
.from(registration)
.groupBy(registration.registrationType),
]);
return {
@@ -257,6 +302,10 @@ export const appRouter = {
artForm: r.artForm,
count: r.count,
})),
byType: typeResult.map((r) => ({
registrationType: r.registrationType,
count: r.count,
})),
};
}),
@@ -272,24 +321,46 @@ export const appRouter = {
"Last Name",
"Email",
"Phone",
"Wants To Perform",
"Type",
"Art Form",
"Experience",
"Is Over 16",
"Drink Card Value",
"Guest Count",
"Guests",
"Extra Questions",
"Created At",
];
const rows = data.map((r) => [
const rows = data.map((r) => {
const guests: Array<{
firstName: string;
lastName: string;
email?: string;
phone?: string;
}> = r.guests ? JSON.parse(r.guests) : [];
const guestSummary = guests
.map(
(g) =>
`${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.wantsToPerform ? "Yes" : "No",
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 = [
headers.join(","),
@@ -321,16 +392,17 @@ export const appRouter = {
.limit(1);
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" };
}
if (existingRequest[0].status === "approved") {
if (existing.status === "approved") {
return {
success: false,
message: "Je aanvraag is al goedgekeurd, log opnieuw in",
};
}
if (existingRequest[0].status === "rejected") {
if (existing.status === "rejected") {
// Allow re-requesting if previously rejected
await db
.update(adminRequest)
@@ -387,7 +459,8 @@ export const appRouter = {
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");
}
@@ -405,7 +478,7 @@ export const appRouter = {
await db
.update(user)
.set({ role: "admin" })
.where(eq(user.id, request[0].userId));
.where(eq(user.id, req.userId));
return { success: true, message: "Admin toegang goedgekeurd" };
}),
@@ -423,7 +496,8 @@ export const appRouter = {
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");
}

View File

@@ -2,7 +2,7 @@ import dotenv from "dotenv";
import { defineConfig } from "drizzle-kit";
dotenv.config({
path: "../../apps/web/.env",
path: "../env/.env",
});
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,
"tag": "0001_third_stark_industries",
"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(),
email: text("email").notNull(),
phone: text("phone"),
wantsToPerform: integer("wants_to_perform", { mode: "boolean" })
.notNull()
.default(false),
// registrationType: 'performer' | 'watcher'
registrationType: text("registration_type").notNull().default("watcher"),
// Performer-specific fields
artForm: text("art_form"),
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"),
managementToken: text("management_token").unique(),
cancelledAt: integer("cancelled_at", { mode: "timestamp_ms" }),
@@ -23,6 +31,7 @@ export const registration = sqliteTable(
},
(table) => [
index("registration_email_idx").on(table.email),
index("registration_registrationType_idx").on(table.registrationType),
index("registration_artForm_idx").on(table.artForm),
index("registration_createdAt_idx").on(table.createdAt),
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_FROM: getEnvVar("SMTP_FROM"),
},
domains: ["kunstenkamp.be"],
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
});
console.log(`Web -> ${web.url}`);