feat: resume existing application by email lookup

Add GET /api/application/lookup endpoint for public email-based lookups,
PUT /api/application handler for updating existing applications, and
frontend flow that detects returning users after step 2 with a
welcome-back modal to pre-fill and update their application.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-06 13:50:24 -04:00
parent 0eafbb35a0
commit b8823e32ec
3 changed files with 366 additions and 12 deletions

View File

@ -196,7 +196,7 @@ async function logEmail(recipientEmail, recipientName, emailType, subject, messa
module.exports = async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
@ -469,5 +469,241 @@ 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',
[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`,
[email]
);
if (result.rows.length === 0) {
return res.status(404).json({ found: false });
}
const row = result.rows[0];
// Map DB column names to frontend form field names that restoreFormData() expects
const mapped = {
id: row.id,
first_name: row.first_name,
last_name: row.last_name,
email: row.email,
social_links: row.social_links,
how_heard: row.how_heard,
referral_name: row.referral_name,
affiliations: row.commons_experience,
motivation: row.motivation,
current_work: row.projects,
contribution: row.contribution,
themes_familiarity: row.workshops_offer,
belief_update: row.belief_update,
weeks: row.selected_weeks || [],
top_themes: row.top_themes || [],
need_accommodation: row.need_accommodation,
accommodation_type: row.accommodation_type,
food_preference: row.food_preference,
accessibility_needs: row.accessibility_needs,
volunteer_interest: row.volunteer_interest,
coupon_code: row.coupon_code,
privacy_policy_accepted: row.privacy_policy_accepted,
payment_status: row.payment_status,
submitted_at: row.submitted_at,
};
return res.status(200).json({ found: true, application: mapped });
} catch (error) {
console.error('Application lookup error:', error);
return res.status(500).json({ error: 'Lookup failed' });
}
};

View File

@ -500,6 +500,48 @@
.next-steps-box li { margin-bottom: 0.5rem; }
/* ===== Welcome-back modal ===== */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-overlay.visible { display: flex; }
.modal-box {
background: #fff;
border-radius: 12px;
padding: 2rem;
max-width: 480px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
text-align: center;
}
.modal-box h3 {
font-family: 'Cormorant Garamond', serif;
color: var(--forest);
font-size: 1.4rem;
margin-bottom: 0.75rem;
}
.modal-box p { font-size: 0.95rem; color: #555; margin-bottom: 1.5rem; }
.modal-actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.modal-actions .btn { min-width: 160px; }
.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; }
@ -551,6 +593,18 @@
<div class="progress-text">Step <span id="progress-step">1</span> of 10 — <span id="progress-percent">10</span>%</div>
</div>
<!-- Welcome-back modal -->
<div class="modal-overlay" id="welcome-back-modal">
<div class="modal-box">
<h3 id="welcome-back-heading">Welcome back!</h3>
<p>We found your previous application. Would you like to load it and continue where you left off?</p>
<div class="modal-actions">
<button type="button" class="btn btn-primary" onclick="loadExistingApplication()">Load my application</button>
<button type="button" class="btn btn-secondary" onclick="closeWelcomeModal()">Cancel</button>
</div>
</div>
</div>
<form id="application-form" style="display: none;">
<!-- Step 1: Which weeks + price calculator -->
<div class="form-section" data-step="1">
@ -887,7 +941,7 @@
<div class="form-group">
<label style="display: flex; align-items: flex-start; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="volunteer_interest" name="volunteer_interest" style="width: 18px; height: 18px; margin-top: 0.2rem; accent-color: var(--forest);">
<span>I'm interested in volunteering (e.g. helping with setup, cooking, facilitation) in exchange for a reduced fee</span>
<span>I'm interested in volunteering (e.g. helping with setup, cooking, facilitation)</span>
</label>
</div>
@ -1111,17 +1165,24 @@
updateThemeCounter();
}
// Food preference
// Food preference — handle "allergies: <text>" from DB
if (data.food_preference) {
const radio = document.querySelector(`input[name="food_preference"][value="${data.food_preference}"]`);
let foodVal = data.food_preference;
let allergyText = '';
if (foodVal.startsWith('allergies: ')) {
allergyText = foodVal.substring('allergies: '.length);
foodVal = 'allergies';
}
const radio = document.querySelector(`input[name="food_preference"][value="${foodVal}"]`);
if (radio) {
radio.checked = true;
if (data.food_preference === 'allergies') {
if (foodVal === 'allergies') {
document.getElementById('food-allergies-detail').style.display = 'block';
if (allergyText) document.getElementById('food_allergies_text').value = allergyText;
}
}
}
if (data.food_allergies_text) {
if (data.food_allergies_text && !document.getElementById('food_allergies_text').value) {
document.getElementById('food_allergies_text').value = data.food_allergies_text;
}
@ -1223,8 +1284,42 @@
return valid;
}
function nextStep() {
// State for existing application resume flow
window._existingApplicationId = null;
window._pendingLookupData = null;
async function nextStep() {
if (!validateStep(currentStep)) return;
// Email check when leaving step 2
if (currentStep === 2 && !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;
}
// Show welcome-back modal
window._pendingLookupData = result.application;
const heading = document.getElementById('welcome-back-heading');
heading.textContent = `Welcome back, ${result.application.first_name}!`;
document.getElementById('welcome-back-modal').classList.add('visible');
return; // Don't advance until user decides
}
}
// 404 = no existing application, continue normally
} catch (e) {
console.error('Email lookup failed:', e);
// Non-blocking — continue with new application
}
}
}
if (currentStep < totalSteps) {
currentStep++;
showStep(currentStep);
@ -1232,6 +1327,24 @@
}
}
function loadExistingApplication() {
const data = window._pendingLookupData;
if (!data) return;
window._existingApplicationId = data.id;
restoreFormData(data);
saveFormData();
document.getElementById('welcome-back-modal').classList.remove('visible');
// Advance to step 3
currentStep = 3;
showStep(3);
}
function closeWelcomeModal() {
window._pendingLookupData = null;
document.getElementById('welcome-back-modal').classList.remove('visible');
// Stay on step 2 — user can change their email
}
function prevStep() {
if (currentStep > 1) {
currentStep--;
@ -1629,13 +1742,14 @@
errorDiv.style.display = 'none';
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
const isUpdate = !!window._existingApplicationId;
submitBtn.textContent = isUpdate ? 'Updating...' : 'Submitting...';
try {
const data = collectFormData();
const response = await fetch('/api/application', {
method: 'POST',
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
@ -1649,7 +1763,9 @@
// Show success state
document.getElementById('confirm-name').textContent = data.first_name;
document.getElementById('confirm-email').textContent = data.email;
document.getElementById('success-heading').textContent = `You're in the process, ${data.first_name}!`;
document.getElementById('success-heading').textContent = isUpdate
? `Application updated, ${data.first_name}!`
: `You're in the process, ${data.first_name}!`;
document.querySelectorAll('.form-section').forEach(s => s.classList.remove('active'));
document.querySelector('.form-section[data-step="success"]').style.display = 'block';

View File

@ -11,8 +11,8 @@ app.use(express.urlencoded({ extended: true }));
// CORS middleware
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
@ -23,6 +23,7 @@ app.use((req, res, next) => {
const waitlistHandler = require('./api/waitlist-db');
const newsletterHandler = require('./api/newsletter');
const applicationHandler = require('./api/application');
const applicationLookupHandler = require('./api/application').lookup;
const gameChatHandler = require('./api/game-chat');
const shareToGithubHandler = require('./api/share-to-github');
const { handleWebhook, getPaymentStatus, resumePayment } = require('./api/mollie');
@ -42,6 +43,7 @@ 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));