feat: add per-day attendance and per-night accommodation pricing
CI/CD / deploy (push) Failing after 1m19s Details

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-14 15:07:54 -04:00
parent 657d0b50c2
commit e155408d9c
5 changed files with 705 additions and 161 deletions

View File

@ -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({
spreadsheetId: sheetId,
// 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,
resource: {
valueInputOption: 'USER_ENTERED',
requestBody: { values: [[guestName]] },
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,

View File

@ -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 @@
<button type="button" class="btn btn-back" id="btn-back-step1">&larr; Back to details</button>
<div class="form-group">
<label>Do you need on-site accommodation? (7 nights, June 713)</label>
<label>Which days will you attend?</label>
<button type="button" class="fullweek-btn" id="btn-fullweek">Full Week — June 713 (&euro;50)</button>
<div class="day-or">— or pick individual days (&euro;10/day) —</div>
<div class="day-grid">
<button type="button" class="day-btn" data-day="2026-06-07">Jun 7<br><small>Sat</small></button>
<button type="button" class="day-btn" data-day="2026-06-08">Jun 8<br><small>Sun</small></button>
<button type="button" class="day-btn" data-day="2026-06-09">Jun 9<br><small>Mon</small></button>
<button type="button" class="day-btn" data-day="2026-06-10">Jun 10<br><small>Tue</small></button>
<button type="button" class="day-btn" data-day="2026-06-11">Jun 11<br><small>Wed</small></button>
<button type="button" class="day-btn" data-day="2026-06-12">Jun 12<br><small>Thu</small></button>
<button type="button" class="day-btn" data-day="2026-06-13">Jun 13<br><small>Fri</small></button>
</div>
<div class="day-price-note" id="day-price-note" style="display:none;">
<span id="day-count">0</span> day(s) selected — <strong>&euro;<span id="day-price">0</span></strong> participation
</div>
</div>
<div class="form-group">
<label>Do you need on-site accommodation?</label>
<div class="accom-toggle">
<button type="button" class="accom-toggle-btn selected" data-accom="no">No, I'll arrange my own</button>
<button type="button" class="accom-toggle-btn" data-accom="yes">Yes, book a room</button>
@ -1909,14 +1984,14 @@
<div class="accom-cards" id="accom-cards">
<div class="accom-venue-label">Commons Hub</div>
<div class="accom-card" data-type="ch-shared" data-price="275">
<div class="accom-card" data-type="ch-shared" data-price="275" data-nightly-rate="40">
<div>
<div class="accom-name">Shared Room</div>
<div class="accom-note">Bunk beds / shared room</div>
</div>
<div class="accom-price">&euro;275</div>
</div>
<div class="accom-card" data-type="ch-double" data-price="350">
<div class="accom-card" data-type="ch-double" data-price="350" data-nightly-rate="50">
<div>
<div class="accom-name">Double Room</div>
<div class="accom-note">Double bed, private or shared</div>
@ -1924,6 +1999,7 @@
<div class="accom-price">&euro;350</div>
</div>
<div id="hh-section">
<div class="accom-venue-label">Herrnhof Villa</div>
<div class="accom-card" data-type="hh-living" data-price="315">
<div>
@ -1961,12 +2037,13 @@
<div class="accom-price">&euro;700</div>
</div>
</div>
</div>
<div class="price-summary" id="price-summary">
<h4>Price Summary</h4>
<div class="price-line">
<span>Participation fee</span>
<span class="price-amount">&euro;50.00</span>
<span id="price-participation-label">Participation fee</span>
<span class="price-amount" id="price-participation-amount">&euro;50.00</span>
</div>
<div class="price-line" id="price-accom-line" style="display: none;">
<span id="price-accom-label">Accommodation</span>
@ -2257,6 +2334,8 @@
let registrationId = null;
let selectedAccom = null; // null = no accommodation
let selectedDays = null; // 'full-week' or array of date strings
let isFullWeek = false;
function showMsg(el, text, type) {
el.textContent = text;
@ -2311,6 +2390,11 @@
const result = await response.json();
if (!response.ok) {
if (result.code === 'DUPLICATE_EMAIL') {
msgStep1.innerHTML = 'This email is already registered. <a href="/pay" style="color: inherit; text-decoration: underline; font-weight: bold;">Complete your payment here</a>.';
msgStep1.className = 'form-message error';
throw { handled: true };
}
throw new Error(result.error || 'Registration failed');
}
@ -2327,7 +2411,7 @@
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (error) {
showMsg(msgStep1, error.message, 'error');
if (!error.handled) showMsg(msgStep1, error.message, 'error');
} finally {
btnToStep2.disabled = false;
btnToStep2.textContent = 'Continue to Accommodation & Payment';
@ -2343,7 +2427,98 @@
stepInd1.classList.add('active');
});
// Accommodation toggle (yes/no)
// ===== Day selection logic =====
const btnFullWeek = document.getElementById('btn-fullweek');
const dayBtns = document.querySelectorAll('.day-btn');
const dayPriceNote = document.getElementById('day-price-note');
const dayCountEl = document.getElementById('day-count');
const dayPriceEl = document.getElementById('day-price');
function getSelectedIndividualDays() {
return Array.from(dayBtns).filter(b => b.classList.contains('selected')).map(b => b.dataset.day);
}
function updateDaySelection() {
const days = getSelectedIndividualDays();
if (days.length === 7) {
// Auto-promote to full-week
setFullWeekMode(true);
return;
}
if (days.length > 0) {
isFullWeek = false;
selectedDays = days;
dayPriceNote.style.display = 'block';
dayCountEl.textContent = days.length;
dayPriceEl.textContent = (days.length * 10);
btnFullWeek.classList.remove('selected');
} else if (!isFullWeek) {
selectedDays = null;
dayPriceNote.style.display = 'none';
}
applyDayDependentUI();
}
function setFullWeekMode(on) {
isFullWeek = on;
if (on) {
selectedDays = 'full-week';
btnFullWeek.classList.add('selected');
dayBtns.forEach(b => b.classList.remove('selected'));
dayPriceNote.style.display = 'none';
} else {
selectedDays = null;
btnFullWeek.classList.remove('selected');
}
applyDayDependentUI();
}
function applyDayDependentUI() {
// Show/hide Villa section
const hhSection = document.getElementById('hh-section');
if (isFullWeek || !selectedDays) {
hhSection.classList.remove('hidden');
} else {
hhSection.classList.add('hidden');
// Deselect any villa selection
if (selectedAccom && selectedAccom.startsWith('hh-')) {
selectedAccom = null;
document.querySelectorAll('.accom-card').forEach(c => c.classList.remove('selected'));
}
}
// Update Commons Hub card prices for partial-week
const numDays = isFullWeek ? 7 : (Array.isArray(selectedDays) ? selectedDays.length : 7);
document.querySelectorAll('.accom-card[data-nightly-rate]').forEach(card => {
const nightlyRate = parseInt(card.dataset.nightlyRate);
if (isFullWeek || !selectedDays) {
card.querySelector('.accom-price').textContent = '\u20AC' + parseInt(card.dataset.price);
} else {
const total = nightlyRate * numDays;
card.querySelector('.accom-price').textContent = '\u20AC' + total + ' (' + numDays + ' \u00D7 \u20AC' + nightlyRate + ')';
}
});
updatePriceSummary();
}
btnFullWeek.addEventListener('click', () => {
setFullWeekMode(!isFullWeek);
if (!isFullWeek) updateDaySelection();
});
dayBtns.forEach(btn => {
btn.addEventListener('click', () => {
btn.classList.toggle('selected');
if (isFullWeek) {
isFullWeek = false;
btnFullWeek.classList.remove('selected');
}
updateDaySelection();
});
});
// ===== Accommodation toggle (yes/no) =====
const accomCards = document.getElementById('accom-cards');
document.querySelectorAll('.accom-toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
@ -2373,18 +2548,35 @@
// Live price calculator
function updatePriceSummary() {
const participation = 50;
const numDays = isFullWeek ? 7 : (Array.isArray(selectedDays) ? selectedDays.length : 7);
const participation = isFullWeek ? 50 : (selectedDays ? numDays * 10 : 50);
const participationLabel = document.getElementById('price-participation-label');
const participationAmount = document.getElementById('price-participation-amount');
const accomLine = document.getElementById('price-accom-line');
const accomLabel = document.getElementById('price-accom-label');
const accomAmount = document.getElementById('price-accom-amount');
const processingEl = document.getElementById('price-processing');
const totalEl = document.getElementById('price-total');
if (selectedDays && !isFullWeek) {
participationLabel.textContent = 'Participation fee (' + numDays + ' day' + (numDays > 1 ? 's' : '') + ')';
} else {
participationLabel.textContent = 'Participation fee (full week)';
}
participationAmount.textContent = '\u20AC' + participation.toFixed(2);
let accomPrice = 0;
if (selectedAccom) {
const card = document.querySelector(`.accom-card[data-type="${selectedAccom}"]`);
if (isFullWeek || !selectedDays) {
accomPrice = parseInt(card.dataset.price);
accomLabel.textContent = card.querySelector('.accom-name').textContent + ' (7 nights)';
} else {
const nightlyRate = parseInt(card.dataset.nightlyRate);
accomPrice = nightlyRate * numDays;
accomLabel.textContent = card.querySelector('.accom-name').textContent + ' (' + numDays + ' night' + (numDays > 1 ? 's' : '') + ' \u00D7 \u20AC' + nightlyRate + ')';
}
accomAmount.textContent = '\u20AC' + accomPrice.toFixed(2);
accomLine.style.display = 'flex';
} else {
@ -2403,6 +2595,12 @@
btnPay.addEventListener('click', async () => {
clearMsg(msgStep2);
// Validate day selection
if (!selectedDays) {
showMsg(msgStep2, 'Please select which days you will attend.', 'error');
return;
}
// If accommodation is toggled to "yes" but no card selected
const accomToggled = document.querySelector('.accom-toggle-btn[data-accom="yes"]').classList.contains('selected');
if (accomToggled && !selectedAccom) {
@ -2420,6 +2618,7 @@
body: JSON.stringify({
registrationId,
accommodationType: selectedAccom || null,
selectedDays,
}),
});

229
pay.html
View File

@ -316,7 +316,69 @@
color: var(--accent-cyan);
}
#state-days { display: none; }
#state-payment { display: none; }
.day-selection-header {
text-align: center;
margin-bottom: 1.5rem;
}
.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; }
</style>
</head>
<body>
@ -339,7 +401,34 @@
<div class="form-message" id="lookup-msg"></div>
</div>
<!-- State 2: Accommodation & Payment -->
<!-- State 2: Day Selection -->
<div id="state-days">
<h2>Welcome back, <span class="welcome-name" id="welcome-name-days"></span>!</h2>
<p>Which days will you attend?</p>
<button type="button" class="fullweek-btn" id="btn-fullweek">Full Week — June 713 (&euro;50)</button>
<div class="day-or">— or pick individual days (&euro;10/day) —</div>
<div class="day-grid">
<button type="button" class="day-btn" data-day="2026-06-07">Jun 7<br><small>Sat</small></button>
<button type="button" class="day-btn" data-day="2026-06-08">Jun 8<br><small>Sun</small></button>
<button type="button" class="day-btn" data-day="2026-06-09">Jun 9<br><small>Mon</small></button>
<button type="button" class="day-btn" data-day="2026-06-10">Jun 10<br><small>Tue</small></button>
<button type="button" class="day-btn" data-day="2026-06-11">Jun 11<br><small>Wed</small></button>
<button type="button" class="day-btn" data-day="2026-06-12">Jun 12<br><small>Thu</small></button>
<button type="button" class="day-btn" data-day="2026-06-13">Jun 13<br><small>Fri</small></button>
</div>
<div class="day-price-note" id="day-price-note" style="display:none;">
<span id="day-count">0</span> day(s) selected — <strong>&euro;<span id="day-price">0</span></strong> participation
</div>
<button class="btn btn-primary" id="btn-continue-days" disabled>Continue to Accommodation</button>
<div class="form-message" id="days-msg"></div>
</div>
<!-- State 3: Accommodation & Payment -->
<div id="state-payment">
<h2>Welcome back, <span class="welcome-name" id="welcome-name"></span>!</h2>
<p>Choose your accommodation and complete payment.</p>
@ -354,14 +443,14 @@
<div class="accom-cards" id="accom-cards">
<div class="accom-venue-label">Commons Hub</div>
<div class="accom-card" data-type="ch-shared" data-price="275">
<div class="accom-card" data-type="ch-shared" data-price="275" data-nightly-rate="40">
<div>
<div class="accom-name">Shared Room</div>
<div class="accom-note">Bunk beds / shared room</div>
</div>
<div class="accom-price">&euro;275</div>
</div>
<div class="accom-card" data-type="ch-double" data-price="350">
<div class="accom-card" data-type="ch-double" data-price="350" data-nightly-rate="50">
<div>
<div class="accom-name">Double Room</div>
<div class="accom-note">Double bed, private or shared</div>
@ -369,6 +458,7 @@
<div class="accom-price">&euro;350</div>
</div>
<div id="hh-section">
<div class="accom-venue-label">Herrnhof Villa</div>
<div class="accom-card" data-type="hh-living" data-price="315">
<div>
@ -406,12 +496,13 @@
<div class="accom-price">&euro;700</div>
</div>
</div>
</div>
<div class="price-summary" id="price-summary">
<h4>Price Summary</h4>
<div class="price-line">
<span>Participation fee</span>
<span class="price-amount">&euro;50.00</span>
<span id="price-participation-label">Participation fee</span>
<span class="price-amount" id="price-participation-amount">&euro;50.00</span>
</div>
<div class="price-line" id="price-accom-line" style="display: none;">
<span id="price-accom-label">Accommodation</span>
@ -445,6 +536,7 @@
<script>
const stateLookup = document.getElementById('state-lookup');
const stateDays = document.getElementById('state-days');
const statePayment = document.getElementById('state-payment');
const emailInput = document.getElementById('email');
const btnLookup = document.getElementById('btn-lookup');
@ -453,6 +545,8 @@
let registrationId = null;
let selectedAccom = null;
let selectedDays = null; // 'full-week' or array of date strings
let isFullWeek = false;
function showMsg(el, html, type) {
el.innerHTML = html;
@ -500,11 +594,12 @@
return;
}
// Show payment state
// Show day-selection state
registrationId = data.id;
document.getElementById('welcome-name-days').textContent = data.firstName;
document.getElementById('welcome-name').textContent = data.firstName;
stateLookup.style.display = 'none';
statePayment.style.display = 'block';
stateDays.style.display = 'block';
} catch (err) {
showMsg(lookupMsg, err.message, 'error');
@ -514,7 +609,107 @@
}
});
// Accommodation toggle
// ===== Day selection logic =====
const btnFullWeek = document.getElementById('btn-fullweek');
const dayBtns = document.querySelectorAll('.day-btn');
const dayPriceNote = document.getElementById('day-price-note');
const dayCountEl = document.getElementById('day-count');
const dayPriceEl = document.getElementById('day-price');
const btnContinueDays = document.getElementById('btn-continue-days');
function getSelectedIndividualDays() {
return Array.from(dayBtns).filter(b => b.classList.contains('selected')).map(b => b.dataset.day);
}
function updateDaySelection() {
const days = getSelectedIndividualDays();
if (days.length === 7) {
// Auto-promote to full-week
setFullWeek(true);
return;
}
if (days.length > 0) {
isFullWeek = false;
selectedDays = days;
dayPriceNote.style.display = 'block';
dayCountEl.textContent = days.length;
dayPriceEl.textContent = (days.length * 10);
btnFullWeek.classList.remove('selected');
btnContinueDays.disabled = false;
} else if (!isFullWeek) {
selectedDays = null;
dayPriceNote.style.display = 'none';
btnContinueDays.disabled = true;
}
}
function setFullWeek(on) {
isFullWeek = on;
if (on) {
selectedDays = 'full-week';
btnFullWeek.classList.add('selected');
dayBtns.forEach(b => b.classList.remove('selected'));
dayPriceNote.style.display = 'none';
btnContinueDays.disabled = false;
} else {
selectedDays = null;
btnFullWeek.classList.remove('selected');
btnContinueDays.disabled = true;
}
}
btnFullWeek.addEventListener('click', () => {
setFullWeek(!isFullWeek);
if (!isFullWeek) updateDaySelection();
});
dayBtns.forEach(btn => {
btn.addEventListener('click', () => {
btn.classList.toggle('selected');
if (isFullWeek) {
isFullWeek = false;
btnFullWeek.classList.remove('selected');
}
updateDaySelection();
});
});
// Continue to accommodation
btnContinueDays.addEventListener('click', () => {
stateDays.style.display = 'none';
statePayment.style.display = 'block';
// Show/hide Villa section based on full-week
const hhSection = document.getElementById('hh-section');
if (isFullWeek) {
hhSection.classList.remove('hidden');
} else {
hhSection.classList.add('hidden');
// Deselect any villa selection
if (selectedAccom && selectedAccom.startsWith('hh-')) {
selectedAccom = null;
document.querySelectorAll('.accom-card').forEach(c => c.classList.remove('selected'));
}
}
// Update accommodation card prices for partial-week
document.querySelectorAll('.accom-card[data-nightly-rate]').forEach(card => {
const nightlyRate = parseInt(card.dataset.nightlyRate);
const numDays = isFullWeek ? 7 : selectedDays.length;
if (isFullWeek) {
// Show weekly price
card.querySelector('.accom-price').textContent = '\u20AC' + parseInt(card.dataset.price);
} else {
// Show per-night price
const total = nightlyRate * numDays;
card.querySelector('.accom-price').textContent = '\u20AC' + total + ' (' + numDays + ' × \u20AC' + nightlyRate + ')';
}
});
updatePriceSummary();
});
// ===== Accommodation toggle =====
const accomCards = document.getElementById('accom-cards');
document.querySelectorAll('.accom-toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
@ -544,18 +739,33 @@
// Live price calculator
function updatePriceSummary() {
const participation = 50;
const numDays = isFullWeek ? 7 : (Array.isArray(selectedDays) ? selectedDays.length : 7);
const participation = isFullWeek ? 50 : numDays * 10;
const participationLabel = document.getElementById('price-participation-label');
const participationAmount = document.getElementById('price-participation-amount');
const accomLine = document.getElementById('price-accom-line');
const accomLabel = document.getElementById('price-accom-label');
const accomAmount = document.getElementById('price-accom-amount');
const processingEl = document.getElementById('price-processing');
const totalEl = document.getElementById('price-total');
participationLabel.textContent = isFullWeek
? 'Participation fee (full week)'
: 'Participation fee (' + numDays + ' day' + (numDays > 1 ? 's' : '') + ')';
participationAmount.textContent = '\u20AC' + participation.toFixed(2);
let accomPrice = 0;
if (selectedAccom) {
const card = document.querySelector(`.accom-card[data-type="${selectedAccom}"]`);
if (isFullWeek) {
accomPrice = parseInt(card.dataset.price);
accomLabel.textContent = card.querySelector('.accom-name').textContent + ' (7 nights)';
} else {
const nightlyRate = parseInt(card.dataset.nightlyRate);
accomPrice = nightlyRate * numDays;
accomLabel.textContent = card.querySelector('.accom-name').textContent + ' (' + numDays + ' night' + (numDays > 1 ? 's' : '') + ' \u00D7 \u20AC' + nightlyRate + ')';
}
accomAmount.textContent = '\u20AC' + accomPrice.toFixed(2);
accomLine.style.display = 'flex';
} else {
@ -591,6 +801,7 @@
body: JSON.stringify({
registrationId,
accommodationType: selectedAccom || null,
selectedDays,
}),
});

View File

@ -288,11 +288,36 @@
document.getElementById('guest-name').textContent = `${data.firstName} ${data.lastName}`;
// Days label
const DAY_LABELS = {
'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',
};
let daysText = 'Full week';
if (!data.isFullWeek && Array.isArray(data.selectedDays)) {
daysText = data.numDays + ' day' + (data.numDays > 1 ? 's' : '') + ': ' +
data.selectedDays.map(d => DAY_LABELS[d] || d).join(', ');
}
// Participation label
const participationLabel = data.isFullWeek
? 'Participation (full week)'
: `Participation (${data.numDays} day${data.numDays > 1 ? 's' : ''})`;
let rows = `
<div class="row"><span>Participation fee</span><span class="val">&euro;${data.participationFee.toFixed(2)}</span></div>
<div class="row"><span style="color:var(--accent-cyan);font-size:0.85rem;">${daysText}</span><span></span></div>
<div class="row"><span>${participationLabel}</span><span class="val">&euro;${data.participationFee.toFixed(2)}</span></div>
`;
if (data.accommodationPrice > 0) {
rows += `<div class="row"><span>${data.accommodationLabel} (7 nights)</span><span class="val">&euro;${data.accommodationPrice.toFixed(2)}</span></div>`;
let accomNightsLabel;
if (data.isFullWeek) {
accomNightsLabel = `${data.accommodationLabel} (7 nights)`;
} else {
const nightlyRate = Math.round(data.accommodationPrice / data.numDays);
accomNightsLabel = `${data.accommodationLabel} (${data.numDays} night${data.numDays > 1 ? 's' : ''} \u00D7 \u20AC${nightlyRate})`;
}
rows += `<div class="row"><span>${accomNightsLabel}</span><span class="val">&euro;${data.accommodationPrice.toFixed(2)}</span></div>`;
}
rows += `
<div class="row"><span>Processing fee (2%)</span><span class="val">&euro;${data.processingFee.toFixed(2)}</span></div>

158
server.js
View File

@ -52,6 +52,56 @@ const ACCOMMODATION_OPTIONS = {
'hh-couple': { label: 'Herrnhof Villa — Couple Room', price: 700 },
};
// Per-day attendance and per-night accommodation pricing
const EVENT_DAYS = [
{ id: '2026-06-07', label: 'Jun 7' },
{ id: '2026-06-08', label: 'Jun 8' },
{ id: '2026-06-09', label: 'Jun 9' },
{ id: '2026-06-10', label: 'Jun 10' },
{ id: '2026-06-11', label: 'Jun 11' },
{ id: '2026-06-12', label: 'Jun 12' },
{ id: '2026-06-13', label: 'Jun 13' },
];
const VALID_DAY_IDS = new Set(EVENT_DAYS.map(d => d.id));
const PARTICIPATION_FEE_PERDAY = 10;
const ACCOMMODATION_PERNIGHT = {
'ch-shared': { label: 'Commons Hub — Shared Room', nightlyRate: 40 },
'ch-double': { label: 'Commons Hub — Double Room', nightlyRate: 50 },
};
const FULLWEEK_ONLY_ACCOM = ['hh-living', 'hh-triple', 'hh-twin', 'hh-single', 'hh-couple'];
/**
* Central pricing logic.
* @param {string|string[]} selectedDays - 'full-week' or array of date strings
* @param {string|null} accommodationType - e.g. 'ch-shared', 'hh-single', or null
* @returns {{ isFullWeek, numDays, participationFee, accomPrice, accomLabel, subtotal, processingFee, total }}
*/
function calculatePrice(selectedDays, accommodationType) {
const isFullWeek = selectedDays === 'full-week';
const numDays = isFullWeek ? 7 : selectedDays.length;
const participationFee = isFullWeek ? PARTICIPATION_FEE : numDays * PARTICIPATION_FEE_PERDAY;
let accomPrice = 0;
let accomLabel = 'Participation only';
if (accommodationType) {
if (isFullWeek && ACCOMMODATION_OPTIONS[accommodationType]) {
accomPrice = ACCOMMODATION_OPTIONS[accommodationType].price;
accomLabel = ACCOMMODATION_OPTIONS[accommodationType].label;
} else if (!isFullWeek && ACCOMMODATION_PERNIGHT[accommodationType]) {
accomPrice = ACCOMMODATION_PERNIGHT[accommodationType].nightlyRate * numDays;
accomLabel = ACCOMMODATION_PERNIGHT[accommodationType].label;
}
}
const subtotal = participationFee + accomPrice;
const processingFee = Math.round(subtotal * PROCESSING_FEE_RATE * 100) / 100;
const total = subtotal + processingFee;
return { isFullWeek, numDays, participationFee, accomPrice, accomLabel, subtotal, processingFee, total };
}
// Google Sheets configuration
const GOOGLE_SHEET_ID = process.env.GOOGLE_SHEET_ID;
function loadGoogleCredentials() {
@ -435,15 +485,18 @@ async function sendPaymentConfirmationEmail(registration) {
}
try {
const accomLabel = registration.accommodationType
? (ACCOMMODATION_OPTIONS[registration.accommodationType]?.label || 'None')
: 'None (participation only)';
const accomPrice = registration.accommodationType
? (ACCOMMODATION_OPTIONS[registration.accommodationType]?.price || 0)
: 0;
const subtotal = PARTICIPATION_FEE + accomPrice;
const processingFee = Math.round(subtotal * PROCESSING_FEE_RATE * 100) / 100;
const total = subtotal + processingFee;
const selectedDays = registration.selectedDays || 'full-week';
const pricing = calculatePrice(selectedDays, registration.accommodationType);
const participationLabel = pricing.isFullWeek
? 'Participation fee (full week)'
: `Participation fee (${pricing.numDays} day${pricing.numDays > 1 ? 's' : ''})`;
const accomNightsLabel = pricing.isFullWeek
? `${pricing.accomLabel} (7 nights)`
: `${pricing.accomLabel} (${pricing.numDays} night${pricing.numDays > 1 ? 's' : ''} ×${pricing.isFullWeek ? '' : (ACCOMMODATION_PERNIGHT[registration.accommodationType]?.nightlyRate || '')})`;
const daysAttendingText = pricing.isFullWeek
? 'Full week — June 713'
: EVENT_DAYS.filter(d => selectedDays.includes(d.id)).map(d => d.label).join(', ');
const bookingInfo = registration.bookingResult?.success
? `<tr><td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Room:</strong></td><td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.bookingResult.venue} — Room ${registration.bookingResult.room} (${registration.bookingResult.bedType})</td></tr>`
@ -470,28 +523,31 @@ async function sendPaymentConfirmationEmail(registration) {
<p style="color: #a0a0b0; line-height: 1.7;">
Thank you, <strong style="color: #f0f0f5;">${registration.firstName}</strong>! Your registration and payment for WORLDPLAY have been confirmed. You're officially in.
</p>
<p style="color: #a0a0b0; line-height: 1.7; margin-top: 8px;">
<strong style="color: #f0f0f5;">Attending:</strong> ${daysAttendingText}
</p>
</div>
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
<h3 style="color: #9d4edd; margin-top: 0; font-size: 16px; text-transform: uppercase; letter-spacing: 0.1em;">Payment Breakdown</h3>
<table style="width: 100%; color: #a0a0b0; font-size: 14px;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">Participation fee</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${PARTICIPATION_FEE.toFixed(2)}</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${participationLabel}</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${pricing.participationFee.toFixed(2)}</td>
</tr>
${accomPrice > 0 ? `
${pricing.accomPrice > 0 ? `
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${accomLabel} (7 nights)</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${accomPrice.toFixed(2)}</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${accomNightsLabel}</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${pricing.accomPrice.toFixed(2)}</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">Processing fee (2%)</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${processingFee.toFixed(2)}</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${pricing.processingFee.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><strong style="color: #f0f0f5;">Total paid</strong></td>
<td style="padding: 8px 0; text-align: right;"><strong style="color: #00ff88;">&euro;${total.toFixed(2)}</strong></td>
<td style="padding: 8px 0; text-align: right;"><strong style="color: #00ff88;">&euro;${pricing.total.toFixed(2)}</strong></td>
</tr>
</table>
</div>
@ -557,7 +613,7 @@ app.post('/api/register', async (req, res) => {
// Check for duplicate email
if (registrations.some(r => r.email.toLowerCase() === email.toLowerCase())) {
return res.status(400).json({ error: 'This email is already registered' });
return res.status(409).json({ error: 'This email is already registered', code: 'DUPLICATE_EMAIL' });
}
// Create new registration with awaiting_payment status
@ -607,12 +663,34 @@ app.post('/api/create-checkout-session', async (req, res) => {
}
try {
const { registrationId, accommodationType } = req.body;
const { registrationId, accommodationType, selectedDays } = req.body;
if (!registrationId) {
return res.status(400).json({ error: 'Registration ID is required' });
}
// Validate selectedDays
if (!selectedDays) {
return res.status(400).json({ error: 'selectedDays is required' });
}
if (selectedDays !== 'full-week') {
if (!Array.isArray(selectedDays) || selectedDays.length === 0) {
return res.status(400).json({ error: 'selectedDays must be "full-week" or a non-empty array of dates' });
}
for (const d of selectedDays) {
if (!VALID_DAY_IDS.has(d)) {
return res.status(400).json({ error: `Invalid day: ${d}` });
}
}
}
const isFullWeek = selectedDays === 'full-week';
// Reject villa types for partial-week
if (!isFullWeek && accommodationType && FULLWEEK_ONLY_ACCOM.includes(accommodationType)) {
return res.status(400).json({ error: 'Herrnhof Villa accommodation is only available for full-week attendees' });
}
// Find registration
const registrations = await loadRegistrations();
const registration = registrations.find(r => r.id === registrationId);
@ -625,34 +703,27 @@ app.post('/api/create-checkout-session', async (req, res) => {
}
// Calculate price
let accomPrice = 0;
let accomLabel = 'Participation only';
if (accommodationType && ACCOMMODATION_OPTIONS[accommodationType]) {
accomPrice = ACCOMMODATION_OPTIONS[accommodationType].price;
accomLabel = ACCOMMODATION_OPTIONS[accommodationType].label;
}
const pricing = calculatePrice(selectedDays, accommodationType);
const subtotal = PARTICIPATION_FEE + accomPrice;
const processingFee = Math.round(subtotal * PROCESSING_FEE_RATE * 100) / 100;
const total = subtotal + processingFee;
// Save accommodation choice to registration
// Save choices to registration
registration.accommodationType = accommodationType || null;
registration.paymentAmount = total;
registration.selectedDays = selectedDays;
registration.paymentAmount = pricing.total;
await saveRegistrations(registrations);
// Create Mollie payment
const payment = await mollieClient.payments.create({
amount: {
currency: 'EUR',
value: total.toFixed(2),
value: pricing.total.toFixed(2),
},
description: `WORLDPLAY 2026 — ${registration.firstName} ${registration.lastName}${accommodationType ? ` + ${accomLabel}` : ''}`,
description: `WORLDPLAY 2026 — ${registration.firstName} ${registration.lastName}${accommodationType ? ` + ${pricing.accomLabel}` : ''}`,
redirectUrl: `${BASE_URL}/payment-success.html?id=${registrationId}`,
webhookUrl: `${BASE_URL}/api/mollie/webhook`,
metadata: {
registrationId,
accommodationType: accommodationType || 'none',
selectedDays: JSON.stringify(selectedDays),
},
});
@ -663,7 +734,7 @@ app.post('/api/create-checkout-session', async (req, res) => {
// Update sheet with pending payment info
updateRegistrationPaymentStatus(registrationId, 'awaiting_payment', payment.id, accommodationType).catch(err => console.error('Sheet update error:', err));
console.log(`Mollie checkout created for ${registration.email}: ${payment.id} (€${total.toFixed(2)})`);
console.log(`Mollie checkout created for ${registration.email}: ${payment.id} (€${pricing.total.toFixed(2)})`);
res.json({
success: true,
@ -721,7 +792,7 @@ app.post('/api/mollie/webhook', async (req, res) => {
// Assign bed on booking sheet if accommodation was selected
if (registration.accommodationType) {
const guestName = `${registration.firstName} ${registration.lastName}`;
const bookingResult = await assignBooking(guestName, registration.accommodationType);
const bookingResult = await assignBooking(guestName, registration.accommodationType, registration.selectedDays || 'full-week');
registration.bookingResult = bookingResult;
await saveRegistrations(registrations);
@ -770,10 +841,8 @@ app.get('/api/payment-status', async (req, res) => {
}
const accomType = registration.accommodationType;
const accomPrice = accomType ? (ACCOMMODATION_OPTIONS[accomType]?.price || 0) : 0;
const subtotal = PARTICIPATION_FEE + accomPrice;
const processingFee = Math.round(subtotal * PROCESSING_FEE_RATE * 100) / 100;
const total = subtotal + processingFee;
const selectedDays = registration.selectedDays || 'full-week';
const pricing = calculatePrice(selectedDays, accomType);
res.json({
paymentStatus: registration.paymentStatus,
@ -781,11 +850,14 @@ app.get('/api/payment-status', async (req, res) => {
lastName: registration.lastName,
email: registration.email,
accommodationType: accomType || null,
accommodationLabel: accomType ? (ACCOMMODATION_OPTIONS[accomType]?.label || null) : null,
participationFee: PARTICIPATION_FEE,
accommodationPrice: accomPrice,
processingFee,
total,
accommodationLabel: accomType ? pricing.accomLabel : null,
selectedDays,
isFullWeek: pricing.isFullWeek,
numDays: pricing.numDays,
participationFee: pricing.participationFee,
accommodationPrice: pricing.accomPrice,
processingFee: pricing.processingFee,
total: pricing.total,
bookingResult: registration.bookingResult || null,
});
} catch (error) {