Compare commits

..

2 Commits
main ... dev

Author SHA1 Message Date
Jeff Emmett d1364a0ad0 ci: use internal registry (bypass Cloudflare upload limit)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 10:47:01 -07:00
Jeff Emmett 8cfb49324e ci: add Gitea Actions CI/CD pipeline
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 10:33:30 -07:00
7 changed files with 172 additions and 573 deletions

66
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,66 @@
# Gitea Actions CI/CD — Static Site (no tests, build + deploy only)
# Copy to: <repo>/.gitea/workflows/ci.yml
# Replace: valley-commons, /opt/websites/valley-commons, https://valleyofthecommons.com/
name: CI/CD
on:
push:
branches: [main]
env:
REGISTRY: localhost:3000
IMAGE: localhost:3000/jeffemmett/valley-commons
jobs:
deploy:
runs-on: ubuntu-latest
container:
image: docker:cli
steps:
- name: Setup tools
run: apk add --no-cache git openssh-client curl
- name: Checkout
run: git clone --depth 1 --branch ${{ github.ref_name }} http://token:${{ github.token }}@server:3000/${{ github.repository }}.git .
- name: Set image tag
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-8)
echo "IMAGE_TAG=${SHORT_SHA}" >> $GITHUB_ENV
- name: Build and push image
run: |
docker build -t ${{ env.IMAGE }}:${{ env.IMAGE_TAG }} -t ${{ env.IMAGE }}:latest .
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
docker push ${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
docker push ${{ env.IMAGE }}:latest
- name: Deploy
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" | base64 -d > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "
cd /opt/websites/valley-commons
cat .last-deployed-tag 2>/dev/null > .rollback-tag || true
echo '${{ env.IMAGE_TAG }}' > .last-deployed-tag
docker pull ${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
IMAGE_TAG=${{ env.IMAGE_TAG }} docker compose up -d --no-build
"
- name: Smoke test
run: |
sleep 15
HTTP_CODE=$(curl -sSL -o /dev/null -w "%{http_code}" --max-time 30 https://valleyofthecommons.com/ 2>/dev/null || echo "000")
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 400 ]; then
echo "Smoke test failed (HTTP $HTTP_CODE) — rolling back"
ROLLBACK_TAG=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "cat /opt/websites/valley-commons/.rollback-tag 2>/dev/null")
if [ -n "$ROLLBACK_TAG" ]; then
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
"cd /opt/websites/valley-commons && IMAGE_TAG=$ROLLBACK_TAG docker compose up -d --no-build"
echo "Rolled back to $ROLLBACK_TAG"
fi
exit 1
fi
echo "Smoke test passed (HTTP $HTTP_CODE)"

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, PUT, OPTIONS');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, 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 ORDER BY submitted_at DESC LIMIT 1',
'SELECT id FROM applications WHERE email = $1',
[data.email.toLowerCase().trim()]
);
@ -469,242 +469,5 @@ 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,10 +6,6 @@ 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': {
@ -101,20 +97,17 @@ async function parseBookingSheet() {
}
const sheetId = process.env.BOOKING_SHEET_ID || process.env.GOOGLE_SHEET_ID;
const sheetName = process.env.BOOKING_SHEET_TAB || 'VotC26 Occupancy';
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
const response = await sheets.spreadsheets.values.get({
spreadsheetId: sheetId,
range: `${sheetName}!A:L`,
range: `${sheetName}!A:G`,
});
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];
@ -122,50 +115,34 @@ 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.startsWith('Occupancy Commons Hub') || firstCell === 'Commons Hub') {
currentVenue = 'Commons Hub';
if (firstCell === 'Commons Hub' || firstCell === 'Herrnhof Villa') {
currentVenue = firstCell;
weekColIndexes = {};
currentRoom = null;
continue;
}
if (firstCell === 'Herrnhof Villa') {
currentVenue = 'Herrnhof Villa';
weekColIndexes = {};
currentRoom = null;
continue;
}
// Check if this is the column header row (look for week headers)
if (currentVenue && !Object.keys(weekColIndexes).length) {
let foundWeeks = false;
// Check if this is the column header row (contains "Room" and week columns)
if (firstCell.toLowerCase() === 'room' && currentVenue) {
for (let c = 0; c < row.length; c++) {
const header = (row[c] || '').toString().trim();
if (WEEK_COLUMNS.includes(header)) {
weekColIndexes[header] = c;
foundWeeks = true;
}
if (header.toLowerCase() === 'room #' || header.toLowerCase() === 'room') roomCol = c;
if (header.toLowerCase() === 'bed type') bedTypeCol = c;
}
if (foundWeeks) continue;
continue;
}
// If we have a venue and week columns, this is a bed row
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;
if (currentVenue && Object.keys(weekColIndexes).length > 0 && firstCell) {
const room = firstCell;
const bedType = (row[1] || '').toString().trim();
if (!bedType) continue;
const occupancy = {};
for (const [week, colIdx] of Object.entries(weekColIndexes)) {
@ -175,7 +152,7 @@ async function parseBookingSheet() {
beds.push({
venue: currentVenue,
room: currentRoom,
room,
bedType,
rowIndex: i,
weekColumns: { ...weekColIndexes },
@ -187,38 +164,6 @@ 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.
@ -284,7 +229,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 || 'VotC26 Occupancy';
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
// Convert week values to column headers
const weekHeaders = selectedWeeks.map(w => `Week ${w.replace('week', '')}`);
@ -312,10 +257,6 @@ 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 {
@ -330,4 +271,4 @@ async function assignBooking(guestName, accommodationType, selectedWeeks) {
}
}
module.exports = { assignBooking, parseBookingSheet, findAvailableBed, checkAvailability, ACCOMMODATION_CRITERIA };
module.exports = { assignBooking, parseBookingSheet, findAvailableBed, ACCOMMODATION_CRITERIA };

View File

@ -371,10 +371,9 @@ 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: bookingAlertEmail,
to: 'team@valleyofthecommons.com',
subject: bookingNotification.subject,
html: bookingNotification.html,
});

View File

@ -177,28 +177,6 @@
.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;
@ -522,19 +500,6 @@
.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; }
@ -556,7 +521,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 as many weeks as you like.</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 one week or all four.</p>
<div class="pricing-cards">
<div class="pricing-card">
@ -575,10 +540,10 @@
<div id="resume-notice" class="resume-notice" style="display: none;">
You have saved progress from a previous session.
<a href="#" onclick="startFormAndResume(); return false;" style="color: var(--forest); font-weight: 600;">Resume where you left off</a>
<a href="#" onclick="startForm(true); return false;" style="color: var(--forest); font-weight: 600;">Resume where you left off</a>
</div>
<button id="start-btn" class="btn btn-primary" onclick="startForm()" style="font-size: 1.1rem; padding: 1rem 3rem;">Start Application</button>
<button class="btn btn-primary" onclick="startForm(false)" style="font-size: 1.1rem; padding: 1rem 3rem;">Begin Application</button>
</div>
<div class="progress-container" id="progress-container">
@ -588,51 +553,8 @@
<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>
@ -677,6 +599,48 @@
<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>
@ -923,7 +887,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)</span>
<span>I'm interested in volunteering (e.g. helping with setup, cooking, facilitation) in exchange for a reduced fee</span>
</label>
</div>
@ -1067,66 +1031,33 @@
document.getElementById('landing-reg-price').innerHTML =
`&euro;${tierPricing.perWeek} &euro;${REGISTRATION_PRICING.lastMin.perWeek}/wk`;
// Show resume notice if there's saved progress
// Check for saved data on load
(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() {
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() {
function startForm(resume) {
document.getElementById('landing-screen').style.display = 'none';
document.getElementById('application-form').style.display = 'block';
document.getElementById('progress-container').style.display = 'block';
// 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;
}
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);
}
} catch (e) {
console.error('Email lookup failed:', e);
}
}
currentStep = 1;
showStep(1);
}
// ===== Autosave =====
@ -1180,24 +1111,17 @@
updateThemeCounter();
}
// Food preference — handle "allergies: <text>" from DB
// Food preference
if (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}"]`);
const radio = document.querySelector(`input[name="food_preference"][value="${data.food_preference}"]`);
if (radio) {
radio.checked = true;
if (foodVal === 'allergies') {
if (data.food_preference === 'allergies') {
document.getElementById('food-allergies-detail').style.display = 'block';
if (allergyText) document.getElementById('food_allergies_text').value = allergyText;
}
}
}
if (data.food_allergies_text && !document.getElementById('food_allergies_text').value) {
if (data.food_allergies_text) {
document.getElementById('food_allergies_text').value = data.food_allergies_text;
}
@ -1287,8 +1211,8 @@
emailField.style.borderColor = 'var(--error)';
}
// Week selection (step 2)
if (step === 2) {
// Week selection (step 1)
if (step === 1) {
const checked = document.querySelectorAll('input[name="weeks"]:checked');
if (checked.length === 0) {
valid = false;
@ -1299,37 +1223,8 @@
return valid;
}
// State for existing application resume flow
window._existingApplicationId = null;
async function nextStep() {
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);
@ -1351,34 +1246,6 @@
}
// ===== 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;
@ -1386,8 +1253,6 @@
syncSelectAll();
updatePriceSummary();
saveFormData();
// Re-check availability when weeks change
if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability();
}
function toggleAllWeeks(card) {
@ -1402,7 +1267,6 @@
});
updatePriceSummary();
saveFormData();
if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability();
}
function syncSelectAll() {
@ -1429,8 +1293,6 @@
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();
}
}
@ -1457,10 +1319,6 @@
}
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);
@ -1622,11 +1480,22 @@
let html = '';
// Contact (step 1)
// 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
html += `<div class="review-section">
<div class="review-section-header">
<h3>Contact Information</h3>
<span class="review-edit-link" onclick="jumpToStep(1)">Edit</span>
<span class="review-edit-link" onclick="jumpToStep(2)">Edit</span>
</div>
<div class="review-field">
<div class="review-label">Name</div>
@ -1637,19 +1506,11 @@
<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>` : ''}
${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 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>`;
// Affiliations
@ -1768,14 +1629,13 @@
errorDiv.style.display = 'none';
submitBtn.disabled = true;
const isUpdate = !!window._existingApplicationId;
submitBtn.textContent = isUpdate ? 'Updating...' : 'Submitting...';
submitBtn.textContent = 'Submitting...';
try {
const data = collectFormData();
const response = await fetch('/api/application', {
method: isUpdate ? 'PUT' : 'POST',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
@ -1783,15 +1643,13 @@
const result = await response.json();
if (response.ok && result.success) {
// Save submitted data so "Resume" works on next visit
saveFormData();
// Clear saved data
localStorage.removeItem(STORAGE_KEY);
// Show success state
document.getElementById('confirm-name').textContent = data.first_name;
document.getElementById('confirm-email').textContent = data.email;
document.getElementById('success-heading').textContent = isUpdate
? `Application updated, ${data.first_name}!`
: `You're in the process, ${data.first_name}!`;
document.getElementById('success-heading').textContent = `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,8 +20,6 @@ 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, PUT, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
@ -23,7 +23,6 @@ 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');
@ -43,37 +42,12 @@ 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'],