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:
parent
f2736fc3d3
commit
3091c01819
|
|
@ -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
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
import { type NextRequest, NextResponse } from "next/server"
|
||||
import Stripe from "stripe"
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2024-12-18.acacia",
|
||||
})
|
||||
// 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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
|
|
@ -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!, {
|
||||
apiVersion: "2024-12-18.acacia",
|
||||
})
|
||||
// Lazy initialization to avoid build-time errors
|
||||
let stripe: Stripe | null = null
|
||||
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
|
||||
function getStripe() {
|
||||
if (!stripe) {
|
||||
stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2024-12-18.acacia",
|
||||
})
|
||||
}
|
||||
return stripe
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
setStep("payment")
|
||||
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue