feat: add per-day attendance and per-night accommodation pricing
CI/CD / deploy (push) Failing after 1m19s
Details
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:
parent
657d0b50c2
commit
e155408d9c
|
|
@ -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,
|
||||
|
|
|
|||
215
index.html
215
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 @@
|
|||
<button type="button" class="btn btn-back" id="btn-back-step1">← Back to details</button>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Do you need on-site accommodation? (7 nights, June 7–13)</label>
|
||||
<label>Which days will you attend?</label>
|
||||
<button type="button" class="fullweek-btn" id="btn-fullweek">Full Week — June 7–13 (€50)</button>
|
||||
<div class="day-or">— or pick individual days (€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>€<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">€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">€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">€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">€50.00</span>
|
||||
<span id="price-participation-label">Participation fee</span>
|
||||
<span class="price-amount" id="price-participation-amount">€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
229
pay.html
|
|
@ -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 7–13 (€50)</button>
|
||||
|
||||
<div class="day-or">— or pick individual days (€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>€<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">€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">€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">€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">€50.00</span>
|
||||
<span id="price-participation-label">Participation fee</span>
|
||||
<span class="price-amount" id="price-participation-amount">€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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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">€${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">€${data.participationFee.toFixed(2)}</span></div>
|
||||
`;
|
||||
if (data.accommodationPrice > 0) {
|
||||
rows += `<div class="row"><span>${data.accommodationLabel} (7 nights)</span><span class="val">€${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">€${data.accommodationPrice.toFixed(2)}</span></div>`;
|
||||
}
|
||||
rows += `
|
||||
<div class="row"><span>Processing fee (2%)</span><span class="val">€${data.processingFee.toFixed(2)}</span></div>
|
||||
|
|
|
|||
158
server.js
158
server.js
|
|
@ -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 7–13'
|
||||
: 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;">€${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;">€${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;">€${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;">€${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;">€${processingFee.toFixed(2)}</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">€${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;">€${total.toFixed(2)}</strong></td>
|
||||
<td style="padding: 8px 0; text-align: right;"><strong style="color: #00ff88;">€${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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue