Compare commits
2 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
d1364a0ad0 | |
|
|
8cfb49324e |
|
|
@ -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)"
|
||||
|
|
@ -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' });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
316
apply.html
316
apply.html
|
|
@ -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 =
|
||||
`€${tierPricing.perWeek} – €${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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
30
server.js
30
server.js
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Reference in New Issue