diff --git a/api/booking-sheet.js b/api/booking-sheet.js index 7657d09..1d873f7 100644 --- a/api/booking-sheet.js +++ b/api/booking-sheet.js @@ -6,6 +6,10 @@ const fs = require('fs'); let sheetsClient = null; +// In-memory cache for parsed booking sheet (2-min TTL) +let sheetCache = { data: null, timestamp: 0 }; +const CACHE_TTL_MS = 2 * 60 * 1000; + // Accommodation criteria mapping — maps accommodation_type to sheet matching rules const ACCOMMODATION_CRITERIA = { 'ch-multi': { @@ -164,6 +168,38 @@ async function parseBookingSheet() { return beds; } +/** + * Cached wrapper around parseBookingSheet(). + */ +async function getCachedBeds() { + const now = Date.now(); + if (sheetCache.data && (now - sheetCache.timestamp) < CACHE_TTL_MS) { + return sheetCache.data; + } + const beds = await parseBookingSheet(); + if (beds) { + sheetCache.data = beds; + sheetCache.timestamp = now; + } + return beds; +} + +/** + * Check availability of all accommodation types for the given weeks. + * @param {string[]} selectedWeeks - e.g. ['week1', 'week2'] or ['week1','week2','week3','week4'] for full month + * @returns {object|null} Map of accommodation type → boolean (available) + */ +async function checkAvailability(selectedWeeks) { + const beds = await getCachedBeds(); + if (!beds) return null; + + const availability = {}; + for (const type of Object.keys(ACCOMMODATION_CRITERIA)) { + availability[type] = !!findAvailableBed(beds, type, selectedWeeks); + } + return availability; +} + /** * Find an available bed matching the accommodation criteria for the given weeks. * A bed is "available" only if ALL requested week columns are empty. @@ -257,6 +293,10 @@ async function assignBooking(guestName, accommodationType, selectedWeeks) { }); } + // Invalidate cache after successful assignment + sheetCache.data = null; + sheetCache.timestamp = 0; + console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for weeks: ${selectedWeeks.join(', ')}`); return { @@ -271,4 +311,4 @@ async function assignBooking(guestName, accommodationType, selectedWeeks) { } } -module.exports = { assignBooking, parseBookingSheet, findAvailableBed, ACCOMMODATION_CRITERIA }; +module.exports = { assignBooking, parseBookingSheet, findAvailableBed, checkAvailability, ACCOMMODATION_CRITERIA }; diff --git a/api/mollie.js b/api/mollie.js index c1255d9..7654dc9 100644 --- a/api/mollie.js +++ b/api/mollie.js @@ -371,9 +371,10 @@ async function handleWebhook(req, res) { }; try { + const bookingAlertEmail = process.env.BOOKING_ALERT_EMAIL || 'jeff@jeffemmett.com'; await smtp.sendMail({ from: process.env.EMAIL_FROM || 'Valley of the Commons ', - to: 'team@valleyofthecommons.com', + to: bookingAlertEmail, subject: bookingNotification.subject, html: bookingNotification.html, }); diff --git a/apply.html b/apply.html index bca26d9..0a40726 100644 --- a/apply.html +++ b/apply.html @@ -177,6 +177,28 @@ .week-card .dates { font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; } .week-card .desc { font-size: 0.85rem; color: #555; } + .week-card.sold-out { + opacity: 0.5; + pointer-events: none; + position: relative; + } + + .week-card.sold-out::after { + content: 'Sold Out'; + position: absolute; + top: 1rem; + right: 1rem; + background: rgba(200, 50, 50, 0.12); + border: 1px solid rgba(200, 50, 50, 0.4); + color: #c83232; + font-size: 0.75rem; + font-weight: 600; + padding: 0.2rem 0.6rem; + border-radius: 6px; + text-transform: uppercase; + letter-spacing: 0.05em; + } + /* Select-all card */ .select-all-card { border-style: dashed; @@ -1329,6 +1351,34 @@ } // ===== Week selection ===== + // Fetch and apply accommodation availability based on selected weeks + async function fetchAndApplyAvailability() { + const weeks = getSelectedWeeks(); + if (weeks.length === 0) return; + try { + const res = await fetch(`/api/accommodation-availability?weeks=${encodeURIComponent(weeks.join(','))}`); + if (!res.ok) return; + const availability = await res.json(); + document.querySelectorAll('input[name="room_type"]').forEach(radio => { + const type = radio.value; + const card = radio.closest('.week-card'); + if (type in availability && !availability[type]) { + card.classList.add('sold-out'); + card.classList.remove('selected'); + radio.checked = false; + if (document.getElementById('accommodation_type').value === type) { + document.getElementById('accommodation_type').value = ''; + updatePriceSummary(); + } + } else { + card.classList.remove('sold-out'); + } + }); + } catch (e) { + // Network error — leave all options enabled + } + } + function toggleWeek(card) { const cb = card.querySelector('input'); cb.checked = !cb.checked; @@ -1336,6 +1386,8 @@ syncSelectAll(); updatePriceSummary(); saveFormData(); + // Re-check availability when weeks change + if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability(); } function toggleAllWeeks(card) { @@ -1350,6 +1402,7 @@ }); updatePriceSummary(); saveFormData(); + if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability(); } function syncSelectAll() { @@ -1376,6 +1429,8 @@ document.querySelectorAll('input[name="room_type"]').forEach(r => { r.checked = false; r.closest('.week-card').classList.remove('selected'); }); document.getElementById('ch-rooms').style.display = 'none'; document.getElementById('hh-rooms').style.display = 'none'; + } else { + fetchAndApplyAvailability(); } } @@ -1402,6 +1457,10 @@ } function selectRoom(roomType) { + // Prevent selecting sold-out rooms + const radio = document.querySelector(`input[name="room_type"][value="${roomType}"]`); + if (radio && radio.closest('.week-card').classList.contains('sold-out')) return; + document.querySelectorAll('input[name="room_type"]').forEach(r => { r.checked = (r.value === roomType); r.closest('.week-card').classList.toggle('selected', r.checked); diff --git a/server.js b/server.js index 96091fd..2f58c48 100644 --- a/server.js +++ b/server.js @@ -50,6 +50,30 @@ app.post('/api/mollie/webhook', vercelToExpress(handleWebhook)); app.all('/api/mollie/status', vercelToExpress(getPaymentStatus)); app.get('/api/mollie/resume', vercelToExpress(resumePayment)); +// Accommodation availability check +const { checkAvailability } = require('./api/booking-sheet'); +const VALID_WEEKS = new Set(['week1', 'week2', 'week3', 'week4']); +app.get('/api/accommodation-availability', async (req, res) => { + try { + const weeksParam = (req.query.weeks || '').trim(); + if (!weeksParam) { + return res.status(400).json({ error: 'weeks parameter required (e.g. week1,week2)' }); + } + const selectedWeeks = weeksParam.split(',').filter(w => VALID_WEEKS.has(w)); + if (selectedWeeks.length === 0) { + return res.status(400).json({ error: 'No valid weeks provided' }); + } + const availability = await checkAvailability(selectedWeeks); + if (!availability) { + return res.status(503).json({ error: 'Booking sheet not configured' }); + } + res.json(availability); + } catch (error) { + console.error('Availability check error:', error); + res.status(500).json({ error: 'Failed to check availability' }); + } +}); + // Static files app.use(express.static(path.join(__dirname), { extensions: ['html'],