From e155408d9c675933a6ba9ccb124d27149d8f3a80 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 14 Apr 2026 15:07:54 -0400 Subject: [PATCH] feat: add per-day attendance and per-night accommodation pricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now attend individual days at €10/day instead of full-week only (€50). Partial-week attendees get Commons Hub accommodation at per-night rates (€40 shared, €50 double). Villa restricted to full-week. Booking sheet writes guest names only on selected days. Co-Authored-By: Claude Opus 4.6 --- api/booking-sheet.js | 107 ++++++++++------ index.html | 279 ++++++++++++++++++++++++++++++++++------ pay.html | 293 +++++++++++++++++++++++++++++++++++++------ payment-success.html | 29 ++++- server.js | 158 ++++++++++++++++------- 5 files changed, 705 insertions(+), 161 deletions(-) diff --git a/api/booking-sheet.js b/api/booking-sheet.js index ff99496..9b75ce5 100644 --- a/api/booking-sheet.js +++ b/api/booking-sheet.js @@ -38,8 +38,19 @@ const ACCOMMODATION_CRITERIA = { }, }; -// Single week for WORLDPLAY (June 7-13) -const WEEK_COLUMNS = ['Week 1']; +// Per-day columns for WORLDPLAY (June 7-13) +const DAY_COLUMNS = ['Jun 7', 'Jun 8', 'Jun 9', 'Jun 10', 'Jun 11', 'Jun 12', 'Jun 13']; +const DAY_ID_TO_LABEL = { + '2026-06-07': 'Jun 7', '2026-06-08': 'Jun 8', '2026-06-09': 'Jun 9', + '2026-06-10': 'Jun 10', '2026-06-11': 'Jun 11', '2026-06-12': 'Jun 12', + '2026-06-13': 'Jun 13', +}; + +/** Convert 0-based column index to spreadsheet letter (supports A-Z, AA-AZ) */ +function colIdxToLetter(col) { + if (col < 26) return String.fromCharCode(65 + col); + return String.fromCharCode(64 + Math.floor(col / 26)) + String.fromCharCode(65 + (col % 26)); +} function getCredentials() { const filePath = process.env.GOOGLE_SERVICE_ACCOUNT_FILE; @@ -87,7 +98,7 @@ async function getSheetsClient() { * Empty row (section separator) * * Returns array of bed objects: - * { venue, room, bedType, rowIndex, weekColumns: { 'Week 1': colIndex }, occupancy: { 'Week 1': 'Guest Name' | null } } + * { venue, room, bedType, rowIndex, dayColumns: { 'Jun 7': colIndex, ... }, occupancy: { 'Jun 7': 'Guest Name' | null, ... } } */ async function parseBookingSheet() { const sheets = await getSheetsClient(); @@ -105,19 +116,19 @@ async function parseBookingSheet() { const response = await sheets.spreadsheets.values.get({ spreadsheetId: sheetId, - range: `${sheetName}!A:G`, + range: `${sheetName}!A:J`, }); const rows = response.data.values || []; const beds = []; let currentVenue = null; - let weekColIndexes = {}; + let dayColIndexes = {}; for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (!row || row.length === 0 || row.every(cell => !cell || !cell.toString().trim())) { currentVenue = null; - weekColIndexes = {}; + dayColIndexes = {}; continue; } @@ -126,31 +137,31 @@ async function parseBookingSheet() { // Check if this is a venue header if (firstCell === 'Commons Hub' || firstCell === 'Herrnhof Villa') { currentVenue = firstCell; - weekColIndexes = {}; + dayColIndexes = {}; continue; } - // Check if this is the column header row (contains "Room" and week columns) + // Check if this is the column header row (contains "Room" and day columns) if (firstCell.toLowerCase() === 'room' && currentVenue) { for (let c = 0; c < row.length; c++) { const header = (row[c] || '').toString().trim(); - if (WEEK_COLUMNS.includes(header)) { - weekColIndexes[header] = c; + if (DAY_COLUMNS.includes(header)) { + dayColIndexes[header] = c; } } continue; } - // If we have a venue and week columns, this is a bed row - if (currentVenue && Object.keys(weekColIndexes).length > 0 && firstCell) { + // If we have a venue and day columns, this is a bed row + if (currentVenue && Object.keys(dayColIndexes).length > 0 && firstCell) { const room = firstCell; const bedType = (row[1] || '').toString().trim(); if (!bedType) continue; const occupancy = {}; - for (const [week, colIdx] of Object.entries(weekColIndexes)) { + for (const [day, colIdx] of Object.entries(dayColIndexes)) { const cellValue = (row[colIdx] || '').toString().trim(); - occupancy[week] = cellValue || null; + occupancy[day] = cellValue || null; } beds.push({ @@ -158,7 +169,7 @@ async function parseBookingSheet() { room, bedType, rowIndex: i, - weekColumns: { ...weekColIndexes }, + dayColumns: { ...dayColIndexes }, occupancy, }); } @@ -169,9 +180,12 @@ async function parseBookingSheet() { /** * Find an available bed matching the accommodation criteria. - * A bed is "available" if the Week 1 column is empty. + * A bed is "available" if ALL selected day columns are empty. + * @param {object[]} beds - parsed bed objects + * @param {string} accommodationType - e.g. 'ch-shared' + * @param {string[]} dayLabels - e.g. ['Jun 7', 'Jun 9', 'Jun 10'] */ -function findAvailableBed(beds, accommodationType) { +function findAvailableBed(beds, accommodationType, dayLabels) { const criteria = ACCOMMODATION_CRITERIA[accommodationType]; if (!criteria) { console.error(`[Booking Sheet] Unknown accommodation type: ${accommodationType}`); @@ -185,8 +199,9 @@ function findAvailableBed(beds, accommodationType) { const matchesBedType = criteria.bedTypes.some(bt => bedTypeLower.includes(bt.toLowerCase())); if (!matchesBedType) continue; - // Check Week 1 is empty - if (!bed.occupancy['Week 1']) { + // Check ALL selected days are empty for this bed + const allEmpty = dayLabels.every(day => !bed.occupancy[day]); + if (allEmpty) { return bed; } } @@ -196,49 +211,71 @@ function findAvailableBed(beds, accommodationType) { /** * Assign a guest to a bed on the booking sheet. - * Writes guest name to the Week 1 column for the matched bed row. + * Writes guest name into each selected day column for the matched bed row. * * @param {string} guestName - Full name of the guest * @param {string} accommodationType - e.g. 'ch-shared', 'hh-single' + * @param {string|string[]} selectedDays - 'full-week' or array of date strings like ['2026-06-07', ...] * @returns {object} Result with success status, venue, room, bedType */ -async function assignBooking(guestName, accommodationType) { +async function assignBooking(guestName, accommodationType, selectedDays) { if (!accommodationType) { return { success: false, reason: 'Missing accommodation type' }; } + // Convert selectedDays to day labels + let dayLabels; + if (selectedDays === 'full-week' || !selectedDays) { + dayLabels = [...DAY_COLUMNS]; // all 7 days + } else { + dayLabels = selectedDays.map(id => DAY_ID_TO_LABEL[id]).filter(Boolean); + if (dayLabels.length === 0) { + return { success: false, reason: 'No valid days selected' }; + } + } + try { const beds = await parseBookingSheet(); if (!beds) { return { success: false, reason: 'Booking sheet not configured' }; } - const bed = findAvailableBed(beds, accommodationType); + const bed = findAvailableBed(beds, accommodationType, dayLabels); if (!bed) { - console.warn(`[Booking Sheet] No available bed for ${accommodationType}`); + console.warn(`[Booking Sheet] No available bed for ${accommodationType} on days: ${dayLabels.join(', ')}`); return { success: false, reason: 'No available bed matching criteria' }; } const sheets = await getSheetsClient(); const sheetId = process.env.BOOKING_SHEET_ID; const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet'; - - const weekCol = bed.weekColumns['Week 1']; - if (weekCol === undefined) { - return { success: false, reason: 'Week 1 column not found' }; - } - - const colLetter = String.fromCharCode(65 + weekCol); const rowNum = bed.rowIndex + 1; // Sheets is 1-indexed - await sheets.spreadsheets.values.update({ + // Build batch update data for each selected day column + const data = []; + for (const dayLabel of dayLabels) { + const colIdx = bed.dayColumns[dayLabel]; + if (colIdx === undefined) continue; + const colLetter = colIdxToLetter(colIdx); + data.push({ + range: `${sheetName}!${colLetter}${rowNum}`, + values: [[guestName]], + }); + } + + if (data.length === 0) { + return { success: false, reason: 'No matching day columns found on sheet' }; + } + + await sheets.spreadsheets.values.batchUpdate({ spreadsheetId: sheetId, - range: `${sheetName}!${colLetter}${rowNum}`, - valueInputOption: 'USER_ENTERED', - requestBody: { values: [[guestName]] }, + resource: { + valueInputOption: 'USER_ENTERED', + data, + }, }); - console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType})`); + console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for ${dayLabels.length} days`); return { success: true, diff --git a/index.html b/index.html index 1ebafed..53d31ea 100644 --- a/index.html +++ b/index.html @@ -1091,6 +1091,63 @@ margin-top: 2px; } + /* Day selection */ + .fullweek-btn { + display: block; + width: 100%; + padding: 1.1rem; + background: rgba(0, 217, 255, 0.08); + border: 2px solid rgba(0, 217, 255, 0.3); + border-radius: 12px; + color: var(--accent-cyan); + font-family: 'Space Grotesk', sans-serif; + font-size: 1.05rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + margin-bottom: 1rem; + } + + .fullweek-btn:hover { border-color: var(--accent-cyan); background: rgba(0, 217, 255, 0.15); } + .fullweek-btn.selected { border-color: var(--accent-green); background: rgba(0, 255, 136, 0.1); color: var(--accent-green); } + + .day-or { text-align: center; color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 1rem; } + + .day-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + margin-bottom: 1rem; + } + + .day-btn { + padding: 0.7rem 0.4rem; + background: var(--bg-input); + border: 2px solid rgba(157, 78, 221, 0.2); + border-radius: 10px; + color: var(--text-secondary); + font-family: 'Space Grotesk', sans-serif; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.3s; + text-align: center; + } + + .day-btn:hover { border-color: var(--accent-purple); } + .day-btn.selected { border-color: var(--accent-cyan); background: rgba(0, 217, 255, 0.08); color: var(--text-primary); } + + .day-price-note { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 1rem; + text-align: center; + } + + .day-price-note strong { color: var(--accent-cyan); font-family: 'Space Mono', monospace; } + + #hh-section { display: block; } + #hh-section.hidden { display: none; } + /* Price summary box */ .price-summary { background: rgba(157, 78, 221, 0.08); @@ -1900,7 +1957,25 @@
- + + +
— or pick individual days (€10/day) —
+
+ + + + + + + +
+ +
+ +
+
@@ -1909,14 +1984,14 @@
Commons Hub
-
+
Shared Room
Bunk beds / shared room
€275
-
+
Double Room
Double bed, private or shared
@@ -1924,49 +1999,51 @@
€350
-
Herrnhof Villa
-
-
-
Living Room
-
Sofa bed / daybed in shared living area
+
+
Herrnhof Villa
+
+
+
Living Room
+
Sofa bed / daybed in shared living area
+
+
€315
-
€315
-
-
-
-
Triple Room
-
Shared with two others
+
+
+
Triple Room
+
Shared with two others
+
+
€350
-
€350
-
-
-
-
Twin Room
-
Two separate beds, shared with one other
+
+
+
Twin Room
+
Two separate beds, shared with one other
+
+
€420
-
€420
-
-
-
-
Single Room
-
Private room for one
+
+
+
Single Room
+
Private room for one
+
+
€665
-
€665
-
-
-
-
Couple Room
-
Double bed, private room for two
+
+
+
Couple Room
+
Double bed, private room for two
+
+
€700
-
€700

Price Summary

- Participation fee - €50.00 + Participation fee + €50.00
- + +
+

Welcome back, !

+

Which days will you attend?

+ + + +
— or pick individual days (€10/day) —
+ +
+ + + + + + + +
+ + + + +
+
+ +

Welcome back, !

Choose your accommodation and complete payment.

@@ -354,14 +443,14 @@
Commons Hub
-
+
Shared Room
Bunk beds / shared room
€275
-
+
Double Room
Double bed, private or shared
@@ -369,49 +458,51 @@
€350
-
Herrnhof Villa
-
-
-
Living Room
-
Sofa bed / daybed in shared living area
+
+
Herrnhof Villa
+
+
+
Living Room
+
Sofa bed / daybed in shared living area
+
+
€315
-
€315
-
-
-
-
Triple Room
-
Shared with two others
+
+
+
Triple Room
+
Shared with two others
+
+
€350
-
€350
-
-
-
-
Twin Room
-
Two separate beds, shared with one other
+
+
+
Twin Room
+
Two separate beds, shared with one other
+
+
€420
-
€420
-
-
-
-
Single Room
-
Private room for one
+
+
+
Single Room
+
Private room for one
+
+
€665
-
€665
-
-
-
-
Couple Room
-
Double bed, private room for two
+
+
+
Couple Room
+
Double bed, private room for two
+
+
€700
-
€700

Price Summary

- Participation fee - €50.00 + Participation fee + €50.00