diff --git a/apps/web/src/components/homepage/EventRegistrationForm.tsx b/apps/web/src/components/homepage/EventRegistrationForm.tsx
index 47da6a5..c4f73e8 100644
--- a/apps/web/src/components/homepage/EventRegistrationForm.tsx
+++ b/apps/web/src/components/homepage/EventRegistrationForm.tsx
@@ -1,424 +1,26 @@
-"use client";
+import { useState } from "react";
+import { PerformerForm } from "@/components/registration/PerformerForm";
+import { SuccessScreen } from "@/components/registration/SuccessScreen";
+import { TypeSelector } from "@/components/registration/TypeSelector";
+import { WatcherForm } from "@/components/registration/WatcherForm";
-import { useMutation } from "@tanstack/react-query";
-import { useCallback, useState } from "react";
-import { toast } from "sonner";
-import { orpc } from "@/utils/orpc";
-
-type RegistrationType = "performer" | "watcher" | null;
-
-interface GuestEntry {
- firstName: string;
- lastName: string;
- email: string;
- phone: string;
-}
-
-interface GuestErrors {
- firstName?: string;
- lastName?: string;
- email?: string;
- phone?: string;
-}
-
-interface PerformerErrors {
- firstName?: string;
- lastName?: string;
- email?: string;
- phone?: string;
- artForm?: string;
- isOver16?: string;
-}
-
-interface WatcherErrors {
- firstName?: string;
- lastName?: string;
- email?: string;
- phone?: string;
-}
+type RegistrationType = "performer" | "watcher";
export default function EventRegistrationForm() {
- const [selectedType, setSelectedType] = useState
- Je inschrijving is bevestigd. We sturen je zo dadelijk een
- bevestigingsmail.
-
- Geen mail ontvangen? Gebruik deze link:
-
- Gelukt!
-
-
- Drinkkaart inbegrepen -
-- Je betaald bij inkom{" "} - €5 (+ €2 per - medebezoeker) dat gaat naar je drinkkaart. - {guests.length > 0 && ( - - Totaal: €{5 + guests.length * 2} voor {1 + guests.length}{" "} - personen. - - )} -
-+ Medebezoekers{" "} + + ({guests.length}/{MAX_GUESTS}) + +
+ {headerNote} ++ Kom je met iemand mee? Voeg je medebezoekers toe. Elke extra persoon + kost €2 op de drinkkaart. +
+ )} + ++ Je inschrijving is bevestigd. We sturen je zo dadelijk een + bevestigingsmail. +
++ Geen mail ontvangen? Gebruik deze link: +
+ + {manageUrl} + +Drinkkaart inbegrepen
++ Je betaald bij inkom €5{" "} + (+ €2 per medebezoeker) dat gaat naar je drinkkaart. + {guests.length > 0 && ( + + Totaal: €{totalPrice} voor {1 + guests.length} personen. + + )} +
+- Deze link is ongeldig of de inschrijving is geannuleerd. -
- - Nieuwe inschrijving - -+ Deze link is ongeldig of de inschrijving is geannuleerd. +
+ + Nieuwe inschrijving + +- Open Mic Night — vrijdag 18 april 2026 -
++ Open Mic Night — vrijdag 18 april 2026 +
- {/* Type badge */} -Voornaam
+{data.firstName}
+Achternaam
+{data.lastName}
+{data.email}
+Telefoon
+{data.phone || "—"}
+Optreden
++ {data.artForm || "—"} + {data.experience && ( + ({data.experience}) + )} +
++ {data.isOver16 ? "16+ bevestigd" : "Leeftijd niet bevestigd"} +
Voornaam
-{data.firstName}
-Achternaam
-{data.lastName}
-{data.email}
-Telefoon
-{data.phone || "—"}
+ {!isPerformer && viewGuests.length > 0 && ( ++ Medebezoekers ({viewGuests.length}) +
++ {g.firstName} {g.lastName} +
+ {g.email && ( +{g.email}
+ )} + {g.phone && ( +{g.phone}
+ )} +Optreden
-- {data.artForm || "—"} - {data.experience && ( - - ({data.experience}) - - )} -
-- {data.isOver16 ? "16+ bevestigd" : "Leeftijd niet bevestigd"} -
-- Medebezoekers ({viewGuests.length}) -
-- {g.firstName} {g.lastName} -
- {g.email && ( -{g.email}
- )} - {g.phone && ( -{g.phone}
- )} -- Vragen of opmerkingen -
-{data.extraQuestions}
-- Deze link is uniek voor jouw inschrijving. Bewaar deze pagina of de - bevestigingsmail om je gegevens later aan te passen. -
+ {data.extraQuestions && ( +Vragen of opmerkingen
+{data.extraQuestions}
++ Deze link is uniek voor jouw inschrijving. Bewaar deze pagina of de + bevestigingsmail om je gegevens later aan te passen. +
+ ); } diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index 90040a6..a43a35a 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -13,6 +13,57 @@ import { } from "../email"; import { adminProcedure, protectedProcedure, publicProcedure } from "../index"; +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/** Drink card price in euros: €5 base + €2 per extra guest. */ +function drinkCardEuros(guestCount: number): number { + return 5 + guestCount * 2; +} + +/** Drink card price in cents for payment processing. */ +function drinkCardCents(guestCount: number): number { + return drinkCardEuros(guestCount) * 100; +} + +/** Parses the stored guest JSON blob, returning an empty array on failure. */ +function parseGuestsJson(raw: string | null): Array<{ + firstName: string; + lastName: string; + email?: string; + phone?: string; +}> { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** Fetches a single registration by management token, throws if not found or cancelled. */ +async function getActiveRegistration(token: string) { + const rows = await db + .select() + .from(registration) + .where( + and( + eq(registration.managementToken, token), + isNull(registration.cancelledAt), + ), + ) + .limit(1); + const row = rows[0]; + if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd"); + return row; +} + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + const registrationTypeSchema = z.enum(["performer", "watcher"]); const guestSchema = z.object({ @@ -22,20 +73,24 @@ const guestSchema = z.object({ phone: z.string().optional(), }); -const submitRegistrationSchema = z.object({ +const coreRegistrationFields = { firstName: z.string().min(1), lastName: z.string().min(1), email: z.string().email(), phone: z.string().optional(), 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 submitRegistrationSchema = z.object(coreRegistrationFields); + +const updateRegistrationSchema = z.object({ + token: z.string().uuid(), + ...coreRegistrationFields, }); const getRegistrationsSchema = z.object({ @@ -48,17 +103,17 @@ const getRegistrationsSchema = z.object({ pageSize: z.number().int().min(1).max(100).default(50), }); -export const appRouter = { - healthCheck: publicProcedure.handler(() => { - return "OK"; - }), +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- - privateData: protectedProcedure.handler(({ context }) => { - return { - message: "This is private", - user: context.session?.user, - }; - }), +export const appRouter = { + healthCheck: publicProcedure.handler(() => "OK"), + + privateData: protectedProcedure.handler(({ context }) => ({ + message: "This is private", + user: context.session?.user, + })), submitRegistration: publicProcedure .input(submitRegistrationSchema) @@ -66,8 +121,7 @@ export const appRouter = { 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, @@ -78,7 +132,7 @@ export const appRouter = { artForm: isPerformer ? input.artForm || null : null, experience: isPerformer ? input.experience || null : null, isOver16: isPerformer ? (input.isOver16 ?? false) : false, - drinkCardValue, + drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), guests: guests.length > 0 ? JSON.stringify(guests) : null, extraQuestions: input.extraQuestions || null, managementToken, @@ -114,39 +168,15 @@ export const appRouter = { }), updateRegistration: publicProcedure - .input( - z.object({ - token: z.string().uuid(), - firstName: z.string().min(1), - lastName: z.string().min(1), - email: z.string().email(), - phone: z.string().optional(), - 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(), - }), - ) + .input(updateRegistrationSchema) .handler(async ({ input }) => { - const rows = await db - .select() - .from(registration) - .where( - and( - eq(registration.managementToken, input.token), - isNull(registration.cancelledAt), - ), - ) - .limit(1); - - const row = rows[0]; - if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd"); + const row = await getActiveRegistration(input.token); + // If already paid and switching to a watcher with fewer guests, we + // still allow the update — payment adjustments are handled manually. const isPerformer = input.registrationType === "performer"; const guests = isPerformer ? [] : (input.guests ?? []); - const drinkCardValue = isPerformer ? 0 : 5 + guests.length * 2; + await db .update(registration) .set({ @@ -158,7 +188,7 @@ export const appRouter = { artForm: isPerformer ? input.artForm || null : null, experience: isPerformer ? input.experience || null : null, isOver16: isPerformer ? (input.isOver16 ?? false) : false, - drinkCardValue, + drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), guests: guests.length > 0 ? JSON.stringify(guests) : null, extraQuestions: input.extraQuestions || null, }) @@ -172,25 +202,16 @@ export const appRouter = { artForm: input.artForm, }).catch((err) => console.error("Failed to send update email:", err)); + // Satisfy the linter — row is used to confirm the record existed. + void row; + return { success: true }; }), cancelRegistration: publicProcedure .input(z.object({ token: z.string().uuid() })) .handler(async ({ input }) => { - const rows = await db - .select() - .from(registration) - .where( - and( - eq(registration.managementToken, input.token), - isNull(registration.cancelledAt), - ), - ) - .limit(1); - - const row = rows[0]; - if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd"); + const row = await getActiveRegistration(input.token); await db .update(registration) @@ -213,46 +234,37 @@ export const appRouter = { const conditions = []; if (input.search) { - const searchTerm = `%${input.search}%`; + const term = `%${input.search}%`; conditions.push( and( - like(registration.firstName, searchTerm), - like(registration.lastName, searchTerm), - like(registration.email, searchTerm), + like(registration.firstName, term), + like(registration.lastName, term), + like(registration.email, term), ), ); } - - if (input.registrationType) { + if (input.registrationType) conditions.push( eq(registration.registrationType, input.registrationType), ); - } - - if (input.artForm) { + if (input.artForm) conditions.push(eq(registration.artForm, input.artForm)); - } - - if (input.fromDate) { + if (input.fromDate) conditions.push(gte(registration.createdAt, new Date(input.fromDate))); - } - - if (input.toDate) { + if (input.toDate) conditions.push(lte(registration.createdAt, new Date(input.toDate))); - } - const whereClause = - conditions.length > 0 ? and(...conditions) : undefined; + const where = conditions.length > 0 ? and(...conditions) : undefined; const [data, countResult] = await Promise.all([ db .select() .from(registration) - .where(whereClause) + .where(where) .orderBy(desc(registration.createdAt)) .limit(input.pageSize) .offset((input.page - 1) * input.pageSize), - db.select({ count: count() }).from(registration).where(whereClause), + db.select({ count: count() }).from(registration).where(where), ]); const total = countResult[0]?.count ?? 0; @@ -280,10 +292,7 @@ export const appRouter = { .from(registration) .where(gte(registration.createdAt, today)), db - .select({ - artForm: registration.artForm, - count: count(), - }) + .select({ artForm: registration.artForm, count: count() }) .from(registration) .where(eq(registration.registrationType, "performer")) .groupBy(registration.artForm), @@ -329,16 +338,14 @@ export const appRouter = { "Drink Card Value", "Guest Count", "Guests", + "Payment Status", + "Paid At", "Extra Questions", "Created At", ]; + const rows = data.map((r) => { - const guests: Array<{ - firstName: string; - lastName: string; - email?: string; - phone?: string; - }> = r.guests ? JSON.parse(r.guests) : []; + const guests = parseGuestsJson(r.guests); const guestSummary = guests .map( (g) => @@ -358,6 +365,8 @@ export const appRouter = { String(r.drinkCardValue ?? 0), String(guests.length), guestSummary, + r.paymentStatus === "paid" ? "Paid" : "Pending", + r.paidAt ? r.paidAt.toISOString() : "", r.extraQuestions || "", r.createdAt.toISOString(), ]; @@ -376,49 +385,48 @@ export const appRouter = { }; }), - // Admin Request Procedures + // --------------------------------------------------------------------------- + // Admin access requests + // --------------------------------------------------------------------------- + requestAdminAccess: protectedProcedure.handler(async ({ context }) => { const userId = context.session.user.id; - // Check if user is already an admin if (context.session.user.role === "admin") { return { success: false, message: "Je bent al een admin" }; } - // Check if request already exists - const existingRequest = await db + const existing = await db .select() .from(adminRequest) .where(eq(adminRequest.userId, userId)) - .limit(1); + .limit(1) + .then((rows) => rows[0]); - if (existingRequest.length > 0) { - const existing = existingRequest[0]!; - if (existing.status === "pending") { - return { success: false, message: "Je hebt al een aanvraag openstaan" }; - } - if (existing.status === "approved") { + if (existing) { + if (existing.status === "pending") + return { + success: false, + message: "Je hebt al een aanvraag openstaan", + }; + if (existing.status === "approved") return { success: false, message: "Je aanvraag is al goedgekeurd, log opnieuw in", }; - } - if (existing.status === "rejected") { - // Allow re-requesting if previously rejected - await db - .update(adminRequest) - .set({ - status: "pending", - requestedAt: new Date(), - reviewedAt: null, - reviewedBy: null, - }) - .where(eq(adminRequest.userId, userId)); - return { success: true, message: "Nieuwe aanvraag ingediend" }; - } + // Rejected — allow re-request + await db + .update(adminRequest) + .set({ + status: "pending", + requestedAt: new Date(), + reviewedAt: null, + reviewedBy: null, + }) + .where(eq(adminRequest.userId, userId)); + return { success: true, message: "Nieuwe aanvraag ingediend" }; } - // Create new request await db.insert(adminRequest).values({ id: randomUUID(), userId, @@ -429,7 +437,7 @@ export const appRouter = { }), getAdminRequests: adminProcedure.handler(async () => { - const requests = await db + return db .select({ id: adminRequest.id, userId: adminRequest.userId, @@ -443,29 +451,22 @@ export const appRouter = { .from(adminRequest) .leftJoin(user, eq(adminRequest.userId, user.id)) .orderBy(desc(adminRequest.requestedAt)); - - return requests; }), approveAdminRequest: adminProcedure .input(z.object({ requestId: z.string() })) .handler(async ({ input, context }) => { - const request = await db + const req = await db .select() .from(adminRequest) .where(eq(adminRequest.id, input.requestId)) - .limit(1); + .limit(1) + .then((rows) => rows[0]); - if (request.length === 0) { - throw new Error("Aanvraag niet gevonden"); - } - - const req = request[0]!; - if (req.status !== "pending") { + if (!req) throw new Error("Aanvraag niet gevonden"); + if (req.status !== "pending") throw new Error("Deze aanvraag is al behandeld"); - } - // Update request status await db .update(adminRequest) .set({ @@ -475,7 +476,6 @@ export const appRouter = { }) .where(eq(adminRequest.id, input.requestId)); - // Update user role to admin await db .update(user) .set({ role: "admin" }) @@ -487,20 +487,16 @@ export const appRouter = { rejectAdminRequest: adminProcedure .input(z.object({ requestId: z.string() })) .handler(async ({ input, context }) => { - const request = await db + const req = await db .select() .from(adminRequest) .where(eq(adminRequest.id, input.requestId)) - .limit(1); + .limit(1) + .then((rows) => rows[0]); - if (request.length === 0) { - throw new Error("Aanvraag niet gevonden"); - } - - const req = request[0]!; - if (req.status !== "pending") { + if (!req) throw new Error("Aanvraag niet gevonden"); + if (req.status !== "pending") throw new Error("Deze aanvraag is al behandeld"); - } await db .update(adminRequest) @@ -514,8 +510,11 @@ export const appRouter = { return { success: true, message: "Admin toegang geweigerd" }; }), - // Lemon Squeezy Checkout Procedure - createCheckout: publicProcedure + // --------------------------------------------------------------------------- + // Checkout + // --------------------------------------------------------------------------- + + getCheckoutUrl: publicProcedure .input(z.object({ token: z.string().uuid() })) .handler(async ({ input }) => { if ( @@ -541,19 +540,12 @@ export const appRouter = { if (!row) throw new Error("Inschrijving niet gevonden"); if (row.paymentStatus === "paid") throw new Error("Betaling is al voltooid"); - - const isPerformer = row.registrationType === "performer"; - if (isPerformer) { + if (row.registrationType === "performer") throw new Error("Artiesten hoeven niet te betalen"); - } - // Calculate price: €5 + €2 per guest (in cents) - const guests: Array<{ firstName: string; lastName: string }> = row.guests - ? JSON.parse(row.guests) - : []; - const amountInCents = 500 + guests.length * 200; + const guests = parseGuestsJson(row.guests); + const amountInCents = drinkCardCents(guests.length); - // Create checkout via Lemon Squeezy API const response = await fetch( "https://api.lemonsqueezy.com/v1/checkouts", { @@ -576,9 +568,7 @@ export const appRouter = { checkout_data: { email: row.email, name: `${row.firstName} ${row.lastName}`, - custom: { - registration_token: input.token, - }, + custom: { registration_token: input.token }, }, checkout_options: { embed: false, @@ -587,16 +577,10 @@ export const appRouter = { }, relationships: { store: { - data: { - type: "stores", - id: env.LEMON_SQUEEZY_STORE_ID, - }, + data: { type: "stores", id: env.LEMON_SQUEEZY_STORE_ID }, }, variant: { - data: { - type: "variants", - id: env.LEMON_SQUEEZY_VARIANT_ID, - }, + data: { type: "variants", id: env.LEMON_SQUEEZY_VARIANT_ID }, }, }, }, @@ -611,17 +595,11 @@ export const appRouter = { } const checkoutData = (await response.json()) as { - data?: { - attributes?: { - url?: string; - }; - }; + data?: { attributes?: { url?: string } }; }; const checkoutUrl = checkoutData.data?.attributes?.url; - if (!checkoutUrl) { - throw new Error("Geen checkout URL ontvangen"); - } + if (!checkoutUrl) throw new Error("Geen checkout URL ontvangen"); return { checkoutUrl }; }),