diff --git a/api/application.js b/api/application.js index 348fed8..14f4219 100644 --- a/api/application.js +++ b/api/application.js @@ -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 => `
  • ${WEEK_LABELS[w] || w}
  • `).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 = ` + + Accommodation: + ${label}
    €${perWeek.toFixed(2)}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${pricing.accommodation} + `; } - if (application.want_food) addOns.push('Food included'); - const addOnsHtml = addOns.length > 0 - ? `Add-ons:${addOns.join(', ')} (invoiced separately)` + + // Food note + const foodNote = application.want_food + ? 'Food:Interest registered — we are exploring co-producing meals as a community. More details and costs coming soon.' : ''; return { @@ -64,13 +72,22 @@ const confirmationEmail = (application) => { - + + + ${accomHtml} + + + + + + + - ${addOnsHtml} + ${foodNote}
    Registration:€${PRICE_PER_WEEK}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${amount}€${PRICE_PER_WEEK}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${pricing.registration}
    Processing fee (2%):€${pricing.processingFee}
    Total:€${pricing.total}
    Attendance: ${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}
    ${weeksHtml ? `

    Weeks selected:

    ` : ''} @@ -82,7 +99,7 @@ const confirmationEmail = (application) => {
  • Our team will review your application
  • We may reach out with follow-up questions
  • You'll receive a decision within 2-3 weeks
  • - ${addOns.length > 0 ? '
  • Accommodation and food costs will be invoiced separately after acceptance
  • ' : ''} + ${accomType ? '
  • Your bed will be assigned automatically once payment is confirmed
  • ' : ''} @@ -134,11 +151,11 @@ const adminNotificationEmail = (application) => ({ Accommodation: - ${application.need_accommodation ? `Yes${application.accommodation_preference ? ' (' + application.accommodation_preference + ')' : ''}` : 'No'} + ${application.accommodation_type ? (ACCOMMODATION_LABELS[application.accommodation_type] || application.accommodation_type) : (application.need_accommodation ? 'Yes (no type selected)' : 'No')} - Food: - ${application.want_food ? 'Yes' : 'No'} + Food interest: + ${application.want_food ? 'Yes — wants to co-produce meals' : 'No'} Scholarship: @@ -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 diff --git a/api/booking-sheet.js b/api/booking-sheet.js new file mode 100644 index 0000000..f4ffed5 --- /dev/null +++ b/api/booking-sheet.js @@ -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 }; diff --git a/api/google-sheets.js b/api/google-sheets.js index 304129a..2272bc3 100644 --- a/api/google-sheets.js +++ b/api/google-sheets.js @@ -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 || '', diff --git a/api/mollie.js b/api/mollie.js index b3170c2..160e3e0 100644 --- a/api/mollie.js +++ b/api/mollie.js @@ -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,20 +127,50 @@ 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) => ({ - subject: 'Payment Confirmed - Valley of the Commons', - html: ` +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 ? ` + + Accommodation: + ${accomLabel} + ` : ''; + + // Booking assignment info + let bookingHtml = ''; + if (bookingResult) { + if (bookingResult.success) { + bookingHtml = ` +
    +

    Bed Assignment

    +

    You have been assigned to ${bookingResult.venue} — Room ${bookingResult.room}, ${bookingResult.bedType}.

    +
    `; + } else { + bookingHtml = ` +
    +

    Your accommodation request has been noted. Our team will follow up with your room assignment shortly.

    +
    `; + } + } + + return { + subject: 'Payment Confirmed - Valley of the Commons', + html: `

    Payment Confirmed!

    @@ -106,8 +183,9 @@ const paymentConfirmationEmail = (application) => ({ - + + ${accomRow} @@ -119,6 +197,8 @@ const paymentConfirmationEmail = (application) => ({
    Type:Event RegistrationEvent Registration${accomLabel ? ' + Accommodation' : ''}
    Amount: €${application.payment_amount}
    + ${bookingHtml} +

    Your application is now complete. Our team will review it and get back to you within 2-3 weeks.

    If you have any questions, reply to this email and we'll get back to you.

    @@ -134,7 +214,8 @@ const paymentConfirmationEmail = (application) => ({

    ` -}); + }; +}; async function logEmail(recipientEmail, recipientName, emailType, subject, messageId, metadata = {}) { try { @@ -196,30 +277,87 @@ 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 info = await smtp.sendMail({ - from: process.env.EMAIL_FROM || 'Valley of the Commons ', - 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 }); + 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 ', + 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: ` +
    +

    + ${bookingResult?.success ? 'Bed Assigned' : 'Manual Assignment Needed'} +

    + + + + + + + +
    Guest:${application.first_name} ${application.last_name}
    Email:${application.email}
    Accommodation:${accomLabel}
    Weeks:${selectedWeeks.join(', ') || 'N/A'}
    Status:${bookingStatus}
    Payment:€${application.payment_amount}
    +
    + `, + }; + + try { + await smtp.sendMail({ + from: process.env.EMAIL_FROM || 'Valley of the Commons ', + 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, +}; diff --git a/apply.html b/apply.html index 4ad5e72..fb4785e 100644 --- a/apply.html +++ b/apply.html @@ -621,38 +621,102 @@ - +
    -

    Optional Add-ons

    -

    These can be arranged and paid separately after acceptance.

    +

    Accommodation

    -
    - Registration fee: Select at least one week
    - Accommodation and food costs will be invoiced separately after acceptance. +
    Select at least one week
    @@ -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' : ''} = €${total.toLocaleString()}`; + 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 = `Registration: €${PRICE_PER_WEEK} × ${weeksCount} wk = €${registration.toFixed(2)}`; + + if (accommodation > 0) { + const label = ACCOMMODATION_LABELS[accomType] || accomType; + html += `
    Accommodation: ${label}
      €${accomPerWeek.toFixed(2)} × ${weeksCount} wk = €${accommodation.toFixed(2)}`; + } + + html += `
    Processing fee (2%): €${fee.toFixed(2)}`; + html += `
    Total: €${total.toFixed(2)}`; + + const wantFood = document.getElementById('want_food').checked; + if (wantFood) { + html += `
    Food: interest registered — details & costs coming soon.`; + } + + 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, diff --git a/db/migration-003-accommodation.sql b/db/migration-003-accommodation.sql new file mode 100644 index 0000000..390b20e --- /dev/null +++ b/db/migration-003-accommodation.sql @@ -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); diff --git a/db/schema.sql b/db/schema.sql index 1d4b122..9b00478 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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), diff --git a/server.js b/server.js index ce49a0d..573ac6d 100644 --- a/server.js +++ b/server.js @@ -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)