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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-19 11:37:45 -07:00
commit 84b4e78a7e
34 changed files with 4708 additions and 0 deletions

34
.env.example Normal file
View File

@ -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 <newsletter@cofi.example.com>
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

40
.gitignore vendored Normal file
View File

@ -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

33
CLAUDE.md Normal file
View File

@ -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.

46
Dockerfile Normal file
View File

@ -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"]

View File

@ -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<string, string> = {}
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 },
)
}

55
app/api/register/route.ts Normal file
View File

@ -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 }
)
}

122
app/api/webhook/route.ts Normal file
View File

@ -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<string, string>
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 })
}
}

91
app/globals.css Normal file
View File

@ -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;
}
}

44
app/layout.tsx Normal file
View File

@ -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,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" fontSize="90">🤝</text></svg>',
},
],
},
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 (
<html lang="en">
<body className={`font-sans antialiased`}>
{children}
</body>
</html>
)
}

5
app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function Home() {
redirect("/register")
}

584
app/register/page.tsx Normal file
View File

@ -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 (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b">
<div className="container mx-auto px-4 py-4">
<Link href={LINKS.website} className="text-2xl font-bold text-primary">
{EVENT_SHORT}
</Link>
</div>
</header>
<main className="container mx-auto px-4 py-12 max-w-5xl">
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold mb-4">Complete Your Registration</h1>
<p className="text-xl text-muted-foreground">Choose your payment method</p>
</div>
<Card className="mb-8 border-primary/40">
<CardHeader>
<CardTitle>Event Registration</CardTitle>
<CardDescription>
{pricingSummary}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Ticket */}
<div className="flex items-start justify-between py-4 border-b border-border">
<div>
<div className="font-medium">{EVENT_SHORT} Ticket</div>
<div className="text-sm text-muted-foreground">
{pricingSummary}
</div>
</div>
<span className="text-lg font-semibold whitespace-nowrap ml-4">{baseTicketPrice.toFixed(2)}</span>
</div>
{/* Accommodation */}
<div className="py-4 border-b border-border">
<div className="flex items-start gap-3">
<Checkbox
id="include-accommodation"
checked={includeAccommodation}
onCheckedChange={(checked) => setIncludeAccommodation(checked as boolean)}
className="mt-1"
/>
<div className="flex-1">
<div className="flex justify-between items-start">
<Label htmlFor="include-accommodation" className="font-medium cursor-pointer">
Accommodation ({ACCOMMODATION_NIGHTS} nights)
</Label>
{includeAccommodation && (
<span className="text-lg font-semibold whitespace-nowrap ml-4">{accommodationPrice.toFixed(2)}</span>
)}
</div>
{includeAccommodation ? (
<div className="mt-3 space-y-4">
{/* Venue selection */}
<RadioGroup
value={selectedVenueKey}
onValueChange={(value: string) => {
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) => (
<div key={venue.key} className="flex items-center space-x-2">
<RadioGroupItem value={venue.key} id={`venue-${venue.key}`} />
<Label htmlFor={`venue-${venue.key}`} className="font-medium cursor-pointer text-sm">
{venue.name}
</Label>
</div>
))}
</RadioGroup>
{/* Sub-options for selected venue */}
{ACCOMMODATION_VENUES.filter((v) => v.key === selectedVenueKey).map((venue) => (
<div key={venue.key} className="pl-6 border-l-2 border-primary/20">
<p className="text-xs text-muted-foreground mb-2">
{venue.description}
</p>
<RadioGroup
value={accommodationType}
onValueChange={setAccommodationType}
className="space-y-2"
>
{venue.options.map((opt) => (
<div key={opt.id} className="flex items-center space-x-2">
<RadioGroupItem value={opt.id} id={opt.id} />
<Label htmlFor={opt.id} className="font-normal cursor-pointer text-sm">
{opt.label} {opt.price.toFixed(2)} ({opt.nightlyRate}/night)
</Label>
</div>
))}
</RadioGroup>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground mt-1">
I&apos;ll arrange my own accommodation
</p>
)}
</div>
</div>
</div>
{includeAccommodation && (
<p className="text-xs text-muted-foreground bg-muted/50 rounded-lg p-3">
We&apos;ll follow up closer to the event to confirm room assignments and accommodation details.
</p>
)}
{/* Food */}
<div className="py-4 border-b border-border">
<div className="flex items-start gap-3">
<Checkbox
id="include-food"
checked={wantFood}
onCheckedChange={(checked) => setWantFood(checked as boolean)}
className="mt-1"
/>
<div className="flex-1">
<Label htmlFor="include-food" className="font-medium cursor-pointer">
I would like to include food
</Label>
<p className="text-sm text-muted-foreground 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.
</p>
</div>
</div>
</div>
{/* Processing fee */}
<div className="flex items-start justify-between py-3">
<div>
<div className="text-sm text-muted-foreground">Payment processing fee ({(PROCESSING_FEE_PERCENT * 100).toFixed(0)}%)</div>
</div>
<span className="text-sm text-muted-foreground whitespace-nowrap ml-4">{processingFee.toFixed(2)}</span>
</div>
{/* Total */}
<div className="flex justify-between items-center py-4 bg-primary/10 -mx-6 px-6 mt-4">
<div>
<div className="font-bold text-lg">Total Amount</div>
<div className="text-sm text-muted-foreground">
Ticket{includeAccommodation ? " + accommodation" : ""}
</div>
</div>
<span className="text-2xl font-bold text-primary">{totalPrice.toFixed(2)}</span>
</div>
</div>
</CardContent>
</Card>
{/* Payment Methods */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Payment Options</CardTitle>
<CardDescription>Choose your preferred payment method</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form action="/api/create-checkout-session" method="POST">
<input type="hidden" name="registrationData" value={JSON.stringify(formData)} />
<input type="hidden" name="includeAccommodation" value={includeAccommodation ? "true" : "false"} />
<input type="hidden" name="accommodationType" value={accommodationType} />
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
You'll be redirected to Mollie's secure checkout where you can pay by credit card,
SEPA bank transfer, iDEAL, PayPal, or other methods.
</p>
<div className="flex gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => setStep("form")} className="flex-1">
Back to Form
</Button>
<Button type="submit" className="flex-1">
Proceed to Payment
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
<div className="text-center text-sm text-muted-foreground">
<p>
All payments are processed securely through Mollie. You'll receive a confirmation email after successful
payment.
</p>
</div>
</div>
</main>
{/* Simplified Footer */}
<footer className="py-8 px-4 border-t border-border">
<div className="container mx-auto max-w-6xl text-center text-sm text-muted-foreground">
<p>
{EVENT_FULL_NAME} · {EVENT_DATES} · {EVENT_LOCATION}
</p>
<p className="mt-2">
<a href={LINKS.website} className="hover:text-foreground transition-colors">
{LINKS.website.replace("https://", "")}
</a>
{" · "}
<a href={`mailto:${LINKS.contactEmail}`} className="hover:text-foreground transition-colors">
{LINKS.contactEmail}
</a>
</p>
</div>
</footer>
</div>
)
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b">
<div className="container mx-auto px-4 py-4">
<Link href={LINKS.website} className="text-2xl font-bold text-primary">
{EVENT_SHORT}
</Link>
</div>
</header>
<main className="container mx-auto px-4 py-12 max-w-3xl">
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold mb-4">Register for {EVENT_SHORT}</h1>
<p className="text-xl text-muted-foreground">{EVENT_DATES} · {EVENT_LOCATION}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Registration Form</CardTitle>
<CardDescription>Tell us about yourself and what you'd like to bring to {EVENT_SHORT}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">What's your name? *</Label>
<Input
id="name"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email">Email address *</Label>
<Input
id="email"
type="email"
required
placeholder="your@email.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
<p className="text-sm text-muted-foreground">
We&apos;ll send your registration confirmation and event updates here.
</p>
</div>
{/* Contact */}
<div className="space-y-2">
<Label htmlFor="contact">How can we contact you besides via email? *</Label>
<Input
id="contact"
required
placeholder="Telegram, Signal, phone, etc."
value={formData.contact}
onChange={(e) => setFormData({ ...formData, contact: e.target.value })}
/>
</div>
{/* Contributions */}
<div className="space-y-2">
<Label htmlFor="contributions">What do you want to contribute to {EVENT_SHORT}? *</Label>
<Textarea
id="contributions"
required
placeholder="This could be a talk, workshop, research inquiry, prototype, game, performance, etc."
className="min-h-[120px]"
value={formData.contributions}
onChange={(e) => setFormData({ ...formData, contributions: e.target.value })}
/>
<p className="text-sm text-muted-foreground">
This event is participant-driven each attendee is invited to co-create the program.
</p>
</div>
{/* Expectations */}
<div className="space-y-2">
<Label htmlFor="expectations">What do you expect to gain from participating? *</Label>
<Textarea
id="expectations"
required
placeholder="What are you looking for in particular?"
className="min-h-[100px]"
value={formData.expectations}
onChange={(e) => setFormData({ ...formData, expectations: e.target.value })}
/>
</div>
{/* How heard */}
<div className="space-y-2">
<Label htmlFor="howHeard">How did you hear about {EVENT_SHORT}?</Label>
<Input
id="howHeard"
placeholder="First timers: Did anyone recommend it to you?"
value={formData.howHeard}
onChange={(e) => setFormData({ ...formData, howHeard: e.target.value })}
/>
</div>
{/* Dietary Requirements */}
<div className="space-y-3">
<Label>Dietary Requirements</Label>
<p className="text-sm text-muted-foreground">
Do you have any special dietary requirements?
</p>
<div className="space-y-2">
{["vegetarian", "vegan", "gluten free", "lactose free"].map((diet) => (
<div key={diet} className="flex items-center space-x-2">
<Checkbox
id={diet}
checked={formData.dietary.includes(diet)}
onCheckedChange={(checked) => handleDietaryChange(diet, checked as boolean)}
/>
<Label htmlFor={diet} className="font-normal cursor-pointer">
{diet.charAt(0).toUpperCase() + diet.slice(1)}
</Label>
</div>
))}
<div className="flex items-start space-x-2 mt-2">
<Checkbox
id="other"
checked={!!formData.dietaryOther}
onCheckedChange={(checked) => {
if (!checked) setFormData({ ...formData, dietaryOther: "" })
}}
/>
<div className="flex-1">
<Label htmlFor="other" className="font-normal cursor-pointer">
Other:
</Label>
<Input
id="dietaryOther"
className="mt-1"
placeholder="Please specify..."
value={formData.dietaryOther}
onChange={(e) => setFormData({ ...formData, dietaryOther: e.target.value })}
/>
</div>
</div>
</div>
</div>
{/* Participation Consent */}
<div className="space-y-3">
<Label>Participation Commitment *</Label>
<div className="bg-muted p-4 rounded-lg space-y-2 text-sm">
<p>
{EVENT_SHORT} is a collaborative event participants co-create its program,
activities, and atmosphere together.
</p>
<p className="font-medium">
You will be expected to actively contribute to the event beyond just attending sessions.
</p>
</div>
<RadioGroup
required
value={formData.crewConsent}
onValueChange={(value) => setFormData({ ...formData, crewConsent: value })}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="cant-wait" id="cant-wait" />
<Label htmlFor="cant-wait" className="font-normal cursor-pointer">
Can't wait
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="yes-please" id="yes-please" />
<Label htmlFor="yes-please" className="font-normal cursor-pointer">
Yes please, sign me up!
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="love-it" id="love-it" />
<Label htmlFor="love-it" className="font-normal cursor-pointer">
I love getting my hands dirty :D
</Label>
</div>
</RadioGroup>
</div>
<div className="pt-4">
<Button type="submit" className="w-full" size="lg" disabled={isSubmitting}>
{isSubmitting ? "Recording registration..." : "Continue to Payment"}
</Button>
</div>
<div className="text-sm text-muted-foreground text-center">
<p>
Questions? Contact us at{" "}
<a href={`mailto:${LINKS.contactEmail}`} className="text-primary hover:underline">
{LINKS.contactEmail}
</a>
{LINKS.telegram && (
<>
{" or on "}
<a href={LINKS.telegram} className="text-primary hover:underline">
Telegram
</a>
</>
)}
</p>
</div>
</form>
</CardContent>
</Card>
</main>
{/* Simplified Footer */}
<footer className="py-8 px-4 border-t border-border">
<div className="container mx-auto max-w-6xl text-center text-sm text-muted-foreground">
<p>
{EVENT_FULL_NAME} · {EVENT_DATES} · {EVENT_LOCATION}
</p>
<p className="mt-2">
<a href={LINKS.website} className="hover:text-foreground transition-colors">
{LINKS.website.replace("https://", "")}
</a>
{" · "}
<a href={`mailto:${LINKS.contactEmail}`} className="hover:text-foreground transition-colors">
{LINKS.contactEmail}
</a>
</p>
</div>
</footer>
</div>
)
}

66
app/success/page.tsx Normal file
View File

@ -0,0 +1,66 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { CheckCircle2 } from "lucide-react"
import Link from "next/link"
import {
EVENT_SHORT,
EVENT_FULL_NAME,
EVENT_DATES,
EVENT_LOCATION,
LINKS,
} from "@/lib/event.config"
export default function SuccessPage() {
return (
<div className="min-h-screen bg-background flex flex-col">
<div className="flex-1 flex items-center justify-center p-4">
<Card className="max-w-md w-full">
<CardHeader className="text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-10 h-10 text-primary" />
</div>
<CardTitle className="text-2xl">Payment Successful!</CardTitle>
<CardDescription>Welcome to {EVENT_SHORT}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-muted-foreground">
You've successfully registered for {EVENT_SHORT}. Check your email for confirmation details and next steps.
</p>
<div className="space-y-2">
<h3 className="font-semibold">What's Next?</h3>
<ul className="text-sm space-y-1 text-muted-foreground">
<li> Join our community channels</li>
<li> Watch for pre-event communications</li>
<li> Start preparing your contributions</li>
<li> Get ready for an amazing experience</li>
</ul>
</div>
<div className="flex gap-3 pt-4">
<Button asChild className="flex-1">
<Link href={LINKS.website}>Back to Website</Link>
</Button>
{LINKS.telegram && (
<Button asChild variant="outline" className="flex-1 bg-transparent">
<a href={LINKS.telegram}>Join Community</a>
</Button>
)}
</div>
</CardContent>
</Card>
</div>
<footer className="py-8 px-4 border-t border-border">
<div className="container mx-auto max-w-6xl text-center text-sm text-muted-foreground">
<p>
{EVENT_FULL_NAME} · {EVENT_DATES} · {EVENT_LOCATION}
</p>
<p className="mt-2">
<a href={LINKS.website} className="hover:text-foreground transition-colors">
{LINKS.website.replace("https://", "")}
</a>
</p>
</div>
</footer>
</div>
)
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

60
components/ui/button.tsx Normal file
View File

@ -0,0 +1,60 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@ -0,0 +1,92 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,32 @@
'use client'
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

21
components/ui/input.tsx Normal file
View File

@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@ -0,0 +1,24 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,45 @@
'use client'
import * as React from 'react'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn('grid gap-3', className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,18 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
)
}
export { Textarea }

47
docker-compose.yml Normal file
View File

@ -0,0 +1,47 @@
services:
cofi-register:
build: .
container_name: cofi-register
restart: unless-stopped
environment:
- NODE_ENV=production
- MOLLIE_API_KEY=${MOLLIE_API_KEY}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://cofi.example.com}
- GOOGLE_SERVICE_ACCOUNT_KEY=${GOOGLE_SERVICE_ACCOUNT_KEY}
- GOOGLE_SHEET_ID=${GOOGLE_SHEET_ID}
- GOOGLE_SHEET_NAME=${GOOGLE_SHEET_NAME:-Registrations}
- SMTP_HOST=${SMTP_HOST:-mail.rmail.online}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
- INTERNAL_NOTIFY_EMAIL=${INTERNAL_NOTIFY_EMAIL}
- LISTMONK_DB_HOST=${LISTMONK_DB_HOST:-listmonk-db}
- LISTMONK_DB_PORT=${LISTMONK_DB_PORT:-5432}
- LISTMONK_DB_NAME=${LISTMONK_DB_NAME:-listmonk}
- LISTMONK_DB_USER=${LISTMONK_DB_USER:-listmonk}
- LISTMONK_DB_PASS=${LISTMONK_DB_PASS}
- LISTMONK_LIST_ID=${LISTMONK_LIST_ID:-1}
- BOOKING_SHEET_ID=${BOOKING_SHEET_ID}
- BOOKING_SHEET_NAME=${BOOKING_SHEET_NAME:-Sheet1}
labels:
- "traefik.enable=true"
- "traefik.http.routers.cofi-register.rule=Host(`cofi.example.com`)"
- "traefik.http.routers.cofi-register.entrypoints=web,websecure"
- "traefik.http.services.cofi-register.loadbalancer.server.port=3000"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
networks:
- traefik-public
- listmonk-internal
networks:
traefik-public:
external: true
listmonk-internal:
external:
name: listmonk_listmonk-internal

269
lib/booking-sheet.ts Normal file
View File

@ -0,0 +1,269 @@
import { getGoogleSheetsClient } from "./google-sheets"
import { BOOKING_CRITERIA, type BookingCriteria } from "./event.config"
const BOOKING_SHEET_ID = process.env.BOOKING_SHEET_ID
const BOOKING_SHEET_NAME = process.env.BOOKING_SHEET_NAME || "Sheet1"
interface BedRow {
rowIndex: number // 0-based index in the sheet data
venue: string
room: string
bedType: string
dateColumns: number[] // column indices for date cells
occupied: boolean // true if any date column has a value
}
interface BookingResult {
success: boolean
venue?: string
room?: string
bedType?: string
error?: string
}
/**
* Parse the booking spreadsheet to extract bed information.
*
* Expected sheet structure:
* - Two venue sections appear as section headers (names from event.config)
* - Below each header: column headers with Room, Bed Type, then date columns
* - Bed rows follow with room number, bed type, and occupant names in date columns
* - Room numbers may be merged (only first row of a room group has the room number)
*/
function parseBookingSheet(data: string[][]): BedRow[] {
const beds: BedRow[] = []
let currentVenue = ""
let dateColumnIndices: number[] = []
let roomCol = -1
let bedTypeCol = -1
let lastRoom = ""
let inDataSection = false
// Build set of known venue names from config
const venueNames = [...new Set(Object.values(BOOKING_CRITERIA).map((c) => c.venue.toLowerCase()))]
for (let i = 0; i < data.length; i++) {
const row = data[i]
if (!row || row.length === 0) {
// Empty row — could be section separator
inDataSection = false
continue
}
const firstCell = (row[0] || "").trim()
const firstCellLower = firstCell.toLowerCase()
// Detect venue section headers
const matchedVenue = venueNames.find((v) => firstCellLower.includes(v))
if (matchedVenue) {
// Find the full venue name from criteria
const criteria = Object.values(BOOKING_CRITERIA).find(
(c) => c.venue.toLowerCase() === matchedVenue
)
currentVenue = criteria?.venue || firstCell
inDataSection = false
lastRoom = ""
continue
}
if (!currentVenue) continue
// Detect column headers row (contains "room" and date-like patterns)
const lowerRow = row.map((c) => (c || "").trim().toLowerCase())
const roomIdx = lowerRow.findIndex(
(c) => c === "room" || c === "room #" || c === "room number"
)
const bedIdx = lowerRow.findIndex(
(c) =>
c === "bed type" ||
c === "bed" ||
c === "type" ||
c === "bed/type"
)
if (roomIdx !== -1 && bedIdx !== -1) {
roomCol = roomIdx
bedTypeCol = bedIdx
// Date columns are everything after bedTypeCol that looks like a date or has content
dateColumnIndices = []
for (let j = bedTypeCol + 1; j < row.length; j++) {
const cell = (row[j] || "").trim()
if (cell) {
dateColumnIndices.push(j)
}
}
inDataSection = true
lastRoom = ""
continue
}
// Parse data rows
if (inDataSection && roomCol !== -1 && bedTypeCol !== -1) {
let bedType = (row[bedTypeCol] || "").trim().toLowerCase()
if (!bedType) continue // Skip rows without bed type
// Normalize: "double (shared" → "double (shared)"
if (bedType.includes("(") && !bedType.includes(")")) {
bedType += ")"
}
// Carry forward room number from merged cells
const roomValue = (row[roomCol] || "").trim()
if (roomValue) {
lastRoom = roomValue
}
if (!lastRoom) continue
// Check if any date column has an occupant
const occupied = dateColumnIndices.some((colIdx) => {
const cell = (row[colIdx] || "").trim()
return cell.length > 0
})
beds.push({
rowIndex: i,
venue: currentVenue,
room: lastRoom,
bedType,
dateColumns: dateColumnIndices,
occupied,
})
}
}
return beds
}
/**
* Find the first available bed matching the given criteria
*/
function findFirstAvailableBed(
beds: BedRow[],
venue: string,
bedTypes: string[],
roomFilter?: (room: string) => boolean
): BedRow | null {
return (
beds.find((bed) => {
if (bed.venue !== venue) return false
if (!bedTypes.includes(bed.bedType)) return false
if (bed.occupied) return false
if (roomFilter && !roomFilter(bed.room)) return false
return true
}) || null
)
}
/**
* Write a guest name into all date columns for a given bed row
*/
async function assignGuestToBed(
guestName: string,
bed: BedRow,
): Promise<void> {
const sheets = getGoogleSheetsClient()
// Build batch update data — one value range per date column
const data = bed.dateColumns.map((colIdx) => {
const colLetter = columnToLetter(colIdx)
const rowNum = bed.rowIndex + 1 // Convert to 1-indexed
return {
range: `${BOOKING_SHEET_NAME}!${colLetter}${rowNum}`,
values: [[guestName]],
}
})
await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: BOOKING_SHEET_ID!,
requestBody: {
valueInputOption: "USER_ENTERED",
data,
},
})
}
/**
* Convert 0-based column index to spreadsheet column letter (0A, 25Z, 26AA)
*/
function columnToLetter(col: number): string {
let letter = ""
let c = col
while (c >= 0) {
letter = String.fromCharCode((c % 26) + 65) + letter
c = Math.floor(c / 26) - 1
}
return letter
}
/**
* Main entry point: assign a guest to the first available matching bed.
* Best-effort failures are logged but don't throw.
*/
export async function assignBooking(
guestName: string,
accommodationType: string
): Promise<BookingResult> {
if (!BOOKING_SHEET_ID) {
return { success: false, error: "BOOKING_SHEET_ID not configured" }
}
const criteria = BOOKING_CRITERIA[accommodationType]
if (!criteria) {
return {
success: false,
error: `Unknown accommodation type: ${accommodationType}`,
}
}
try {
const sheets = getGoogleSheetsClient()
// Read the entire booking sheet
const response = await sheets.spreadsheets.values.get({
spreadsheetId: BOOKING_SHEET_ID,
range: BOOKING_SHEET_NAME,
})
const sheetData = response.data.values || []
if (sheetData.length === 0) {
return { success: false, error: "Booking sheet is empty" }
}
// Parse the sheet into bed rows
const beds = parseBookingSheet(sheetData)
// Find first available bed matching criteria
const bed = findFirstAvailableBed(
beds,
criteria.venue,
criteria.bedTypes,
criteria.roomFilter
)
if (!bed) {
return {
success: false,
error: `No available ${criteria.bedTypes.join("/")} beds in ${criteria.venue}`,
}
}
// Assign the guest
await assignGuestToBed(guestName, bed)
console.log(
`[Booking] Assigned ${guestName} to ${criteria.venue} Room ${bed.room} (${bed.bedType})`
)
return {
success: true,
venue: criteria.venue,
room: bed.room,
bedType: bed.bedType,
}
} catch (error) {
console.error("[Booking] Error assigning booking:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}

191
lib/email.ts Normal file
View File

@ -0,0 +1,191 @@
import nodemailer from "nodemailer"
import {
EVENT_SHORT,
EVENT_FULL_NAME,
EVENT_DATES,
EVENT_LOCATION,
EMAIL_BRANDING,
LINKS,
} from "./event.config"
// Lazy-initialized SMTP transport (Mailcow)
let transporter: nodemailer.Transporter | null = null
function getTransporter() {
if (!transporter && process.env.SMTP_PASS) {
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || "mail.rmail.online",
port: parseInt(process.env.SMTP_PORT || "587"),
secure: false,
auth: {
user: process.env.SMTP_USER || "newsletter@cofi.example.com",
pass: process.env.SMTP_PASS,
},
tls: { rejectUnauthorized: false },
})
}
return transporter
}
const EMAIL_FROM = process.env.EMAIL_FROM || EMAIL_BRANDING.fromDefault
const INTERNAL_NOTIFY_EMAIL =
process.env.INTERNAL_NOTIFY_EMAIL || EMAIL_BRANDING.internalNotifyDefault
interface BookingNotificationData {
guestName: string
guestEmail: string
accommodationType: string
amountPaid: string
bookingSuccess: boolean
venue?: string
room?: string
bedType?: string
error?: string
}
export async function sendBookingNotification(
data: BookingNotificationData
): Promise<boolean> {
const transport = getTransporter()
if (!transport) {
console.log("[Email] SMTP not configured, skipping booking notification")
return false
}
const statusColor = data.bookingSuccess ? "#16a34a" : "#dc2626"
const statusLabel = data.bookingSuccess ? "ASSIGNED" : "FAILED"
const flags: string[] = []
if (!data.bookingSuccess) {
flags.push(`Booking assignment failed: ${data.error || "unknown reason"}`)
}
if (!data.guestEmail) {
flags.push("No email address on file for this guest")
}
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h2 style="margin-bottom: 4px;">Accommodation Update: ${data.guestName}</h2>
<p style="margin-top: 0; color: ${statusColor}; font-weight: bold;">${statusLabel}</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr><td style="padding: 4px 0;"><strong>Guest:</strong></td><td>${data.guestName}</td></tr>
<tr><td style="padding: 4px 0;"><strong>Email:</strong></td><td>${data.guestEmail || "N/A"}</td></tr>
<tr><td style="padding: 4px 0;"><strong>Paid:</strong></td><td>${data.amountPaid}</td></tr>
<tr><td style="padding: 4px 0;"><strong>Requested:</strong></td><td>${data.accommodationType}</td></tr>
${data.bookingSuccess ? `
<tr><td style="padding: 4px 0;"><strong>Assigned Venue:</strong></td><td>${data.venue}</td></tr>
<tr><td style="padding: 4px 0;"><strong>Room:</strong></td><td>${data.room}</td></tr>
<tr><td style="padding: 4px 0;"><strong>Bed Type:</strong></td><td>${data.bedType}</td></tr>
` : ""}
</table>
${
flags.length > 0
? `<div style="background: #fef2f2; padding: 12px 16px; border-radius: 6px; border-left: 3px solid #dc2626; margin: 16px 0;">
<strong style="color: #dc2626;">Flags:</strong>
<ul style="margin: 4px 0 0 0; padding-left: 20px;">${flags.map((f) => `<li>${f}</li>`).join("")}</ul>
</div>`
: `<p style="color: #16a34a;">No issues detected.</p>`
}
<p style="font-size: 12px; color: #666; margin-top: 24px;">Automated notification from ${EVENT_SHORT} registration system</p>
</div>
`
try {
const info = await transport.sendMail({
from: EMAIL_FROM,
to: INTERNAL_NOTIFY_EMAIL,
subject: `[${EVENT_SHORT} Booking] ${statusLabel}: ${data.guestName}${data.accommodationType}`,
html,
})
console.log(`[Email] Booking notification sent to ${INTERNAL_NOTIFY_EMAIL} (${info.messageId})`)
return true
} catch (error) {
console.error("[Email] Failed to send booking notification:", error)
return false
}
}
interface PaymentConfirmationData {
name: string
email: string
amountPaid: string
paymentMethod: string
contributions: string
dietary: string
accommodationVenue?: string
accommodationRoom?: string
}
export async function sendPaymentConfirmation(
data: PaymentConfirmationData
): Promise<boolean> {
const transport = getTransporter()
if (!transport) {
console.log("[Email] SMTP not configured, skipping confirmation email")
return false
}
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<h1 style="color: ${EMAIL_BRANDING.primaryColor}; margin-bottom: 8px;">${EMAIL_BRANDING.headerText}</h1>
<p style="font-size: 15px; color: ${EMAIL_BRANDING.taglineColor}; margin-top: 0; margin-bottom: 28px; font-style: italic;">${EVENT_FULL_NAME} ${EVENT_DATES}</p>
<p>Dear ${data.name},</p>
<p>Your payment of <strong>${data.amountPaid}</strong> has been confirmed. You are now registered for <strong>${EVENT_SHORT}</strong>, ${EVENT_DATES} in ${EVENT_LOCATION}.</p>
<div style="background: ${EMAIL_BRANDING.highlightBg}; padding: 20px; border-radius: 8px; margin: 24px 0; border-left: 3px solid ${EMAIL_BRANDING.highlightBorder};">
<h3 style="margin-top: 0; color: ${EMAIL_BRANDING.taglineColor};">Registration Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 4px 0;"><strong>Amount:</strong></td>
<td style="padding: 4px 0;">${data.amountPaid}</td>
</tr>
<tr>
<td style="padding: 4px 0;"><strong>Payment:</strong></td>
<td style="padding: 4px 0;">${data.paymentMethod}</td>
</tr>
${data.dietary ? `<tr><td style="padding: 4px 0;"><strong>Dietary:</strong></td><td style="padding: 4px 0;">${data.dietary}</td></tr>` : ""}
${data.accommodationVenue ? `<tr><td style="padding: 4px 0;"><strong>Accommodation:</strong></td><td style="padding: 4px 0;">${data.accommodationVenue}${data.accommodationRoom ? `, Room ${data.accommodationRoom}` : ""}</td></tr>` : ""}
</table>
</div>
<h3 style="color: ${EMAIL_BRANDING.taglineColor};">What's Next?</h3>
<ul style="line-height: 1.8;">
<li>Join the <a href="${LINKS.telegram}" style="color: ${EMAIL_BRANDING.primaryColor};">${EVENT_SHORT} community</a> to connect with other participants</li>
<li>We'll follow up with further details on logistics and schedule</li>
</ul>
<p style="margin-top: 32px;">
See you there,<br>
<strong>The ${EVENT_SHORT} Team</strong>
</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 32px 0;">
<p style="font-size: 12px; color: #666;">
You received this email because you registered at ${LINKS.website.replace("https://", "")}.<br>
<a href="${LINKS.website}">${LINKS.website.replace("https://", "")}</a>
</p>
</div>
`
try {
const info = await transport.sendMail({
from: EMAIL_FROM,
to: data.email,
bcc: INTERNAL_NOTIFY_EMAIL,
subject: `Registration Confirmed - ${EVENT_SHORT}`,
html,
})
console.log(
`[Email] Payment confirmation sent to ${data.email} (${info.messageId})`
)
return true
} catch (error) {
console.error("[Email] Failed to send payment confirmation:", error)
return false
}
}

191
lib/event.config.ts Normal file
View File

@ -0,0 +1,191 @@
/**
* Centralized event configuration for CoFi registration.
*
* All event-specific values (pricing, dates, accommodation, branding)
* live here instead of being scattered across components and API routes.
* To adapt this app for a new event, edit this file only.
*/
// ── Event basics ──────────────────────────────────────────────────
export const EVENT_NAME = "CoFi"
export const EVENT_FULL_NAME = "Collaborative Finance"
export const EVENT_YEAR = 2026
export const EVENT_TAGLINE = "Reimagining finance for the commons"
export const EVENT_DATES = "TBD, 2026"
export const EVENT_LOCATION = "TBD"
export const EVENT_SHORT = `${EVENT_NAME} ${EVENT_YEAR}`
// ── Ticket pricing tiers (EUR) ────────────────────────────────────
interface PricingTier {
label: string
price: number
/** Tier is active if today < cutoff date (ISO string, exclusive) */
cutoff: string
}
export const PRICING_TIERS: PricingTier[] = [
{ label: "Early bird", price: 80, cutoff: "2026-04-01" },
{ label: "Regular", price: 120, cutoff: "2026-07-01" },
{ label: "Late", price: 150, cutoff: "2099-12-31" },
]
/** Returns the currently active pricing tier based on today's date */
export function getCurrentTier(): PricingTier {
const now = new Date().toISOString().slice(0, 10)
return PRICING_TIERS.find((t) => now < t.cutoff) ?? PRICING_TIERS[PRICING_TIERS.length - 1]
}
/** Human-readable pricing summary for display */
export function getPricingSummary(): string {
return PRICING_TIERS.map((t) => `${t.price} ${t.label}`).join(" · ")
}
// ── Processing fee ────────────────────────────────────────────────
export const PROCESSING_FEE_PERCENT = 0.02 // 2% to cover Mollie fees
// ── Accommodation ─────────────────────────────────────────────────
export interface AccommodationOption {
id: string
label: string
price: number
nightlyRate: number
venue: string
venueKey: string
description?: string
}
export interface AccommodationVenue {
key: string
name: string
description: string
options: AccommodationOption[]
}
export const ACCOMMODATION_VENUES: AccommodationVenue[] = [
{
key: "venue-a",
name: "Venue A",
description: "TBD — Primary event venue accommodation.",
options: [
{
id: "va-shared",
label: "Bed in shared room",
price: 280,
nightlyRate: 40,
venue: "Venue A",
venueKey: "venue-a",
},
{
id: "va-double",
label: "Bed in double room",
price: 350,
nightlyRate: 50,
venue: "Venue A",
venueKey: "venue-a",
},
],
},
{
key: "venue-b",
name: "Venue B",
description: "TBD — Secondary accommodation option.",
options: [
{
id: "vb-single",
label: "Single room",
price: 560,
nightlyRate: 80,
venue: "Venue B",
venueKey: "venue-b",
},
{
id: "vb-double",
label: "Double room (per person)",
price: 350,
nightlyRate: 50,
venue: "Venue B",
venueKey: "venue-b",
},
],
},
]
/** Flat map of accommodation ID → option for quick lookup */
export const ACCOMMODATION_MAP: Record<string, AccommodationOption> = Object.fromEntries(
ACCOMMODATION_VENUES.flatMap((v) => v.options.map((o) => [o.id, o]))
)
/** Number of nights (used in display) */
export const ACCOMMODATION_NIGHTS = 7
// ── Booking sheet criteria (maps accommodation IDs to bed search criteria) ──
export interface BookingCriteria {
venue: string
bedTypes: string[]
roomFilter?: (room: string) => boolean
}
/**
* Map accommodation option IDs to booking sheet search criteria.
* Update this when you configure the actual booking spreadsheet.
*/
export const BOOKING_CRITERIA: Record<string, BookingCriteria> = {
"va-shared": {
venue: "Venue A",
bedTypes: ["bunk up", "bunk down", "single"],
},
"va-double": {
venue: "Venue A",
bedTypes: ["double", "double (shared)"],
},
"vb-single": {
venue: "Venue B",
bedTypes: ["double"],
},
"vb-double": {
venue: "Venue B",
bedTypes: ["single", "double (shared)"],
},
}
// ── Form field toggles ────────────────────────────────────────────
export const FORM_FIELDS = {
dietary: true,
howHeard: true,
crewConsent: true,
wantFood: true,
}
// ── Email branding ────────────────────────────────────────────────
export const EMAIL_BRANDING = {
primaryColor: "#2563eb", // blue-600
accentColor: "#0d9488", // teal-600
headerText: "You're In!",
taglineColor: "#1e40af", // blue-800
highlightBg: "#eff6ff", // blue-50
highlightBorder: "#2563eb", // blue-600
fromDefault: `${EVENT_NAME} <newsletter@cofi.example.com>`,
internalNotifyDefault: "team@cofi.example.com",
}
// ── External links ────────────────────────────────────────────────
export const LINKS = {
website: "https://cofi.example.com",
telegram: "https://t.me/cofi_example",
community: "https://t.me/cofi_community",
contactEmail: "team@cofi.example.com",
}
// ── Payment description template ──────────────────────────────────
export function buildPaymentDescription(parts: string[]): string {
return `${EVENT_SHORT} Registration — ${parts.join(" + ")}`
}

214
lib/google-sheets.ts Normal file
View File

@ -0,0 +1,214 @@
import { google } from "googleapis"
// Initialize Google Sheets API
export function getGoogleSheetsClient() {
const credentials = JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_KEY || "{}")
const auth = new google.auth.GoogleAuth({
credentials,
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
})
return google.sheets({ version: "v4", auth })
}
const SPREADSHEET_ID = process.env.GOOGLE_SHEET_ID!
const SHEET_NAME = process.env.GOOGLE_SHEET_NAME || "Registrations"
export interface RegistrationData {
name: string
email?: string
contact: string
contributions: string
expectations: string
howHeard: string
dietary: string
crewConsent: string
wantFood: boolean
}
export interface PaymentUpdateData {
email?: string
name?: string
paymentSessionId: string
paymentStatus: "Paid" | "Failed"
paymentMethod?: string
amountPaid?: string
paymentDate?: string
accommodationVenue?: string
accommodationType?: string
}
/**
* Add a new registration to the Google Sheet with "Pending" status
* Returns the row number where the registration was added
*/
export async function addRegistration(data: RegistrationData): Promise<number> {
const sheets = getGoogleSheetsClient()
const timestamp = new Date().toISOString()
const values = [
[
timestamp, // A: Timestamp
data.name, // B: Name
data.email || "", // C: Email
data.contact, // D: Contact
data.contributions, // E: Contributions
data.expectations, // F: Expectations
data.howHeard || "", // G: How Heard
data.dietary, // H: Dietary
data.crewConsent, // I: Crew Consent
"Pending", // J: Payment Status
"", // K: Payment Method
"", // L: Payment Session ID
"", // M: Amount Paid
"", // N: Payment Date
"", // O: Accommodation Venue
"", // P: Accommodation Type
data.wantFood ? "Yes" : "No", // Q: Want Food
],
]
const response = await sheets.spreadsheets.values.append({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A:Q`,
valueInputOption: "USER_ENTERED",
insertDataOption: "INSERT_ROWS",
requestBody: { values },
})
// Extract row number from the updated range (e.g., "Registrations!A5:N5" -> 5)
const updatedRange = response.data.updates?.updatedRange || ""
const match = updatedRange.match(/!A(\d+):/)
const rowNumber = match ? parseInt(match[1], 10) : -1
console.log(`[Google Sheets] Added registration for ${data.name} at row ${rowNumber}`)
return rowNumber
}
/**
* Update payment status for a registration
* Finds the row by name (since email might not be available until payment)
*/
export async function updatePaymentStatus(data: PaymentUpdateData): Promise<boolean> {
const sheets = getGoogleSheetsClient()
try {
// First, get all rows to find the matching registration
const response = await sheets.spreadsheets.values.get({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A:Q`,
})
const rows = response.data.values || []
// Find the row that matches (by name and has "Pending" status)
// Start from index 1 to skip header row
let targetRowIndex = -1
for (let i = rows.length - 1; i >= 1; i--) {
const row = rows[i]
const rowName = row[1] // Column B: Name
const rowStatus = row[9] // Column J: Payment Status
// Match by name and pending status (most recent first)
if (rowName === data.name && rowStatus === "Pending") {
targetRowIndex = i + 1 // Convert to 1-indexed row number
break
}
}
if (targetRowIndex === -1) {
console.error(`[Google Sheets] Could not find pending registration for ${data.name}`)
return false
}
// Update the row with payment information
const existingRow = rows[targetRowIndex - 1]
const updateRange = `${SHEET_NAME}!C${targetRowIndex}:Q${targetRowIndex}`
const updateValues = [
[
data.email || existingRow[2] || "", // C: Email (from Mollie or existing)
existingRow[3], // D: Contact (preserve)
existingRow[4], // E: Contributions (preserve)
existingRow[5], // F: Expectations (preserve)
existingRow[6], // G: How Heard (preserve)
existingRow[7], // H: Dietary (preserve)
existingRow[8], // I: Crew Consent (preserve)
data.paymentStatus, // J: Payment Status
data.paymentMethod || "", // K: Payment Method
data.paymentSessionId, // L: Payment Session ID
data.amountPaid || "", // M: Amount Paid
data.paymentDate || new Date().toISOString(), // N: Payment Date
data.accommodationVenue || "", // O: Accommodation Venue
data.accommodationType || "", // P: Accommodation Type
existingRow[16] || "", // Q: Want Food (preserve)
],
]
await sheets.spreadsheets.values.update({
spreadsheetId: SPREADSHEET_ID,
range: updateRange,
valueInputOption: "USER_ENTERED",
requestBody: { values: updateValues },
})
console.log(`[Google Sheets] Updated payment status to ${data.paymentStatus} for ${data.name} at row ${targetRowIndex}`)
return true
} catch (error) {
console.error("[Google Sheets] Error updating payment status:", error)
return false
}
}
/**
* Initialize the sheet with headers if it's empty
*/
export async function initializeSheetHeaders(): Promise<void> {
const sheets = getGoogleSheetsClient()
try {
// Check if first row has data
const response = await sheets.spreadsheets.values.get({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A1:Q1`,
})
if (!response.data.values || response.data.values.length === 0) {
// Add headers
const headers = [
[
"Timestamp",
"Name",
"Email",
"Contact",
"Contributions",
"Expectations",
"How Heard",
"Dietary",
"Crew Consent",
"Payment Status",
"Payment Method",
"Payment Session ID",
"Amount Paid",
"Payment Date",
"Accommodation Venue",
"Accommodation Type",
"Want Food",
],
]
await sheets.spreadsheets.values.update({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A1:Q1`,
valueInputOption: "USER_ENTERED",
requestBody: { values: headers },
})
console.log("[Google Sheets] Initialized sheet with headers")
}
} catch (error) {
console.error("[Google Sheets] Error initializing headers:", error)
}
}

92
lib/listmonk.ts Normal file
View File

@ -0,0 +1,92 @@
import pg from "pg"
const { Pool } = pg
// Lazy-initialized Listmonk PostgreSQL connection
let pool: pg.Pool | null = null
function getPool() {
if (!pool && process.env.LISTMONK_DB_HOST) {
pool = new Pool({
host: process.env.LISTMONK_DB_HOST,
port: parseInt(process.env.LISTMONK_DB_PORT || "5432"),
database: process.env.LISTMONK_DB_NAME || "listmonk",
user: process.env.LISTMONK_DB_USER || "listmonk",
password: process.env.LISTMONK_DB_PASS || "",
})
}
return pool
}
const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID || "1")
interface SubscriberData {
email: string
name: string
attribs?: Record<string, unknown>
}
export async function addToListmonk(data: SubscriberData): Promise<boolean> {
const db = getPool()
if (!db) {
console.log("[Listmonk] Database not configured, skipping")
return false
}
const client = await db.connect()
try {
const attribs = {
cofi: {
...data.attribs,
registeredAt: new Date().toISOString(),
},
}
// Check if subscriber exists
const existing = await client.query(
"SELECT id, attribs FROM subscribers WHERE email = $1",
[data.email]
)
let subscriberId: number
if (existing.rows.length > 0) {
subscriberId = existing.rows[0].id
const mergedAttribs = { ...existing.rows[0].attribs, ...attribs }
await client.query(
"UPDATE subscribers SET name = $1, attribs = $2, updated_at = NOW() WHERE id = $3",
[data.name, JSON.stringify(mergedAttribs), subscriberId]
)
console.log(
`[Listmonk] Updated existing subscriber: ${data.email} (ID: ${subscriberId})`
)
} else {
const result = await client.query(
`INSERT INTO subscribers (uuid, email, name, status, attribs, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, 'enabled', $3, NOW(), NOW())
RETURNING id`,
[data.email, data.name, JSON.stringify(attribs)]
)
subscriberId = result.rows[0].id
console.log(
`[Listmonk] Created new subscriber: ${data.email} (ID: ${subscriberId})`
)
}
// Add to CoFi list
await client.query(
`INSERT INTO subscriber_lists (subscriber_id, list_id, status, created_at, updated_at)
VALUES ($1, $2, 'confirmed', NOW(), NOW())
ON CONFLICT (subscriber_id, list_id) DO UPDATE SET status = 'confirmed', updated_at = NOW()`,
[subscriberId, LISTMONK_LIST_ID]
)
console.log(`[Listmonk] Added to list ${LISTMONK_LIST_ID}: ${data.email}`)
return true
} catch (error) {
console.error("[Listmonk] Error:", error)
return false
} finally {
client.release()
}
}

11
lib/mollie.ts Normal file
View File

@ -0,0 +1,11 @@
import createMollieClient from "@mollie/api-client"
/** Shared lazy-initialized Mollie client singleton */
let mollieClient: ReturnType<typeof createMollieClient> | null = null
export function getMollie() {
if (!mollieClient) {
mollieClient = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY! })
}
return mollieClient
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

12
next.config.mjs Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
}
export default nextConfig

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "cofi-register",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start"
},
"dependencies": {
"@mollie/api-client": "^4.4.0",
"@radix-ui/react-checkbox": "latest",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-radio-group": "latest",
"@radix-ui/react-slot": "1.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"googleapis": "^170.1.0",
"lucide-react": "^0.454.0",
"next": "16.0.10",
"nodemailer": "^6.9.0",
"pg": "^8.13.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"tailwind-merge": "^2.5.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/nodemailer": "^6.4.0",
"@types/pg": "^8.11.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
}
}

2064
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

41
tsconfig.json Normal file
View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}