diff --git a/api/booking-sheet.js b/api/booking-sheet.js index e6f5d89..2c17d09 100644 --- a/api/booking-sheet.js +++ b/api/booking-sheet.js @@ -12,6 +12,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-shared': { @@ -202,6 +206,47 @@ async function parseBookingSheet() { return beds; } +/** + * Cached wrapper around parseBookingSheet(). + * Returns cached result if less than CACHE_TTL_MS old. + */ +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 days. + * @param {string|string[]} selectedDays - 'full-week' or array of ISO date strings + * @returns {object|null} Map of accommodation type → boolean (available), or null if sheet not configured + */ +async function checkAvailability(selectedDays) { + const beds = await getCachedBeds(); + if (!beds) return null; + + let dayHeaders; + if (selectedDays === 'full-week' || !selectedDays) { + dayHeaders = [...DAY_SHEET_HEADERS]; + } else { + dayHeaders = selectedDays.map(id => DAY_ID_TO_SHEET_HEADER[id]).filter(Boolean); + if (dayHeaders.length === 0) return null; + } + + const availability = {}; + for (const type of Object.keys(ACCOMMODATION_CRITERIA)) { + availability[type] = !!findAvailableBed(beds, type, dayHeaders); + } + return availability; +} + /** * Find an available bed matching the accommodation criteria. * A bed is "available" if ALL selected day columns are empty. @@ -299,6 +344,10 @@ async function assignBooking(guestName, accommodationType, selectedDays) { }, }); + // 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 ${dayHeaders.length} days`); return { @@ -313,4 +362,4 @@ async function assignBooking(guestName, accommodationType, selectedDays) { } } -module.exports = { assignBooking, parseBookingSheet, findAvailableBed, ACCOMMODATION_CRITERIA }; +module.exports = { assignBooking, parseBookingSheet, findAvailableBed, checkAvailability, ACCOMMODATION_CRITERIA }; diff --git a/index.html b/index.html index 53d31ea..ae127df 100644 --- a/index.html +++ b/index.html @@ -1091,6 +1091,31 @@ margin-top: 2px; } + .accom-card.sold-out { + opacity: 0.5; + pointer-events: none; + position: relative; + } + + .accom-card.sold-out::after { + content: 'Sold Out'; + position: absolute; + top: 50%; + right: 1.25rem; + transform: translateY(-50%); + background: rgba(255, 0, 110, 0.15); + border: 1px solid rgba(255, 0, 110, 0.4); + color: var(--accent-magenta); + font-size: 0.75rem; + font-weight: 600; + padding: 0.2rem 0.6rem; + border-radius: 6px; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .accom-card.sold-out .accom-price { display: none; } + /* Day selection */ .fullweek-btn { display: block; @@ -2500,6 +2525,9 @@ }); updatePriceSummary(); + + // Fetch availability when days change + if (selectedDays) fetchAndApplyAvailability(); } btnFullWeek.addEventListener('click', () => { @@ -2518,6 +2546,32 @@ }); }); + // Fetch and apply accommodation availability + async function fetchAndApplyAvailability() { + if (!selectedDays) return; + const daysParam = isFullWeek ? 'full-week' : selectedDays.join(','); + try { + const res = await fetch(`/api/accommodation-availability?days=${encodeURIComponent(daysParam)}`); + if (!res.ok) return; + const availability = await res.json(); + document.querySelectorAll('.accom-card[data-type]').forEach(card => { + const type = card.dataset.type; + if (type in availability && !availability[type]) { + card.classList.add('sold-out'); + card.classList.remove('selected'); + if (selectedAccom === type) { + selectedAccom = null; + updatePriceSummary(); + } + } else { + card.classList.remove('sold-out'); + } + }); + } catch (e) { + // Network error — leave all cards enabled + } + } + // ===== Accommodation toggle (yes/no) ===== const accomCards = document.getElementById('accom-cards'); document.querySelectorAll('.accom-toggle-btn').forEach(btn => { @@ -2539,6 +2593,7 @@ // Accommodation card selection document.querySelectorAll('.accom-card').forEach(card => { card.addEventListener('click', () => { + if (card.classList.contains('sold-out')) return; document.querySelectorAll('.accom-card').forEach(c => c.classList.remove('selected')); card.classList.add('selected'); selectedAccom = card.dataset.type; diff --git a/pay.html b/pay.html index bd90bf4..5254491 100644 --- a/pay.html +++ b/pay.html @@ -379,6 +379,31 @@ #hh-section { display: block; } #hh-section.hidden { display: none; } + + .accom-card.sold-out { + opacity: 0.5; + pointer-events: none; + position: relative; + } + + .accom-card.sold-out::after { + content: 'Sold Out'; + position: absolute; + top: 50%; + right: 1.25rem; + transform: translateY(-50%); + background: rgba(255, 0, 110, 0.15); + border: 1px solid rgba(255, 0, 110, 0.4); + color: var(--accent-pink); + font-size: 0.75rem; + font-weight: 600; + padding: 0.2rem 0.6rem; + border-radius: 6px; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .accom-card.sold-out .accom-price { display: none; } @@ -674,8 +699,33 @@ }); }); + // Fetch and apply accommodation availability + async function fetchAndApplyAvailability() { + const daysParam = isFullWeek ? 'full-week' : selectedDays.join(','); + try { + const res = await fetch(`/api/accommodation-availability?days=${encodeURIComponent(daysParam)}`); + if (!res.ok) return; // fail silently — all cards stay enabled + const availability = await res.json(); + document.querySelectorAll('.accom-card[data-type]').forEach(card => { + const type = card.dataset.type; + if (type in availability && !availability[type]) { + card.classList.add('sold-out'); + card.classList.remove('selected'); + if (selectedAccom === type) { + selectedAccom = null; + updatePriceSummary(); + } + } else { + card.classList.remove('sold-out'); + } + }); + } catch (e) { + // Network error — leave all cards enabled + } + } + // Continue to accommodation - btnContinueDays.addEventListener('click', () => { + btnContinueDays.addEventListener('click', async () => { stateDays.style.display = 'none'; statePayment.style.display = 'block'; @@ -707,6 +757,9 @@ }); updatePriceSummary(); + + // Fetch availability after UI is set up + fetchAndApplyAvailability(); }); // ===== Accommodation toggle ===== @@ -730,6 +783,7 @@ // Accommodation card selection document.querySelectorAll('.accom-card').forEach(card => { card.addEventListener('click', () => { + if (card.classList.contains('sold-out')) return; document.querySelectorAll('.accom-card').forEach(c => c.classList.remove('selected')); card.classList.add('selected'); selectedAccom = card.dataset.type; diff --git a/server.js b/server.js index 9de96e0..20073c7 100644 --- a/server.js +++ b/server.js @@ -5,7 +5,7 @@ const { google } = require('googleapis'); const nodemailer = require('nodemailer'); const { Pool } = require('pg'); const { createMollieClient } = require('@mollie/api-client'); -const { assignBooking } = require('./api/booking-sheet'); +const { assignBooking, checkAvailability } = require('./api/booking-sheet'); const app = express(); const PORT = process.env.PORT || 3000; @@ -747,6 +747,39 @@ app.post('/api/create-checkout-session', async (req, res) => { } }); +// Send overbooking alert email when bed assignment fails after payment +const BOOKING_ALERT_EMAIL = process.env.BOOKING_ALERT_EMAIL || 'jeff@jeffemmett.com'; +async function sendOverbookingAlert(registration, bookingResult) { + if (!smtp) return; + const accomLabel = ACCOMMODATION_OPTIONS[registration.accommodationType]?.label || registration.accommodationType; + try { + await smtp.sendMail({ + from: process.env.EMAIL_FROM || 'WORLDPLAY ', + to: BOOKING_ALERT_EMAIL, + subject: `⚠️ WORLDPLAY — Bed Assignment Failed: ${registration.firstName} ${registration.lastName}`, + html: ` +
+

Accommodation Overbooking Alert

+

A guest has paid for accommodation but no beds were available for assignment. Manual intervention required.

+ + + + + + + +
Guest:${registration.firstName} ${registration.lastName}
Email:${registration.email}
Requested:${accomLabel}
Days:${registration.selectedDays === 'full-week' ? 'Full week' : (registration.selectedDays || []).join(', ')}
Reason:${bookingResult.reason || 'No available bed matching criteria'}
Paid:${registration.paidAt || 'Just now'}
+

+ Action needed: Contact the guest to discuss alternative accommodation options, or manually assign a bed on the booking sheet. +

+
`, + }); + console.log(`Overbooking alert sent to ${BOOKING_ALERT_EMAIL} for ${registration.email}`); + } catch (err) { + console.error('Failed to send overbooking alert:', err.message); + } +} + // Mollie webhook — called when payment status changes app.post('/api/mollie/webhook', async (req, res) => { if (!mollieClient) { @@ -800,6 +833,7 @@ app.post('/api/mollie/webhook', async (req, res) => { console.log(`Bed assigned for ${registration.email}: ${bookingResult.venue} Room ${bookingResult.room}`); } else { console.warn(`Bed assignment failed for ${registration.email}: ${bookingResult.reason}`); + sendOverbookingAlert(registration, bookingResult).catch(err => console.error('Alert email error:', err)); } } @@ -956,6 +990,32 @@ app.get('/api/lookup-registration', async (req, res) => { } }); +// Accommodation availability check +app.get('/api/accommodation-availability', async (req, res) => { + try { + const daysParam = (req.query.days || '').trim(); + let selectedDays; + if (!daysParam || daysParam === 'full-week') { + selectedDays = 'full-week'; + } else { + selectedDays = daysParam.split(',').filter(d => VALID_DAY_IDS.has(d)); + if (selectedDays.length === 0) { + return res.status(400).json({ error: 'No valid days provided' }); + } + } + + const availability = await checkAvailability(selectedDays); + 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' }); + } +}); + // Complete payment page app.get('/pay', (req, res) => { res.sendFile(path.join(__dirname, 'pay.html'));