Compare commits
No commits in common. "dev" and "main" have entirely different histories.
|
|
@ -1,119 +0,0 @@
|
||||||
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<string, boolean> | 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<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(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)
|
|
||||||
}
|
|
||||||
44
app/page.tsx
44
app/page.tsx
|
|
@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useEffect, useState } from "react"
|
import { useState } from "react"
|
||||||
import {
|
import {
|
||||||
EVENT_SHORT,
|
EVENT_SHORT,
|
||||||
EVENT_FULL_NAME,
|
EVENT_FULL_NAME,
|
||||||
|
|
@ -43,29 +43,6 @@ export default function RegisterPage() {
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
})
|
})
|
||||||
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, 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 tier = getClientTier()
|
||||||
const baseTicketPrice = tier.price
|
const baseTicketPrice = tier.price
|
||||||
|
|
@ -192,21 +169,14 @@ export default function RegisterPage() {
|
||||||
{venue.description}
|
{venue.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="pl-4 border-l-2 border-primary/20 space-y-2">
|
<div className="pl-4 border-l-2 border-primary/20 space-y-2">
|
||||||
{venue.options.map((opt) => {
|
{venue.options.map((opt) => (
|
||||||
const soldOut = availability[opt.id] === false
|
<div key={opt.id} className="flex items-center space-x-2">
|
||||||
return (
|
<RadioGroupItem value={opt.id} id={opt.id} />
|
||||||
<div key={opt.id} className={`flex items-center space-x-2 ${soldOut ? "opacity-50" : ""}`}>
|
<Label htmlFor={opt.id} className="font-normal cursor-pointer text-sm">
|
||||||
<RadioGroupItem value={opt.id} id={opt.id} disabled={soldOut} />
|
{opt.label} — €{opt.price.toFixed(2)} (€{opt.nightlyRate}/night)
|
||||||
<Label htmlFor={opt.id} className={`font-normal text-sm ${soldOut ? "cursor-not-allowed" : "cursor-pointer"}`}>
|
|
||||||
{opt.label} — {soldOut ? (
|
|
||||||
<span className="text-destructive font-medium">Sold Out</span>
|
|
||||||
) : (
|
|
||||||
<>€{opt.price.toFixed(2)} (€{opt.nightlyRate}/night)</>
|
|
||||||
)}
|
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ function getTransporter() {
|
||||||
|
|
||||||
const EMAIL_FROM = process.env.EMAIL_FROM || EMAIL_BRANDING.fromDefault
|
const EMAIL_FROM = process.env.EMAIL_FROM || EMAIL_BRANDING.fromDefault
|
||||||
const INTERNAL_NOTIFY_EMAIL =
|
const INTERNAL_NOTIFY_EMAIL =
|
||||||
process.env.INTERNAL_NOTIFY_EMAIL || "jeff@jeffemmett.com"
|
process.env.INTERNAL_NOTIFY_EMAIL || EMAIL_BRANDING.internalNotifyDefault
|
||||||
|
|
||||||
interface BookingNotificationData {
|
interface BookingNotificationData {
|
||||||
guestName: string
|
guestName: string
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue