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:
commit
84b4e78a7e
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/register")
|
||||||
|
}
|
||||||
|
|
@ -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'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'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 (0→A, 25→Z, 26→AA)
|
||||||
|
*/
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(" + ")}`
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue