From d5fde568da728c5327ccf2af6f7b0840d5a7ad36 Mon Sep 17 00:00:00 2001 From: zias Date: Sat, 7 Mar 2026 15:49:56 +0100 Subject: [PATCH] feat(registration): update event date and add required guest fields Change event date from April 18 to April 24 across all pages and emails. Add birthdate and postcode as required fields for guest registration. Update API to support multiple registrations per user. Enhance admin panel with expandable guest details view. --- .../homepage/EventRegistrationForm.tsx | 1 + apps/web/src/components/homepage/Hero.tsx | 4 +- .../src/components/registration/GuestList.tsx | 45 ++ .../components/registration/SuccessScreen.tsx | 13 +- .../components/registration/WatcherForm.tsx | 11 +- apps/web/src/lib/registration.ts | 8 + apps/web/src/routes/__root.tsx | 2 +- apps/web/src/routes/account.tsx | 222 ++++--- apps/web/src/routes/admin/index.tsx | 604 +++++++++++------- apps/web/src/routes/contact.tsx | 2 +- apps/web/src/routes/manage.$token.tsx | 48 +- packages/api/src/email.ts | 2 +- packages/api/src/routers/index.ts | 18 +- 13 files changed, 604 insertions(+), 376 deletions(-) diff --git a/apps/web/src/components/homepage/EventRegistrationForm.tsx b/apps/web/src/components/homepage/EventRegistrationForm.tsx index 1b45dd3..3e7cd8b 100644 --- a/apps/web/src/components/homepage/EventRegistrationForm.tsx +++ b/apps/web/src/components/homepage/EventRegistrationForm.tsx @@ -36,6 +36,7 @@ export default function EventRegistrationForm() { token={successState.token} email={successState.email} name={successState.name} + isLoggedIn={isLoggedIn} onReset={() => { setSuccessState(null); setSelectedType(null); diff --git a/apps/web/src/components/homepage/Hero.tsx b/apps/web/src/components/homepage/Hero.tsx index edb38e8..cc9fc17 100644 --- a/apps/web/src/components/homepage/Hero.tsx +++ b/apps/web/src/components/homepage/Hero.tsx @@ -83,7 +83,7 @@ export default function Hero() { {/* Bottom Right - Dark Teal with date - above mic */}

- VRIJDAG 18 + VRIJDAG 24
april

@@ -153,7 +153,7 @@ export default function Hero() {

VRIJDAG
- 18 april + 24 april

diff --git a/apps/web/src/components/registration/GuestList.tsx b/apps/web/src/components/registration/GuestList.tsx index 1dd0a32..c0ada89 100644 --- a/apps/web/src/components/registration/GuestList.tsx +++ b/apps/web/src/components/registration/GuestList.tsx @@ -204,6 +204,51 @@ export function GuestList({ )} +
+ + onChange(idx, "birthdate", e.target.value)} + autoComplete="off" + className={inputCls(!!errors[idx]?.birthdate)} + /> + {errors[idx]?.birthdate && ( + + {errors[idx].birthdate} + + )} +
+ +
+ + onChange(idx, "postcode", e.target.value)} + placeholder="1234 AB" + autoComplete="off" + className={inputCls(!!errors[idx]?.postcode)} + /> + {errors[idx]?.postcode && ( + + {errors[idx].postcode} + + )} +
+
- {/* Account creation prompt */} - {!drinkkaartPromptDismissed && ( + {/* Account creation prompt — hidden when already logged in */} + {!isLoggedIn && !drinkkaartPromptDismissed && (

Maak een gratis account aan diff --git a/apps/web/src/components/registration/WatcherForm.tsx b/apps/web/src/components/registration/WatcherForm.tsx index b8387ee..34fce49 100644 --- a/apps/web/src/components/registration/WatcherForm.tsx +++ b/apps/web/src/components/registration/WatcherForm.tsx @@ -389,7 +389,14 @@ export function WatcherForm({ if (guests.length >= 9) return; setGuests((prev) => [ ...prev, - { firstName: "", lastName: "", email: "", phone: "" }, + { + firstName: "", + lastName: "", + email: "", + phone: "", + birthdate: "", + postcode: "", + }, ]); setGuestErrors((prev) => [...prev, {}]); } @@ -418,6 +425,8 @@ export function WatcherForm({ lastName: g.lastName.trim(), email: g.email.trim() || undefined, phone: g.phone.trim() || undefined, + birthdate: g.birthdate.trim(), + postcode: g.postcode.trim(), })), extraQuestions: data.extraQuestions.trim() || undefined, giftAmount, diff --git a/apps/web/src/lib/registration.ts b/apps/web/src/lib/registration.ts index f90fefb..5ad1c7e 100644 --- a/apps/web/src/lib/registration.ts +++ b/apps/web/src/lib/registration.ts @@ -28,6 +28,8 @@ export interface GuestEntry { lastName: string; email: string; phone: string; + birthdate: string; + postcode: string; } export interface GuestErrors { @@ -35,6 +37,8 @@ export interface GuestErrors { lastName?: string; email?: string; phone?: string; + birthdate?: string; + postcode?: string; } /** @@ -51,6 +55,8 @@ export function parseGuests(raw: string | null | undefined): GuestEntry[] { lastName: g.lastName ?? "", email: g.email ?? "", phone: g.phone ?? "", + birthdate: g.birthdate ?? "", + postcode: g.postcode ?? "", })); } catch { return []; @@ -102,6 +108,8 @@ export function validateGuests(guests: GuestEntry[]): { g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, "")) ? "Voer een geldig telefoonnummer in" : undefined, + birthdate: !g.birthdate.trim() ? "Geboortedatum is verplicht" : undefined, + postcode: !g.postcode.trim() ? "Postcode is verplicht" : undefined, })); const valid = !errors.some((e) => Object.values(e).some(Boolean)); return { errors, valid }; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 24e6ad4..3911b75 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,7 +17,7 @@ import appCss from "../index.css?url"; const siteUrl = "https://kunstenkamp.be"; const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord"; const siteDescription = - "Doe mee met de Open Mic Night op 18 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional."; + "Doe mee met de Open Mic Night op 24 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional."; const eventImage = `${siteUrl}/assets/og-image.jpg`; export interface RouterAppContext { diff --git a/apps/web/src/routes/account.tsx b/apps/web/src/routes/account.tsx index d6b45b7..4630efd 100644 --- a/apps/web/src/routes/account.tsx +++ b/apps/web/src/routes/account.tsx @@ -90,7 +90,7 @@ function AccountPage() { orpc.drinkkaart.getMyDrinkkaart.queryOptions(), ); - const registrationQuery = useQuery(orpc.getMyRegistration.queryOptions()); + const registrationQuery = useQuery(orpc.getMyRegistrations.queryOptions()); // Handle topup=success redirect (from Lemon Squeezy returning to /account) useEffect(() => { @@ -128,7 +128,7 @@ function AccountPage() { | { name?: string; email?: string } | undefined; - const registration = registrationQuery.data; + const registrations = registrationQuery.data ?? []; const drinkkaart = drinkkaartQuery.data; const isLoading = @@ -170,112 +170,122 @@ function AccountPage() { Mijn Inschrijving

- {registration ? ( -
- {/* Type badge */} -
-
- {registration.registrationType === "performer" ? ( - - - Artiest - - ) : ( - - - Bezoeker - + {registrations.length > 0 ? ( +
+ {registrations.map((registration) => ( +
+ {/* Type badge */} +
+
+ {registration.registrationType === "performer" ? ( + + + Artiest + + ) : ( + + + Bezoeker + + )} +
+ {(registration.registrationType !== "performer" || + (registration.giftAmount ?? 0) > 0) && ( + + )} +
+ + {/* Name */} +

+ {registration.firstName} {registration.lastName} +

+ + {/* Art form (performer only) */} + {registration.registrationType === "performer" && + registration.artForm && ( +

+ Kunstvorm:{" "} + + {registration.artForm} + +

+ )} + + {/* Guests (watcher only) */} + {registration.registrationType === "watcher" && + registration.guests.length > 0 && ( +

+ {registration.guests.length + 1} personen (jij +{" "} + {registration.guests.length} gast + {registration.guests.length > 1 ? "en" : ""}) +

+ )} + + {/* Drink card value */} + {registration.registrationType === "watcher" && + (registration.drinkCardValue ?? 0) > 0 && ( +

+ Drinkkaart:{" "} + + €{registration.drinkCardValue} + +

+ )} + + {/* Gift */} + {(registration.giftAmount ?? 0) > 0 && ( +

+ Gift:{" "} + + €{(registration.giftAmount ?? 0) / 100} + +

+ )} + + {/* Date */} +

+ Ingeschreven op{" "} + {new Date(registration.createdAt).toLocaleDateString( + "nl-BE", + { + day: "numeric", + month: "long", + year: "numeric", + }, + )} +

+ + {/* Action */} + {registration.managementToken && ( +
+ + Beheer inschrijving + + +
)}
- -
- - {/* Name */} -

- {registration.firstName} {registration.lastName} -

- - {/* Art form (performer only) */} - {registration.registrationType === "performer" && - registration.artForm && ( -

- Kunstvorm:{" "} - - {registration.artForm} - -

- )} - - {/* Guests (watcher only) */} - {registration.registrationType === "watcher" && - registration.guests.length > 0 && ( -

- {registration.guests.length + 1} personen (jij +{" "} - {registration.guests.length} gast - {registration.guests.length > 1 ? "en" : ""}) -

- )} - - {/* Drink card value */} - {registration.registrationType === "watcher" && - (registration.drinkCardValue ?? 0) > 0 && ( -

- Drinkkaart:{" "} - - €{registration.drinkCardValue} - -

- )} - - {/* Gift */} - {(registration.giftAmount ?? 0) > 0 && ( -

- Gift:{" "} - - €{(registration.giftAmount ?? 0) / 100} - -

- )} - - {/* Date */} -

- Ingeschreven op{" "} - {new Date(registration.createdAt).toLocaleDateString( - "nl-BE", - { - day: "numeric", - month: "long", - year: "numeric", - }, - )} -

- - {/* Action */} - {registration.managementToken && ( -
- - Beheer inschrijving - - -
- )} + ))}
) : (
diff --git a/apps/web/src/routes/admin/index.tsx b/apps/web/src/routes/admin/index.tsx index bccca42..15756d6 100644 --- a/apps/web/src/routes/admin/index.tsx +++ b/apps/web/src/routes/admin/index.tsx @@ -13,7 +13,7 @@ import { Users, X, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -44,6 +44,19 @@ function AdminPage() { const pageSize = 20; const [copiedId, setCopiedId] = useState(null); + const [expandedGuests, setExpandedGuests] = useState>(new Set()); + + const toggleGuests = (id: string) => { + setExpandedGuests((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; // Get current session to check user role const sessionQuery = useQuery({ @@ -278,6 +291,25 @@ function AdminPage() { return `€${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`; }; + const parseGuestsJson = ( + raw: string | null | undefined, + ): Array<{ + firstName: string; + lastName: string; + email?: string; + phone?: string; + birthdate?: string; + postcode?: string; + }> => { + if (!raw) return []; + try { + const parsed = JSON.parse(raw as string); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }; + // Check if user is admin const user = sessionQuery.data?.data?.user as | { role?: string; name?: string } @@ -486,7 +518,6 @@ function AdminPage() { )} - {/* Stats Cards */}
@@ -592,7 +623,6 @@ function AdminPage() {
- {/* Filters */} @@ -700,7 +730,6 @@ function AdminPage() {
- {/* Export Button */}

@@ -715,7 +744,6 @@ function AdminPage() { {exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}

- {/* Registrations Table / Cards */} @@ -803,15 +831,9 @@ function AdminPage() { sortedRegistrations.map((reg) => { const isPerformer = reg.registrationType === "performer"; - const guestCount = (() => { - if (!reg.guests) return 0; - try { - const g = JSON.parse(reg.guests as string); - return Array.isArray(g) ? g.length : 0; - } catch { - return 0; - } - })(); + const guests = parseGuestsJson(reg.guests); + const guestCount = guests.length; + const isGuestsExpanded = expandedGuests.has(reg.id); const detailLabel = isPerformer ? reg.artForm || "-" @@ -833,121 +855,190 @@ function AdminPage() { })(); return ( - - - {reg.firstName} {reg.lastName} - - - {reg.email} - - - {reg.phone || "-"} - - - - {isPerformer ? "Artiest" : "Bezoeker"} - - - - {detailLabel} - - - {guestCount > 0 - ? `${guestCount} gast${guestCount === 1 ? "" : "en"}` - : "-"} - - - {formatCents(reg.giftAmount)} - - - {isPerformer ? ( - - - ) : reg.paymentStatus === "paid" ? ( - - - Betaald - - ) : reg.paymentStatus === - "extra_payment_pending" ? ( - - - Extra (€ - {((reg.paymentAmount ?? 0) / 100).toFixed(0)}) - - ) : ( - - Open - - )} - - - {dateLabel} - - - {reg.postcode || "-"} - - - {reg.birthdate || "-"} - - - {isPerformer ? reg.experience || "-" : "-"} - - - {isPerformer ? ( - reg.isOver16 ? ( - Ja - ) : ( - Nee - ) - ) : ( - "-" - )} - - - - {reg.extraQuestions || "-"} - - - - {reg.managementToken ? ( - + ) : ( + "-" + )} + + + {formatCents(reg.giftAmount)} + + + {isPerformer ? ( + - + ) : reg.paymentStatus === "paid" ? ( + + + Betaald + + ) : reg.paymentStatus === + "extra_payment_pending" ? ( + + + Extra (€ + {((reg.paymentAmount ?? 0) / 100).toFixed(0)}) + + ) : ( + + Open + + )} + + + {dateLabel} + + + {reg.postcode || "-"} + + + {reg.birthdate || "-"} + + + {isPerformer ? reg.experience || "-" : "-"} + + + {isPerformer ? ( + reg.isOver16 ? ( + Ja ) : ( - - )} - - ) : ( - - )} - - + Nee + ) + ) : ( + "-" + )} + + + + {reg.extraQuestions || "-"} + + + + {reg.managementToken ? ( + + ) : ( + + )} + + + {isGuestsExpanded && + guests.map((guest, gi) => ( + + + ↳ {guest.firstName} {guest.lastName} + + + {guest.email || "-"} + + + {guest.phone || "-"} + + + + Gast + + + + - + + + - + + + - + + + - + + + - + + + {guest.postcode || "-"} + + + {guest.birthdate || "-"} + + + - + + + - + + + - + + + - + + + ))} + ); }) )}
- - {/* Mobile Cards */}
{registrationsQuery.isLoading ? (
@@ -962,15 +1053,9 @@ function AdminPage() { {sortedRegistrations.map((reg) => { const isPerformer = reg.registrationType === "performer"; - const guestCount = (() => { - if (!reg.guests) return 0; - try { - const g = JSON.parse(reg.guests as string); - return Array.isArray(g) ? g.length : 0; - } catch { - return 0; - } - })(); + const guests = parseGuestsJson(reg.guests); + const guestCount = guests.length; + const isGuestsExpanded = expandedGuests.has(reg.id); const detailLabel = isPerformer ? reg.artForm || "-" @@ -992,126 +1077,171 @@ function AdminPage() { })(); return ( -
-
-
-
- - {reg.firstName} {reg.lastName} - +
+
+
+
+
+ + {reg.firstName} {reg.lastName} + +
+
+ {reg.email} + {reg.phone && ( + + • {reg.phone} + + )} +
-
- {reg.email} - {reg.phone && ( - • {reg.phone} +
+ + {isPerformer ? "Artiest" : "Bezoeker"} + + {reg.managementToken && ( + )}
-
- - {isPerformer ? "Artiest" : "Bezoeker"} - - {reg.managementToken && ( + +
+
+ Details:{" "} + {detailLabel} +
+ {guestCount > 0 && ( )} + {(reg.giftAmount ?? 0) > 0 && ( +
+ Gift:{" "} + {formatCents(reg.giftAmount)} +
+ )} + {!isPerformer && ( +
+ Betaling:{" "} + {reg.paymentStatus === "paid" ? ( + + + Betaald + + ) : reg.paymentStatus === + "extra_payment_pending" ? ( + + + Extra (€ + {((reg.paymentAmount ?? 0) / 100).toFixed( + 0, + )} + ) + + ) : ( + Open + )} +
+ )} + {reg.postcode && ( +
+ Postcode:{" "} + {reg.postcode} +
+ )} + {reg.birthdate && ( +
+ + Geboortedatum: + {" "} + {reg.birthdate} +
+ )} + {isPerformer && reg.experience && ( +
+ Ervaring:{" "} + {reg.experience} +
+ )} + {isPerformer && ( +
+ 16+:{" "} + {reg.isOver16 ? ( + Ja + ) : ( + Nee + )} +
+ )} + {reg.extraQuestions && ( +
+ + Opmerkingen: + {" "} + {reg.extraQuestions} +
+ )} +
{dateLabel}
- -
-
- Details:{" "} - {detailLabel} + {isGuestsExpanded && guestCount > 0 && ( +
+
+ Gasten +
+
+ {guests.map((guest, gi) => ( +
+
+ {guest.firstName} {guest.lastName} +
+
+ {guest.email && {guest.email}} + {guest.phone && {guest.phone}} + {guest.birthdate && ( + {guest.birthdate} + )} + {guest.postcode && ( + {guest.postcode} + )} +
+
+ ))} +
- {guestCount > 0 && ( -
- Gasten:{" "} - {guestCount} -
- )} - {(reg.giftAmount ?? 0) > 0 && ( -
- Gift:{" "} - {formatCents(reg.giftAmount)} -
- )} - {!isPerformer && ( -
- Betaling:{" "} - {reg.paymentStatus === "paid" ? ( - - - Betaald - - ) : reg.paymentStatus === - "extra_payment_pending" ? ( - - - Extra (€ - {((reg.paymentAmount ?? 0) / 100).toFixed(0)}) - - ) : ( - Open - )} -
- )} - {reg.postcode && ( -
- Postcode:{" "} - {reg.postcode} -
- )} - {reg.birthdate && ( -
- - Geboortedatum: - {" "} - {reg.birthdate} -
- )} - {isPerformer && reg.experience && ( -
- Ervaring:{" "} - {reg.experience} -
- )} - {isPerformer && ( -
- 16+:{" "} - {reg.isOver16 ? ( - Ja - ) : ( - Nee - )} -
- )} - {reg.extraQuestions && ( -
- - Opmerkingen: - {" "} - {reg.extraQuestions} -
- )} -
{dateLabel}
-
+ )}
); })} @@ -1120,8 +1250,6 @@ function AdminPage() {
- - {/* Pagination */} {pagination && pagination.totalPages > 1 && (
- {/* Payment status - shown for everyone with pending/extra payment or gift */} - {(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && ( -
- {data.paymentStatus === "paid" ? ( - - ) : data.paymentStatus === "extra_payment_pending" ? ( - - ) : ( - - )} -
- )} + {/* Payment status - not shown for performers without a gift */} + {(!isPerformer || (data.giftAmount ?? 0) > 0) && + (data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && ( +
+ {data.paymentStatus === "paid" ? ( + + ) : data.paymentStatus === "extra_payment_pending" ? ( + + ) : ( + + )} +
+ )} {/* Gift display */} {(data.giftAmount ?? 0) > 0 && ( @@ -710,6 +720,16 @@ function ManageRegistrationPage() {

{g.firstName} {g.lastName}

+ {g.birthdate && ( +

+ Geboortedatum: {g.birthdate} +

+ )} + {g.postcode && ( +

+ Postcode: {g.postcode} +

+ )} {g.email && (

{g.email}

)} diff --git a/packages/api/src/email.ts b/packages/api/src/email.ts index 997ec0c..55362d2 100644 --- a/packages/api/src/email.ts +++ b/packages/api/src/email.ts @@ -86,7 +86,7 @@ function registrationConfirmationHtml(params: { Hoi ${params.firstName},

- We hebben je inschrijving voor Open Mic Night — vrijdag 18 april 2026 in goede orde ontvangen. + We hebben je inschrijving voor Open Mic Night — vrijdag 24 april 2026 in goede orde ontvangen.

diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index 9b14ca9..de56b9b 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -36,6 +36,8 @@ function parseGuestsJson(raw: string | null): Array<{ lastName: string; email?: string; phone?: string; + birthdate?: string; + postcode?: string; }> { if (!raw) return []; try { @@ -218,6 +220,8 @@ const guestSchema = z.object({ lastName: z.string().min(1), email: z.string().email().optional().or(z.literal("")), phone: z.string().optional(), + birthdate: z.string().min(1), + postcode: z.string().min(1), }); const coreRegistrationFields = { @@ -542,7 +546,7 @@ export const appRouter = { const guestSummary = guests .map( (g) => - `${g.firstName} ${g.lastName}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`, + `${g.firstName} ${g.lastName}${g.birthdate ? ` (${g.birthdate})` : ""}${g.postcode ? ` [${g.postcode}]` : ""}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`, ) .join(" | "); return [ @@ -593,7 +597,7 @@ export const appRouter = { return result; }), - getMyRegistration: protectedProcedure.handler(async ({ context }) => { + getMyRegistrations: protectedProcedure.handler(async ({ context }) => { const email = context.session.user.email; const rows = await db @@ -602,16 +606,12 @@ export const appRouter = { .where( and(eq(registration.email, email), isNull(registration.cancelledAt)), ) - .orderBy(desc(registration.createdAt)) - .limit(1); + .orderBy(desc(registration.createdAt)); - const row = rows[0]; - if (!row) return null; - - return { + return rows.map((row) => ({ ...row, guests: parseGuestsJson(row.guests), - }; + })); }), // ---------------------------------------------------------------------------