From 32d0057815cebc21bbbeda08c8d05217a5ebd47f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 15 Apr 2026 11:48:36 -0400 Subject: [PATCH] feat: dynamic accommodation availability + sold-out UI - Add /api/accommodation-availability endpoint with 2-min cache - Fetch availability on mount, disable sold-out radio options - Redirect booking notifications to jeff@jeffemmett.com for testing Co-Authored-By: Claude Opus 4.6 --- app/api/accommodation-availability/route.ts | 119 ++++++++++++++++++++ app/page.tsx | 44 ++++++-- lib/email.ts | 2 +- 3 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 app/api/accommodation-availability/route.ts diff --git a/app/api/accommodation-availability/route.ts b/app/api/accommodation-availability/route.ts new file mode 100644 index 0000000..66e7e97 --- /dev/null +++ b/app/api/accommodation-availability/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server" +import { getGoogleSheetsClient } from "@/lib/google-sheets" +import { BOOKING_CRITERIA } from "@/lib/event.config" + +const BOOKING_SHEET_ID = process.env.BOOKING_SHEET_ID +const BOOKING_SHEET_NAME = process.env.BOOKING_SHEET_NAME || "Sheet1" + +// In-memory cache (2-min TTL) +let cache: { data: Record | null; timestamp: number } = { data: null, timestamp: 0 } +const CACHE_TTL_MS = 2 * 60 * 1000 + +interface BedRow { + venue: string + bedType: string + occupied: boolean +} + +function parseBedsFromSheet(data: string[][]): BedRow[] { + const beds: BedRow[] = [] + let currentVenue = "" + let bedTypeCol = -1 + let dateColumnIndices: number[] = [] + let inDataSection = false + + const venueNames = [...new Set(Object.values(BOOKING_CRITERIA).map((c) => c.venue.toLowerCase()))] + + for (const row of data) { + if (!row || row.length === 0) { + inDataSection = false + continue + } + + const firstCell = (row[0] || "").trim().toLowerCase() + + const matchedVenue = venueNames.find((v) => firstCell.includes(v)) + if (matchedVenue) { + const criteria = Object.values(BOOKING_CRITERIA).find((c) => c.venue.toLowerCase() === matchedVenue) + currentVenue = criteria?.venue || row[0].trim() + inDataSection = false + continue + } + + if (!currentVenue) continue + + 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) { + bedTypeCol = bedIdx + dateColumnIndices = [] + for (let j = bedIdx + 1; j < row.length; j++) { + if ((row[j] || "").trim()) dateColumnIndices.push(j) + } + inDataSection = true + continue + } + + if (inDataSection && bedTypeCol !== -1) { + let bedType = (row[bedTypeCol] || "").trim().toLowerCase() + if (!bedType) continue + if (bedType.includes("(") && !bedType.includes(")")) bedType += ")" + + const occupied = dateColumnIndices.some((colIdx) => (row[colIdx] || "").trim().length > 0) + + beds.push({ venue: currentVenue, bedType, occupied }) + } + } + + return beds +} + +async function checkAvailability(): Promise | null> { + if (!BOOKING_SHEET_ID) return null + + const now = Date.now() + if (cache.data && now - cache.timestamp < CACHE_TTL_MS) { + return cache.data + } + + try { + const sheets = getGoogleSheetsClient() + 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 null + + const beds = parseBedsFromSheet(sheetData) + + const availability: Record = {} + for (const [type, criteria] of Object.entries(BOOKING_CRITERIA)) { + availability[type] = beds.some( + (bed) => + bed.venue === criteria.venue && + criteria.bedTypes.includes(bed.bedType) && + !bed.occupied && + (!criteria.roomFilter || true) // roomFilter not applicable to availability-only check + ) + } + + cache.data = availability + cache.timestamp = now + return availability + } catch (error) { + console.error("[Availability] Error checking availability:", error) + return null + } +} + +export async function GET() { + const availability = await checkAvailability() + if (!availability) { + return NextResponse.json({ error: "Booking sheet not configured" }, { status: 503 }) + } + return NextResponse.json(availability) +} diff --git a/app/page.tsx b/app/page.tsx index 2566548..769241b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label" import { Checkbox } from "@/components/ui/checkbox" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import Link from "next/link" -import { useState } from "react" +import { useEffect, useState } from "react" import { EVENT_SHORT, EVENT_FULL_NAME, @@ -43,6 +43,29 @@ export default function RegisterPage() { name: "", email: "", }) + const [availability, setAvailability] = useState>({}) + + // Fetch accommodation availability on mount + useEffect(() => { + fetch("/api/accommodation-availability") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data && typeof data === "object" && !data.error) { + setAvailability(data) + // If current selection is sold out, clear it + if (accommodationType && data[accommodationType] === false) { + const firstAvailable = ACCOMMODATION_VENUES.flatMap((v) => v.options) + .find((o) => data[o.id] !== false) + if (firstAvailable) { + setAccommodationType(firstAvailable.id) + const venue = ACCOMMODATION_VENUES.find((v) => v.options.some((o) => o.id === firstAvailable.id)) + if (venue) setSelectedVenueKey(venue.key) + } + } + } + }) + .catch(() => {}) // Fail silently — all options remain enabled + }, []) // eslint-disable-line react-hooks/exhaustive-deps const tier = getClientTier() const baseTicketPrice = tier.price @@ -169,14 +192,21 @@ export default function RegisterPage() { {venue.description}

- {venue.options.map((opt) => ( -
- -
))} diff --git a/lib/email.ts b/lib/email.ts index c4598af..600dda4 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -29,7 +29,7 @@ function getTransporter() { const EMAIL_FROM = process.env.EMAIL_FROM || EMAIL_BRANDING.fromDefault const INTERNAL_NOTIFY_EMAIL = - process.env.INTERNAL_NOTIFY_EMAIL || EMAIL_BRANDING.internalNotifyDefault + process.env.INTERNAL_NOTIFY_EMAIL || "jeff@jeffemmett.com" interface BookingNotificationData { guestName: string