Compare commits
12 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
396b6d1c7e | |
|
|
78b2ba0499 | |
|
|
ccc79c0489 | |
|
|
4e4273c27b | |
|
|
f4dca61631 | |
|
|
a2341dfa38 | |
|
|
8c13a80843 | |
|
|
e42cccaf6d | |
|
|
83caf44a16 | |
|
|
b8823e32ec | |
|
|
0eafbb35a0 | |
|
|
8c06e2e9eb |
|
|
@ -196,7 +196,7 @@ async function logEmail(recipientEmail, recipientName, emailType, subject, messa
|
||||||
module.exports = async function handler(req, res) {
|
module.exports = async function handler(req, res) {
|
||||||
// CORS headers
|
// CORS headers
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
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');
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
|
|
@ -223,7 +223,7 @@ module.exports = async function handler(req, res) {
|
||||||
|
|
||||||
// Check for duplicate application
|
// Check for duplicate application
|
||||||
const existing = await pool.query(
|
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()]
|
[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' });
|
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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ const fs = require('fs');
|
||||||
|
|
||||||
let sheetsClient = null;
|
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
|
// Accommodation criteria mapping — maps accommodation_type to sheet matching rules
|
||||||
const ACCOMMODATION_CRITERIA = {
|
const ACCOMMODATION_CRITERIA = {
|
||||||
'ch-multi': {
|
'ch-multi': {
|
||||||
|
|
@ -97,17 +101,20 @@ async function parseBookingSheet() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetId = process.env.BOOKING_SHEET_ID || process.env.GOOGLE_SHEET_ID;
|
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({
|
const response = await sheets.spreadsheets.values.get({
|
||||||
spreadsheetId: sheetId,
|
spreadsheetId: sheetId,
|
||||||
range: `${sheetName}!A:G`,
|
range: `${sheetName}!A:L`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = response.data.values || [];
|
const rows = response.data.values || [];
|
||||||
const beds = [];
|
const beds = [];
|
||||||
let currentVenue = null;
|
let currentVenue = null;
|
||||||
let weekColIndexes = {};
|
let weekColIndexes = {};
|
||||||
|
let currentRoom = null;
|
||||||
|
let bedTypeCol = -1;
|
||||||
|
let roomCol = -1;
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const row = rows[i];
|
const row = rows[i];
|
||||||
|
|
@ -115,34 +122,50 @@ async function parseBookingSheet() {
|
||||||
// Empty row — reset venue context
|
// Empty row — reset venue context
|
||||||
currentVenue = null;
|
currentVenue = null;
|
||||||
weekColIndexes = {};
|
weekColIndexes = {};
|
||||||
|
currentRoom = null;
|
||||||
|
bedTypeCol = -1;
|
||||||
|
roomCol = -1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstCell = (row[0] || '').toString().trim();
|
const firstCell = (row[0] || '').toString().trim();
|
||||||
|
|
||||||
// Check if this is a venue header
|
// Check if this is a venue header
|
||||||
if (firstCell === 'Commons Hub' || firstCell === 'Herrnhof Villa') {
|
if (firstCell.startsWith('Occupancy Commons Hub') || firstCell === 'Commons Hub') {
|
||||||
currentVenue = firstCell;
|
currentVenue = 'Commons Hub';
|
||||||
weekColIndexes = {};
|
weekColIndexes = {};
|
||||||
|
currentRoom = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (firstCell === 'Herrnhof Villa') {
|
||||||
|
currentVenue = 'Herrnhof Villa';
|
||||||
|
weekColIndexes = {};
|
||||||
|
currentRoom = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is the column header row (contains "Room" and week columns)
|
// Check if this is the column header row (look for week headers)
|
||||||
if (firstCell.toLowerCase() === 'room' && currentVenue) {
|
if (currentVenue && !Object.keys(weekColIndexes).length) {
|
||||||
|
let foundWeeks = false;
|
||||||
for (let c = 0; c < row.length; c++) {
|
for (let c = 0; c < row.length; c++) {
|
||||||
const header = (row[c] || '').toString().trim();
|
const header = (row[c] || '').toString().trim();
|
||||||
if (WEEK_COLUMNS.includes(header)) {
|
if (WEEK_COLUMNS.includes(header)) {
|
||||||
weekColIndexes[header] = c;
|
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 we have a venue and week columns, this is a bed row
|
||||||
if (currentVenue && Object.keys(weekColIndexes).length > 0 && firstCell) {
|
if (currentVenue && Object.keys(weekColIndexes).length > 0 && roomCol >= 0 && bedTypeCol >= 0) {
|
||||||
const room = firstCell;
|
const roomCell = (row[roomCol] || '').toString().trim();
|
||||||
const bedType = (row[1] || '').toString().trim();
|
if (roomCell) currentRoom = roomCell;
|
||||||
if (!bedType) continue;
|
|
||||||
|
const bedType = (row[bedTypeCol] || '').toString().trim();
|
||||||
|
if (!bedType || !currentRoom) continue;
|
||||||
|
|
||||||
const occupancy = {};
|
const occupancy = {};
|
||||||
for (const [week, colIdx] of Object.entries(weekColIndexes)) {
|
for (const [week, colIdx] of Object.entries(weekColIndexes)) {
|
||||||
|
|
@ -152,7 +175,7 @@ async function parseBookingSheet() {
|
||||||
|
|
||||||
beds.push({
|
beds.push({
|
||||||
venue: currentVenue,
|
venue: currentVenue,
|
||||||
room,
|
room: currentRoom,
|
||||||
bedType,
|
bedType,
|
||||||
rowIndex: i,
|
rowIndex: i,
|
||||||
weekColumns: { ...weekColIndexes },
|
weekColumns: { ...weekColIndexes },
|
||||||
|
|
@ -164,6 +187,38 @@ async function parseBookingSheet() {
|
||||||
return beds;
|
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.
|
* Find an available bed matching the accommodation criteria for the given weeks.
|
||||||
* A bed is "available" only if ALL requested week columns are empty.
|
* 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
|
// Write guest name to the selected week columns
|
||||||
const sheets = await getSheetsClient();
|
const sheets = await getSheetsClient();
|
||||||
const sheetId = process.env.BOOKING_SHEET_ID || process.env.GOOGLE_SHEET_ID;
|
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
|
// Convert week values to column headers
|
||||||
const weekHeaders = selectedWeeks.map(w => `Week ${w.replace('week', '')}`);
|
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(', ')}`);
|
console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for weeks: ${selectedWeeks.join(', ')}`);
|
||||||
|
|
||||||
return {
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -371,9 +371,10 @@ async function handleWebhook(req, res) {
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const bookingAlertEmail = process.env.BOOKING_ALERT_EMAIL || 'jeff@jeffemmett.com';
|
||||||
await smtp.sendMail({
|
await smtp.sendMail({
|
||||||
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
|
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
|
||||||
to: 'team@valleyofthecommons.com',
|
to: bookingAlertEmail,
|
||||||
subject: bookingNotification.subject,
|
subject: bookingNotification.subject,
|
||||||
html: bookingNotification.html,
|
html: bookingNotification.html,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
314
apply.html
314
apply.html
|
|
@ -177,6 +177,28 @@
|
||||||
.week-card .dates { font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; }
|
.week-card .dates { font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; }
|
||||||
.week-card .desc { font-size: 0.85rem; color: #555; }
|
.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 */
|
||||||
.select-all-card {
|
.select-all-card {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
|
|
@ -500,6 +522,19 @@
|
||||||
|
|
||||||
.next-steps-box li { margin-bottom: 0.5rem; }
|
.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) {
|
@media (max-width: 600px) {
|
||||||
.container { padding: 1rem; }
|
.container { padding: 1rem; }
|
||||||
.form-section, .landing { padding: 1.5rem; }
|
.form-section, .landing { padding: 1.5rem; }
|
||||||
|
|
@ -521,7 +556,7 @@
|
||||||
<div class="landing" id="landing-screen">
|
<div class="landing" id="landing-screen">
|
||||||
<span class="event-badge">August 24 – September 20, 2026</span>
|
<span class="event-badge">August 24 – September 20, 2026</span>
|
||||||
<h1>Application Form</h1>
|
<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-cards">
|
||||||
<div class="pricing-card">
|
<div class="pricing-card">
|
||||||
|
|
@ -540,10 +575,10 @@
|
||||||
|
|
||||||
<div id="resume-notice" class="resume-notice" style="display: none;">
|
<div id="resume-notice" class="resume-notice" style="display: none;">
|
||||||
You have saved progress from a previous session.
|
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>
|
</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>
|
||||||
|
|
||||||
<div class="progress-container" id="progress-container">
|
<div class="progress-container" id="progress-container">
|
||||||
|
|
@ -553,8 +588,51 @@
|
||||||
|
|
||||||
<form id="application-form" style="display: none;">
|
<form id="application-form" style="display: none;">
|
||||||
<!-- Step 1: Which weeks + price calculator -->
|
<!-- Step 1: Which weeks + price calculator -->
|
||||||
|
<!-- Step 1: Contact info + how heard + referral -->
|
||||||
<div class="form-section" data-step="1">
|
<div class="form-section" data-step="1">
|
||||||
<div class="question-number">Step 1 of 10</div>
|
<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>
|
<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>
|
<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 id="price-calc">Select at least one week to see pricing</div>
|
||||||
</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">
|
<div class="form-nav">
|
||||||
<button type="button" class="btn btn-secondary" onclick="prevStep()">Back</button>
|
<button type="button" class="btn btn-secondary" onclick="prevStep()">Back</button>
|
||||||
<button type="button" class="btn btn-primary" onclick="nextStep()">Continue</button>
|
<button type="button" class="btn btn-primary" onclick="nextStep()">Continue</button>
|
||||||
|
|
@ -887,7 +923,7 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label style="display: flex; align-items: flex-start; gap: 0.5rem; cursor: pointer;">
|
<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);">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1031,33 +1067,66 @@
|
||||||
document.getElementById('landing-reg-price').innerHTML =
|
document.getElementById('landing-reg-price').innerHTML =
|
||||||
`€${tierPricing.perWeek} – €${REGISTRATION_PRICING.lastMin.perWeek}/wk`;
|
`€${tierPricing.perWeek} – €${REGISTRATION_PRICING.lastMin.perWeek}/wk`;
|
||||||
|
|
||||||
// Check for saved data on load
|
// Show resume notice if there's saved progress
|
||||||
(function checkSavedProgress() {
|
(function checkSavedProgress() {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
document.getElementById('resume-notice').style.display = 'block';
|
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('landing-screen').style.display = 'none';
|
||||||
document.getElementById('application-form').style.display = 'block';
|
document.getElementById('application-form').style.display = 'block';
|
||||||
document.getElementById('progress-container').style.display = 'block';
|
document.getElementById('progress-container').style.display = 'block';
|
||||||
|
|
||||||
if (resume) {
|
// Restore locally saved progress
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
if (saved) {
|
let email = null;
|
||||||
try {
|
if (saved) {
|
||||||
const data = JSON.parse(saved);
|
try {
|
||||||
restoreFormData(data);
|
const data = JSON.parse(saved);
|
||||||
} catch (e) {
|
restoreFormData(data);
|
||||||
console.error('Failed to restore saved data:', e);
|
email = data.email;
|
||||||
}
|
} catch (e) {
|
||||||
|
console.error('Failed to restore saved data:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentStep = 1;
|
currentStep = 1;
|
||||||
showStep(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 =====
|
// ===== Autosave =====
|
||||||
|
|
@ -1111,17 +1180,24 @@
|
||||||
updateThemeCounter();
|
updateThemeCounter();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Food preference
|
// Food preference — handle "allergies: <text>" from DB
|
||||||
if (data.food_preference) {
|
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) {
|
if (radio) {
|
||||||
radio.checked = true;
|
radio.checked = true;
|
||||||
if (data.food_preference === 'allergies') {
|
if (foodVal === 'allergies') {
|
||||||
document.getElementById('food-allergies-detail').style.display = 'block';
|
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;
|
document.getElementById('food_allergies_text').value = data.food_allergies_text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1211,8 +1287,8 @@
|
||||||
emailField.style.borderColor = 'var(--error)';
|
emailField.style.borderColor = 'var(--error)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Week selection (step 1)
|
// Week selection (step 2)
|
||||||
if (step === 1) {
|
if (step === 2) {
|
||||||
const checked = document.querySelectorAll('input[name="weeks"]:checked');
|
const checked = document.querySelectorAll('input[name="weeks"]:checked');
|
||||||
if (checked.length === 0) {
|
if (checked.length === 0) {
|
||||||
valid = false;
|
valid = false;
|
||||||
|
|
@ -1223,8 +1299,37 @@
|
||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextStep() {
|
// State for existing application resume flow
|
||||||
|
window._existingApplicationId = null;
|
||||||
|
|
||||||
|
async function nextStep() {
|
||||||
if (!validateStep(currentStep)) return;
|
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) {
|
if (currentStep < totalSteps) {
|
||||||
currentStep++;
|
currentStep++;
|
||||||
showStep(currentStep);
|
showStep(currentStep);
|
||||||
|
|
@ -1246,6 +1351,34 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Week selection =====
|
// ===== 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) {
|
function toggleWeek(card) {
|
||||||
const cb = card.querySelector('input');
|
const cb = card.querySelector('input');
|
||||||
cb.checked = !cb.checked;
|
cb.checked = !cb.checked;
|
||||||
|
|
@ -1253,6 +1386,8 @@
|
||||||
syncSelectAll();
|
syncSelectAll();
|
||||||
updatePriceSummary();
|
updatePriceSummary();
|
||||||
saveFormData();
|
saveFormData();
|
||||||
|
// Re-check availability when weeks change
|
||||||
|
if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAllWeeks(card) {
|
function toggleAllWeeks(card) {
|
||||||
|
|
@ -1267,6 +1402,7 @@
|
||||||
});
|
});
|
||||||
updatePriceSummary();
|
updatePriceSummary();
|
||||||
saveFormData();
|
saveFormData();
|
||||||
|
if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability();
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSelectAll() {
|
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.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('ch-rooms').style.display = 'none';
|
||||||
document.getElementById('hh-rooms').style.display = 'none';
|
document.getElementById('hh-rooms').style.display = 'none';
|
||||||
|
} else {
|
||||||
|
fetchAndApplyAvailability();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1319,6 +1457,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRoom(roomType) {
|
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 => {
|
document.querySelectorAll('input[name="room_type"]').forEach(r => {
|
||||||
r.checked = (r.value === roomType);
|
r.checked = (r.value === roomType);
|
||||||
r.closest('.week-card').classList.toggle('selected', r.checked);
|
r.closest('.week-card').classList.toggle('selected', r.checked);
|
||||||
|
|
@ -1480,22 +1622,11 @@
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
// Weeks
|
// Contact (step 1)
|
||||||
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">
|
html += `<div class="review-section">
|
||||||
<div class="review-section-header">
|
<div class="review-section-header">
|
||||||
<h3>Contact Information</h3>
|
<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>
|
||||||
<div class="review-field">
|
<div class="review-field">
|
||||||
<div class="review-label">Name</div>
|
<div class="review-label">Name</div>
|
||||||
|
|
@ -1506,13 +1637,21 @@
|
||||||
<div class="review-value">${esc(data.email)}</div>
|
<div class="review-value">${esc(data.email)}</div>
|
||||||
</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.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">
|
${data.how_heard ? `<div class="review-field"><div class="review-label">How heard</div><div class="review-value">${esc(data.how_heard)}</div></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>` : ''}
|
${data.referral_name ? `<div class="review-field"><div class="review-label">Referral</div><div class="review-value">${esc(data.referral_name)}</div></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
|
// Affiliations
|
||||||
html += `<div class="review-section">
|
html += `<div class="review-section">
|
||||||
<div class="review-section-header">
|
<div class="review-section-header">
|
||||||
|
|
@ -1629,13 +1768,14 @@
|
||||||
|
|
||||||
errorDiv.style.display = 'none';
|
errorDiv.style.display = 'none';
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Submitting...';
|
const isUpdate = !!window._existingApplicationId;
|
||||||
|
submitBtn.textContent = isUpdate ? 'Updating...' : 'Submitting...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = collectFormData();
|
const data = collectFormData();
|
||||||
|
|
||||||
const response = await fetch('/api/application', {
|
const response = await fetch('/api/application', {
|
||||||
method: 'POST',
|
method: isUpdate ? 'PUT' : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
|
|
@ -1643,13 +1783,15 @@
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
if (response.ok && result.success) {
|
||||||
// Clear saved data
|
// Save submitted data so "Resume" works on next visit
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
saveFormData();
|
||||||
|
|
||||||
// Show success state
|
// Show success state
|
||||||
document.getElementById('confirm-name').textContent = data.first_name;
|
document.getElementById('confirm-name').textContent = data.first_name;
|
||||||
document.getElementById('confirm-email').textContent = data.email;
|
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.querySelectorAll('.form-section').forEach(s => s.classList.remove('active'));
|
||||||
document.querySelector('.form-section[data-step="success"]').style.display = 'block';
|
document.querySelector('.form-section[data-step="success"]').style.display = 'block';
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ services:
|
||||||
- SMTP_USER=contact@valleyofthecommons.com
|
- SMTP_USER=contact@valleyofthecommons.com
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- EMAIL_FROM=Valley of the Commons <contact@valleyofthecommons.com>
|
- EMAIL_FROM=Valley of the Commons <contact@valleyofthecommons.com>
|
||||||
|
- BOOKING_SHEET_ID=1kjVy5jfGSG2vcavqkbrw_CHSn4-HY-OE3NAgyjUUpkk
|
||||||
|
- BOOKING_SHEET_TAB=VotC26 Occupancy
|
||||||
depends_on:
|
depends_on:
|
||||||
votc-db:
|
votc-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
30
server.js
30
server.js
|
|
@ -11,8 +11,8 @@ app.use(express.urlencoded({ extended: true }));
|
||||||
// CORS middleware
|
// CORS middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
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');
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ app.use((req, res, next) => {
|
||||||
const waitlistHandler = require('./api/waitlist-db');
|
const waitlistHandler = require('./api/waitlist-db');
|
||||||
const newsletterHandler = require('./api/newsletter');
|
const newsletterHandler = require('./api/newsletter');
|
||||||
const applicationHandler = require('./api/application');
|
const applicationHandler = require('./api/application');
|
||||||
|
const applicationLookupHandler = require('./api/application').lookup;
|
||||||
const gameChatHandler = require('./api/game-chat');
|
const gameChatHandler = require('./api/game-chat');
|
||||||
const shareToGithubHandler = require('./api/share-to-github');
|
const shareToGithubHandler = require('./api/share-to-github');
|
||||||
const { handleWebhook, getPaymentStatus, resumePayment } = require('./api/mollie');
|
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/waitlist', vercelToExpress(waitlistHandler));
|
||||||
app.all('/api/newsletter', vercelToExpress(newsletterHandler));
|
app.all('/api/newsletter', vercelToExpress(newsletterHandler));
|
||||||
app.all('/api/application', vercelToExpress(applicationHandler));
|
app.all('/api/application', vercelToExpress(applicationHandler));
|
||||||
|
app.get('/api/application/lookup', vercelToExpress(applicationLookupHandler));
|
||||||
app.all('/api/game-chat', vercelToExpress(gameChatHandler));
|
app.all('/api/game-chat', vercelToExpress(gameChatHandler));
|
||||||
app.all('/api/share-to-github', vercelToExpress(shareToGithubHandler));
|
app.all('/api/share-to-github', vercelToExpress(shareToGithubHandler));
|
||||||
app.post('/api/mollie/webhook', vercelToExpress(handleWebhook));
|
app.post('/api/mollie/webhook', vercelToExpress(handleWebhook));
|
||||||
app.all('/api/mollie/status', vercelToExpress(getPaymentStatus));
|
app.all('/api/mollie/status', vercelToExpress(getPaymentStatus));
|
||||||
app.get('/api/mollie/resume', vercelToExpress(resumePayment));
|
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
|
// Static files
|
||||||
app.use(express.static(path.join(__dirname), {
|
app.use(express.static(path.join(__dirname), {
|
||||||
extensions: ['html'],
|
extensions: ['html'],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue