feat: dynamic accommodation availability + sold-out UI
- Add /api/accommodation-availability endpoint with 2-min cache + room filtering - 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 <noreply@anthropic.com>
This commit is contained in:
parent
d6e33f12e3
commit
ebd22ccb91
|
|
@ -0,0 +1,145 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { getGoogleSheetsClient } from "@/lib/google-sheets"
|
||||
|
||||
const BOOKING_SHEET_ID = process.env.BOOKING_SHEET_ID
|
||||
const BOOKING_SHEET_NAME = process.env.BOOKING_SHEET_NAME || "Sheet1"
|
||||
|
||||
// Accommodation criteria (mirrors booking-sheet.ts)
|
||||
const ACCOMMODATION_CRITERIA: Record<
|
||||
string,
|
||||
{ venue: string; bedTypes: string[]; roomFilter?: (room: string) => boolean }
|
||||
> = {
|
||||
"ch-multi": {
|
||||
venue: "Commons Hub",
|
||||
bedTypes: ["bunk up", "bunk down", "single"],
|
||||
roomFilter: (room) => {
|
||||
const num = parseInt(room, 10)
|
||||
return num >= 5 && num <= 9
|
||||
},
|
||||
},
|
||||
"ch-double": {
|
||||
venue: "Commons Hub",
|
||||
bedTypes: ["double", "double (shared)"],
|
||||
roomFilter: (room) => room === "2",
|
||||
},
|
||||
}
|
||||
|
||||
// In-memory cache (2-min TTL)
|
||||
let cache: { data: Record<string, boolean> | null; timestamp: number } = { data: null, timestamp: 0 }
|
||||
const CACHE_TTL_MS = 2 * 60 * 1000
|
||||
|
||||
interface BedRow {
|
||||
venue: string
|
||||
room: string
|
||||
bedType: string
|
||||
occupied: boolean
|
||||
}
|
||||
|
||||
function parseBedsFromSheet(data: string[][]): BedRow[] {
|
||||
const beds: BedRow[] = []
|
||||
let currentVenue = ""
|
||||
let roomCol = -1
|
||||
let bedTypeCol = -1
|
||||
let dateColumnIndices: number[] = []
|
||||
let lastRoom = ""
|
||||
let inDataSection = false
|
||||
|
||||
for (const row of data) {
|
||||
if (!row || row.length === 0) {
|
||||
inDataSection = false
|
||||
continue
|
||||
}
|
||||
|
||||
const firstCell = (row[0] || "").trim()
|
||||
if (firstCell.toLowerCase().includes("commons hub")) {
|
||||
currentVenue = "Commons Hub"
|
||||
inDataSection = false
|
||||
lastRoom = ""
|
||||
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) {
|
||||
roomCol = roomIdx
|
||||
bedTypeCol = bedIdx
|
||||
dateColumnIndices = []
|
||||
for (let j = bedIdx + 1; j < row.length; j++) {
|
||||
if ((row[j] || "").trim()) dateColumnIndices.push(j)
|
||||
}
|
||||
inDataSection = true
|
||||
lastRoom = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if (inDataSection && roomCol !== -1 && bedTypeCol !== -1) {
|
||||
let bedType = (row[bedTypeCol] || "").trim().toLowerCase()
|
||||
if (!bedType) continue
|
||||
if (bedType.includes("(") && !bedType.includes(")")) bedType += ")"
|
||||
|
||||
const roomValue = (row[roomCol] || "").trim()
|
||||
if (roomValue) lastRoom = roomValue
|
||||
if (!lastRoom) continue
|
||||
|
||||
const occupied = dateColumnIndices.some((colIdx) => (row[colIdx] || "").trim().length > 0)
|
||||
|
||||
beds.push({ venue: currentVenue, room: lastRoom, bedType, occupied })
|
||||
}
|
||||
}
|
||||
|
||||
return beds
|
||||
}
|
||||
|
||||
async function checkAvailability(): Promise<Record<string, boolean> | 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<string, boolean> = {}
|
||||
for (const [type, criteria] of Object.entries(ACCOMMODATION_CRITERIA)) {
|
||||
availability[type] = beds.some(
|
||||
(bed) =>
|
||||
bed.venue === criteria.venue &&
|
||||
criteria.bedTypes.includes(bed.bedType) &&
|
||||
!bed.occupied &&
|
||||
(!criteria.roomFilter || criteria.roomFilter(bed.room))
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import { Textarea } from "@/components/ui/textarea"
|
|||
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"
|
||||
|
||||
// ── Pricing & accommodation constants ─────────────────────────
|
||||
|
||||
|
|
@ -65,6 +65,25 @@ export default function RegisterForm({ tierOverride, promoCode, banner }: Regist
|
|||
crewConsent: "",
|
||||
})
|
||||
|
||||
const [availability, setAvailability] = useState<Record<string, boolean>>({})
|
||||
|
||||
// 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, switch to first available
|
||||
if (data[accommodationType] === false) {
|
||||
const firstAvailable = Object.keys(ACCOMMODATION_PRICES).find((k) => data[k] !== false)
|
||||
if (firstAvailable) setAccommodationType(firstAvailable)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const currentTier = tierOverride ?? getCurrentTier()
|
||||
const baseTicketPrice = currentTier.price
|
||||
|
||||
|
|
@ -211,16 +230,24 @@ export default function RegisterForm({ tierOverride, promoCode, banner }: Regist
|
|||
onValueChange={setAccommodationType}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="ch-multi" id="ch-multi" />
|
||||
<Label htmlFor="ch-multi" className="font-normal cursor-pointer text-sm">
|
||||
Bed in shared room — €279.30 (€39.90/night)
|
||||
<div className={`flex items-center space-x-2 ${availability["ch-multi"] === false ? "opacity-50" : ""}`}>
|
||||
<RadioGroupItem value="ch-multi" id="ch-multi" disabled={availability["ch-multi"] === false} />
|
||||
<Label htmlFor="ch-multi" className={`font-normal text-sm ${availability["ch-multi"] === false ? "cursor-not-allowed" : "cursor-pointer"}`}>
|
||||
Bed in shared room — {availability["ch-multi"] === false ? (
|
||||
<span className="text-destructive font-medium">Sold Out</span>
|
||||
) : (
|
||||
<>€279.30 (€39.90/night)</>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="ch-double" id="ch-double" />
|
||||
<Label htmlFor="ch-double" className="font-normal cursor-pointer text-sm">
|
||||
Bed in double room — €356.30 (€50.90/night)
|
||||
<div className={`flex items-center space-x-2 ${availability["ch-double"] === false ? "opacity-50" : ""}`}>
|
||||
<RadioGroupItem value="ch-double" id="ch-double" disabled={availability["ch-double"] === false} />
|
||||
<Label htmlFor="ch-double" className={`font-normal text-sm ${availability["ch-double"] === false ? "cursor-not-allowed" : "cursor-pointer"}`}>
|
||||
Bed in double room — {availability["ch-double"] === false ? (
|
||||
<span className="text-destructive font-medium">Sold Out</span>
|
||||
) : (
|
||||
<>€356.30 (€50.90/night)</>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const EMAIL_FROM =
|
|||
process.env.EMAIL_FROM ||
|
||||
"Crypto Commons Gathering <newsletter@cryptocommonsgather.ing>"
|
||||
|
||||
const INTERNAL_NOTIFY_EMAIL = "contact@cryptocommonsgather.ing"
|
||||
const INTERNAL_NOTIFY_EMAIL = process.env.INTERNAL_NOTIFY_EMAIL || "jeff@jeffemmett.com"
|
||||
|
||||
interface BookingNotificationData {
|
||||
guestName: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue