feat: restructure registration to per-week toggles with accommodation/food add-ons

Replace the old ticket picker (dorm/shared/single × full/weekly) with a
simpler model: €300/week base registration with opt-in toggles for
accommodation and food, which are invoiced separately after acceptance.

- Merge form steps 11+12 into single "Weeks & Options" step (13→12 total)
- Add "Select all 4 weeks" toggle, accommodation yes/no + preference, food toggle
- Live price summary (€300 × weeks)
- Simplify Mollie pricing to flat per-week rate
- Add need_accommodation and want_food DB columns with auto-migration
- Update confirmation/admin emails and Google Sheets sync

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 17:47:10 -08:00
parent 42062a2467
commit a397e4abe9
6 changed files with 183 additions and 166 deletions

View File

@ -4,7 +4,7 @@
const { Pool } = require('pg');
const nodemailer = require('nodemailer');
const { syncApplication } = require('./google-sheets');
const { createPayment, TICKET_LABELS, calculateAmount } = require('./mollie');
const { createPayment, TICKET_LABELS, PRICE_PER_WEEK, calculateAmount } = require('./mollie');
const { addToListmonk } = require('./listmonk');
// Initialize PostgreSQL connection pool
@ -35,10 +35,20 @@ const WEEK_LABELS = {
// Email templates
const confirmationEmail = (application) => {
const ticketLabel = TICKET_LABELS[application.contribution_amount] || application.contribution_amount || 'Not selected';
const amount = application.contribution_amount ? calculateAmount(application.contribution_amount, (application.weeks || []).length) : null;
const weeksCount = (application.weeks || []).length;
const amount = calculateAmount('registration', weeksCount);
const weeksHtml = (application.weeks || []).map(w => `<li>${WEEK_LABELS[w] || w}</li>`).join('');
const addOns = [];
if (application.need_accommodation) {
const prefLabel = application.accommodation_preference ? ` (preference: ${application.accommodation_preference})` : '';
addOns.push(`Accommodation${prefLabel}`);
}
if (application.want_food) addOns.push('Food included');
const addOnsHtml = addOns.length > 0
? `<tr><td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Add-ons:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${addOns.join(', ')} <em>(invoiced separately)</em></td></tr>`
: '';
return {
subject: 'Application Received - Valley of the Commons',
html: `
@ -53,17 +63,14 @@ const confirmationEmail = (application) => {
<h3 style="margin-top: 0; color: #2d5016;">Your Booking Summary</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Ticket:</strong></td>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${ticketLabel}</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;">&euro;${PRICE_PER_WEEK}/week &times; ${weeksCount} week${weeksCount > 1 ? 's' : ''} = &euro;${amount}</td>
</tr>
${amount ? `<tr>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Amount:</strong></td>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">&euro;${amount}</td>
</tr>` : ''}
<tr>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Attendance:</strong></td>
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}</td>
</tr>
${addOnsHtml}
</table>
${weeksHtml ? `<p style="margin-top: 12px; margin-bottom: 0;"><strong>Weeks selected:</strong></p><ul style="margin-top: 4px; margin-bottom: 0;">${weeksHtml}</ul>` : ''}
</div>
@ -71,10 +78,11 @@ const confirmationEmail = (application) => {
<div style="background: #f5f5f0; padding: 20px; border-radius: 8px; margin: 24px 0;">
<h3 style="margin-top: 0; color: #2d5016;">What happens next?</h3>
<ol style="margin-bottom: 0;">
<li>Complete your payment (if you haven't already)</li>
<li>Complete your registration payment (if you haven't already)</li>
<li>Our team will review your application</li>
<li>We may reach out with follow-up questions</li>
<li>You'll receive a decision within 2-3 weeks</li>
${addOns.length > 0 ? '<li>Accommodation and food costs will be invoiced separately after acceptance</li>' : ''}
</ol>
</div>
@ -124,6 +132,14 @@ const adminNotificationEmail = (application) => ({
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Attendance:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Accommodation:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.need_accommodation ? `Yes${application.accommodation_preference ? ' (' + application.accommodation_preference + ')' : ''}` : 'No'}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Food:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.want_food ? 'Yes' : 'No'}</td>
</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;">${application.scholarship_needed ? 'Yes' : 'No'}</td>
@ -219,11 +235,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
ip_address, user_agent, need_accommodation, want_food
) 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
$33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43
) RETURNING id, submitted_at`,
[
data.first_name?.trim(),
@ -264,9 +280,11 @@ module.exports = async function handler(req, res) {
data.photo_consent || false,
data.scholarship_needed || false,
data.scholarship_reason?.trim() || null,
data.contribution_amount || null,
'registration',
req.headers['x-forwarded-for'] || req.connection?.remoteAddress || null,
req.headers['user-agent'] || null
req.headers['user-agent'] || null,
data.need_accommodation || false,
data.want_food || false
]
);
@ -290,7 +308,10 @@ module.exports = async function handler(req, res) {
arrival_date: data.arrival_date,
departure_date: data.departure_date,
weeks: weeksSelected,
contribution_amount: data.contribution_amount,
need_accommodation: data.need_accommodation || false,
accommodation_preference: data.accommodation_preference || null,
want_food: data.want_food || false,
contribution_amount: 'registration',
};
// Sync to Google Sheets (fire-and-forget backup)
@ -336,15 +357,14 @@ module.exports = async function handler(req, res) {
}
}
// Create Mollie payment if ticket was selected and Mollie is configured
// Create Mollie payment for registration fee (€300/week)
let checkoutUrl = null;
if (data.contribution_amount && process.env.MOLLIE_API_KEY) {
if (weeksSelected.length > 0 && process.env.MOLLIE_API_KEY) {
try {
const weeksCount = Array.isArray(data.weeks) ? data.weeks.length : 1;
const paymentResult = await createPayment(
application.id,
data.contribution_amount,
weeksCount,
'registration',
weeksSelected.length,
application.email,
application.first_name,
application.last_name

View File

@ -90,8 +90,8 @@ 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 | Motivation | Contribution | How Heard |
* Referral | Scholarship | Scholarship Reason | Weeks/Dates
* Country | City | Attendance | Weeks | Accommodation | Accom Pref | Food |
* Motivation | Contribution | How Heard | Referral | Scholarship | Scholarship Reason
*/
function syncApplication(app) {
appendRow('Registrations', [
@ -105,15 +105,16 @@ function syncApplication(app) {
app.country || '',
app.city || '',
app.attendance_type || '',
(app.weeks || []).join(', '),
app.need_accommodation ? 'Yes' : 'No',
app.accommodation_preference || '',
app.want_food ? 'Yes' : 'No',
app.motivation || '',
app.contribution || '',
app.how_heard || '',
app.referral_name || '',
app.scholarship_needed ? 'Yes' : 'No',
app.scholarship_reason || '',
app.arrival_date && app.departure_date
? `${app.arrival_date} to ${app.departure_date}`
: '',
]);
}

View File

@ -22,23 +22,16 @@ const smtp = nodemailer.createTransport({
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER || 'newsletter@valleyofthecommons.com',
user: process.env.SMTP_USER || 'contact@valleyofthecommons.com',
pass: process.env.SMTP_PASS || '',
},
tls: { rejectUnauthorized: false },
});
// Ticket price mapping (in EUR)
const TICKET_PRICES = {
'full-dorm': 1500.00,
'full-shared': 1800.00,
'full-single': 3200.00,
'week-dorm': 425.00,
'week-shared': 500.00,
'week-single': 850.00,
'no-accom': 300.00, // per week
};
// Base registration price per week (EUR)
const PRICE_PER_WEEK = 300.00;
// Legacy ticket labels (kept for backward-compat with existing DB records)
const TICKET_LABELS = {
'full-dorm': 'Full Resident - Dorm (4-6 people)',
'full-shared': 'Full Resident - Shared Double',
@ -47,18 +40,12 @@ const TICKET_LABELS = {
'week-shared': '1-Week Visitor - Shared Double',
'week-single': '1-Week Visitor - Single (deluxe apartment)',
'no-accom': 'Non-Accommodation Pass',
'registration': 'Event Registration',
};
function calculateAmount(ticketType, weeksCount) {
const basePrice = TICKET_PRICES[ticketType];
if (!basePrice) return null;
// no-accom is priced per week
if (ticketType === 'no-accom') {
return (basePrice * (weeksCount || 1)).toFixed(2);
}
return basePrice.toFixed(2);
// New pricing: flat €300/week
return (PRICE_PER_WEEK * (weeksCount || 1)).toFixed(2);
}
// Create a Mollie payment for an application
@ -69,7 +56,7 @@ async function createPayment(applicationId, ticketType, weeksCount, email, first
}
const baseUrl = process.env.BASE_URL || 'https://valleyofthecommons.com';
const description = `Valley of the Commons - ${TICKET_LABELS[ticketType] || ticketType}`;
const description = `Valley of the Commons - Registration (${weeksCount} week${weeksCount > 1 ? 's' : ''})`;
const payment = await mollieClient.payments.create({
amount: {
@ -118,8 +105,8 @@ const paymentConfirmationEmail = (application) => ({
<h3 style="margin-top: 0; color: #2d5016;">Payment Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 4px 0;"><strong>Ticket:</strong></td>
<td style="padding: 4px 0;">${TICKET_LABELS[application.contribution_amount] || application.contribution_amount}</td>
<td style="padding: 4px 0;"><strong>Type:</strong></td>
<td style="padding: 4px 0;">Event Registration</td>
</tr>
<tr>
<td style="padding: 4px 0;"><strong>Amount:</strong></td>
@ -221,7 +208,7 @@ async function handleWebhook(req, res) {
const application = appResult.rows[0];
const confirmEmail = paymentConfirmationEmail(application);
const info = await smtp.sendMail({
from: process.env.EMAIL_FROM || 'Valley of the Commons <newsletter@valleyofthecommons.com>',
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
to: application.email,
subject: confirmEmail.subject,
html: confirmEmail.html,
@ -281,4 +268,4 @@ async function getPaymentStatus(req, res) {
}
}
module.exports = { createPayment, handleWebhook, getPaymentStatus, TICKET_PRICES, TICKET_LABELS, calculateAmount };
module.exports = { createPayment, handleWebhook, getPaymentStatus, PRICE_PER_WEEK, TICKET_LABELS, calculateAmount };

View File

@ -238,29 +238,14 @@
.week-card .dates { font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; }
.week-card .desc { font-size: 0.85rem; color: #555; }
/* Ticket options */
.ticket-section { margin-bottom: 1.5rem; }
.ticket-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: var(--charcoal); }
.ticket-options { display: flex; flex-direction: column; gap: 0.5rem; }
.ticket-card {
border: 2px solid #ddd;
border-radius: 8px;
padding: 0.875rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
/* Select-all card */
.select-all-card {
border-style: dashed;
background: var(--sand);
}
.select-all-card.selected {
border-style: solid;
}
.ticket-card:hover { border-color: var(--forest-light); }
.ticket-card.selected { border-color: var(--forest); background: rgba(45, 80, 22, 0.05); }
.ticket-card input { display: none; }
.ticket-name { font-size: 0.9rem; }
.ticket-price { font-weight: 600; color: var(--forest); }
.ticket-note {
font-size: 0.8rem;
@ -369,7 +354,7 @@
<form id="application-form">
<!-- Q1: Contact Information -->
<div class="form-section active" data-step="1">
<div class="question-number">Question 1 of 13</div>
<div class="question-number">Question 1 of 12</div>
<h2>Contact Information</h2>
<div class="form-group" style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
@ -406,7 +391,7 @@
<!-- Q2: How did you hear -->
<div class="form-section" data-step="2">
<div class="question-number">Question 2 of 13</div>
<div class="question-number">Question 2 of 12</div>
<h2>How did you hear about Valley of the Commons? <span class="required">*</span></h2>
<div class="form-group">
@ -421,7 +406,7 @@
<!-- Q3: Referral names -->
<div class="form-section" data-step="3">
<div class="question-number">Question 3 of 13</div>
<div class="question-number">Question 3 of 12</div>
<h2>Referral name(s) <span class="optional">(optional)</span></h2>
<p class="hint">Who can vouch for you?</p>
@ -437,7 +422,7 @@
<!-- Q4: Affiliations -->
<div class="form-section" data-step="4">
<div class="question-number">Question 4 of 13</div>
<div class="question-number">Question 4 of 12</div>
<h2>What are your affiliations? <span class="required">*</span></h2>
<p class="hint">What projects or groups are you affiliated with?</p>
@ -453,7 +438,7 @@
<!-- Q5: Why join -->
<div class="form-section" data-step="5">
<div class="question-number">Question 5 of 13</div>
<div class="question-number">Question 5 of 12</div>
<h2>Why would you like to join Valley of the Commons, and why are you a good fit? <span class="required">*</span></h2>
<div class="form-group">
@ -468,7 +453,7 @@
<!-- Q6: Current work -->
<div class="form-section" data-step="6">
<div class="question-number">Question 6 of 13</div>
<div class="question-number">Question 6 of 12</div>
<h2>What are you currently building, researching, or working on? <span class="required">*</span></h2>
<div class="form-group">
@ -483,7 +468,7 @@
<!-- Q7: How contribute -->
<div class="form-section" data-step="7">
<div class="question-number">Question 7 of 13</div>
<div class="question-number">Question 7 of 12</div>
<h2>How will you contribute to Valley of the Commons? <span class="required">*</span></h2>
<p class="hint">Villagers co-create their experience. You can start an interest club, lead a discussion or workshop, teach a cooking class, or more.</p>
@ -499,7 +484,7 @@
<!-- Q8: Rank themes -->
<div class="form-section" data-step="8">
<div class="question-number">Question 8 of 13</div>
<div class="question-number">Question 8 of 12</div>
<h2>Please rank your interest in our themes <span class="required">*</span></h2>
<p class="hint">Drag to reorder from most interested (top) to least interested (bottom).</p>
@ -564,7 +549,7 @@
<!-- Q9: Theme familiarity -->
<div class="form-section" data-step="9">
<div class="question-number">Question 9 of 13</div>
<div class="question-number">Question 9 of 12</div>
<h2>Please explain your familiarity and interest in our themes and event overall <span class="required">*</span></h2>
<p class="hint">🏞️ Valley Future · 🌐 Cosmo-localism · 🪙 Funding & Token Engineering · 👾 Fablabs · 🌌 Future Living · 🧑‍⚖️ Network Governance · ⛲️ Commons · 👤 Privacy · 🌏 d/acc · 💭 (Meta)rationality</p>
@ -580,7 +565,7 @@
<!-- Q10: Belief update -->
<div class="form-section" data-step="10">
<div class="question-number">Question 10 of 13</div>
<div class="question-number">Question 10 of 12</div>
<h2>Describe a belief you have updated within the last 1-2 years <span class="required">*</span></h2>
<p class="hint">What was the belief and why did it change?</p>
@ -594,12 +579,19 @@
</div>
</div>
<!-- Q11: Which weeks -->
<!-- Q11: Weeks & Options -->
<div class="form-section" data-step="11">
<div class="question-number">Question 11 of 13</div>
<div class="question-number">Question 11 of 12</div>
<h2>Which week(s) would you like to attend? <span class="required">*</span></h2>
<p class="hint">Select the weeks you'd like to join. Base registration is €300 per week.</p>
<div class="week-cards">
<label class="week-card select-all-card" onclick="toggleAllWeeks(this)">
<input type="checkbox" id="select-all-weeks">
<h4>Select all 4 weeks</h4>
<div class="desc">Full residency: August 24 September 20, 2026</div>
</label>
<label class="week-card" onclick="toggleWeek(this)">
<input type="checkbox" name="weeks" value="week1">
<h4>Week 1: Return to the Commons</h4>
@ -629,86 +621,49 @@
</label>
</div>
<!-- Add-ons -->
<div style="margin-top: 2rem;">
<h3 style="font-family: 'Cormorant Garamond', serif; font-size: 1.25rem; color: var(--forest); margin-bottom: 1rem;">Optional Add-ons</h3>
<p class="hint" style="margin-bottom: 1rem;">These can be arranged and paid separately after acceptance.</p>
<label class="week-card" onclick="toggleAddon(this, 'accommodation')" style="margin-bottom: 0.75rem;">
<input type="checkbox" id="need_accommodation">
<h4>I need accommodation</h4>
<div class="desc">On-site housing for the duration of your stay. Pricing depends on room type.</div>
</label>
<div id="accommodation-preference" style="display: none; padding: 0.75rem 1rem; margin-bottom: 0.75rem;">
<label for="accom_type" style="font-size: 0.85rem;">Preferred room type <span class="optional">(non-binding)</span></label>
<select id="accom_type" name="accom_type" style="margin-top: 0.25rem;">
<option value="">-- Select preference --</option>
<option value="dorm">Dorm (4-6 people)</option>
<option value="shared">Shared Double</option>
<option value="single">Single (deluxe apartment)</option>
</select>
</div>
<label class="week-card" onclick="toggleAddon(this, 'food')">
<input type="checkbox" id="want_food">
<h4>I want food included</h4>
<div class="desc">Communal meals during your stay (~€10/day). Can be arranged and paid after acceptance.</div>
</label>
</div>
<!-- Price summary -->
<div id="price-summary" class="ticket-note" style="margin-top: 1.5rem;">
<strong>Registration fee:</strong> <span id="price-calc">Select at least one week</span><br>
<span style="font-size: 0.75rem; color: #888;">Accommodation and food costs will be invoiced separately after acceptance.</span>
</div>
<div class="form-nav">
<button type="button" class="btn btn-secondary" onclick="prevStep()">Back</button>
<button type="button" class="btn btn-primary" onclick="nextStep()">Continue</button>
</div>
</div>
<!-- Q12: Ticket option -->
<!-- Q12: Anything else -->
<div class="form-section" data-step="12">
<div class="question-number">Question 12 of 13</div>
<h2>Which ticket option would you prefer? <span class="required">*</span></h2>
<p class="hint">Prices and options subject to change.</p>
<div class="ticket-section">
<h4>🏡 Full Resident (4 weeks)</h4>
<div class="ticket-options">
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="full-dorm" required>
<span class="ticket-name">Dorm (4-6 people)</span>
<span class="ticket-price">€1,500</span>
</label>
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="full-shared">
<span class="ticket-name">Shared Double</span>
<span class="ticket-price">€1,800</span>
</label>
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="full-single">
<span class="ticket-name">Single (deluxe apartment)</span>
<span class="ticket-price">€3,200</span>
</label>
</div>
</div>
<div class="ticket-section">
<h4>🗓 1-Week Visitor (max 20 per week)</h4>
<div class="ticket-options">
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="week-dorm">
<span class="ticket-name">Dorm (4-6 people)</span>
<span class="ticket-price">€425</span>
</label>
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="week-shared">
<span class="ticket-name">Shared Double</span>
<span class="ticket-price">€500</span>
</label>
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="week-single">
<span class="ticket-name">Single (deluxe apartment)</span>
<span class="ticket-price">€850</span>
</label>
</div>
</div>
<div class="ticket-section">
<h4>🎟 Non-Accommodation Pass</h4>
<div class="ticket-options">
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="no-accom">
<span class="ticket-name">Event access only</span>
<span class="ticket-price">€300/week</span>
</label>
</div>
</div>
<div class="ticket-note">
<strong>Included:</strong> Accommodation (if applicable), venue access, event ticket<br>
<strong>Not included:</strong> Food (~€10/day)<br>
<strong>Note:</strong> +10% after June 1 (goes to event org costs)
</div>
<div class="form-nav">
<button type="button" class="btn btn-secondary" onclick="prevStep()">Back</button>
<button type="button" class="btn btn-primary" onclick="nextStep()">Continue</button>
</div>
</div>
<!-- Q13: Anything else -->
<div class="form-section" data-step="13">
<div class="question-number">Question 13 of 13</div>
<div class="question-number">Question 12 of 12</div>
<h2>Anything else you'd like to add? <span class="optional">(optional)</span></h2>
<div class="form-group">
@ -756,7 +711,8 @@
<script>
let currentStep = 1;
const totalSteps = 13;
const totalSteps = 12;
const PRICE_PER_WEEK = 300;
function updateProgress() {
const percent = Math.round(((currentStep - 1) / totalSteps) * 100);
@ -827,12 +783,51 @@
const cb = card.querySelector('input');
cb.checked = !cb.checked;
card.classList.toggle('selected', cb.checked);
syncSelectAll();
updatePriceSummary();
}
function selectTicket(card) {
document.querySelectorAll('.ticket-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
card.querySelector('input').checked = true;
function toggleAllWeeks(card) {
const selectAllCb = card.querySelector('input');
selectAllCb.checked = !selectAllCb.checked;
card.classList.toggle('selected', selectAllCb.checked);
const weekCbs = document.querySelectorAll('input[name="weeks"]');
weekCbs.forEach(cb => {
cb.checked = selectAllCb.checked;
cb.closest('.week-card').classList.toggle('selected', selectAllCb.checked);
});
updatePriceSummary();
}
function syncSelectAll() {
const weekCbs = document.querySelectorAll('input[name="weeks"]');
const allChecked = Array.from(weekCbs).every(cb => cb.checked);
const selectAllCb = document.getElementById('select-all-weeks');
const selectAllCard = selectAllCb.closest('.week-card');
selectAllCb.checked = allChecked;
selectAllCard.classList.toggle('selected', allChecked);
}
function toggleAddon(card, type) {
const cb = card.querySelector('input');
cb.checked = !cb.checked;
card.classList.toggle('selected', cb.checked);
if (type === 'accommodation') {
document.getElementById('accommodation-preference').style.display = cb.checked ? 'block' : 'none';
}
}
function updatePriceSummary() {
const weeksCount = document.querySelectorAll('input[name="weeks"]:checked').length;
const el = document.getElementById('price-calc');
if (weeksCount === 0) {
el.textContent = 'Select at least one week';
} else {
const total = weeksCount * PRICE_PER_WEEK;
el.innerHTML = `€${PRICE_PER_WEEK} × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = <strong>€${total.toLocaleString()}</strong>`;
}
}
// Theme ranking drag & drop
@ -887,6 +882,7 @@
function collectFormData() {
const form = document.getElementById('application-form');
const weeks = getSelectedWeeks();
return {
first_name: form.first_name.value.trim(),
@ -903,9 +899,11 @@
theme_ranking: JSON.stringify(getThemeRanking()),
theme_familiarity: form.theme_familiarity.value,
belief_update: form.belief_update.value,
weeks: getSelectedWeeks(),
attendance_type: getSelectedWeeks().length === 4 ? 'full' : 'partial',
contribution_amount: form.querySelector('input[name="ticket"]:checked')?.value || null,
weeks: weeks,
attendance_type: weeks.length === 4 ? 'full' : 'partial',
need_accommodation: document.getElementById('need_accommodation').checked,
accommodation_preference: document.getElementById('accom_type').value || null,
want_food: document.getElementById('want_food').checked,
anything_else: form.anything_else.value,
privacy_policy_accepted: form.privacy_accepted.checked,
code_of_conduct_accepted: true

View File

@ -83,7 +83,11 @@ CREATE TABLE IF NOT EXISTS applications (
-- Financial
scholarship_needed BOOLEAN DEFAULT FALSE,
scholarship_reason TEXT,
contribution_amount VARCHAR(50), -- sliding scale selection
contribution_amount VARCHAR(50), -- 'registration' (base fee) or legacy ticket type
-- Add-ons (invoiced separately after acceptance)
need_accommodation BOOLEAN DEFAULT FALSE,
want_food BOOLEAN DEFAULT FALSE,
-- Payment (Mollie)
mollie_payment_id VARCHAR(255),

View File

@ -76,6 +76,13 @@ async function runMigrations() {
await pool.query('CREATE INDEX IF NOT EXISTS idx_applications_mollie_id ON applications(mollie_payment_id)');
await pool.query('CREATE INDEX IF NOT EXISTS idx_applications_payment_status ON applications(payment_status)');
// Add accommodation/food add-on columns
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
`);
// Rename resend_id → message_id in email_log (legacy column name)
const colCheck = await pool.query(`
SELECT column_name FROM information_schema.columns