commit 84b4e78a7ee3fdca0471a9b7fef6dbc7b12e5d1b Author: Jeff Emmett Date: Thu Mar 19 11:37:45 2026 -0700 Initial CoFi registration app Standalone event registration + payment system adapted from crypto-commons-gather.ing-website. Centralized event config in lib/event.config.ts for easy customization. Stack: Next.js 16 + Mollie + Google Sheets + Mailcow SMTP + Listmonk Flow: Registration form → accommodation selection → Mollie payment → webhook confirmation Co-Authored-By: Claude Opus 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8a6e7f2 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# CoFi Registration — Environment Variables +# Copy to .env and fill in values + +# ── Mollie Payment Gateway ── +MOLLIE_API_KEY=test_xxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# ── Public URL (used for Mollie redirects and webhooks) ── +NEXT_PUBLIC_BASE_URL=https://cofi.example.com + +# ── Google Sheets (registration data) ── +# JSON string of the service account key +GOOGLE_SERVICE_ACCOUNT_KEY={} +GOOGLE_SHEET_ID=your-spreadsheet-id +GOOGLE_SHEET_NAME=Registrations + +# ── Google Sheets (booking/accommodation assignment) ── +BOOKING_SHEET_ID=your-booking-spreadsheet-id +BOOKING_SHEET_NAME=Sheet1 + +# ── SMTP (Mailcow) ── +SMTP_HOST=mail.rmail.online +SMTP_PORT=587 +SMTP_USER=newsletter@cofi.example.com +SMTP_PASS= +EMAIL_FROM=CoFi +INTERNAL_NOTIFY_EMAIL=team@cofi.example.com + +# ── Listmonk (direct PostgreSQL) ── +LISTMONK_DB_HOST=listmonk-db +LISTMONK_DB_PORT=5432 +LISTMONK_DB_NAME=listmonk +LISTMONK_DB_USER=listmonk +LISTMONK_DB_PASS= +LISTMONK_LIST_ID=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb5c473 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Next.js +.next/ +out/ + +# Build +dist/ +build/ + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ + +# Misc +*.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1d03a8b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# CoFi Registration App + +## Overview +Standalone event registration + payment app for CoFi (Collaborative Finance). +Adapted from `crypto-commons-gather.ing-website` with centralized config. + +## Architecture +- **Stack**: Next.js 16 (App Router, standalone) + Mollie + Google Sheets + Mailcow SMTP + Listmonk +- **Deploy**: Docker on Netcup with Traefik labels +- **Key pattern**: All event-specific config centralized in `lib/event.config.ts` + +## Flow +1. User fills `/register` form → POST `/api/register` → Google Sheets (Pending) +2. User picks accommodation/payment → POST `/api/create-checkout-session` → Mollie redirect +3. Mollie webhook POST `/api/webhook` → verify payment → assign booking → update sheet → email → Listmonk + +## Key Files +- `lib/event.config.ts` — **Edit this to configure a new event** (pricing, dates, venues, branding) +- `lib/mollie.ts` — Shared Mollie client singleton +- `app/globals.css` — Blue/teal OKLch color theme +- `docker-compose.yml` — Traefik-labeled deployment + +## Dev Workflow +```bash +pnpm install +pnpm dev # localhost:3000 → redirects to /register +``` + +## Deployment +```bash +docker compose up -d --build +``` +Traefik auto-discovers via labels. Update Host rule in `docker-compose.yml` for your domain. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea43fdc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM node:20-alpine AS base + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +RUN pnpm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/app/api/create-checkout-session/route.ts b/app/api/create-checkout-session/route.ts new file mode 100644 index 0000000..9ed17b2 --- /dev/null +++ b/app/api/create-checkout-session/route.ts @@ -0,0 +1,84 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getMollie } from "@/lib/mollie" +import { + getCurrentTier, + ACCOMMODATION_MAP, + PROCESSING_FEE_PERCENT, + EVENT_SHORT, + buildPaymentDescription, +} from "@/lib/event.config" + +// Public base URL +const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://cofi.example.com" + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const registrationDataStr = formData.get("registrationData") as string + const includeAccommodation = formData.get("includeAccommodation") === "true" + const accommodationType = (formData.get("accommodationType") as string) || "" + + const registrationData = registrationDataStr ? JSON.parse(registrationDataStr) : null + + // Calculate subtotal + const tier = getCurrentTier() + let subtotal = tier.price + const descriptionParts = [`${EVENT_SHORT} Ticket (€${tier.price})`] + + if (includeAccommodation) { + const accom = ACCOMMODATION_MAP[accommodationType] + if (accom) { + subtotal += accom.price + descriptionParts.push(`${accom.label} (€${accom.price.toFixed(2)})`) + } + } + + // Add processing fee on top + const processingFee = Math.round(subtotal * PROCESSING_FEE_PERCENT * 100) / 100 + const total = subtotal + processingFee + descriptionParts.push(`Processing fee (€${processingFee.toFixed(2)})`) + + // Build metadata for webhook + const metadata: Record = {} + if (registrationData) { + metadata.name = registrationData.name || "" + metadata.email = registrationData.email || "" + metadata.contact = registrationData.contact || "" + metadata.contributions = (registrationData.contributions || "").substring(0, 500) + metadata.expectations = (registrationData.expectations || "").substring(0, 500) + metadata.howHeard = registrationData.howHeard || "" + metadata.dietary = + (registrationData.dietary || []).join(", ") + + (registrationData.dietaryOther ? `, ${registrationData.dietaryOther}` : "") + metadata.crewConsent = registrationData.crewConsent || "" + metadata.accommodation = includeAccommodation ? accommodationType : "none" + } + + const payment = await getMollie().payments.create({ + amount: { + value: total.toFixed(2), + currency: "EUR", + }, + description: buildPaymentDescription(descriptionParts), + redirectUrl: `${BASE_URL}/success`, + webhookUrl: `${BASE_URL}/api/webhook`, + metadata, + }) + + // Redirect to Mollie checkout + return new Response(null, { + status: 303, + headers: { Location: payment.getCheckoutUrl()! }, + }) + } catch (err) { + console.error("Error creating Mollie payment:", err) + return NextResponse.json({ error: "Error creating payment" }, { status: 500 }) + } +} + +export async function GET() { + return NextResponse.json( + { message: "This is an API endpoint. Use POST to create a checkout session." }, + { status: 405 }, + ) +} diff --git a/app/api/register/route.ts b/app/api/register/route.ts new file mode 100644 index 0000000..cd3b78d --- /dev/null +++ b/app/api/register/route.ts @@ -0,0 +1,55 @@ +import { type NextRequest, NextResponse } from "next/server" +import { addRegistration, initializeSheetHeaders } from "@/lib/google-sheets" + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + + const { name, email, contact, contributions, expectations, howHeard, dietary, crewConsent, wantFood } = body + + // Validate required fields + if (!name || !email || !contact || !contributions || !expectations || !crewConsent) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ) + } + + // Initialize headers if needed (first registration) + await initializeSheetHeaders() + + // Add registration to Google Sheet + const rowNumber = await addRegistration({ + name, + email, + contact, + contributions, + expectations, + howHeard: howHeard || "", + dietary: Array.isArray(dietary) ? dietary.join(", ") : dietary || "", + crewConsent, + wantFood: wantFood || false, + }) + + console.log(`[Register API] Registration added for ${name} at row ${rowNumber}`) + + return NextResponse.json({ + success: true, + message: "Registration recorded", + rowNumber, + }) + } catch (error) { + console.error("[Register API] Error:", error) + return NextResponse.json( + { error: "Failed to record registration" }, + { status: 500 } + ) + } +} + +export async function GET() { + return NextResponse.json( + { message: "Use POST to submit registration" }, + { status: 405 } + ) +} diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts new file mode 100644 index 0000000..1855591 --- /dev/null +++ b/app/api/webhook/route.ts @@ -0,0 +1,122 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getMollie } from "@/lib/mollie" +import { updatePaymentStatus } from "@/lib/google-sheets" +import { sendPaymentConfirmation, sendBookingNotification } from "@/lib/email" +import { addToListmonk } from "@/lib/listmonk" +import { assignBooking } from "@/lib/booking-sheet" + +export async function POST(request: NextRequest) { + try { + // Mollie sends payment ID in the body as form data + const formData = await request.formData() + const paymentId = formData.get("id") as string + + if (!paymentId) { + console.error("[Webhook] No payment ID received") + return NextResponse.json({ error: "Missing payment ID" }, { status: 400 }) + } + + // Fetch the full payment from Mollie API (this is how you verify — no signature needed) + const payment = await getMollie().payments.get(paymentId) + const metadata = (payment.metadata || {}) as Record + + console.log(`[Webhook] Payment ${paymentId} status: ${payment.status}`) + + if (payment.status === "paid") { + const customerEmail = metadata.email || payment.billingAddress?.email || "" + const amountPaid = `€${payment.amount.value}` + const accommodationType = metadata.accommodation || "none" + + // Attempt room booking assignment (best-effort, don't fail webhook) + let bookingResult: { success: boolean; venue?: string; room?: string; bedType?: string } = { success: false } + if (accommodationType !== "none") { + try { + bookingResult = await assignBooking(metadata.name || "Unknown", accommodationType) + if (bookingResult.success) { + console.log(`[Webhook] Booking assigned: ${bookingResult.venue} Room ${bookingResult.room}`) + } else { + console.warn(`[Webhook] Booking assignment failed (non-fatal): ${(bookingResult as { error?: string }).error}`) + } + } catch (err) { + console.error("[Webhook] Booking assignment error (non-fatal):", err) + } + } + + // Send internal notification about accommodation assignment + if (accommodationType !== "none") { + sendBookingNotification({ + guestName: metadata.name || "Unknown", + guestEmail: customerEmail, + accommodationType, + amountPaid, + bookingSuccess: bookingResult.success, + venue: bookingResult.venue, + room: bookingResult.room, + bedType: bookingResult.bedType, + error: (bookingResult as { error?: string }).error, + }).catch((err) => console.error("[Webhook] Booking notification failed:", err)) + } + + // Update Google Sheet + const updated = await updatePaymentStatus({ + name: metadata.name || "", + email: customerEmail, + paymentSessionId: paymentId, + paymentStatus: "Paid", + paymentMethod: payment.method || "unknown", + amountPaid, + paymentDate: new Date().toISOString(), + accommodationVenue: bookingResult.venue || "", + accommodationType: accommodationType !== "none" ? accommodationType : "", + }) + + if (updated) { + console.log(`[Webhook] Google Sheet updated for ${metadata.name}`) + } else { + console.error(`[Webhook] Failed to update Google Sheet for ${metadata.name}`) + } + + // Send confirmation email + if (customerEmail) { + await sendPaymentConfirmation({ + name: metadata.name || "", + email: customerEmail, + amountPaid, + paymentMethod: payment.method || "card", + contributions: metadata.contributions || "", + dietary: metadata.dietary || "", + accommodationVenue: bookingResult.success ? bookingResult.venue : undefined, + accommodationRoom: bookingResult.success ? bookingResult.room : undefined, + }) + + // Add to Listmonk newsletter + addToListmonk({ + email: customerEmail, + name: metadata.name || "", + attribs: { + contact: metadata.contact, + contributions: metadata.contributions, + expectations: metadata.expectations, + }, + }).catch((err) => console.error("[Webhook] Listmonk sync failed:", err)) + } + } else if (payment.status === "failed" || payment.status === "canceled" || payment.status === "expired") { + console.log(`[Webhook] Payment ${payment.status}: ${paymentId}`) + + if (metadata.name) { + await updatePaymentStatus({ + name: metadata.name, + paymentSessionId: paymentId, + paymentStatus: "Failed", + paymentDate: new Date().toISOString(), + }) + } + } + + // Mollie expects 200 OK + return NextResponse.json({ received: true }) + } catch (err) { + console.error("[Webhook] Error:", err) + return NextResponse.json({ error: "Webhook error" }, { status: 500 }) + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..932c4b1 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,91 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + /* CoFi blue/teal theme */ + --background: oklch(0.99 0.002 230); + --foreground: oklch(0.2 0.01 230); + --card: oklch(0.995 0.001 230); + --card-foreground: oklch(0.2 0.01 230); + --popover: oklch(0.995 0.001 230); + --popover-foreground: oklch(0.2 0.01 230); + --primary: oklch(0.55 0.18 230); + --primary-foreground: oklch(0.99 0.002 230); + --secondary: oklch(0.95 0.005 230); + --secondary-foreground: oklch(0.2 0.01 230); + --muted: oklch(0.96 0.003 230); + --muted-foreground: oklch(0.5 0.01 230); + --accent: oklch(0.55 0.12 180); + --accent-foreground: oklch(0.99 0.002 230); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(0.9 0.01 230); + --input: oklch(0.9 0.01 230); + --ring: oklch(0.55 0.18 230); + --chart-1: oklch(0.55 0.18 230); + --chart-2: oklch(0.55 0.12 180); + --chart-3: oklch(0.45 0.16 240); + --chart-4: oklch(0.65 0.14 200); + --chart-5: oklch(0.6 0.15 220); + --radius: 0.5rem; + --sidebar: oklch(0.99 0.002 230); + --sidebar-foreground: oklch(0.2 0.01 230); + --sidebar-primary: oklch(0.55 0.18 230); + --sidebar-primary-foreground: oklch(0.99 0.002 230); + --sidebar-accent: oklch(0.95 0.005 230); + --sidebar-accent-foreground: oklch(0.2 0.01 230); + --sidebar-border: oklch(0.9 0.01 230); + --sidebar-ring: oklch(0.55 0.18 230); +} + +@theme inline { + --font-sans: "Geist", "Geist Fallback"; + --font-mono: "Geist Mono", "Geist Mono Fallback"; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..0ebc197 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,44 @@ +import type React from "react" +import type { Metadata } from "next" +import { Geist, Geist_Mono } from "next/font/google" +import "./globals.css" + +const _geist = Geist({ subsets: ["latin"] }) +const _geistMono = Geist_Mono({ subsets: ["latin"] }) + +export const metadata: Metadata = { + metadataBase: new URL("https://cofi.example.com"), + title: "CoFi 2026 — Registration", + description: + "Register for CoFi 2026 — Collaborative Finance. Reimagining finance for the commons.", + icons: { + icon: [ + { + url: 'data:image/svg+xml,🤝', + }, + ], + }, + openGraph: { + title: "CoFi 2026 — Registration", + description: + "Register for CoFi 2026 — Collaborative Finance. Reimagining finance for the commons.", + url: "https://cofi.example.com", + siteName: "CoFi", + locale: "en_US", + type: "website", + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..2b74e9f --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function Home() { + redirect("/register") +} diff --git a/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 0000000..63613ea --- /dev/null +++ b/app/register/page.tsx @@ -0,0 +1,584 @@ +"use client" + +import type React from "react" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import Link from "next/link" +import { useState } from "react" +import { + EVENT_SHORT, + EVENT_FULL_NAME, + EVENT_DATES, + EVENT_LOCATION, + PRICING_TIERS, + PROCESSING_FEE_PERCENT, + ACCOMMODATION_VENUES, + ACCOMMODATION_MAP, + ACCOMMODATION_NIGHTS, + LINKS, +} from "@/lib/event.config" + +// Determine current tier client-side +function getClientTier() { + const now = new Date().toISOString().slice(0, 10) + return PRICING_TIERS.find((t) => now < t.cutoff) ?? PRICING_TIERS[PRICING_TIERS.length - 1] +} + +export default function RegisterPage() { + const [step, setStep] = useState<"form" | "payment">("form") + const [isSubmitting, setIsSubmitting] = useState(false) + const [includeAccommodation, setIncludeAccommodation] = useState(true) + const [wantFood, setWantFood] = useState(false) + const [selectedVenueKey, setSelectedVenueKey] = useState(ACCOMMODATION_VENUES[0]?.key || "") + const [accommodationType, setAccommodationType] = useState( + ACCOMMODATION_VENUES[0]?.options[0]?.id || "" + ) + const [formData, setFormData] = useState({ + name: "", + email: "", + contact: "", + contributions: "", + expectations: "", + howHeard: "", + dietary: [] as string[], + dietaryOther: "", + crewConsent: "", + }) + + const tier = getClientTier() + const baseTicketPrice = tier.price + + const accommodationPrice = ACCOMMODATION_MAP[accommodationType]?.price ?? 0 + const subtotalPrice = + baseTicketPrice + + (includeAccommodation ? accommodationPrice : 0) + const processingFee = Math.round(subtotalPrice * PROCESSING_FEE_PERCENT * 100) / 100 + const totalPrice = subtotalPrice + processingFee + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + // Validate required fields + if ( + !formData.name || + !formData.email || + !formData.contact || + !formData.contributions || + !formData.expectations || + !formData.crewConsent + ) { + alert("Please fill in all required fields") + return + } + + setIsSubmitting(true) + + try { + // Submit registration to Google Sheet first + const dietaryString = + formData.dietary.join(", ") + + (formData.dietaryOther ? `, ${formData.dietaryOther}` : "") + + const response = await fetch("/api/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: formData.name, + email: formData.email, + contact: formData.contact, + contributions: formData.contributions, + expectations: formData.expectations, + howHeard: formData.howHeard, + dietary: dietaryString, + crewConsent: formData.crewConsent, + wantFood, + }), + }) + + if (!response.ok) { + throw new Error("Failed to record registration") + } + + // Proceed to payment step + setStep("payment") + } catch (error) { + console.error("Registration error:", error) + alert("There was an error recording your registration. Please try again.") + } finally { + setIsSubmitting(false) + } + } + + const handleDietaryChange = (value: string, checked: boolean) => { + setFormData((prev) => ({ + ...prev, + dietary: checked ? [...prev.dietary, value] : prev.dietary.filter((item) => item !== value), + })) + } + + // Pricing summary line + const pricingSummary = PRICING_TIERS.map( + (t) => `€${t.price} ${t.label}${t === tier ? " (current)" : ""}` + ).join(" · ") + + if (step === "payment") { + return ( +
+ {/* Header */} +
+
+ + {EVENT_SHORT} + +
+
+ +
+
+

Complete Your Registration

+

Choose your payment method

+
+ + + + Event Registration + + {pricingSummary} + + + +
+ {/* Ticket */} +
+
+
{EVENT_SHORT} Ticket
+
+ {pricingSummary} +
+
+ €{baseTicketPrice.toFixed(2)} +
+ + {/* Accommodation */} +
+
+ setIncludeAccommodation(checked as boolean)} + className="mt-1" + /> +
+
+ + {includeAccommodation && ( + €{accommodationPrice.toFixed(2)} + )} +
+ {includeAccommodation ? ( +
+ {/* Venue selection */} + { + setSelectedVenueKey(value) + const venue = ACCOMMODATION_VENUES.find((v) => v.key === value) + if (venue?.options[0]) { + setAccommodationType(venue.options[0].id) + } + }} + className="space-y-2" + > + {ACCOMMODATION_VENUES.map((venue) => ( +
+ + +
+ ))} +
+ + {/* Sub-options for selected venue */} + {ACCOMMODATION_VENUES.filter((v) => v.key === selectedVenueKey).map((venue) => ( +
+

+ {venue.description} +

+ + {venue.options.map((opt) => ( +
+ + +
+ ))} +
+
+ ))} +
+ ) : ( +

+ I'll arrange my own accommodation +

+ )} +
+
+
+ + {includeAccommodation && ( +

+ We'll follow up closer to the event to confirm room assignments and accommodation details. +

+ )} + + {/* Food */} +
+
+ setWantFood(checked as boolean)} + className="mt-1" + /> +
+ +

+ More details and costs will be shared soon — checking this box registers your interest + so we can plan accordingly. Your dietary preferences from step 1 have been noted. +

+
+
+
+ + {/* Processing fee */} +
+
+
Payment processing fee ({(PROCESSING_FEE_PERCENT * 100).toFixed(0)}%)
+
+ €{processingFee.toFixed(2)} +
+ + {/* Total */} +
+
+
Total Amount
+
+ Ticket{includeAccommodation ? " + accommodation" : ""} +
+
+ €{totalPrice.toFixed(2)} +
+
+
+
+ + {/* Payment Methods */} +
+ + + Payment Options + Choose your preferred payment method + + +
+ + + + +
+

+ You'll be redirected to Mollie's secure checkout where you can pay by credit card, + SEPA bank transfer, iDEAL, PayPal, or other methods. +

+ +
+ + +
+
+
+
+
+ +
+

+ All payments are processed securely through Mollie. You'll receive a confirmation email after successful + payment. +

+
+
+
+ + {/* Simplified Footer */} + +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + {EVENT_SHORT} + +
+
+ +
+
+

Register for {EVENT_SHORT}

+

{EVENT_DATES} · {EVENT_LOCATION}

+
+ + + + Registration Form + Tell us about yourself and what you'd like to bring to {EVENT_SHORT} + + +
+ {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+ + {/* Email */} +
+ + setFormData({ ...formData, email: e.target.value })} + /> +

+ We'll send your registration confirmation and event updates here. +

+
+ + {/* Contact */} +
+ + setFormData({ ...formData, contact: e.target.value })} + /> +
+ + {/* Contributions */} +
+ +