128 lines
4.9 KiB
TypeScript
128 lines
4.9 KiB
TypeScript
import { type NextRequest, NextResponse } from "next/server"
|
|
import createMollieClient from "@mollie/api-client"
|
|
|
|
// Lazy initialization to avoid build-time errors
|
|
let mollieClient: ReturnType<typeof createMollieClient> | null = null
|
|
|
|
function getMollie() {
|
|
if (!mollieClient) {
|
|
mollieClient = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY! })
|
|
}
|
|
return mollieClient
|
|
}
|
|
|
|
// Dynamic pricing tiers (in EUR)
|
|
const PRICING_TIERS = [
|
|
{ label: "Early bird", price: 80, cutoff: "2026-03-31" },
|
|
{ label: "Regular", price: 120, cutoff: "2026-07-01" },
|
|
{ label: "Late", price: 150, cutoff: "2099-12-31" },
|
|
]
|
|
|
|
// Promo codes — map code → tier label
|
|
const PROMO_CODES: Record<string, string> = {
|
|
"earlybird-friends": "Early bird",
|
|
}
|
|
|
|
function getCurrentTicketPrice(): number {
|
|
const now = new Date().toISOString().slice(0, 10)
|
|
const tier = PRICING_TIERS.find((t) => now < t.cutoff) ?? PRICING_TIERS[PRICING_TIERS.length - 1]
|
|
return tier.price
|
|
}
|
|
|
|
function getTicketPriceForPromo(code: string): number | null {
|
|
const tierLabel = PROMO_CODES[code]
|
|
if (!tierLabel) return null
|
|
const tier = PRICING_TIERS.find((t) => t.label === tierLabel)
|
|
return tier?.price ?? null
|
|
}
|
|
|
|
const PROCESSING_FEE_PERCENT = 0.02 // 2% to cover Mollie payment processing fees
|
|
|
|
// Accommodation prices per person for 7 nights
|
|
const ACCOMMODATION_PRICES: Record<string, { label: string; price: number }> = {
|
|
"ch-multi": { label: "Bed in shared room (Commons Hub)", price: 279.30 },
|
|
"ch-double": { label: "Bed in double room (Commons Hub)", price: 356.30 },
|
|
"hh-single": { label: "Single room (Herrnhof)", price: 665 },
|
|
"hh-double-separate": { label: "Double room, separate beds (Herrnhof)", price: 420 },
|
|
"hh-double-shared": { label: "Double room, shared double bed (Herrnhof)", price: 350 },
|
|
"hh-triple": { label: "Triple room (Herrnhof)", price: 350 },
|
|
"hh-daybed": { label: "Daybed or extra bed in living room (Herrnhof)", price: 280 },
|
|
}
|
|
|
|
// Public base URL
|
|
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "https://cryptocommonsgather.ing"
|
|
|
|
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) || "ch-multi"
|
|
|
|
const registrationData = registrationDataStr ? JSON.parse(registrationDataStr) : null
|
|
|
|
// Calculate subtotal — check for valid promo code override
|
|
const promoCode = (formData.get("promoCode") as string) || ""
|
|
const promoPrice = promoCode ? getTicketPriceForPromo(promoCode) : null
|
|
const ticketPrice = promoPrice ?? getCurrentTicketPrice()
|
|
let subtotal = ticketPrice
|
|
const descriptionParts = [`CCG 2026 Ticket (€${ticketPrice})`]
|
|
|
|
if (includeAccommodation) {
|
|
const accom = ACCOMMODATION_PRICES[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: `CCG 2026 Registration — ${descriptionParts.join(" + ")}`,
|
|
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 },
|
|
)
|
|
}
|