feat: dynamic accommodation availability + overbooking alerts
- Add checkAvailability() with 2-min cache to booking-sheet.js - Add GET /api/accommodation-availability endpoint (weeks-based) - Fetch availability on accommodation toggle/week change, disable sold-out rooms - 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
ccc79c0489
commit
78b2ba0499
|
|
@ -6,6 +6,10 @@ const fs = require('fs');
|
||||||
|
|
||||||
let sheetsClient = null;
|
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
|
// Accommodation criteria mapping — maps accommodation_type to sheet matching rules
|
||||||
const ACCOMMODATION_CRITERIA = {
|
const ACCOMMODATION_CRITERIA = {
|
||||||
'ch-multi': {
|
'ch-multi': {
|
||||||
|
|
@ -164,6 +168,38 @@ async function parseBookingSheet() {
|
||||||
return beds;
|
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.
|
* Find an available bed matching the accommodation criteria for the given weeks.
|
||||||
* A bed is "available" only if ALL requested week columns are empty.
|
* 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(', ')}`);
|
console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for weeks: ${selectedWeeks.join(', ')}`);
|
||||||
|
|
||||||
return {
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -371,9 +371,10 @@ async function handleWebhook(req, res) {
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const bookingAlertEmail = process.env.BOOKING_ALERT_EMAIL || 'jeff@jeffemmett.com';
|
||||||
await smtp.sendMail({
|
await smtp.sendMail({
|
||||||
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
|
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
|
||||||
to: 'team@valleyofthecommons.com',
|
to: bookingAlertEmail,
|
||||||
subject: bookingNotification.subject,
|
subject: bookingNotification.subject,
|
||||||
html: bookingNotification.html,
|
html: bookingNotification.html,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
59
apply.html
59
apply.html
|
|
@ -177,6 +177,28 @@
|
||||||
.week-card .dates { font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; }
|
.week-card .dates { font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; }
|
||||||
.week-card .desc { font-size: 0.85rem; color: #555; }
|
.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 */
|
||||||
.select-all-card {
|
.select-all-card {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
|
|
@ -1329,6 +1351,34 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Week selection =====
|
// ===== 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) {
|
function toggleWeek(card) {
|
||||||
const cb = card.querySelector('input');
|
const cb = card.querySelector('input');
|
||||||
cb.checked = !cb.checked;
|
cb.checked = !cb.checked;
|
||||||
|
|
@ -1336,6 +1386,8 @@
|
||||||
syncSelectAll();
|
syncSelectAll();
|
||||||
updatePriceSummary();
|
updatePriceSummary();
|
||||||
saveFormData();
|
saveFormData();
|
||||||
|
// Re-check availability when weeks change
|
||||||
|
if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAllWeeks(card) {
|
function toggleAllWeeks(card) {
|
||||||
|
|
@ -1350,6 +1402,7 @@
|
||||||
});
|
});
|
||||||
updatePriceSummary();
|
updatePriceSummary();
|
||||||
saveFormData();
|
saveFormData();
|
||||||
|
if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability();
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSelectAll() {
|
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.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('ch-rooms').style.display = 'none';
|
||||||
document.getElementById('hh-rooms').style.display = 'none';
|
document.getElementById('hh-rooms').style.display = 'none';
|
||||||
|
} else {
|
||||||
|
fetchAndApplyAvailability();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1402,6 +1457,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRoom(roomType) {
|
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 => {
|
document.querySelectorAll('input[name="room_type"]').forEach(r => {
|
||||||
r.checked = (r.value === roomType);
|
r.checked = (r.value === roomType);
|
||||||
r.closest('.week-card').classList.toggle('selected', r.checked);
|
r.closest('.week-card').classList.toggle('selected', r.checked);
|
||||||
|
|
|
||||||
24
server.js
24
server.js
|
|
@ -50,6 +50,30 @@ app.post('/api/mollie/webhook', vercelToExpress(handleWebhook));
|
||||||
app.all('/api/mollie/status', vercelToExpress(getPaymentStatus));
|
app.all('/api/mollie/status', vercelToExpress(getPaymentStatus));
|
||||||
app.get('/api/mollie/resume', vercelToExpress(resumePayment));
|
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
|
// Static files
|
||||||
app.use(express.static(path.join(__dirname), {
|
app.use(express.static(path.join(__dirname), {
|
||||||
extensions: ['html'],
|
extensions: ['html'],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue