feat: CCG-style accommodation selection with upfront payment and booking sheet

Replace simple accommodation dropdown with full venue/room-type selector
(Commons Hub + Herrnhof Villa) matching CCG registration flow. Accommodation
is now paid upfront with registration via Mollie instead of invoiced separately.

- Add 7 accommodation types with per-week pricing (€279-665/wk)
- Add 2% payment processing fee on registration + accommodation subtotal
- Create booking-sheet.js for automatic bed assignment on payment confirmation
- Update Mollie webhook to assign beds and send internal booking notifications
- Add accommodation_type column to DB schema + migration
- Update confirmation/admin emails with full price breakdown
- Add food interest checkbox with co-producing meals messaging
- Track accommodation type, venue, and food interest in Google Sheets
- Add startup migration for accommodation_type column in server.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 13:42:04 -07:00
parent 5963d64da6
commit 2d0bdc7dd1
8 changed files with 684 additions and 80 deletions

View File

@ -4,7 +4,7 @@
const { Pool } = require('pg');
const nodemailer = require('nodemailer');
const { syncApplication } = require('./google-sheets');
const { createPayment, TICKET_LABELS, PRICE_PER_WEEK, calculateAmount } = require('./mollie');
const { createPayment, TICKET_LABELS, PRICE_PER_WEEK, ACCOMMODATION_PRICES, ACCOMMODATION_LABELS, PROCESSING_FEE_PERCENT, calculateAmount } = require('./mollie');
const { addToListmonk } = require('./listmonk');
// Initialize PostgreSQL connection pool
@ -36,17 +36,25 @@ const WEEK_LABELS = {
// Email templates
const confirmationEmail = (application) => {
const weeksCount = (application.weeks || []).length;
const amount = calculateAmount('registration', weeksCount);
const accomType = application.accommodation_type || null;
const pricing = calculateAmount('registration', weeksCount, accomType);
const weeksHtml = (application.weeks || []).map(w => `<li>${WEEK_LABELS[w] || w}</li>`).join('');
const addOns = [];
if (application.need_accommodation) {
const prefLabel = application.accommodation_preference ? ` (preference: ${application.accommodation_preference})` : '';
addOns.push(`Accommodation${prefLabel}`);
// Accommodation row
let accomHtml = '';
if (accomType && ACCOMMODATION_PRICES[accomType]) {
const label = ACCOMMODATION_LABELS[accomType] || accomType;
const perWeek = ACCOMMODATION_PRICES[accomType];
accomHtml = `
<tr>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Accommodation:</strong></td>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${label}<br>&euro;${perWeek.toFixed(2)}/week &times; ${weeksCount} week${weeksCount > 1 ? 's' : ''} = &euro;${pricing.accommodation}</td>
</tr>`;
}
if (application.want_food) addOns.push('Food included');
const addOnsHtml = addOns.length > 0
? `<tr><td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Add-ons:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${addOns.join(', ')} <em>(invoiced separately)</em></td></tr>`
// Food note
const foodNote = application.want_food
? '<tr><td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Food:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">Interest registered — we are exploring co-producing meals as a community. More details and costs coming soon.</td></tr>'
: '';
return {
@ -64,13 +72,22 @@ const confirmationEmail = (application) => {
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Registration:</strong></td>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">&euro;${PRICE_PER_WEEK}/week &times; ${weeksCount} week${weeksCount > 1 ? 's' : ''} = &euro;${amount}</td>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">&euro;${PRICE_PER_WEEK}/week &times; ${weeksCount} week${weeksCount > 1 ? 's' : ''} = &euro;${pricing.registration}</td>
</tr>
${accomHtml}
<tr>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Processing fee (2%):</strong></td>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">&euro;${pricing.processingFee}</td>
</tr>
<tr>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Total:</strong></td>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>&euro;${pricing.total}</strong></td>
</tr>
<tr>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Attendance:</strong></td>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}</td>
</tr>
${addOnsHtml}
${foodNote}
</table>
${weeksHtml ? `<p style="margin-top: 12px; margin-bottom: 0;"><strong>Weeks selected:</strong></p><ul style="margin-top: 4px; margin-bottom: 0;">${weeksHtml}</ul>` : ''}
</div>
@ -82,7 +99,7 @@ const confirmationEmail = (application) => {
<li>Our team will review your application</li>
<li>We may reach out with follow-up questions</li>
<li>You'll receive a decision within 2-3 weeks</li>
${addOns.length > 0 ? '<li>Accommodation and food costs will be invoiced separately after acceptance</li>' : ''}
${accomType ? '<li>Your bed will be assigned automatically once payment is confirmed</li>' : ''}
</ol>
</div>
@ -134,11 +151,11 @@ const adminNotificationEmail = (application) => ({
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Accommodation:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.need_accommodation ? `Yes${application.accommodation_preference ? ' (' + application.accommodation_preference + ')' : ''}` : 'No'}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.accommodation_type ? (ACCOMMODATION_LABELS[application.accommodation_type] || application.accommodation_type) : (application.need_accommodation ? 'Yes (no type selected)' : 'No')}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Food:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.want_food ? 'Yes' : 'No'}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Food interest:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.want_food ? 'Yes — wants to co-produce meals' : 'No'}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Scholarship:</strong></td>
@ -235,11 +252,11 @@ module.exports = async function handler(req, res) {
how_heard, referral_name, previous_events, emergency_name, emergency_phone,
emergency_relationship, code_of_conduct_accepted, privacy_policy_accepted,
photo_consent, scholarship_needed, scholarship_reason, contribution_amount,
ip_address, user_agent, need_accommodation, want_food
ip_address, user_agent, need_accommodation, want_food, accommodation_type
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32,
$33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43
$33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44
) RETURNING id, submitted_at`,
[
data.first_name?.trim(),
@ -284,7 +301,8 @@ module.exports = async function handler(req, res) {
req.headers['x-forwarded-for'] || req.connection?.remoteAddress || null,
req.headers['user-agent'] || null,
data.need_accommodation || false,
data.want_food || false
data.want_food || false,
data.accommodation_type || null
]
);
@ -310,6 +328,7 @@ module.exports = async function handler(req, res) {
weeks: weeksSelected,
need_accommodation: data.need_accommodation || false,
accommodation_preference: data.accommodation_preference || null,
accommodation_type: data.accommodation_type || null,
want_food: data.want_food || false,
contribution_amount: 'registration',
};
@ -358,7 +377,7 @@ module.exports = async function handler(req, res) {
}
}
// Create Mollie payment for registration fee (€300/week)
// Create Mollie payment for registration + accommodation fee
let checkoutUrl = null;
if (weeksSelected.length > 0 && process.env.MOLLIE_API_KEY) {
try {
@ -368,10 +387,12 @@ module.exports = async function handler(req, res) {
weeksSelected.length,
application.email,
application.first_name,
application.last_name
application.last_name,
application.accommodation_type,
weeksSelected
);
checkoutUrl = paymentResult.checkoutUrl;
console.log(`Mollie payment created: ${paymentResult.paymentId} (${paymentResult.amount} EUR)`);
console.log(`Mollie payment created: ${paymentResult.paymentId} (${paymentResult.amount})`);
} catch (paymentError) {
console.error('Failed to create Mollie payment:', paymentError);
// Don't fail the application - payment can be retried

274
api/booking-sheet.js Normal file
View File

@ -0,0 +1,274 @@
// Booking sheet integration
// Manages bed assignments on a Google Sheets booking sheet
// Ported from CCG's lib/booking-sheet.ts, adapted for week-based columns
const fs = require('fs');
let sheetsClient = null;
// Accommodation criteria mapping — maps accommodation_type to sheet matching rules
const ACCOMMODATION_CRITERIA = {
'ch-multi': {
venue: 'Commons Hub',
bedTypes: ['Bunk up', 'Bunk down', 'Single'],
},
'ch-double': {
venue: 'Commons Hub',
bedTypes: ['Double'],
},
'hh-single': {
venue: 'Herrnhof Villa',
bedTypes: ['Single'],
},
'hh-double-separate': {
venue: 'Herrnhof Villa',
bedTypes: ['Double (separate)', 'Twin'],
},
'hh-double-shared': {
venue: 'Herrnhof Villa',
bedTypes: ['Double (shared)', 'Double'],
},
'hh-triple': {
venue: 'Herrnhof Villa',
bedTypes: ['Triple'],
},
'hh-daybed': {
venue: 'Herrnhof Villa',
bedTypes: ['Daybed', 'Extra bed', 'Sofa bed'],
},
};
// Week column headers expected in the sheet
const WEEK_COLUMNS = ['Week 1', 'Week 2', 'Week 3', 'Week 4'];
function getCredentials() {
const filePath = process.env.GOOGLE_SERVICE_ACCOUNT_FILE;
if (filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (err) {
console.error('[Booking Sheet] Failed to read credentials file:', err.message);
return null;
}
}
const raw = process.env.GOOGLE_SERVICE_ACCOUNT;
if (!raw) return null;
try {
return JSON.parse(raw.trim());
} catch {
console.error('[Booking Sheet] Failed to parse service account JSON');
return null;
}
}
async function getSheetsClient() {
if (sheetsClient) return sheetsClient;
const creds = getCredentials();
if (!creds) return null;
const { google } = require('googleapis');
const auth = new google.auth.GoogleAuth({
credentials: creds,
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
sheetsClient = google.sheets({ version: 'v4', auth });
return sheetsClient;
}
/**
* Read and parse the booking sheet.
* Expected structure per venue section:
* Row: "Commons Hub" or "Herrnhof Villa" (venue header)
* Row: Room | Bed Type | Week 1 | Week 2 | Week 3 | Week 4 (column headers)
* Row: 5 | Bunk up | | | | (bed rows)
* ...
* Empty row (section separator)
*
* Returns array of bed objects:
* { venue, room, bedType, rowIndex, weekColumns: { 'Week 1': colIndex, ... }, occupancy: { 'Week 1': 'Guest Name' | null, ... } }
*/
async function parseBookingSheet() {
const sheets = await getSheetsClient();
if (!sheets) {
console.log('[Booking Sheet] No credentials configured — skipping');
return null;
}
const sheetId = process.env.BOOKING_SHEET_ID || process.env.GOOGLE_SHEET_ID;
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
const response = await sheets.spreadsheets.values.get({
spreadsheetId: sheetId,
range: `${sheetName}!A:G`,
});
const rows = response.data.values || [];
const beds = [];
let currentVenue = null;
let weekColIndexes = {};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row || row.length === 0 || row.every(cell => !cell || !cell.toString().trim())) {
// Empty row — reset venue context
currentVenue = null;
weekColIndexes = {};
continue;
}
const firstCell = (row[0] || '').toString().trim();
// Check if this is a venue header
if (firstCell === 'Commons Hub' || firstCell === 'Herrnhof Villa') {
currentVenue = firstCell;
weekColIndexes = {};
continue;
}
// Check if this is the column header row (contains "Room" and week 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;
}
}
continue;
}
// If we have a venue and week columns, this is a bed row
if (currentVenue && Object.keys(weekColIndexes).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)) {
const cellValue = (row[colIdx] || '').toString().trim();
occupancy[week] = cellValue || null;
}
beds.push({
venue: currentVenue,
room,
bedType,
rowIndex: i,
weekColumns: { ...weekColIndexes },
occupancy,
});
}
}
return beds;
}
/**
* Find an available bed matching the accommodation criteria for the given weeks.
* A bed is "available" only if ALL requested week columns are empty.
*/
function findAvailableBed(beds, accommodationType, selectedWeeks) {
const criteria = ACCOMMODATION_CRITERIA[accommodationType];
if (!criteria) {
console.error(`[Booking Sheet] Unknown accommodation type: ${accommodationType}`);
return null;
}
// Map week values (week1, week2, etc.) to column headers (Week 1, Week 2, etc.)
const weekHeaders = selectedWeeks.map(w => {
const num = w.replace('week', '');
return `Week ${num}`;
});
for (const bed of beds) {
// Match venue
if (bed.venue !== criteria.venue) continue;
// Match bed type (case-insensitive partial match)
const bedTypeLower = bed.bedType.toLowerCase();
const matchesBedType = criteria.bedTypes.some(bt => bedTypeLower.includes(bt.toLowerCase()));
if (!matchesBedType) continue;
// Check all requested weeks are empty
const allWeeksAvailable = weekHeaders.every(wh => !bed.occupancy[wh]);
if (!allWeeksAvailable) continue;
return bed;
}
return null;
}
/**
* Assign a guest to a bed on the booking sheet.
* Writes guest name to the selected week columns for the matched bed row.
*
* @param {string} guestName - Full name of the guest
* @param {string} accommodationType - e.g. 'ch-multi', 'hh-single'
* @param {string[]} selectedWeeks - e.g. ['week1', 'week2', 'week3']
* @returns {object} Result with success status, venue, room, bedType
*/
async function assignBooking(guestName, accommodationType, selectedWeeks) {
if (!accommodationType || !selectedWeeks || selectedWeeks.length === 0) {
return { success: false, reason: 'Missing accommodation type or weeks' };
}
try {
const beds = await parseBookingSheet();
if (!beds) {
return { success: false, reason: 'Booking sheet not configured' };
}
const bed = findAvailableBed(beds, accommodationType, selectedWeeks);
if (!bed) {
console.warn(`[Booking Sheet] No available bed for ${accommodationType}, weeks: ${selectedWeeks.join(', ')}`);
return { success: false, reason: 'No available bed matching criteria' };
}
// Write guest name to the selected week columns
const sheets = await getSheetsClient();
const sheetId = process.env.BOOKING_SHEET_ID || process.env.GOOGLE_SHEET_ID;
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
// Convert week values to column headers
const weekHeaders = selectedWeeks.map(w => `Week ${w.replace('week', '')}`);
// Build batch update data
const data = weekHeaders
.filter(wh => bed.weekColumns[wh] !== undefined)
.map(wh => {
const col = bed.weekColumns[wh];
const colLetter = String.fromCharCode(65 + col); // A=0, B=1, ...
const rowNum = bed.rowIndex + 1; // Sheets is 1-indexed
return {
range: `${sheetName}!${colLetter}${rowNum}`,
values: [[guestName]],
};
});
if (data.length > 0) {
await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: sheetId,
resource: {
valueInputOption: 'USER_ENTERED',
data,
},
});
}
console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for weeks: ${selectedWeeks.join(', ')}`);
return {
success: true,
venue: bed.venue,
room: bed.room,
bedType: bed.bedType,
};
} catch (error) {
console.error('[Booking Sheet] Assignment failed:', error);
return { success: false, reason: error.message };
}
}
module.exports = { assignBooking, parseBookingSheet, findAvailableBed, ACCOMMODATION_CRITERIA };

View File

@ -90,10 +90,16 @@ function syncWaitlistSignup({ email, name, involvement }) {
/**
* Sync an application to the "Registrations" sheet tab.
* Columns: Timestamp | App ID | Status | First Name | Last Name | Email | Phone |
* Country | City | Attendance | Weeks | Accommodation | Accom Pref | Food |
* Country | City | Attendance | Weeks | Accommodation | Accom Type | Accom Venue | Food |
* Motivation | Contribution | How Heard | Referral | Scholarship | Scholarship Reason
*/
function syncApplication(app) {
// Derive venue from accommodation_type prefix
const accomType = app.accommodation_type || '';
const accomVenue = accomType.startsWith('ch-') ? 'Commons Hub'
: accomType.startsWith('hh-') ? 'Herrnhof Villa'
: '';
appendRow('Registrations', [
new Date().toISOString(),
app.id || '',
@ -107,7 +113,8 @@ function syncApplication(app) {
app.attendance_type || '',
(app.weeks || []).join(', '),
app.need_accommodation ? 'Yes' : 'No',
app.accommodation_preference || '',
accomType,
accomVenue,
app.want_food ? 'Yes' : 'No',
app.motivation || '',
app.contribution || '',

View File

@ -4,6 +4,7 @@
const { createMollieClient } = require('@mollie/api-client');
const { Pool } = require('pg');
const nodemailer = require('nodemailer');
const { assignBooking } = require('./booking-sheet');
// Initialize PostgreSQL connection pool
const pool = new Pool({
@ -31,6 +32,31 @@ const smtp = nodemailer.createTransport({
// Base registration price per week (EUR)
const PRICE_PER_WEEK = 300.00;
// Processing fee percentage (added on top of subtotal)
const PROCESSING_FEE_PERCENT = 0.02;
// Accommodation prices per week (EUR) — same as CCG rates
const ACCOMMODATION_PRICES = {
'ch-multi': 279.30, // Commons Hub shared room (multi-bed)
'ch-double': 356.30, // Commons Hub double room
'hh-single': 665.00, // Herrnhof single room
'hh-double-separate': 420.00, // Herrnhof double room, separate beds
'hh-double-shared': 350.00, // Herrnhof double room, shared bed
'hh-triple': 350.00, // Herrnhof triple room
'hh-daybed': 280.00, // Herrnhof daybed/extra bed
};
// Human-readable labels for accommodation types
const ACCOMMODATION_LABELS = {
'ch-multi': 'Commons Hub — Shared Room',
'ch-double': 'Commons Hub — Double Room',
'hh-single': 'Herrnhof Villa — Single Room',
'hh-double-separate': 'Herrnhof Villa — Double Room (separate beds)',
'hh-double-shared': 'Herrnhof Villa — Double Room (shared bed)',
'hh-triple': 'Herrnhof Villa — Triple Room',
'hh-daybed': 'Herrnhof Villa — Daybed / Extra Bed',
};
// Legacy ticket labels (kept for backward-compat with existing DB records)
const TICKET_LABELS = {
'full-dorm': 'Full Resident - Dorm (4-6 people)',
@ -43,25 +69,43 @@ const TICKET_LABELS = {
'registration': 'Event Registration',
};
function calculateAmount(ticketType, weeksCount) {
// New pricing: flat €300/week
return (PRICE_PER_WEEK * (weeksCount || 1)).toFixed(2);
function calculateAmount(ticketType, weeksCount, accommodationType) {
const weeks = weeksCount || 1;
const registration = PRICE_PER_WEEK * weeks;
const accommodation = accommodationType && ACCOMMODATION_PRICES[accommodationType]
? ACCOMMODATION_PRICES[accommodationType] * weeks
: 0;
const subtotal = registration + accommodation;
const processingFee = subtotal * PROCESSING_FEE_PERCENT;
const total = subtotal + processingFee;
return {
registration: registration.toFixed(2),
accommodation: accommodation.toFixed(2),
subtotal: subtotal.toFixed(2),
processingFee: processingFee.toFixed(2),
total: total.toFixed(2),
};
}
// Create a Mollie payment for an application
async function createPayment(applicationId, ticketType, weeksCount, email, firstName, lastName) {
const amount = calculateAmount(ticketType, weeksCount);
if (!amount) {
throw new Error(`Invalid ticket type: ${ticketType}`);
}
async function createPayment(applicationId, ticketType, weeksCount, email, firstName, lastName, accommodationType, selectedWeeks) {
const pricing = calculateAmount(ticketType, weeksCount, accommodationType);
const baseUrl = process.env.BASE_URL || 'https://valleyofthecommons.com';
const description = `Valley of the Commons - Registration (${weeksCount} week${weeksCount > 1 ? 's' : ''})`;
// Build itemized description
const parts = [`Registration (${weeksCount} week${weeksCount > 1 ? 's' : ''})`];
if (accommodationType && ACCOMMODATION_PRICES[accommodationType]) {
const label = ACCOMMODATION_LABELS[accommodationType] || accommodationType;
parts.push(`Accommodation: ${label} (${weeksCount} week${weeksCount > 1 ? 's' : ''})`);
}
parts.push('incl. 2% processing fee');
const description = `Valley of the Commons - ${parts.join(' + ')}`;
const payment = await mollieClient.payments.create({
amount: {
currency: 'EUR',
value: amount,
value: pricing.total,
},
description,
redirectUrl: `${baseUrl}/payment-return.html?id=${applicationId}`,
@ -70,6 +114,9 @@ async function createPayment(applicationId, ticketType, weeksCount, email, first
applicationId,
ticketType,
weeksCount,
accommodationType: accommodationType || null,
selectedWeeks: selectedWeeks || [],
breakdown: pricing,
},
});
@ -80,18 +127,48 @@ async function createPayment(applicationId, ticketType, weeksCount, email, first
payment_amount = $2,
payment_status = 'pending'
WHERE id = $3`,
[payment.id, amount, applicationId]
[payment.id, pricing.total, applicationId]
);
return {
paymentId: payment.id,
checkoutUrl: payment.getCheckoutUrl(),
amount,
amount: pricing.total,
pricing,
};
}
// Payment confirmation email
const paymentConfirmationEmail = (application) => ({
const paymentConfirmationEmail = (application, bookingResult) => {
const accomLabel = application.accommodation_type
? (ACCOMMODATION_LABELS[application.accommodation_type] || application.accommodation_type)
: null;
// Build accommodation row if applicable
const accomRow = accomLabel ? `
<tr>
<td style="padding: 4px 0;"><strong>Accommodation:</strong></td>
<td style="padding: 4px 0;">${accomLabel}</td>
</tr>` : '';
// Booking assignment info
let bookingHtml = '';
if (bookingResult) {
if (bookingResult.success) {
bookingHtml = `
<div style="background: #e8f5e9; padding: 16px; border-radius: 8px; margin: 16px 0;">
<h3 style="margin-top: 0; color: #2d5016;">Bed Assignment</h3>
<p style="margin-bottom: 0;">You have been assigned to <strong>${bookingResult.venue} Room ${bookingResult.room}, ${bookingResult.bedType}</strong>.</p>
</div>`;
} else {
bookingHtml = `
<div style="background: #fff3e0; padding: 16px; border-radius: 8px; margin: 16px 0;">
<p style="margin-bottom: 0;">Your accommodation request has been noted. Our team will follow up with your room assignment shortly.</p>
</div>`;
}
}
return {
subject: 'Payment Confirmed - Valley of the Commons',
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
@ -106,8 +183,9 @@ const paymentConfirmationEmail = (application) => ({
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 4px 0;"><strong>Type:</strong></td>
<td style="padding: 4px 0;">Event Registration</td>
<td style="padding: 4px 0;">Event Registration${accomLabel ? ' + Accommodation' : ''}</td>
</tr>
${accomRow}
<tr>
<td style="padding: 4px 0;"><strong>Amount:</strong></td>
<td style="padding: 4px 0;">&euro;${application.payment_amount}</td>
@ -119,6 +197,8 @@ const paymentConfirmationEmail = (application) => ({
</table>
</div>
${bookingHtml}
<p>Your application is now complete. Our team will review it and get back to you within 2-3 weeks.</p>
<p>If you have any questions, reply to this email and we'll get back to you.</p>
@ -134,7 +214,8 @@ const paymentConfirmationEmail = (application) => ({
</p>
</div>
`
});
};
};
async function logEmail(recipientEmail, recipientName, emailType, subject, messageId, metadata = {}) {
try {
@ -196,17 +277,35 @@ async function handleWebhook(req, res) {
console.log(`Payment ${paymentId} for application ${applicationId}: ${paymentStatus}`);
// Send payment confirmation email if payment succeeded
if (paymentStatus === 'paid' && process.env.SMTP_PASS) {
// On payment success: assign bed + send confirmation emails
if (paymentStatus === 'paid') {
try {
const appResult = await pool.query(
'SELECT id, first_name, last_name, email, contribution_amount, payment_amount, mollie_payment_id FROM applications WHERE mollie_payment_id = $1',
'SELECT id, first_name, last_name, email, contribution_amount, payment_amount, mollie_payment_id, accommodation_type FROM applications WHERE mollie_payment_id = $1',
[paymentId]
);
if (appResult.rows.length > 0) {
const application = appResult.rows[0];
const confirmEmail = paymentConfirmationEmail(application);
const accommodationType = payment.metadata.accommodationType || application.accommodation_type;
const selectedWeeks = payment.metadata.selectedWeeks || [];
// Attempt bed assignment if accommodation was selected
let bookingResult = null;
if (accommodationType) {
try {
const guestName = `${application.first_name} ${application.last_name}`;
bookingResult = await assignBooking(guestName, accommodationType, selectedWeeks);
console.log(`[Booking] ${guestName}: ${bookingResult.success ? 'Assigned' : 'Failed'}${JSON.stringify(bookingResult)}`);
} catch (bookingError) {
console.error('[Booking] Assignment error:', bookingError);
bookingResult = { success: false, reason: bookingError.message };
}
}
// Send payment confirmation email
if (process.env.SMTP_PASS) {
const confirmEmail = paymentConfirmationEmail(application, bookingResult);
const info = await smtp.sendMail({
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
to: application.email,
@ -217,9 +316,48 @@ async function handleWebhook(req, res) {
await logEmail(application.email, `${application.first_name} ${application.last_name}`,
'payment_confirmation', confirmEmail.subject, info.messageId,
{ applicationId: application.id, paymentId, amount: application.payment_amount });
// Send internal booking notification to team
if (accommodationType) {
const accomLabel = ACCOMMODATION_LABELS[accommodationType] || accommodationType;
const bookingStatus = bookingResult?.success
? `Assigned: ${bookingResult.venue} Room ${bookingResult.room} (${bookingResult.bedType})`
: `MANUAL ASSIGNMENT NEEDED — ${bookingResult?.reason || 'unknown error'}`;
const bookingNotification = {
subject: `Booking ${bookingResult?.success ? 'Assigned' : 'NEEDS ATTENTION'}: ${application.first_name} ${application.last_name}`,
html: `
<div style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: ${bookingResult?.success ? '#2d5016' : '#c53030'};">
${bookingResult?.success ? 'Bed Assigned' : 'Manual Assignment Needed'}
</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="padding: 6px 0; border-bottom: 1px solid #eee;"><strong>Guest:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #eee;">${application.first_name} ${application.last_name}</td></tr>
<tr><td style="padding: 6px 0; border-bottom: 1px solid #eee;"><strong>Email:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #eee;">${application.email}</td></tr>
<tr><td style="padding: 6px 0; border-bottom: 1px solid #eee;"><strong>Accommodation:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #eee;">${accomLabel}</td></tr>
<tr><td style="padding: 6px 0; border-bottom: 1px solid #eee;"><strong>Weeks:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #eee;">${selectedWeeks.join(', ') || 'N/A'}</td></tr>
<tr><td style="padding: 6px 0; border-bottom: 1px solid #eee;"><strong>Status:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #eee;">${bookingStatus}</td></tr>
<tr><td style="padding: 6px 0;"><strong>Payment:</strong></td><td style="padding: 6px 0;">&euro;${application.payment_amount}</td></tr>
</table>
</div>
`,
};
try {
await smtp.sendMail({
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
to: 'team@valleyofthecommons.com',
subject: bookingNotification.subject,
html: bookingNotification.html,
});
} catch (notifyError) {
console.error('Failed to send booking notification:', notifyError);
}
}
}
}
} catch (emailError) {
console.error('Failed to send payment confirmation email:', emailError);
console.error('Failed to process paid webhook:', emailError);
}
}
@ -269,4 +407,9 @@ async function getPaymentStatus(req, res) {
}
}
module.exports = { createPayment, handleWebhook, getPaymentStatus, PRICE_PER_WEEK, TICKET_LABELS, calculateAmount };
module.exports = {
createPayment, handleWebhook, getPaymentStatus,
PRICE_PER_WEEK, PROCESSING_FEE_PERCENT,
ACCOMMODATION_PRICES, ACCOMMODATION_LABELS,
TICKET_LABELS, calculateAmount,
};

View File

@ -621,38 +621,102 @@
</label>
</div>
<!-- Add-ons -->
<!-- Accommodation -->
<div style="margin-top: 2rem;">
<h3 style="font-family: 'Cormorant Garamond', serif; font-size: 1.25rem; color: var(--forest); margin-bottom: 1rem;">Optional Add-ons</h3>
<p class="hint" style="margin-bottom: 1rem;">These can be arranged and paid separately after acceptance.</p>
<h3 style="font-family: 'Cormorant Garamond', serif; font-size: 1.25rem; color: var(--forest); margin-bottom: 1rem;">Accommodation</h3>
<label class="week-card" onclick="toggleAddon(this, 'accommodation')" style="margin-bottom: 0.75rem;">
<input type="checkbox" id="need_accommodation">
<h4>I need accommodation</h4>
<div class="desc">On-site housing for the duration of your stay. Pricing depends on room type.</div>
<h4>Include accommodation with registration</h4>
<div class="desc">On-site housing paid upfront with your registration fee.</div>
</label>
<div id="accommodation-preference" style="display: none; padding: 0.75rem 1rem; margin-bottom: 0.75rem;">
<label for="accom_type" style="font-size: 0.85rem;">Preferred room type <span class="optional">(non-binding)</span></label>
<select id="accom_type" name="accom_type" style="margin-top: 0.25rem;">
<option value="">-- Select preference --</option>
<option value="dorm">Dorm (4-6 people)</option>
<option value="shared">Shared Double</option>
<option value="single">Single (deluxe apartment)</option>
</select>
<div id="accommodation-options" style="display: none;">
<!-- Venue selection -->
<div style="padding: 0.75rem 0; margin-bottom: 0.5rem;">
<label style="font-weight: 600; margin-bottom: 0.75rem; display: block;">Choose your venue</label>
<label class="week-card" style="margin-bottom: 0.5rem; cursor: pointer;" onclick="selectVenue('ch')">
<input type="radio" name="venue" value="ch" style="display:none;">
<h4>Commons Hub</h4>
<div class="desc">Shared community living in the main hub building.</div>
</label>
<label class="week-card" style="cursor: pointer;" onclick="selectVenue('hh')">
<input type="radio" name="venue" value="hh" style="display:none;">
<h4>Herrnhof Villa</h4>
<div class="desc">Private villa rooms with more comfort and privacy.</div>
</label>
</div>
<!-- Commons Hub room types -->
<div id="ch-rooms" style="display: none; padding: 0.75rem 0;">
<label style="font-weight: 600; margin-bottom: 0.75rem; display: block;">Room type — Commons Hub</label>
<label class="week-card" style="margin-bottom: 0.5rem; cursor: pointer;" onclick="selectRoom('ch-multi')">
<input type="radio" name="room_type" value="ch-multi" style="display:none;">
<h4>Shared Room <span style="float:right; color: var(--forest); font-weight: 600;">€279.30/wk</span></h4>
<div class="desc">Multi-bed room (bunk beds). The most affordable option.</div>
</label>
<label class="week-card" style="cursor: pointer;" onclick="selectRoom('ch-double')">
<input type="radio" name="room_type" value="ch-double" style="display:none;">
<h4>Double Room <span style="float:right; color: var(--forest); font-weight: 600;">€356.30/wk</span></h4>
<div class="desc">Shared with one other person.</div>
</label>
</div>
<!-- Herrnhof room types -->
<div id="hh-rooms" style="display: none; padding: 0.75rem 0;">
<label style="font-weight: 600; margin-bottom: 0.75rem; display: block;">Room type — Herrnhof Villa</label>
<label class="week-card" style="margin-bottom: 0.5rem; cursor: pointer;" onclick="selectRoom('hh-single')">
<input type="radio" name="room_type" value="hh-single" style="display:none;">
<h4>Single Room <span style="float:right; color: var(--forest); font-weight: 600;">€665.00/wk</span></h4>
<div class="desc">Private room for one person.</div>
</label>
<label class="week-card" style="margin-bottom: 0.5rem; cursor: pointer;" onclick="selectRoom('hh-double-separate')">
<input type="radio" name="room_type" value="hh-double-separate" style="display:none;">
<h4>Double Room (separate beds) <span style="float:right; color: var(--forest); font-weight: 600;">€420.00/wk</span></h4>
<div class="desc">Shared room with two separate beds.</div>
</label>
<label class="week-card" style="margin-bottom: 0.5rem; cursor: pointer;" onclick="selectRoom('hh-double-shared')">
<input type="radio" name="room_type" value="hh-double-shared" style="display:none;">
<h4>Double Room (shared bed) <span style="float:right; color: var(--forest); font-weight: 600;">€350.00/wk</span></h4>
<div class="desc">Shared double bed for couples or friends.</div>
</label>
<label class="week-card" style="margin-bottom: 0.5rem; cursor: pointer;" onclick="selectRoom('hh-triple')">
<input type="radio" name="room_type" value="hh-triple" style="display:none;">
<h4>Triple Room <span style="float:right; color: var(--forest); font-weight: 600;">€350.00/wk</span></h4>
<div class="desc">Room shared between three people.</div>
</label>
<label class="week-card" style="cursor: pointer;" onclick="selectRoom('hh-daybed')">
<input type="radio" name="room_type" value="hh-daybed" style="display:none;">
<h4>Daybed / Extra Bed <span style="float:right; color: var(--forest); font-weight: 600;">€280.00/wk</span></h4>
<div class="desc">Flexible sleeping spot (daybed or extra bed).</div>
</label>
</div>
</div>
<input type="hidden" id="accommodation_type" name="accommodation_type" value="">
<!-- Food add-on -->
<h3 style="font-family: 'Cormorant Garamond', serif; font-size: 1.25rem; color: var(--forest); margin-bottom: 1rem; margin-top: 2rem;">Food</h3>
<label class="week-card" onclick="toggleAddon(this, 'food')">
<input type="checkbox" id="want_food">
<h4>I want food included</h4>
<div class="desc">Communal meals during your stay (~€10/day). Can be arranged and paid after acceptance.</div>
<h4>I would like to include food for the week</h4>
<div class="desc">We are exploring co-producing our own meals as a community. More details and costs will be shared soon — checking this box registers your interest so we can plan accordingly.</div>
</label>
</div>
<!-- Price summary -->
<div id="price-summary" class="ticket-note" style="margin-top: 1.5rem;">
<strong>Registration fee:</strong> <span id="price-calc">Select at least one week</span><br>
<span style="font-size: 0.75rem; color: #888;">Accommodation and food costs will be invoiced separately after acceptance.</span>
<div id="price-calc">Select at least one week</div>
</div>
<div class="form-nav">
@ -713,6 +777,28 @@
let currentStep = 1;
const totalSteps = 12;
const PRICE_PER_WEEK = 300;
const PROCESSING_FEE_PERCENT = 0.02;
// Accommodation prices per week (must match api/mollie.js)
const ACCOMMODATION_PRICES = {
'ch-multi': 279.30,
'ch-double': 356.30,
'hh-single': 665.00,
'hh-double-separate': 420.00,
'hh-double-shared': 350.00,
'hh-triple': 350.00,
'hh-daybed': 280.00,
};
const ACCOMMODATION_LABELS = {
'ch-multi': 'Commons Hub — Shared Room',
'ch-double': 'Commons Hub — Double Room',
'hh-single': 'Herrnhof Villa — Single Room',
'hh-double-separate': 'Herrnhof Villa — Double (separate beds)',
'hh-double-shared': 'Herrnhof Villa — Double (shared bed)',
'hh-triple': 'Herrnhof Villa — Triple Room',
'hh-daybed': 'Herrnhof Villa — Daybed / Extra Bed',
};
function updateProgress() {
const percent = Math.round(((currentStep - 1) / totalSteps) * 100);
@ -815,19 +901,84 @@
card.classList.toggle('selected', cb.checked);
if (type === 'accommodation') {
document.getElementById('accommodation-preference').style.display = cb.checked ? 'block' : 'none';
const opts = document.getElementById('accommodation-options');
opts.style.display = cb.checked ? 'block' : 'none';
if (!cb.checked) {
// Clear accommodation selection
document.getElementById('accommodation_type').value = '';
document.querySelectorAll('input[name="venue"]').forEach(r => { r.checked = false; r.closest('.week-card').classList.remove('selected'); });
document.querySelectorAll('input[name="room_type"]').forEach(r => { r.checked = false; r.closest('.week-card').classList.remove('selected'); });
document.getElementById('ch-rooms').style.display = 'none';
document.getElementById('hh-rooms').style.display = 'none';
}
}
// Always refresh summary (food toggle also affects it)
updatePriceSummary();
}
function selectVenue(venue) {
// Update radio + card styling
document.querySelectorAll('input[name="venue"]').forEach(r => {
r.checked = (r.value === venue);
r.closest('.week-card').classList.toggle('selected', r.checked);
});
// Show/hide room type sections
document.getElementById('ch-rooms').style.display = venue === 'ch' ? 'block' : 'none';
document.getElementById('hh-rooms').style.display = venue === 'hh' ? 'block' : 'none';
// Clear previous room selection
document.querySelectorAll('input[name="room_type"]').forEach(r => {
r.checked = false;
r.closest('.week-card').classList.remove('selected');
});
document.getElementById('accommodation_type').value = '';
updatePriceSummary();
}
function selectRoom(roomType) {
document.querySelectorAll('input[name="room_type"]').forEach(r => {
r.checked = (r.value === roomType);
r.closest('.week-card').classList.toggle('selected', r.checked);
});
document.getElementById('accommodation_type').value = roomType;
updatePriceSummary();
}
function updatePriceSummary() {
const weeksCount = document.querySelectorAll('input[name="weeks"]:checked').length;
const el = document.getElementById('price-calc');
if (weeksCount === 0) {
el.textContent = 'Select at least one week';
} else {
const total = weeksCount * PRICE_PER_WEEK;
el.innerHTML = `€${PRICE_PER_WEEK} × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = <strong>€${total.toLocaleString()}</strong>`;
return;
}
const registration = PRICE_PER_WEEK * weeksCount;
const accomType = document.getElementById('accommodation_type').value;
const accomPerWeek = accomType ? (ACCOMMODATION_PRICES[accomType] || 0) : 0;
const accommodation = accomPerWeek * weeksCount;
const subtotal = registration + accommodation;
const fee = subtotal * PROCESSING_FEE_PERCENT;
const total = subtotal + fee;
let html = `<strong>Registration:</strong> €${PRICE_PER_WEEK} × ${weeksCount} wk = €${registration.toFixed(2)}`;
if (accommodation > 0) {
const label = ACCOMMODATION_LABELS[accomType] || accomType;
html += `<br><strong>Accommodation:</strong> ${label}<br>&nbsp;&nbsp;€${accomPerWeek.toFixed(2)} × ${weeksCount} wk = €${accommodation.toFixed(2)}`;
}
html += `<br><strong>Processing fee (2%):</strong> €${fee.toFixed(2)}`;
html += `<br><strong style="font-size: 1.1em;">Total: €${total.toFixed(2)}</strong>`;
const wantFood = document.getElementById('want_food').checked;
if (wantFood) {
html += `<br><span style="font-size: 0.8rem; color: var(--forest);">Food: interest registered — details & costs coming soon.</span>`;
}
el.innerHTML = html;
}
// Theme ranking drag & drop
@ -902,7 +1053,8 @@
weeks: weeks,
attendance_type: weeks.length === 4 ? 'full' : 'partial',
need_accommodation: document.getElementById('need_accommodation').checked,
accommodation_preference: document.getElementById('accom_type').value || null,
accommodation_type: document.getElementById('accommodation_type').value || null,
accommodation_preference: document.getElementById('accommodation_type').value || null,
want_food: document.getElementById('want_food').checked,
anything_else: form.anything_else.value,
privacy_policy_accepted: form.privacy_accepted.checked,

View File

@ -0,0 +1,5 @@
-- Migration 003: Add accommodation_type column to applications table
-- Stores the CCG-style accommodation selection (e.g. ch-multi, hh-single)
-- Keeps existing accommodation_preference column for backward compatibility
ALTER TABLE applications ADD COLUMN IF NOT EXISTS accommodation_type VARCHAR(50);

View File

@ -85,9 +85,10 @@ CREATE TABLE IF NOT EXISTS applications (
scholarship_reason TEXT,
contribution_amount VARCHAR(50), -- 'registration' (base fee) or legacy ticket type
-- Add-ons (invoiced separately after acceptance)
-- Add-ons
need_accommodation BOOLEAN DEFAULT FALSE,
want_food BOOLEAN DEFAULT FALSE,
accommodation_type VARCHAR(50), -- CCG-style: ch-multi, ch-double, hh-single, etc.
-- Payment (Mollie)
mollie_payment_id VARCHAR(255),

View File

@ -82,7 +82,8 @@ async function runMigrations() {
await pool.query(`
ALTER TABLE applications
ADD COLUMN IF NOT EXISTS need_accommodation BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS want_food BOOLEAN DEFAULT FALSE
ADD COLUMN IF NOT EXISTS want_food BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS accommodation_type VARCHAR(50)
`);
// Rename resend_id → message_id in email_log (legacy column name)