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:
parent
5963d64da6
commit
2d0bdc7dd1
|
|
@ -4,7 +4,7 @@
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const { syncApplication } = require('./google-sheets');
|
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');
|
const { addToListmonk } = require('./listmonk');
|
||||||
|
|
||||||
// Initialize PostgreSQL connection pool
|
// Initialize PostgreSQL connection pool
|
||||||
|
|
@ -36,17 +36,25 @@ const WEEK_LABELS = {
|
||||||
// Email templates
|
// Email templates
|
||||||
const confirmationEmail = (application) => {
|
const confirmationEmail = (application) => {
|
||||||
const weeksCount = (application.weeks || []).length;
|
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 weeksHtml = (application.weeks || []).map(w => `<li>${WEEK_LABELS[w] || w}</li>`).join('');
|
||||||
|
|
||||||
const addOns = [];
|
// Accommodation row
|
||||||
if (application.need_accommodation) {
|
let accomHtml = '';
|
||||||
const prefLabel = application.accommodation_preference ? ` (preference: ${application.accommodation_preference})` : '';
|
if (accomType && ACCOMMODATION_PRICES[accomType]) {
|
||||||
addOns.push(`Accommodation${prefLabel}`);
|
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>€${perWeek.toFixed(2)}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${pricing.accommodation}</td>
|
||||||
|
</tr>`;
|
||||||
}
|
}
|
||||||
if (application.want_food) addOns.push('Food included');
|
|
||||||
const addOnsHtml = addOns.length > 0
|
// Food note
|
||||||
? `<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>`
|
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 {
|
return {
|
||||||
|
|
@ -64,13 +72,22 @@ const confirmationEmail = (application) => {
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
<tr>
|
<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;"><strong>Registration:</strong></td>
|
||||||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">€${PRICE_PER_WEEK}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${amount}</td>
|
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">€${PRICE_PER_WEEK}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${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;">€${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>€${pricing.total}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<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;"><strong>Attendance:</strong></td>
|
||||||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}</td>
|
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
${addOnsHtml}
|
${foodNote}
|
||||||
</table>
|
</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>` : ''}
|
${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>
|
</div>
|
||||||
|
|
@ -82,7 +99,7 @@ const confirmationEmail = (application) => {
|
||||||
<li>Our team will review your application</li>
|
<li>Our team will review your application</li>
|
||||||
<li>We may reach out with follow-up questions</li>
|
<li>We may reach out with follow-up questions</li>
|
||||||
<li>You'll receive a decision within 2-3 weeks</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>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -134,11 +151,11 @@ const adminNotificationEmail = (application) => ({
|
||||||
</tr>
|
</tr>
|
||||||
<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;"><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>
|
||||||
<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;"><strong>Food interest:</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;">${application.want_food ? 'Yes — wants to co-produce meals' : 'No'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Scholarship:</strong></td>
|
<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,
|
how_heard, referral_name, previous_events, emergency_name, emergency_phone,
|
||||||
emergency_relationship, code_of_conduct_accepted, privacy_policy_accepted,
|
emergency_relationship, code_of_conduct_accepted, privacy_policy_accepted,
|
||||||
photo_consent, scholarship_needed, scholarship_reason, contribution_amount,
|
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 (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
|
$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,
|
$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`,
|
) RETURNING id, submitted_at`,
|
||||||
[
|
[
|
||||||
data.first_name?.trim(),
|
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['x-forwarded-for'] || req.connection?.remoteAddress || null,
|
||||||
req.headers['user-agent'] || null,
|
req.headers['user-agent'] || null,
|
||||||
data.need_accommodation || false,
|
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,
|
weeks: weeksSelected,
|
||||||
need_accommodation: data.need_accommodation || false,
|
need_accommodation: data.need_accommodation || false,
|
||||||
accommodation_preference: data.accommodation_preference || null,
|
accommodation_preference: data.accommodation_preference || null,
|
||||||
|
accommodation_type: data.accommodation_type || null,
|
||||||
want_food: data.want_food || false,
|
want_food: data.want_food || false,
|
||||||
contribution_amount: 'registration',
|
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;
|
let checkoutUrl = null;
|
||||||
if (weeksSelected.length > 0 && process.env.MOLLIE_API_KEY) {
|
if (weeksSelected.length > 0 && process.env.MOLLIE_API_KEY) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -368,10 +387,12 @@ module.exports = async function handler(req, res) {
|
||||||
weeksSelected.length,
|
weeksSelected.length,
|
||||||
application.email,
|
application.email,
|
||||||
application.first_name,
|
application.first_name,
|
||||||
application.last_name
|
application.last_name,
|
||||||
|
application.accommodation_type,
|
||||||
|
weeksSelected
|
||||||
);
|
);
|
||||||
checkoutUrl = paymentResult.checkoutUrl;
|
checkoutUrl = paymentResult.checkoutUrl;
|
||||||
console.log(`Mollie payment created: ${paymentResult.paymentId} (${paymentResult.amount} EUR)`);
|
console.log(`Mollie payment created: ${paymentResult.paymentId} (€${paymentResult.amount})`);
|
||||||
} catch (paymentError) {
|
} catch (paymentError) {
|
||||||
console.error('Failed to create Mollie payment:', paymentError);
|
console.error('Failed to create Mollie payment:', paymentError);
|
||||||
// Don't fail the application - payment can be retried
|
// Don't fail the application - payment can be retried
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -90,10 +90,16 @@ function syncWaitlistSignup({ email, name, involvement }) {
|
||||||
/**
|
/**
|
||||||
* Sync an application to the "Registrations" sheet tab.
|
* Sync an application to the "Registrations" sheet tab.
|
||||||
* Columns: Timestamp | App ID | Status | First Name | Last Name | Email | Phone |
|
* 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
|
* Motivation | Contribution | How Heard | Referral | Scholarship | Scholarship Reason
|
||||||
*/
|
*/
|
||||||
function syncApplication(app) {
|
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', [
|
appendRow('Registrations', [
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
app.id || '',
|
app.id || '',
|
||||||
|
|
@ -107,7 +113,8 @@ function syncApplication(app) {
|
||||||
app.attendance_type || '',
|
app.attendance_type || '',
|
||||||
(app.weeks || []).join(', '),
|
(app.weeks || []).join(', '),
|
||||||
app.need_accommodation ? 'Yes' : 'No',
|
app.need_accommodation ? 'Yes' : 'No',
|
||||||
app.accommodation_preference || '',
|
accomType,
|
||||||
|
accomVenue,
|
||||||
app.want_food ? 'Yes' : 'No',
|
app.want_food ? 'Yes' : 'No',
|
||||||
app.motivation || '',
|
app.motivation || '',
|
||||||
app.contribution || '',
|
app.contribution || '',
|
||||||
|
|
|
||||||
209
api/mollie.js
209
api/mollie.js
|
|
@ -4,6 +4,7 @@
|
||||||
const { createMollieClient } = require('@mollie/api-client');
|
const { createMollieClient } = require('@mollie/api-client');
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
|
const { assignBooking } = require('./booking-sheet');
|
||||||
|
|
||||||
// Initialize PostgreSQL connection pool
|
// Initialize PostgreSQL connection pool
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
|
|
@ -31,6 +32,31 @@ const smtp = nodemailer.createTransport({
|
||||||
// Base registration price per week (EUR)
|
// Base registration price per week (EUR)
|
||||||
const PRICE_PER_WEEK = 300.00;
|
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)
|
// Legacy ticket labels (kept for backward-compat with existing DB records)
|
||||||
const TICKET_LABELS = {
|
const TICKET_LABELS = {
|
||||||
'full-dorm': 'Full Resident - Dorm (4-6 people)',
|
'full-dorm': 'Full Resident - Dorm (4-6 people)',
|
||||||
|
|
@ -43,25 +69,43 @@ const TICKET_LABELS = {
|
||||||
'registration': 'Event Registration',
|
'registration': 'Event Registration',
|
||||||
};
|
};
|
||||||
|
|
||||||
function calculateAmount(ticketType, weeksCount) {
|
function calculateAmount(ticketType, weeksCount, accommodationType) {
|
||||||
// New pricing: flat €300/week
|
const weeks = weeksCount || 1;
|
||||||
return (PRICE_PER_WEEK * (weeksCount || 1)).toFixed(2);
|
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
|
// Create a Mollie payment for an application
|
||||||
async function createPayment(applicationId, ticketType, weeksCount, email, firstName, lastName) {
|
async function createPayment(applicationId, ticketType, weeksCount, email, firstName, lastName, accommodationType, selectedWeeks) {
|
||||||
const amount = calculateAmount(ticketType, weeksCount);
|
const pricing = calculateAmount(ticketType, weeksCount, accommodationType);
|
||||||
if (!amount) {
|
|
||||||
throw new Error(`Invalid ticket type: ${ticketType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = process.env.BASE_URL || 'https://valleyofthecommons.com';
|
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({
|
const payment = await mollieClient.payments.create({
|
||||||
amount: {
|
amount: {
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
value: amount,
|
value: pricing.total,
|
||||||
},
|
},
|
||||||
description,
|
description,
|
||||||
redirectUrl: `${baseUrl}/payment-return.html?id=${applicationId}`,
|
redirectUrl: `${baseUrl}/payment-return.html?id=${applicationId}`,
|
||||||
|
|
@ -70,6 +114,9 @@ async function createPayment(applicationId, ticketType, weeksCount, email, first
|
||||||
applicationId,
|
applicationId,
|
||||||
ticketType,
|
ticketType,
|
||||||
weeksCount,
|
weeksCount,
|
||||||
|
accommodationType: accommodationType || null,
|
||||||
|
selectedWeeks: selectedWeeks || [],
|
||||||
|
breakdown: pricing,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -80,20 +127,50 @@ async function createPayment(applicationId, ticketType, weeksCount, email, first
|
||||||
payment_amount = $2,
|
payment_amount = $2,
|
||||||
payment_status = 'pending'
|
payment_status = 'pending'
|
||||||
WHERE id = $3`,
|
WHERE id = $3`,
|
||||||
[payment.id, amount, applicationId]
|
[payment.id, pricing.total, applicationId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paymentId: payment.id,
|
paymentId: payment.id,
|
||||||
checkoutUrl: payment.getCheckoutUrl(),
|
checkoutUrl: payment.getCheckoutUrl(),
|
||||||
amount,
|
amount: pricing.total,
|
||||||
|
pricing,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment confirmation email
|
// Payment confirmation email
|
||||||
const paymentConfirmationEmail = (application) => ({
|
const paymentConfirmationEmail = (application, bookingResult) => {
|
||||||
subject: 'Payment Confirmed - Valley of the Commons',
|
const accomLabel = application.accommodation_type
|
||||||
html: `
|
? (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;">
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
<h1 style="color: #2d5016; margin-bottom: 24px;">Payment Confirmed!</h1>
|
<h1 style="color: #2d5016; margin-bottom: 24px;">Payment Confirmed!</h1>
|
||||||
|
|
||||||
|
|
@ -106,8 +183,9 @@ const paymentConfirmationEmail = (application) => ({
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 4px 0;"><strong>Type:</strong></td>
|
<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>
|
</tr>
|
||||||
|
${accomRow}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 4px 0;"><strong>Amount:</strong></td>
|
<td style="padding: 4px 0;"><strong>Amount:</strong></td>
|
||||||
<td style="padding: 4px 0;">€${application.payment_amount}</td>
|
<td style="padding: 4px 0;">€${application.payment_amount}</td>
|
||||||
|
|
@ -119,6 +197,8 @@ const paymentConfirmationEmail = (application) => ({
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${bookingHtml}
|
||||||
|
|
||||||
<p>Your application is now complete. Our team will review it and get back to you within 2-3 weeks.</p>
|
<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>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|
||||||
async function logEmail(recipientEmail, recipientName, emailType, subject, messageId, metadata = {}) {
|
async function logEmail(recipientEmail, recipientName, emailType, subject, messageId, metadata = {}) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -196,30 +277,87 @@ async function handleWebhook(req, res) {
|
||||||
|
|
||||||
console.log(`Payment ${paymentId} for application ${applicationId}: ${paymentStatus}`);
|
console.log(`Payment ${paymentId} for application ${applicationId}: ${paymentStatus}`);
|
||||||
|
|
||||||
// Send payment confirmation email if payment succeeded
|
// On payment success: assign bed + send confirmation emails
|
||||||
if (paymentStatus === 'paid' && process.env.SMTP_PASS) {
|
if (paymentStatus === 'paid') {
|
||||||
try {
|
try {
|
||||||
const appResult = await pool.query(
|
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]
|
[paymentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (appResult.rows.length > 0) {
|
if (appResult.rows.length > 0) {
|
||||||
const application = appResult.rows[0];
|
const application = appResult.rows[0];
|
||||||
const confirmEmail = paymentConfirmationEmail(application);
|
const accommodationType = payment.metadata.accommodationType || application.accommodation_type;
|
||||||
const info = await smtp.sendMail({
|
const selectedWeeks = payment.metadata.selectedWeeks || [];
|
||||||
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
|
|
||||||
to: application.email,
|
// Attempt bed assignment if accommodation was selected
|
||||||
bcc: 'team@valleyofthecommons.com',
|
let bookingResult = null;
|
||||||
subject: confirmEmail.subject,
|
if (accommodationType) {
|
||||||
html: confirmEmail.html,
|
try {
|
||||||
});
|
const guestName = `${application.first_name} ${application.last_name}`;
|
||||||
await logEmail(application.email, `${application.first_name} ${application.last_name}`,
|
bookingResult = await assignBooking(guestName, accommodationType, selectedWeeks);
|
||||||
'payment_confirmation', confirmEmail.subject, info.messageId,
|
console.log(`[Booking] ${guestName}: ${bookingResult.success ? 'Assigned' : 'Failed'} — ${JSON.stringify(bookingResult)}`);
|
||||||
{ applicationId: application.id, paymentId, amount: application.payment_amount });
|
} 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,
|
||||||
|
bcc: 'team@valleyofthecommons.com',
|
||||||
|
subject: confirmEmail.subject,
|
||||||
|
html: confirmEmail.html,
|
||||||
|
});
|
||||||
|
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;">€${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) {
|
} 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,
|
||||||
|
};
|
||||||
|
|
|
||||||
196
apply.html
196
apply.html
|
|
@ -621,38 +621,102 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add-ons -->
|
<!-- Accommodation -->
|
||||||
<div style="margin-top: 2rem;">
|
<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>
|
<h3 style="font-family: 'Cormorant Garamond', serif; font-size: 1.25rem; color: var(--forest); margin-bottom: 1rem;">Accommodation</h3>
|
||||||
<p class="hint" style="margin-bottom: 1rem;">These can be arranged and paid separately after acceptance.</p>
|
|
||||||
|
|
||||||
<label class="week-card" onclick="toggleAddon(this, 'accommodation')" style="margin-bottom: 0.75rem;">
|
<label class="week-card" onclick="toggleAddon(this, 'accommodation')" style="margin-bottom: 0.75rem;">
|
||||||
<input type="checkbox" id="need_accommodation">
|
<input type="checkbox" id="need_accommodation">
|
||||||
<h4>I need accommodation</h4>
|
<h4>Include accommodation with registration</h4>
|
||||||
<div class="desc">On-site housing for the duration of your stay. Pricing depends on room type.</div>
|
<div class="desc">On-site housing paid upfront with your registration fee.</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div id="accommodation-preference" style="display: none; padding: 0.75rem 1rem; margin-bottom: 0.75rem;">
|
<div id="accommodation-options" style="display: none;">
|
||||||
<label for="accom_type" style="font-size: 0.85rem;">Preferred room type <span class="optional">(non-binding)</span></label>
|
<!-- Venue selection -->
|
||||||
<select id="accom_type" name="accom_type" style="margin-top: 0.25rem;">
|
<div style="padding: 0.75rem 0; margin-bottom: 0.5rem;">
|
||||||
<option value="">-- Select preference --</option>
|
<label style="font-weight: 600; margin-bottom: 0.75rem; display: block;">Choose your venue</label>
|
||||||
<option value="dorm">Dorm (4-6 people)</option>
|
|
||||||
<option value="shared">Shared Double</option>
|
<label class="week-card" style="margin-bottom: 0.5rem; cursor: pointer;" onclick="selectVenue('ch')">
|
||||||
<option value="single">Single (deluxe apartment)</option>
|
<input type="radio" name="venue" value="ch" style="display:none;">
|
||||||
</select>
|
<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>
|
</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')">
|
<label class="week-card" onclick="toggleAddon(this, 'food')">
|
||||||
<input type="checkbox" id="want_food">
|
<input type="checkbox" id="want_food">
|
||||||
<h4>I want food included</h4>
|
<h4>I would like to include food for the week</h4>
|
||||||
<div class="desc">Communal meals during your stay (~€10/day). Can be arranged and paid after acceptance.</div>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Price summary -->
|
<!-- Price summary -->
|
||||||
<div id="price-summary" class="ticket-note" style="margin-top: 1.5rem;">
|
<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>
|
<div id="price-calc">Select at least one week</div>
|
||||||
<span style="font-size: 0.75rem; color: #888;">Accommodation and food costs will be invoiced separately after acceptance.</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-nav">
|
<div class="form-nav">
|
||||||
|
|
@ -713,6 +777,28 @@
|
||||||
let currentStep = 1;
|
let currentStep = 1;
|
||||||
const totalSteps = 12;
|
const totalSteps = 12;
|
||||||
const PRICE_PER_WEEK = 300;
|
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() {
|
function updateProgress() {
|
||||||
const percent = Math.round(((currentStep - 1) / totalSteps) * 100);
|
const percent = Math.round(((currentStep - 1) / totalSteps) * 100);
|
||||||
|
|
@ -815,19 +901,84 @@
|
||||||
card.classList.toggle('selected', cb.checked);
|
card.classList.toggle('selected', cb.checked);
|
||||||
|
|
||||||
if (type === 'accommodation') {
|
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() {
|
function updatePriceSummary() {
|
||||||
const weeksCount = document.querySelectorAll('input[name="weeks"]:checked').length;
|
const weeksCount = document.querySelectorAll('input[name="weeks"]:checked').length;
|
||||||
const el = document.getElementById('price-calc');
|
const el = document.getElementById('price-calc');
|
||||||
|
|
||||||
if (weeksCount === 0) {
|
if (weeksCount === 0) {
|
||||||
el.textContent = 'Select at least one week';
|
el.textContent = 'Select at least one week';
|
||||||
} else {
|
return;
|
||||||
const total = weeksCount * PRICE_PER_WEEK;
|
|
||||||
el.innerHTML = `€${PRICE_PER_WEEK} × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = <strong>€${total.toLocaleString()}</strong>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> €${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
|
// Theme ranking drag & drop
|
||||||
|
|
@ -902,7 +1053,8 @@
|
||||||
weeks: weeks,
|
weeks: weeks,
|
||||||
attendance_type: weeks.length === 4 ? 'full' : 'partial',
|
attendance_type: weeks.length === 4 ? 'full' : 'partial',
|
||||||
need_accommodation: document.getElementById('need_accommodation').checked,
|
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,
|
want_food: document.getElementById('want_food').checked,
|
||||||
anything_else: form.anything_else.value,
|
anything_else: form.anything_else.value,
|
||||||
privacy_policy_accepted: form.privacy_accepted.checked,
|
privacy_policy_accepted: form.privacy_accepted.checked,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -85,9 +85,10 @@ CREATE TABLE IF NOT EXISTS applications (
|
||||||
scholarship_reason TEXT,
|
scholarship_reason TEXT,
|
||||||
contribution_amount VARCHAR(50), -- 'registration' (base fee) or legacy ticket type
|
contribution_amount VARCHAR(50), -- 'registration' (base fee) or legacy ticket type
|
||||||
|
|
||||||
-- Add-ons (invoiced separately after acceptance)
|
-- Add-ons
|
||||||
need_accommodation BOOLEAN DEFAULT FALSE,
|
need_accommodation BOOLEAN DEFAULT FALSE,
|
||||||
want_food BOOLEAN DEFAULT FALSE,
|
want_food BOOLEAN DEFAULT FALSE,
|
||||||
|
accommodation_type VARCHAR(50), -- CCG-style: ch-multi, ch-double, hh-single, etc.
|
||||||
|
|
||||||
-- Payment (Mollie)
|
-- Payment (Mollie)
|
||||||
mollie_payment_id VARCHAR(255),
|
mollie_payment_id VARCHAR(255),
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ async function runMigrations() {
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
ALTER TABLE applications
|
ALTER TABLE applications
|
||||||
ADD COLUMN IF NOT EXISTS need_accommodation BOOLEAN DEFAULT FALSE,
|
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)
|
// Rename resend_id → message_id in email_log (legacy column name)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue