Compare commits

...

12 Commits
dev ... main

Author SHA1 Message Date
Jeff Emmett 396b6d1c7e fix: rewrite booking sheet parser to match actual VotC sheet structure
- Fix venue header detection ('Occupancy Commons Hub' not just 'Commons Hub')
- Dynamically find room # and bed type columns (not hardcoded positions)
- Carry room numbers down for multi-bed rooms
- Expand range from A:G to A:L to capture all 4 week columns
- Change default tab name to 'VotC26 Occupancy'
- Add BOOKING_SHEET_ID to docker-compose.yml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 13:14:57 -04:00
Jeff Emmett 78b2ba0499 feat: dynamic accommodation availability + overbooking alerts
- Add checkAvailability() with 2-min cache to booking-sheet.js
- Add GET /api/accommodation-availability endpoint (weeks-based)
- Fetch availability on accommodation toggle/week change, disable sold-out rooms
- Redirect booking notifications to jeff@jeffemmett.com for testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 11:48:31 -04:00
Jeff Emmett ccc79c0489 fix: show "Start Application" when no saved session exists
Button defaults to green "Start Application" and only switches to
white "Restart Application" when localStorage has saved progress.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 10:14:18 -04:00
Jeff Emmett 4e4273c27b fix: persist form data across visits, don't overwrite local edits
Keep submitted data in localStorage so "Resume" works on return visits.
Stop overwriting local edits with older server data during resume —
email lookup now only sets the update flag, preserving local changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 11:46:22 -04:00
Jeff Emmett f4dca61631 fix: single resume prompt, silent email lookup, rename Begin to Restart
Remove welcome-back modal entirely. Email lookup now silently loads
server data without prompting. Only resume prompt is "Resume where you
left off" on the landing page. Rename "Begin Application" to
"Restart Application" with white/secondary button style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 11:41:28 -04:00
Jeff Emmett a2341dfa38 fix: show resume prompt on landing page, keep step 1 check as fallback
Restore "Resume where you left off" on the landing page. When clicked,
restores localStorage data and does an email lookup to show the
welcome-back modal. Fresh starts still get the email check on step 1
as a fallback for existing applications.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 11:36:08 -04:00
Jeff Emmett 8c13a80843 fix: single resume flow — remove landing screen notice, auto-restore localStorage
Remove the duplicate "Resume where you left off" notice on the landing
screen. Now localStorage data is silently restored on form start, and
the email-based welcome-back modal on step 1 is the only resume prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 10:45:07 -04:00
Jeff Emmett e42cccaf6d feat: move About You to step 1 for immediate email resume, make how-heard optional
Swap steps 1 and 2 so email is collected first, enabling returning
user detection right away. Make "How did you hear about VotC" optional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 10:31:47 -04:00
Jeff Emmett 83caf44a16 fix: lookup/update uses latest application per email
Add ORDER BY submitted_at DESC LIMIT 1 to all email-based queries
so returning users always get their most recent application data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 10:26:58 -04:00
Jeff Emmett b8823e32ec feat: resume existing application by email lookup
Add GET /api/application/lookup endpoint for public email-based lookups,
PUT /api/application handler for updating existing applications, and
frontend flow that detects returning users after step 2 with a
welcome-back modal to pre-fill and update their application.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 13:50:24 -04:00
Jeff Emmett 0eafbb35a0 copy: clarify flexible weekly attendance on apply form
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 13:35:36 -04:00
Jeff Emmett 8c06e2e9eb ci: retrigger pipeline 2026-04-02 15:06:30 -07:00
6 changed files with 572 additions and 105 deletions

View File

@ -196,7 +196,7 @@ async function logEmail(recipientEmail, recipientName, emailType, subject, messa
module.exports = async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
@ -223,7 +223,7 @@ module.exports = async function handler(req, res) {
// Check for duplicate application
const existing = await pool.query(
'SELECT id FROM applications WHERE email = $1',
'SELECT id FROM applications WHERE email = $1 ORDER BY submitted_at DESC LIMIT 1',
[data.email.toLowerCase().trim()]
);
@ -469,5 +469,242 @@ module.exports = async function handler(req, res) {
}
}
// PUT - Update existing application
if (req.method === 'PUT') {
try {
const data = req.body;
// Validate required fields
const required = ['first_name', 'last_name', 'email', 'motivation', 'belief_update', 'privacy_policy_accepted'];
for (const field of required) {
if (!data[field]) {
return res.status(400).json({ error: `Missing required field: ${field}` });
}
}
// Find existing application by email
const existing = await pool.query(
'SELECT id, payment_status, submitted_at, status FROM applications WHERE email = $1 ORDER BY submitted_at DESC LIMIT 1',
[data.email.toLowerCase().trim()]
);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'No application found with this email' });
}
const app = existing.rows[0];
// If already paid, don't allow re-submission
if (app.payment_status === 'paid') {
return res.status(400).json({
error: 'This application has already been paid. Contact us if you need to make changes.',
paid: true,
applicationId: app.id
});
}
// Prepare arrays for PostgreSQL
const skills = Array.isArray(data.skills) ? data.skills : (data.skills ? [data.skills] : null);
const languages = Array.isArray(data.languages) ? data.languages : (data.languages ? [data.languages] : null);
const dietary = Array.isArray(data.dietary_requirements) ? data.dietary_requirements : (data.dietary_requirements ? [data.dietary_requirements] : null);
const governance = Array.isArray(data.governance_interest) ? data.governance_interest : (data.governance_interest ? [data.governance_interest] : null);
const previousEvents = Array.isArray(data.previous_events) ? data.previous_events : (data.previous_events ? [data.previous_events] : null);
const selectedWeeks = Array.isArray(data.weeks) ? data.weeks : (data.weeks ? [data.weeks] : []);
const topThemes = Array.isArray(data.top_themes) ? data.top_themes : (data.top_themes ? [data.top_themes] : null);
// Update the application
await pool.query(
`UPDATE applications SET
first_name = $1, last_name = $2, phone = $3, country = $4, city = $5,
pronouns = $6, date_of_birth = $7, occupation = $8, organization = $9,
skills = $10, languages = $11, website = $12, social_links = $13,
attendance_type = $14, arrival_date = $15, departure_date = $16,
accommodation_preference = $17, dietary_requirements = $18, dietary_notes = $19,
motivation = $20, contribution = $21, projects = $22, workshops_offer = $23,
commons_experience = $24, community_experience = $25, governance_interest = $26,
how_heard = $27, referral_name = $28, previous_events = $29,
emergency_name = $30, emergency_phone = $31, emergency_relationship = $32,
code_of_conduct_accepted = $33, privacy_policy_accepted = $34, photo_consent = $35,
scholarship_needed = $36, scholarship_reason = $37, need_accommodation = $38,
want_food = $39, accommodation_type = $40, selected_weeks = $41,
top_themes = $42, belief_update = $43, volunteer_interest = $44,
coupon_code = $45, food_preference = $46, accessibility_needs = $47
WHERE id = $48`,
[
data.first_name?.trim(),
data.last_name?.trim(),
data.phone?.trim() || null,
data.country?.trim() || null,
data.city?.trim() || null,
data.pronouns?.trim() || null,
data.date_of_birth || null,
data.occupation?.trim() || null,
data.organization?.trim() || null,
skills,
languages,
data.website?.trim() || null,
data.social_links ? JSON.stringify(data.social_links) : null,
data.attendance_type || 'full',
data.arrival_date || null,
data.departure_date || null,
data.accommodation_preference || null,
dietary,
data.dietary_notes?.trim() || null,
data.motivation?.trim(),
data.contribution?.trim() || null,
data.projects?.trim() || null,
data.workshops_offer?.trim() || null,
data.commons_experience?.trim() || null,
data.community_experience?.trim() || null,
governance,
data.how_heard?.trim() || null,
data.referral_name?.trim() || null,
previousEvents,
data.emergency_name?.trim() || null,
data.emergency_phone?.trim() || null,
data.emergency_relationship?.trim() || null,
data.code_of_conduct_accepted || false,
data.privacy_policy_accepted || false,
data.photo_consent || false,
data.scholarship_needed || false,
data.scholarship_reason?.trim() || null,
data.need_accommodation || false,
data.want_food || false,
data.accommodation_type || null,
selectedWeeks.length > 0 ? selectedWeeks : null,
topThemes,
data.belief_update?.trim() || null,
data.volunteer_interest || false,
data.coupon_code?.trim() || null,
data.food_preference?.trim() || null,
data.accessibility_needs?.trim() || null,
app.id
]
);
const application = {
id: app.id,
submitted_at: app.submitted_at,
first_name: data.first_name,
last_name: data.last_name,
email: data.email,
weeks: selectedWeeks,
accommodation_type: data.accommodation_type || null,
};
// Create Mollie payment
let checkoutUrl = null;
if (selectedWeeks.length > 0 && process.env.MOLLIE_API_KEY) {
try {
const paymentResult = await createPayment(
app.id,
'registration',
selectedWeeks.length,
data.email.toLowerCase().trim(),
data.first_name,
data.last_name,
data.accommodation_type,
selectedWeeks
);
checkoutUrl = paymentResult.checkoutUrl;
console.log(`Mollie payment created (update): ${paymentResult.paymentId} (€${paymentResult.amount})`);
} catch (paymentError) {
console.error('Failed to create Mollie payment:', paymentError);
}
}
return res.status(200).json({
success: true,
message: 'Application updated successfully',
applicationId: app.id,
checkoutUrl,
});
} catch (error) {
console.error('Application update error:', error);
return res.status(500).json({ error: 'Failed to update application. Please try again.' });
}
}
return res.status(405).json({ error: 'Method not allowed' });
};
// Lookup handler - public endpoint to check if an application exists by email
module.exports.lookup = async function lookupHandler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const email = (req.query.email || '').toLowerCase().trim();
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid email is required' });
}
try {
const result = await pool.query(
`SELECT id, first_name, last_name, email, phone, country, city, pronouns,
date_of_birth, occupation, organization, skills, languages, website,
social_links, attendance_type, arrival_date, departure_date,
accommodation_preference, dietary_requirements, dietary_notes,
motivation, contribution, projects, workshops_offer, commons_experience,
community_experience, governance_interest, 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, need_accommodation, want_food,
accommodation_type, selected_weeks, top_themes, belief_update,
volunteer_interest, coupon_code, food_preference, accessibility_needs,
payment_status, submitted_at
FROM applications WHERE email = $1
ORDER BY submitted_at DESC LIMIT 1`,
[email]
);
if (result.rows.length === 0) {
return res.status(404).json({ found: false });
}
const row = result.rows[0];
// Map DB column names to frontend form field names that restoreFormData() expects
const mapped = {
id: row.id,
first_name: row.first_name,
last_name: row.last_name,
email: row.email,
social_links: row.social_links,
how_heard: row.how_heard,
referral_name: row.referral_name,
affiliations: row.commons_experience,
motivation: row.motivation,
current_work: row.projects,
contribution: row.contribution,
themes_familiarity: row.workshops_offer,
belief_update: row.belief_update,
weeks: row.selected_weeks || [],
top_themes: row.top_themes || [],
need_accommodation: row.need_accommodation,
accommodation_type: row.accommodation_type,
food_preference: row.food_preference,
accessibility_needs: row.accessibility_needs,
volunteer_interest: row.volunteer_interest,
coupon_code: row.coupon_code,
privacy_policy_accepted: row.privacy_policy_accepted,
payment_status: row.payment_status,
submitted_at: row.submitted_at,
};
return res.status(200).json({ found: true, application: mapped });
} catch (error) {
console.error('Application lookup error:', error);
return res.status(500).json({ error: 'Lookup failed' });
}
};

View File

@ -6,6 +6,10 @@ const fs = require('fs');
let sheetsClient = null;
// In-memory cache for parsed booking sheet (2-min TTL)
let sheetCache = { data: null, timestamp: 0 };
const CACHE_TTL_MS = 2 * 60 * 1000;
// Accommodation criteria mapping — maps accommodation_type to sheet matching rules
const ACCOMMODATION_CRITERIA = {
'ch-multi': {
@ -97,17 +101,20 @@ async function parseBookingSheet() {
}
const sheetId = process.env.BOOKING_SHEET_ID || process.env.GOOGLE_SHEET_ID;
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
const sheetName = process.env.BOOKING_SHEET_TAB || 'VotC26 Occupancy';
const response = await sheets.spreadsheets.values.get({
spreadsheetId: sheetId,
range: `${sheetName}!A:G`,
range: `${sheetName}!A:L`,
});
const rows = response.data.values || [];
const beds = [];
let currentVenue = null;
let weekColIndexes = {};
let currentRoom = null;
let bedTypeCol = -1;
let roomCol = -1;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
@ -115,34 +122,50 @@ async function parseBookingSheet() {
// Empty row — reset venue context
currentVenue = null;
weekColIndexes = {};
currentRoom = null;
bedTypeCol = -1;
roomCol = -1;
continue;
}
const firstCell = (row[0] || '').toString().trim();
// Check if this is a venue header
if (firstCell === 'Commons Hub' || firstCell === 'Herrnhof Villa') {
currentVenue = firstCell;
if (firstCell.startsWith('Occupancy Commons Hub') || firstCell === 'Commons Hub') {
currentVenue = 'Commons Hub';
weekColIndexes = {};
currentRoom = null;
continue;
}
if (firstCell === 'Herrnhof Villa') {
currentVenue = 'Herrnhof Villa';
weekColIndexes = {};
currentRoom = null;
continue;
}
// Check if this is the column header row (contains "Room" and week columns)
if (firstCell.toLowerCase() === 'room' && currentVenue) {
// Check if this is the column header row (look for week headers)
if (currentVenue && !Object.keys(weekColIndexes).length) {
let foundWeeks = false;
for (let c = 0; c < row.length; c++) {
const header = (row[c] || '').toString().trim();
if (WEEK_COLUMNS.includes(header)) {
weekColIndexes[header] = c;
foundWeeks = true;
}
if (header.toLowerCase() === 'room #' || header.toLowerCase() === 'room') roomCol = c;
if (header.toLowerCase() === 'bed type') bedTypeCol = c;
}
continue;
if (foundWeeks) 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;
if (currentVenue && Object.keys(weekColIndexes).length > 0 && roomCol >= 0 && bedTypeCol >= 0) {
const roomCell = (row[roomCol] || '').toString().trim();
if (roomCell) currentRoom = roomCell;
const bedType = (row[bedTypeCol] || '').toString().trim();
if (!bedType || !currentRoom) continue;
const occupancy = {};
for (const [week, colIdx] of Object.entries(weekColIndexes)) {
@ -152,7 +175,7 @@ async function parseBookingSheet() {
beds.push({
venue: currentVenue,
room,
room: currentRoom,
bedType,
rowIndex: i,
weekColumns: { ...weekColIndexes },
@ -164,6 +187,38 @@ async function parseBookingSheet() {
return beds;
}
/**
* Cached wrapper around parseBookingSheet().
*/
async function getCachedBeds() {
const now = Date.now();
if (sheetCache.data && (now - sheetCache.timestamp) < CACHE_TTL_MS) {
return sheetCache.data;
}
const beds = await parseBookingSheet();
if (beds) {
sheetCache.data = beds;
sheetCache.timestamp = now;
}
return beds;
}
/**
* Check availability of all accommodation types for the given weeks.
* @param {string[]} selectedWeeks - e.g. ['week1', 'week2'] or ['week1','week2','week3','week4'] for full month
* @returns {object|null} Map of accommodation type boolean (available)
*/
async function checkAvailability(selectedWeeks) {
const beds = await getCachedBeds();
if (!beds) return null;
const availability = {};
for (const type of Object.keys(ACCOMMODATION_CRITERIA)) {
availability[type] = !!findAvailableBed(beds, type, selectedWeeks);
}
return availability;
}
/**
* Find an available bed matching the accommodation criteria for the given weeks.
* A bed is "available" only if ALL requested week columns are empty.
@ -229,7 +284,7 @@ async function assignBooking(guestName, accommodationType, selectedWeeks) {
// 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';
const sheetName = process.env.BOOKING_SHEET_TAB || 'VotC26 Occupancy';
// Convert week values to column headers
const weekHeaders = selectedWeeks.map(w => `Week ${w.replace('week', '')}`);
@ -257,6 +312,10 @@ async function assignBooking(guestName, accommodationType, selectedWeeks) {
});
}
// Invalidate cache after successful assignment
sheetCache.data = null;
sheetCache.timestamp = 0;
console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for weeks: ${selectedWeeks.join(', ')}`);
return {
@ -271,4 +330,4 @@ async function assignBooking(guestName, accommodationType, selectedWeeks) {
}
}
module.exports = { assignBooking, parseBookingSheet, findAvailableBed, ACCOMMODATION_CRITERIA };
module.exports = { assignBooking, parseBookingSheet, findAvailableBed, checkAvailability, ACCOMMODATION_CRITERIA };

View File

@ -371,9 +371,10 @@ async function handleWebhook(req, res) {
};
try {
const bookingAlertEmail = process.env.BOOKING_ALERT_EMAIL || 'jeff@jeffemmett.com';
await smtp.sendMail({
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
to: 'team@valleyofthecommons.com',
to: bookingAlertEmail,
subject: bookingNotification.subject,
html: bookingNotification.html,
});

View File

@ -177,6 +177,28 @@
.week-card .dates { font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; }
.week-card .desc { font-size: 0.85rem; color: #555; }
.week-card.sold-out {
opacity: 0.5;
pointer-events: none;
position: relative;
}
.week-card.sold-out::after {
content: 'Sold Out';
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(200, 50, 50, 0.12);
border: 1px solid rgba(200, 50, 50, 0.4);
color: #c83232;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.6rem;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Select-all card */
.select-all-card {
border-style: dashed;
@ -500,6 +522,19 @@
.next-steps-box li { margin-bottom: 0.5rem; }
.btn-secondary {
display: inline-block;
padding: 0.75rem 2rem;
border: 2px solid var(--forest);
color: var(--forest);
background: #fff;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
}
.btn-secondary:hover { background: rgba(45, 80, 22, 0.05); }
@media (max-width: 600px) {
.container { padding: 1rem; }
.form-section, .landing { padding: 1.5rem; }
@ -521,7 +556,7 @@
<div class="landing" id="landing-screen">
<span class="event-badge">August 24 September 20, 2026</span>
<h1>Application Form</h1>
<p class="overview-text">Valley of the Commons is a four-week pop-up village exploring housing, production, decision-making and ownership in community. Each week has a different theme — attend one week or all four.</p>
<p class="overview-text">Valley of the Commons is a four-week pop-up village exploring housing, production, decision-making and ownership in community. Each week has a different theme — attend as many weeks as you like.</p>
<div class="pricing-cards">
<div class="pricing-card">
@ -540,10 +575,10 @@
<div id="resume-notice" class="resume-notice" style="display: none;">
You have saved progress from a previous session.
<a href="#" onclick="startForm(true); return false;" style="color: var(--forest); font-weight: 600;">Resume where you left off</a>
<a href="#" onclick="startFormAndResume(); return false;" style="color: var(--forest); font-weight: 600;">Resume where you left off</a>
</div>
<button class="btn btn-primary" onclick="startForm(false)" style="font-size: 1.1rem; padding: 1rem 3rem;">Begin Application</button>
<button id="start-btn" class="btn btn-primary" onclick="startForm()" style="font-size: 1.1rem; padding: 1rem 3rem;">Start Application</button>
</div>
<div class="progress-container" id="progress-container">
@ -553,8 +588,51 @@
<form id="application-form" style="display: none;">
<!-- Step 1: Which weeks + price calculator -->
<!-- Step 1: Contact info + how heard + referral -->
<div class="form-section" data-step="1">
<div class="question-number">Step 1 of 10</div>
<h2>About You</h2>
<div class="form-group" style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label for="first_name">First Name <span class="required">*</span></label>
<input type="text" id="first_name" name="first_name" required placeholder="First name">
</div>
<div>
<label for="last_name">Last Name <span class="required">*</span></label>
<input type="text" id="last_name" name="last_name" required placeholder="Last name">
</div>
</div>
<div class="form-group">
<label for="email">Email <span class="required">*</span></label>
<input type="email" id="email" name="email" required placeholder="your@email.com">
</div>
<div class="form-group">
<label for="social_media">Social Media Handles <span class="optional">(optional)</span></label>
<input type="text" id="social_media" name="social_media" placeholder="@handle (please specify which platforms)">
</div>
<div class="form-group">
<label for="how_heard">How did you hear about Valley of the Commons? <span class="optional">(optional)</span></label>
<textarea id="how_heard" name="how_heard" placeholder="Social media, friend referral, newsletter, event..." rows="3"></textarea>
</div>
<div class="form-group">
<label for="referral_names">Referral name(s) <span class="optional">(optional)</span></label>
<textarea id="referral_names" name="referral_names" placeholder="Names of people who know you or referred you" rows="2"></textarea>
</div>
<div class="form-nav">
<div></div>
<button type="button" class="btn btn-primary" onclick="nextStep()">Continue</button>
</div>
</div>
<!-- Step 2: Week selection -->
<div class="form-section" data-step="2">
<div class="question-number">Step 2 of 10</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. Prices update as you choose.</p>
@ -599,48 +677,6 @@
<div id="price-calc">Select at least one week to see pricing</div>
</div>
<div class="form-nav">
<div></div>
<button type="button" class="btn btn-primary" onclick="nextStep()">Continue</button>
</div>
</div>
<!-- Step 2: Contact info + how heard + referral -->
<div class="form-section" data-step="2">
<div class="question-number">Step 2 of 10</div>
<h2>About You</h2>
<div class="form-group" style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label for="first_name">First Name <span class="required">*</span></label>
<input type="text" id="first_name" name="first_name" required placeholder="First name">
</div>
<div>
<label for="last_name">Last Name <span class="required">*</span></label>
<input type="text" id="last_name" name="last_name" required placeholder="Last name">
</div>
</div>
<div class="form-group">
<label for="email">Email <span class="required">*</span></label>
<input type="email" id="email" name="email" required placeholder="your@email.com">
</div>
<div class="form-group">
<label for="social_media">Social Media Handles <span class="optional">(optional)</span></label>
<input type="text" id="social_media" name="social_media" placeholder="@handle (please specify which platforms)">
</div>
<div class="form-group">
<label for="how_heard">How did you hear about Valley of the Commons? <span class="required">*</span></label>
<textarea id="how_heard" name="how_heard" required placeholder="Social media, friend referral, newsletter, event..." rows="3"></textarea>
</div>
<div class="form-group">
<label for="referral_names">Referral name(s) <span class="optional">(optional)</span></label>
<textarea id="referral_names" name="referral_names" placeholder="Names of people who know you or referred you" rows="2"></textarea>
</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>
@ -887,7 +923,7 @@
<div class="form-group">
<label style="display: flex; align-items: flex-start; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="volunteer_interest" name="volunteer_interest" style="width: 18px; height: 18px; margin-top: 0.2rem; accent-color: var(--forest);">
<span>I'm interested in volunteering (e.g. helping with setup, cooking, facilitation) in exchange for a reduced fee</span>
<span>I'm interested in volunteering (e.g. helping with setup, cooking, facilitation)</span>
</label>
</div>
@ -1031,33 +1067,66 @@
document.getElementById('landing-reg-price').innerHTML =
`&euro;${tierPricing.perWeek} &euro;${REGISTRATION_PRICING.lastMin.perWeek}/wk`;
// Check for saved data on load
// Show resume notice if there's saved progress
(function checkSavedProgress() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
document.getElementById('resume-notice').style.display = 'block';
const btn = document.getElementById('start-btn');
btn.textContent = 'Restart Application';
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
}
})();
function startForm(resume) {
function startForm() {
document.getElementById('landing-screen').style.display = 'none';
document.getElementById('application-form').style.display = 'block';
document.getElementById('progress-container').style.display = 'block';
currentStep = 1;
showStep(1);
}
async function startFormAndResume() {
document.getElementById('landing-screen').style.display = 'none';
document.getElementById('application-form').style.display = 'block';
document.getElementById('progress-container').style.display = 'block';
if (resume) {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const data = JSON.parse(saved);
restoreFormData(data);
} catch (e) {
console.error('Failed to restore saved data:', e);
}
// Restore locally saved progress
const saved = localStorage.getItem(STORAGE_KEY);
let email = null;
if (saved) {
try {
const data = JSON.parse(saved);
restoreFormData(data);
email = data.email;
} catch (e) {
console.error('Failed to restore saved data:', e);
}
}
currentStep = 1;
showStep(1);
// If we have an email, check if application exists in DB (just set the update flag)
if (email) {
try {
const resp = await fetch('/api/application/lookup?email=' + encodeURIComponent(email));
if (resp.ok) {
const result = await resp.json();
if (result.found) {
if (result.application.payment_status === 'paid') {
alert('This email already has a completed (paid) application. Contact us at contact@valleyofthecommons.com if you need to make changes.');
return;
}
// Just flag for PUT on submit — localStorage already has the latest edits
window._existingApplicationId = result.application.id;
}
}
} catch (e) {
console.error('Email lookup failed:', e);
}
}
}
// ===== Autosave =====
@ -1111,17 +1180,24 @@
updateThemeCounter();
}
// Food preference
// Food preference — handle "allergies: <text>" from DB
if (data.food_preference) {
const radio = document.querySelector(`input[name="food_preference"][value="${data.food_preference}"]`);
let foodVal = data.food_preference;
let allergyText = '';
if (foodVal.startsWith('allergies: ')) {
allergyText = foodVal.substring('allergies: '.length);
foodVal = 'allergies';
}
const radio = document.querySelector(`input[name="food_preference"][value="${foodVal}"]`);
if (radio) {
radio.checked = true;
if (data.food_preference === 'allergies') {
if (foodVal === 'allergies') {
document.getElementById('food-allergies-detail').style.display = 'block';
if (allergyText) document.getElementById('food_allergies_text').value = allergyText;
}
}
}
if (data.food_allergies_text) {
if (data.food_allergies_text && !document.getElementById('food_allergies_text').value) {
document.getElementById('food_allergies_text').value = data.food_allergies_text;
}
@ -1211,8 +1287,8 @@
emailField.style.borderColor = 'var(--error)';
}
// Week selection (step 1)
if (step === 1) {
// Week selection (step 2)
if (step === 2) {
const checked = document.querySelectorAll('input[name="weeks"]:checked');
if (checked.length === 0) {
valid = false;
@ -1223,8 +1299,37 @@
return valid;
}
function nextStep() {
// State for existing application resume flow
window._existingApplicationId = null;
async function nextStep() {
if (!validateStep(currentStep)) return;
// Silent email check when leaving step 1 — load existing application data
if (currentStep === 1 && !window._existingApplicationId) {
const email = document.getElementById('email').value.trim().toLowerCase();
if (email) {
try {
const resp = await fetch('/api/application/lookup?email=' + encodeURIComponent(email));
if (resp.ok) {
const result = await resp.json();
if (result.found) {
if (result.application.payment_status === 'paid') {
alert('This email already has a completed (paid) application. Contact us at contact@valleyofthecommons.com if you need to make changes.');
return;
}
// Silently load server data
window._existingApplicationId = result.application.id;
restoreFormData(result.application);
saveFormData();
}
}
} catch (e) {
console.error('Email lookup failed:', e);
}
}
}
if (currentStep < totalSteps) {
currentStep++;
showStep(currentStep);
@ -1246,6 +1351,34 @@
}
// ===== Week selection =====
// Fetch and apply accommodation availability based on selected weeks
async function fetchAndApplyAvailability() {
const weeks = getSelectedWeeks();
if (weeks.length === 0) return;
try {
const res = await fetch(`/api/accommodation-availability?weeks=${encodeURIComponent(weeks.join(','))}`);
if (!res.ok) return;
const availability = await res.json();
document.querySelectorAll('input[name="room_type"]').forEach(radio => {
const type = radio.value;
const card = radio.closest('.week-card');
if (type in availability && !availability[type]) {
card.classList.add('sold-out');
card.classList.remove('selected');
radio.checked = false;
if (document.getElementById('accommodation_type').value === type) {
document.getElementById('accommodation_type').value = '';
updatePriceSummary();
}
} else {
card.classList.remove('sold-out');
}
});
} catch (e) {
// Network error — leave all options enabled
}
}
function toggleWeek(card) {
const cb = card.querySelector('input');
cb.checked = !cb.checked;
@ -1253,6 +1386,8 @@
syncSelectAll();
updatePriceSummary();
saveFormData();
// Re-check availability when weeks change
if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability();
}
function toggleAllWeeks(card) {
@ -1267,6 +1402,7 @@
});
updatePriceSummary();
saveFormData();
if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability();
}
function syncSelectAll() {
@ -1293,6 +1429,8 @@
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';
} else {
fetchAndApplyAvailability();
}
}
@ -1319,6 +1457,10 @@
}
function selectRoom(roomType) {
// Prevent selecting sold-out rooms
const radio = document.querySelector(`input[name="room_type"][value="${roomType}"]`);
if (radio && radio.closest('.week-card').classList.contains('sold-out')) return;
document.querySelectorAll('input[name="room_type"]').forEach(r => {
r.checked = (r.value === roomType);
r.closest('.week-card').classList.toggle('selected', r.checked);
@ -1480,22 +1622,11 @@
let html = '';
// Weeks
html += `<div class="review-section">
<div class="review-section-header">
<h3>Weeks Selected</h3>
<span class="review-edit-link" onclick="jumpToStep(1)">Edit</span>
</div>
<div class="review-field">
<div class="review-value">${data.weeks.map(w => WEEK_LABELS[w] || w).join('<br>')}</div>
</div>
</div>`;
// Contact
// Contact (step 1)
html += `<div class="review-section">
<div class="review-section-header">
<h3>Contact Information</h3>
<span class="review-edit-link" onclick="jumpToStep(2)">Edit</span>
<span class="review-edit-link" onclick="jumpToStep(1)">Edit</span>
</div>
<div class="review-field">
<div class="review-label">Name</div>
@ -1506,13 +1637,21 @@
<div class="review-value">${esc(data.email)}</div>
</div>
${data.social_links ? `<div class="review-field"><div class="review-label">Social</div><div class="review-value">${esc(data.social_links)}</div></div>` : ''}
<div class="review-field">
<div class="review-label">How heard</div>
<div class="review-value">${esc(data.how_heard)}</div>
</div>
${data.how_heard ? `<div class="review-field"><div class="review-label">How heard</div><div class="review-value">${esc(data.how_heard)}</div></div>` : ''}
${data.referral_name ? `<div class="review-field"><div class="review-label">Referral</div><div class="review-value">${esc(data.referral_name)}</div></div>` : ''}
</div>`;
// Weeks (step 2)
html += `<div class="review-section">
<div class="review-section-header">
<h3>Weeks Selected</h3>
<span class="review-edit-link" onclick="jumpToStep(2)">Edit</span>
</div>
<div class="review-field">
<div class="review-value">${data.weeks.map(w => WEEK_LABELS[w] || w).join('<br>')}</div>
</div>
</div>`;
// Affiliations
html += `<div class="review-section">
<div class="review-section-header">
@ -1629,13 +1768,14 @@
errorDiv.style.display = 'none';
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
const isUpdate = !!window._existingApplicationId;
submitBtn.textContent = isUpdate ? 'Updating...' : 'Submitting...';
try {
const data = collectFormData();
const response = await fetch('/api/application', {
method: 'POST',
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
@ -1643,13 +1783,15 @@
const result = await response.json();
if (response.ok && result.success) {
// Clear saved data
localStorage.removeItem(STORAGE_KEY);
// Save submitted data so "Resume" works on next visit
saveFormData();
// Show success state
document.getElementById('confirm-name').textContent = data.first_name;
document.getElementById('confirm-email').textContent = data.email;
document.getElementById('success-heading').textContent = `You're in the process, ${data.first_name}!`;
document.getElementById('success-heading').textContent = isUpdate
? `Application updated, ${data.first_name}!`
: `You're in the process, ${data.first_name}!`;
document.querySelectorAll('.form-section').forEach(s => s.classList.remove('active'));
document.querySelector('.form-section[data-step="success"]').style.display = 'block';

View File

@ -20,6 +20,8 @@ services:
- SMTP_USER=contact@valleyofthecommons.com
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=Valley of the Commons <contact@valleyofthecommons.com>
- BOOKING_SHEET_ID=1kjVy5jfGSG2vcavqkbrw_CHSn4-HY-OE3NAgyjUUpkk
- BOOKING_SHEET_TAB=VotC26 Occupancy
depends_on:
votc-db:
condition: service_healthy

View File

@ -11,8 +11,8 @@ app.use(express.urlencoded({ extended: true }));
// CORS middleware
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
@ -23,6 +23,7 @@ app.use((req, res, next) => {
const waitlistHandler = require('./api/waitlist-db');
const newsletterHandler = require('./api/newsletter');
const applicationHandler = require('./api/application');
const applicationLookupHandler = require('./api/application').lookup;
const gameChatHandler = require('./api/game-chat');
const shareToGithubHandler = require('./api/share-to-github');
const { handleWebhook, getPaymentStatus, resumePayment } = require('./api/mollie');
@ -42,12 +43,37 @@ const vercelToExpress = (handler) => async (req, res) => {
app.all('/api/waitlist', vercelToExpress(waitlistHandler));
app.all('/api/newsletter', vercelToExpress(newsletterHandler));
app.all('/api/application', vercelToExpress(applicationHandler));
app.get('/api/application/lookup', vercelToExpress(applicationLookupHandler));
app.all('/api/game-chat', vercelToExpress(gameChatHandler));
app.all('/api/share-to-github', vercelToExpress(shareToGithubHandler));
app.post('/api/mollie/webhook', vercelToExpress(handleWebhook));
app.all('/api/mollie/status', vercelToExpress(getPaymentStatus));
app.get('/api/mollie/resume', vercelToExpress(resumePayment));
// Accommodation availability check
const { checkAvailability } = require('./api/booking-sheet');
const VALID_WEEKS = new Set(['week1', 'week2', 'week3', 'week4']);
app.get('/api/accommodation-availability', async (req, res) => {
try {
const weeksParam = (req.query.weeks || '').trim();
if (!weeksParam) {
return res.status(400).json({ error: 'weeks parameter required (e.g. week1,week2)' });
}
const selectedWeeks = weeksParam.split(',').filter(w => VALID_WEEKS.has(w));
if (selectedWeeks.length === 0) {
return res.status(400).json({ error: 'No valid weeks provided' });
}
const availability = await checkAvailability(selectedWeeks);
if (!availability) {
return res.status(503).json({ error: 'Booking sheet not configured' });
}
res.json(availability);
} catch (error) {
console.error('Availability check error:', error);
res.status(500).json({ error: 'Failed to check availability' });
}
});
// Static files
app.use(express.static(path.join(__dirname), {
extensions: ['html'],