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:
parent
42062a2467
commit
a397e4abe9
|
|
@ -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;">€${PRICE_PER_WEEK}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${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;">€${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
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
: '',
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
228
apply.html
228
apply.html
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue