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