feat: add Google Sheets registration pipeline

- Add googleapis for Google Sheets integration
- Create /api/register endpoint to record registrations with "Pending" status
- Update webhook to mark registrations as "Paid" when payment completes
- Add lib/google-sheets.ts with addRegistration and updatePaymentStatus functions
- Update docker-compose.yml with Google Sheets env vars
- Add .env.example documenting required environment variables

Flow: Form submit → Sheet (Pending) → Payment → Webhook → Sheet (Paid)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-25 09:43:18 +01:00
parent f2736fc3d3
commit 3091c01819
9 changed files with 1220 additions and 30 deletions

17
.env.example Normal file
View File

@ -0,0 +1,17 @@
# Stripe Payment Integration
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
# Public URL (used for Stripe redirect URLs)
NEXT_PUBLIC_BASE_URL=https://cryptocommonsgather.ing
# Google Sheets Integration
# Service account key JSON (paste as single line, or use escaped JSON)
GOOGLE_SERVICE_ACCOUNT_KEY={"type":"service_account","project_id":"...","private_key_id":"...","private_key":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n","client_email":"...@....iam.gserviceaccount.com","client_id":"...","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"..."}
# Google Sheet ID (from the URL: https://docs.google.com/spreadsheets/d/SHEET_ID/edit)
GOOGLE_SHEET_ID=your-google-sheet-id-here
# Optional: Sheet tab name (defaults to "Registrations")
GOOGLE_SHEET_NAME=Registrations

View File

@ -1,9 +1,17 @@
import { type NextRequest, NextResponse } from "next/server"
import Stripe from "stripe"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// Lazy initialization to avoid build-time errors
let stripe: Stripe | null = null
function getStripe() {
if (!stripe) {
stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
})
}
return stripe
}
// Dynamic pricing configuration (in EUR cents)
const TICKET_PRICE_CENTS = 8000 // €80 early bird
@ -42,7 +50,7 @@ export async function POST(request: NextRequest) {
paymentMethodTypes = ["customer_balance"]
}
const session = await stripe.checkout.sessions.create({
const session = await getStripe().checkout.sessions.create({
payment_method_types: paymentMethodTypes,
line_items: lineItems,
mode: "payment",

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

@ -0,0 +1,53 @@
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, contact, contributions, expectations, howHeard, dietary, crewConsent } = body
// Validate required fields
if (!name || !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,
contact,
contributions,
expectations,
howHeard: howHeard || "",
dietary: Array.isArray(dietary) ? dietary.join(", ") : dietary || "",
crewConsent,
})
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 }
)
}

View File

@ -1,11 +1,22 @@
import { type NextRequest, NextResponse } from "next/server"
import Stripe from "stripe"
import { updatePaymentStatus } from "@/lib/google-sheets"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// Lazy initialization to avoid build-time errors
let stripe: Stripe | null = null
function getStripe() {
if (!stripe) {
stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
})
}
return stripe
}
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
function getWebhookSecret() {
return process.env.STRIPE_WEBHOOK_SECRET!
}
export async function POST(request: NextRequest) {
try {
@ -15,9 +26,9 @@ export async function POST(request: NextRequest) {
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
event = getStripe().webhooks.constructEvent(body, signature, getWebhookSecret())
} catch (err) {
console.error("[v0] Webhook signature verification failed:", err)
console.error("[Webhook] Signature verification failed:", err)
return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
}
@ -25,32 +36,64 @@ export async function POST(request: NextRequest) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session
console.log("[v0] Payment successful:", session.id)
console.log("[Webhook] Payment successful:", session.id)
// Here you would:
// 1. Store the registration in your database
// 2. Send confirmation email
// 3. Add to attendee list
// Extract registration data from metadata
const metadata = session.metadata || {}
const customerEmail = session.customer_details?.email || ""
// Update Google Sheet with payment confirmation
const updated = await updatePaymentStatus({
name: metadata.name || "",
email: customerEmail,
stripeSessionId: session.id,
paymentStatus: "Paid",
paymentMethod: session.payment_method_types?.[0] || "unknown",
amountPaid: session.amount_total
? `${(session.amount_total / 100).toFixed(2)}`
: "",
paymentDate: new Date().toISOString(),
})
if (updated) {
console.log(`[Webhook] Google Sheet updated for ${metadata.name}`)
} else {
console.error(`[Webhook] Failed to update Google Sheet for ${metadata.name}`)
}
// TODO: Send confirmation email
break
}
case "payment_intent.succeeded": {
const paymentIntent = event.data.object as Stripe.PaymentIntent
console.log("[v0] PaymentIntent successful:", paymentIntent.id)
console.log("[Webhook] PaymentIntent successful:", paymentIntent.id)
break
}
case "payment_intent.payment_failed": {
const paymentIntent = event.data.object as Stripe.PaymentIntent
console.error("[v0] Payment failed:", paymentIntent.id)
console.error("[Webhook] Payment failed:", paymentIntent.id)
// Optionally update sheet to mark as failed
const failedMetadata = paymentIntent.metadata || {}
if (failedMetadata.name) {
await updatePaymentStatus({
name: failedMetadata.name,
stripeSessionId: paymentIntent.id,
paymentStatus: "Failed",
paymentDate: new Date().toISOString(),
})
}
break
}
default:
console.log("[v0] Unhandled event type:", event.type)
console.log("[Webhook] Unhandled event type:", event.type)
}
return NextResponse.json({ received: true })
} catch (err) {
console.error("[v0] Webhook error:", err)
console.error("[Webhook] Error:", err)
return NextResponse.json({ error: "Webhook error" }, { status: 500 })
}
}

View File

@ -14,6 +14,7 @@ import { useState } from "react"
export default function RegisterPage() {
const [step, setStep] = useState<"form" | "payment">("form")
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: "",
contact: "",
@ -26,7 +27,7 @@ export default function RegisterPage() {
})
const baseTicketPrice = 80 // Early bird price €80
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validate required fields
if (
@ -39,7 +40,43 @@ export default function RegisterPage() {
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,
contact: formData.contact,
contributions: formData.contributions,
expectations: formData.expectations,
howHeard: formData.howHeard,
dietary: dietaryString,
crewConsent: formData.crewConsent,
}),
})
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) => {
@ -455,8 +492,8 @@ export default function RegisterPage() {
</div>
<div className="pt-4">
<Button type="submit" className="w-full" size="lg">
Continue to Payment
<Button type="submit" className="w-full" size="lg" disabled={isSubmitting}>
{isSubmitting ? "Recording registration..." : "Continue to Payment"}
</Button>
</div>

View File

@ -8,6 +8,10 @@ services:
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://cryptocommonsgather.ing}
- GOOGLE_SERVICE_ACCOUNT_KEY=${GOOGLE_SERVICE_ACCOUNT_KEY}
- GOOGLE_SHEET_ID=${GOOGLE_SHEET_ID}
- GOOGLE_SHEET_NAME=${GOOGLE_SHEET_NAME:-Registrations}
labels:
- "traefik.enable=true"
- "traefik.http.routers.ccg.rule=Host(`cryptocommonsgather.ing`) || Host(`www.cryptocommonsgather.ing`)"

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

@ -0,0 +1,201 @@
import { google } from "googleapis"
// Initialize Google Sheets API
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
}
export interface PaymentUpdateData {
email?: string
name?: string
stripeSessionId: string
paymentStatus: "Paid" | "Failed"
paymentMethod?: string
amountPaid?: string
paymentDate?: 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: Stripe Session ID
"", // M: Amount Paid
"", // N: Payment Date
],
]
const response = await sheets.spreadsheets.values.append({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A:N`,
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:N`,
})
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 updateRange = `${SHEET_NAME}!C${targetRowIndex}:N${targetRowIndex}`
const updateValues = [
[
data.email || rows[targetRowIndex - 1][2] || "", // C: Email (from Stripe or existing)
rows[targetRowIndex - 1][3], // D: Contact (preserve)
rows[targetRowIndex - 1][4], // E: Contributions (preserve)
rows[targetRowIndex - 1][5], // F: Expectations (preserve)
rows[targetRowIndex - 1][6], // G: How Heard (preserve)
rows[targetRowIndex - 1][7], // H: Dietary (preserve)
rows[targetRowIndex - 1][8], // I: Crew Consent (preserve)
data.paymentStatus, // J: Payment Status
data.paymentMethod || "", // K: Payment Method
data.stripeSessionId, // L: Stripe Session ID
data.amountPaid || "", // M: Amount Paid
data.paymentDate || new Date().toISOString(), // N: Payment Date
],
]
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:N1`,
})
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",
"Stripe Session ID",
"Amount Paid",
"Payment Date",
],
]
await sheets.spreadsheets.values.update({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A1:N1`,
valueInputOption: "USER_ENTERED",
requestBody: { values: headers },
})
console.log("[Google Sheets] Initialized sheet with headers")
}
} catch (error) {
console.error("[Google Sheets] Error initializing headers:", error)
}
}

838
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -44,6 +44,7 @@
"cmdk": "1.0.4",
"date-fns": "4.1.0",
"embla-carousel-react": "8.5.1",
"googleapis": "^170.1.0",
"input-otp": "1.4.1",
"lucide-react": "^0.454.0",
"next": "16.0.10",