Add application system with PostgreSQL and email integration

- Replace BlockSurvey with custom application form (apply.html)
- Add admin dashboard for reviewing applications (admin.html)
- Create PostgreSQL schema for waitlist, applications, email logging
- Implement application API with Resend email confirmations
- Replace Google Sheets waitlist with PostgreSQL backend
- Update docker-compose with PostgreSQL service
- Update server.js with new API routes
- Update index.html to link to internal apply.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-02 14:07:06 +00:00
parent 4f6d9f374c
commit 8f67250928
12 changed files with 3033 additions and 7 deletions

View File

@ -1,10 +1,47 @@
# Valley of the Commons Environment Variables
# Copy to .env and fill in values
# ============================================
# PostgreSQL Database (for applications & waitlist)
# ============================================
# DATABASE_URL is set automatically in docker-compose.yml
# Only needed for local development
DATABASE_URL=postgresql://votc:votc_password@localhost:5432/votc
# ============================================
# Resend API for emails
# ============================================
# Get from: https://resend.com/api-keys
RESEND_API_KEY=re_xxxxxxxxxxxxx
# Email sender address (must be verified in Resend)
EMAIL_FROM=Valley of the Commons <noreply@jeffemmett.com>
# ============================================
# Admin Configuration
# ============================================
# Admin API key for accessing application data
# Generate a secure random string
ADMIN_API_KEY=your_secure_admin_key_here
# Admin email addresses (comma-separated)
ADMIN_EMAILS=jeff@jeffemmett.com
# ============================================
# Node Environment
# ============================================
NODE_ENV=production
# ============================================
# Legacy: Google Sheets (optional, for old waitlist)
# ============================================
GOOGLE_SERVICE_ACCOUNT=your_service_account_json_here
GOOGLE_SHEET_ID=your_sheet_id_here
GOOGLE_SHEET_NAME=Waitlist
AI_GATEWAY_API_KEY=your_vercel_ai_gateway_key_here
GAME_MODEL=mistral/mistral-large-latest
# ============================================
# AI Gateway Configuration for Game Chat
# ============================================
# Vercel AI Gateway API key (get from Vercel dashboard)
AI_GATEWAY_API_KEY=your_vercel_ai_gateway_key_here
@ -15,7 +52,9 @@ AI_GATEWAY_API_KEY=your_vercel_ai_gateway_key_here
# anthropic/claude-3-5-sonnet-20241022
GAME_MODEL=mistral/mistral-large-latest
# ============================================
# GitHub Configuration for Idea Sharing
# ============================================
# GitHub Personal Access Token (fine-grained token with repo write access)
# Create at: https://github.com/settings/tokens
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
refs/
node_modules/
# OS files
.DS_Store

947
admin.html Normal file
View File

@ -0,0 +1,947 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - Valley of the Commons</title>
<link rel="icon" type="image/svg+xml" href="icon.svg">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--forest: #2d5016;
--forest-light: #4a7c23;
--cream: #faf8f5;
--sand: #f5f5f0;
--charcoal: #2c2c2c;
--pending: #f59e0b;
--reviewing: #3b82f6;
--accepted: #10b981;
--declined: #ef4444;
--waitlisted: #8b5cf6;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--sand);
color: var(--charcoal);
line-height: 1.5;
}
/* Auth screen */
.auth-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-box {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.auth-box h1 {
color: var(--forest);
margin-bottom: 1.5rem;
font-size: 1.5rem;
}
.auth-box input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
margin-bottom: 1rem;
}
.auth-box button {
width: 100%;
padding: 0.75rem;
background: var(--forest);
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.auth-box button:hover {
background: var(--forest-light);
}
.auth-error {
color: var(--declined);
margin-bottom: 1rem;
font-size: 0.875rem;
}
/* Dashboard */
.dashboard {
display: none;
}
.dashboard.visible {
display: block;
}
.header {
background: white;
padding: 1rem 2rem;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
color: var(--forest);
font-size: 1.25rem;
}
.header-actions {
display: flex;
gap: 1rem;
align-items: center;
}
.btn-logout {
background: none;
border: 1px solid #ddd;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
text-align: center;
}
.stat-card .number {
font-size: 2rem;
font-weight: 600;
color: var(--forest);
}
.stat-card .label {
font-size: 0.875rem;
color: #666;
}
/* Filters */
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background: white;
border-radius: 20px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.filter-btn.active {
background: var(--forest);
color: white;
border-color: var(--forest);
}
.filter-btn .count {
background: var(--sand);
padding: 0 0.5rem;
border-radius: 10px;
margin-left: 0.5rem;
font-size: 0.75rem;
}
.filter-btn.active .count {
background: rgba(255,255,255,0.2);
}
/* Table */
.table-container {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: var(--sand);
font-weight: 500;
font-size: 0.875rem;
color: #666;
}
tr:hover {
background: #fafafa;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.status-pending { background: #fef3c7; color: #92400e; }
.status-reviewing { background: #dbeafe; color: #1e40af; }
.status-accepted { background: #d1fae5; color: #065f46; }
.status-declined { background: #fee2e2; color: #991b1b; }
.status-waitlisted { background: #ede9fe; color: #5b21b6; }
.applicant-name {
font-weight: 500;
}
.applicant-email {
font-size: 0.875rem;
color: #666;
}
.view-btn {
background: var(--forest);
color: white;
border: none;
padding: 0.375rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.view-btn:hover {
background: var(--forest-light);
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 2rem;
}
.modal-overlay.visible {
display: flex;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
background: white;
}
.modal-header h2 {
font-size: 1.25rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
}
.modal-body {
padding: 1.5rem;
}
.detail-section {
margin-bottom: 2rem;
}
.detail-section h3 {
color: var(--forest);
font-size: 1rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #eee;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.detail-item {
padding: 0.5rem 0;
}
.detail-item label {
display: block;
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.detail-item p {
font-size: 0.9rem;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.long-text {
background: var(--sand);
padding: 1rem;
border-radius: 6px;
white-space: pre-wrap;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip {
background: var(--sand);
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid #eee;
display: flex;
gap: 1rem;
justify-content: flex-end;
position: sticky;
bottom: 0;
background: white;
}
.status-select {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.875rem;
}
.btn-save {
background: var(--forest);
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 6px;
cursor: pointer;
}
/* Loading */
.loading {
text-align: center;
padding: 3rem;
color: #666;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
table {
font-size: 0.875rem;
}
th, td {
padding: 0.75rem 0.5rem;
}
}
</style>
</head>
<body>
<!-- Auth Screen -->
<div class="auth-screen" id="auth-screen">
<div class="auth-box">
<h1>Admin Login</h1>
<p id="auth-error" class="auth-error" style="display: none;"></p>
<input type="password" id="api-key" placeholder="Enter admin API key">
<button onclick="login()">Login</button>
</div>
</div>
<!-- Dashboard -->
<div class="dashboard" id="dashboard">
<header class="header">
<h1>Valley of the Commons - Applications</h1>
<div class="header-actions">
<button class="btn-logout" onclick="exportCSV()">Export CSV</button>
<button class="btn-logout" onclick="logout()">Logout</button>
</div>
</header>
<div class="container">
<div class="stats-grid" id="stats">
<div class="stat-card">
<div class="number" id="stat-total">-</div>
<div class="label">Total Applications</div>
</div>
<div class="stat-card">
<div class="number" id="stat-pending">-</div>
<div class="label">Pending</div>
</div>
<div class="stat-card">
<div class="number" id="stat-reviewing">-</div>
<div class="label">Reviewing</div>
</div>
<div class="stat-card">
<div class="number" id="stat-accepted">-</div>
<div class="label">Accepted</div>
</div>
<div class="stat-card">
<div class="number" id="stat-waitlisted">-</div>
<div class="label">Waitlisted</div>
</div>
<div class="stat-card">
<div class="number" id="stat-declined">-</div>
<div class="label">Declined</div>
</div>
</div>
<div class="filters">
<button class="filter-btn active" data-status="all" onclick="filterByStatus('all')">
All <span class="count" id="count-all">0</span>
</button>
<button class="filter-btn" data-status="pending" onclick="filterByStatus('pending')">
Pending <span class="count" id="count-pending">0</span>
</button>
<button class="filter-btn" data-status="reviewing" onclick="filterByStatus('reviewing')">
Reviewing <span class="count" id="count-reviewing">0</span>
</button>
<button class="filter-btn" data-status="accepted" onclick="filterByStatus('accepted')">
Accepted <span class="count" id="count-accepted">0</span>
</button>
<button class="filter-btn" data-status="waitlisted" onclick="filterByStatus('waitlisted')">
Waitlisted <span class="count" id="count-waitlisted">0</span>
</button>
<button class="filter-btn" data-status="declined" onclick="filterByStatus('declined')">
Declined <span class="count" id="count-declined">0</span>
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Applicant</th>
<th>Location</th>
<th>Attendance</th>
<th>Submitted</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="applications-table">
<tr>
<td colspan="6" class="loading">Loading applications...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Application Detail Modal -->
<div class="modal-overlay" id="modal-overlay" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2 id="modal-title">Application Details</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modal-body">
<!-- Populated by JavaScript -->
</div>
<div class="modal-footer">
<select class="status-select" id="status-select">
<option value="pending">Pending</option>
<option value="reviewing">Reviewing</option>
<option value="accepted">Accepted</option>
<option value="waitlisted">Waitlisted</option>
<option value="declined">Declined</option>
</select>
<button class="btn-save" onclick="updateStatus()">Update Status</button>
</div>
</div>
</div>
<script>
let apiKey = localStorage.getItem('votc_admin_key');
let applications = [];
let currentFilter = 'all';
let currentApplicationId = null;
// Check if already logged in
if (apiKey) {
showDashboard();
loadApplications();
}
function login() {
const key = document.getElementById('api-key').value;
if (!key) {
showAuthError('Please enter an API key');
return;
}
apiKey = key;
localStorage.setItem('votc_admin_key', key);
showDashboard();
loadApplications();
}
function logout() {
apiKey = null;
localStorage.removeItem('votc_admin_key');
document.getElementById('auth-screen').style.display = 'flex';
document.getElementById('dashboard').classList.remove('visible');
}
function showAuthError(message) {
const errorEl = document.getElementById('auth-error');
errorEl.textContent = message;
errorEl.style.display = 'block';
}
function showDashboard() {
document.getElementById('auth-screen').style.display = 'none';
document.getElementById('dashboard').classList.add('visible');
}
async function loadApplications() {
try {
const response = await fetch('/api/application?limit=500', {
headers: {
'Authorization': `Bearer ${apiKey}`
}
});
if (response.status === 401) {
logout();
showAuthError('Invalid API key');
return;
}
const data = await response.json();
applications = data.applications || [];
updateStats();
renderTable();
} catch (error) {
console.error('Failed to load applications:', error);
document.getElementById('applications-table').innerHTML =
'<tr><td colspan="6" class="empty-state">Failed to load applications</td></tr>';
}
}
function updateStats() {
const counts = {
total: applications.length,
pending: 0,
reviewing: 0,
accepted: 0,
waitlisted: 0,
declined: 0
};
applications.forEach(app => {
if (counts.hasOwnProperty(app.status)) {
counts[app.status]++;
}
});
document.getElementById('stat-total').textContent = counts.total;
document.getElementById('stat-pending').textContent = counts.pending;
document.getElementById('stat-reviewing').textContent = counts.reviewing;
document.getElementById('stat-accepted').textContent = counts.accepted;
document.getElementById('stat-waitlisted').textContent = counts.waitlisted;
document.getElementById('stat-declined').textContent = counts.declined;
document.getElementById('count-all').textContent = counts.total;
document.getElementById('count-pending').textContent = counts.pending;
document.getElementById('count-reviewing').textContent = counts.reviewing;
document.getElementById('count-accepted').textContent = counts.accepted;
document.getElementById('count-waitlisted').textContent = counts.waitlisted;
document.getElementById('count-declined').textContent = counts.declined;
}
function filterByStatus(status) {
currentFilter = status;
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.status === status);
});
renderTable();
}
function renderTable() {
const tbody = document.getElementById('applications-table');
let filtered = applications;
if (currentFilter !== 'all') {
filtered = applications.filter(app => app.status === currentFilter);
}
if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">No applications found</td></tr>';
return;
}
tbody.innerHTML = filtered.map(app => `
<tr>
<td>
<div class="applicant-name">${app.first_name} ${app.last_name}</div>
<div class="applicant-email">${app.email}</div>
</td>
<td>${app.city || ''}, ${app.country || ''}</td>
<td>${app.attendance_type === 'full' ? 'Full' : 'Partial'}</td>
<td>${new Date(app.submitted_at).toLocaleDateString()}</td>
<td><span class="status-badge status-${app.status}">${app.status}</span></td>
<td><button class="view-btn" onclick="viewApplication('${app.id}')">View</button></td>
</tr>
`).join('');
}
function viewApplication(id) {
const app = applications.find(a => a.id === id);
if (!app) return;
currentApplicationId = id;
document.getElementById('modal-title').textContent = `${app.first_name} ${app.last_name}`;
document.getElementById('status-select').value = app.status;
const body = document.getElementById('modal-body');
body.innerHTML = `
<div class="detail-section">
<h3>Personal Information</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Email</label>
<p><a href="mailto:${app.email}">${app.email}</a></p>
</div>
<div class="detail-item">
<label>Phone</label>
<p>${app.phone || '-'}</p>
</div>
<div class="detail-item">
<label>Location</label>
<p>${app.city || ''} ${app.country ? ', ' + app.country : ''}</p>
</div>
<div class="detail-item">
<label>Pronouns</label>
<p>${app.pronouns || '-'}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Background</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Occupation</label>
<p>${app.occupation || '-'}</p>
</div>
<div class="detail-item">
<label>Organization</label>
<p>${app.organization || '-'}</p>
</div>
<div class="detail-item full-width">
<label>Skills</label>
<div class="chip-list">
${(app.skills || []).map(s => `<span class="chip">${s}</span>`).join('') || '-'}
</div>
</div>
<div class="detail-item">
<label>Languages</label>
<p>${(app.languages || []).join(', ') || '-'}</p>
</div>
<div class="detail-item">
<label>Website</label>
<p>${app.website ? `<a href="${app.website}" target="_blank">${app.website}</a>` : '-'}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Participation</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Attendance</label>
<p>${app.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}</p>
</div>
<div class="detail-item">
<label>Dates</label>
<p>${app.arrival_date || 'Aug 24'} to ${app.departure_date || 'Sep 20'}</p>
</div>
<div class="detail-item">
<label>Accommodation</label>
<p>${app.accommodation_preference || '-'}</p>
</div>
<div class="detail-item">
<label>Dietary</label>
<p>${(app.dietary_requirements || []).join(', ') || 'None specified'}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Motivation & Contribution</h3>
<div class="detail-grid">
<div class="detail-item full-width">
<label>Why do you want to join?</label>
<div class="long-text">${app.motivation || '-'}</div>
</div>
<div class="detail-item full-width">
<label>What will you contribute?</label>
<div class="long-text">${app.contribution || '-'}</div>
</div>
<div class="detail-item full-width">
<label>Workshops to offer</label>
<div class="long-text">${app.workshops_offer || '-'}</div>
</div>
<div class="detail-item full-width">
<label>Areas of Interest</label>
<div class="chip-list">
${(app.governance_interest || []).map(g => `<span class="chip">${g}</span>`).join('') || '-'}
</div>
</div>
<div class="detail-item full-width">
<label>Commons/Community Experience</label>
<div class="long-text">${app.commons_experience || '-'}</div>
</div>
</div>
</div>
<div class="detail-section">
<h3>Financial & Practical</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Contribution Level</label>
<p>${app.contribution_amount || '-'}</p>
</div>
<div class="detail-item">
<label>Scholarship</label>
<p>${app.scholarship_needed ? 'Yes' : 'No'}</p>
</div>
${app.scholarship_reason ? `
<div class="detail-item full-width">
<label>Scholarship Reason</label>
<div class="long-text">${app.scholarship_reason}</div>
</div>
` : ''}
<div class="detail-item">
<label>How Heard</label>
<p>${app.how_heard || '-'}</p>
</div>
<div class="detail-item">
<label>Referral</label>
<p>${app.referral_name || '-'}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Emergency Contact</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Name</label>
<p>${app.emergency_name || '-'}</p>
</div>
<div class="detail-item">
<label>Phone</label>
<p>${app.emergency_phone || '-'}</p>
</div>
<div class="detail-item">
<label>Relationship</label>
<p>${app.emergency_relationship || '-'}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Agreements</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Code of Conduct</label>
<p>${app.code_of_conduct_accepted ? '✓ Accepted' : '✗ Not accepted'}</p>
</div>
<div class="detail-item">
<label>Privacy Policy</label>
<p>${app.privacy_policy_accepted ? '✓ Accepted' : '✗ Not accepted'}</p>
</div>
<div class="detail-item">
<label>Photo Consent</label>
<p>${app.photo_consent ? '✓ Yes' : '✗ No'}</p>
</div>
</div>
</div>
<div class="detail-section" style="font-size: 0.8rem; color: #666;">
<p>Submitted: ${new Date(app.submitted_at).toLocaleString()}</p>
<p>Application ID: ${app.id}</p>
</div>
`;
document.getElementById('modal-overlay').classList.add('visible');
}
function closeModal(event) {
if (event && event.target !== event.currentTarget) return;
document.getElementById('modal-overlay').classList.remove('visible');
currentApplicationId = null;
}
async function updateStatus() {
if (!currentApplicationId) return;
const newStatus = document.getElementById('status-select').value;
try {
const response = await fetch(`/api/application/${currentApplicationId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: newStatus })
});
if (response.ok) {
// Update local data
const app = applications.find(a => a.id === currentApplicationId);
if (app) app.status = newStatus;
updateStats();
renderTable();
closeModal();
} else {
alert('Failed to update status');
}
} catch (error) {
console.error('Failed to update status:', error);
alert('Failed to update status');
}
}
function exportCSV() {
const headers = [
'First Name', 'Last Name', 'Email', 'Phone', 'City', 'Country',
'Occupation', 'Organization', 'Attendance', 'Accommodation',
'Contribution Level', 'Scholarship', 'Status', 'Submitted'
];
const rows = applications.map(app => [
app.first_name,
app.last_name,
app.email,
app.phone || '',
app.city || '',
app.country || '',
app.occupation || '',
app.organization || '',
app.attendance_type,
app.accommodation_preference || '',
app.contribution_amount || '',
app.scholarship_needed ? 'Yes' : 'No',
app.status,
new Date(app.submitted_at).toISOString()
]);
const csv = [headers, ...rows]
.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `votc-applications-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}
// Close modal on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>

330
api/application.js Normal file
View File

@ -0,0 +1,330 @@
// Application form API endpoint
// Handles full event applications with PostgreSQL storage and Resend emails
const { Pool } = require('pg');
const { Resend } = require('resend');
// Initialize PostgreSQL connection pool
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});
// Initialize Resend
const resend = new Resend(process.env.RESEND_API_KEY);
// Email templates
const confirmationEmail = (application) => ({
subject: 'Application Received - Valley of the Commons',
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2d5016; margin-bottom: 24px;">Thank You for Applying!</h1>
<p>Dear ${application.first_name},</p>
<p>We've received your application to join <strong>Valley of the Commons</strong> (August 24 - September 20, 2026).</p>
<div style="background: #f5f5f0; padding: 20px; border-radius: 8px; margin: 24px 0;">
<h3 style="margin-top: 0; color: #2d5016;">What happens next?</h3>
<ol style="margin-bottom: 0;">
<li>Our team will review your application</li>
<li>We may reach out with follow-up questions</li>
<li>You'll receive a decision within 2-3 weeks</li>
</ol>
</div>
<p>In the meantime, feel free to explore more about the Commons Hub and our community:</p>
<ul>
<li><a href="https://www.commons-hub.at/">Commons Hub Website</a></li>
<li><a href="https://votc.jeffemmett.com/">Valley of the Commons</a></li>
</ul>
<p>If you have any questions, reply to this email and we'll get back to you.</p>
<p style="margin-top: 32px;">
With warmth,<br>
<strong>The Valley of the Commons Team</strong>
</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 32px 0;">
<p style="font-size: 12px; color: #666;">
Application ID: ${application.id}<br>
Submitted: ${new Date(application.submitted_at).toLocaleDateString('en-US', { dateStyle: 'long' })}
</p>
</div>
`
});
const adminNotificationEmail = (application) => ({
subject: `New Application: ${application.first_name} ${application.last_name}`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2d5016;">New Application Received</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Name:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.first_name} ${application.last_name}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Email:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><a href="mailto:${application.email}">${application.email}</a></td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Location:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.city || ''}, ${application.country || ''}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Attendance:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Scholarship:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.scholarship_needed ? 'Yes' : 'No'}</td>
</tr>
</table>
<div style="background: #f5f5f0; padding: 16px; border-radius: 8px; margin: 24px 0;">
<h3 style="margin-top: 0;">Motivation</h3>
<p style="margin-bottom: 0; white-space: pre-wrap;">${application.motivation}</p>
</div>
<p>
<a href="https://votc.jeffemmett.com/admin.html" style="display: inline-block; background: #2d5016; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
Review Application
</a>
</p>
<p style="font-size: 12px; color: #666; margin-top: 24px;">
Application ID: ${application.id}
</p>
</div>
`
});
async function logEmail(recipientEmail, recipientName, emailType, subject, resendId, metadata = {}) {
try {
await pool.query(
`INSERT INTO email_log (recipient_email, recipient_name, email_type, subject, resend_id, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
[recipientEmail, recipientName, emailType, subject, resendId, JSON.stringify(metadata)]
);
} catch (error) {
console.error('Failed to log email:', error);
}
}
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-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
// POST - Submit new application
if (req.method === 'POST') {
try {
const data = req.body;
// Validate required fields
const required = ['first_name', 'last_name', 'email', 'motivation', 'code_of_conduct_accepted', 'privacy_policy_accepted'];
for (const field of required) {
if (!data[field]) {
return res.status(400).json({ error: `Missing required field: ${field}` });
}
}
// Validate email format
if (!data.email.includes('@')) {
return res.status(400).json({ error: 'Invalid email address' });
}
// Check for duplicate application
const existing = await pool.query(
'SELECT id FROM applications WHERE email = $1',
[data.email.toLowerCase().trim()]
);
if (existing.rows.length > 0) {
return res.status(409).json({
error: 'An application with this email already exists',
applicationId: existing.rows[0].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);
// Insert application
const result = await pool.query(
`INSERT INTO applications (
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, contribution_amount,
ip_address, user_agent
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32,
$33, $34, $35, $36, $37, $38, $39, $40, $41
) RETURNING id, submitted_at`,
[
data.first_name?.trim(),
data.last_name?.trim(),
data.email?.toLowerCase().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.contribution_amount || null,
req.headers['x-forwarded-for'] || req.connection?.remoteAddress || null,
req.headers['user-agent'] || null
]
);
const application = {
id: result.rows[0].id,
submitted_at: result.rows[0].submitted_at,
first_name: data.first_name,
last_name: data.last_name,
email: data.email,
city: data.city,
country: data.country,
attendance_type: data.attendance_type,
scholarship_needed: data.scholarship_needed,
motivation: data.motivation
};
// Send confirmation email to applicant
if (process.env.RESEND_API_KEY) {
try {
const confirmEmail = confirmationEmail(application);
const { data: emailData } = await resend.emails.send({
from: process.env.EMAIL_FROM || 'Valley of the Commons <noreply@jeffemmett.com>',
to: application.email,
subject: confirmEmail.subject,
html: confirmEmail.html
});
await logEmail(application.email, `${application.first_name} ${application.last_name}`,
'application_confirmation', confirmEmail.subject, emailData?.id, { applicationId: application.id });
} catch (emailError) {
console.error('Failed to send confirmation email:', emailError);
}
// Send notification to admins
try {
const adminEmail = adminNotificationEmail(application);
const adminRecipients = (process.env.ADMIN_EMAILS || 'jeff@jeffemmett.com').split(',');
const { data: emailData } = await resend.emails.send({
from: process.env.EMAIL_FROM || 'Valley of the Commons <noreply@jeffemmett.com>',
to: adminRecipients,
subject: adminEmail.subject,
html: adminEmail.html
});
await logEmail(adminRecipients[0], 'Admin', 'admin_notification',
adminEmail.subject, emailData?.id, { applicationId: application.id });
} catch (emailError) {
console.error('Failed to send admin notification:', emailError);
}
}
return res.status(201).json({
success: true,
message: 'Application submitted successfully',
applicationId: application.id
});
} catch (error) {
console.error('Application submission error:', error);
return res.status(500).json({ error: 'Failed to submit application. Please try again.' });
}
}
// GET - Retrieve applications (admin only)
if (req.method === 'GET') {
// Simple token-based auth for admin access
const authHeader = req.headers.authorization;
if (!authHeader || authHeader !== `Bearer ${process.env.ADMIN_API_KEY}`) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const { status, limit = 50, offset = 0 } = req.query;
let query = 'SELECT * FROM applications';
const params = [];
if (status) {
query += ' WHERE status = $1';
params.push(status);
}
query += ` ORDER BY submitted_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
params.push(parseInt(limit), parseInt(offset));
const result = await pool.query(query, params);
// Get total count
let countQuery = 'SELECT COUNT(*) FROM applications';
if (status) {
countQuery += ' WHERE status = $1';
}
const countResult = await pool.query(countQuery, status ? [status] : []);
return res.status(200).json({
applications: result.rows,
total: parseInt(countResult.rows[0].count),
limit: parseInt(limit),
offset: parseInt(offset)
});
} catch (error) {
console.error('Failed to fetch applications:', error);
return res.status(500).json({ error: 'Failed to fetch applications' });
}
}
return res.status(405).json({ error: 'Method not allowed' });
};

165
api/waitlist-db.js Normal file
View File

@ -0,0 +1,165 @@
// Waitlist API endpoint using PostgreSQL
// Simple interest signups with email confirmation via Resend
const { Pool } = require('pg');
const { Resend } = require('resend');
// Initialize PostgreSQL connection pool
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});
// Initialize Resend
const resend = new Resend(process.env.RESEND_API_KEY);
const welcomeEmail = (signup) => ({
subject: 'Welcome to Valley of the Commons',
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #2d5016; margin-bottom: 24px;">Welcome to the Valley!</h1>
<p>Dear ${signup.name},</p>
<p>Thank you for your interest in <strong>Valley of the Commons</strong> - a four-week pop-up village in the Austrian Alps (August 24 - September 20, 2026).</p>
<p>You've been added to our community list. We'll keep you updated on:</p>
<ul>
<li>Application opening and deadlines</li>
<li>Event announcements and updates</li>
<li>Ways to get involved</li>
</ul>
${signup.involvement ? `
<div style="background: #f5f5f0; padding: 16px; border-radius: 8px; margin: 24px 0;">
<strong>Your interests:</strong>
<p style="margin-bottom: 0;">${signup.involvement}</p>
</div>
` : ''}
<p>
<a href="https://votc.jeffemmett.com/apply.html" style="display: inline-block; background: #2d5016; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
Apply Now
</a>
</p>
<p style="margin-top: 32px;">
See you in the valley,<br>
<strong>The Valley of the Commons Team</strong>
</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 32px 0;">
<p style="font-size: 12px; color: #666;">
You received this email because you signed up at votc.jeffemmett.com.<br>
<a href="https://votc.jeffemmett.com/unsubscribe?email=${encodeURIComponent(signup.email)}">Unsubscribe</a>
</p>
</div>
`
});
async function logEmail(recipientEmail, recipientName, emailType, subject, resendId, metadata = {}) {
try {
await pool.query(
`INSERT INTO email_log (recipient_email, recipient_name, email_type, subject, resend_id, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
[recipientEmail, recipientName, emailType, subject, resendId, JSON.stringify(metadata)]
);
} catch (error) {
console.error('Failed to log email:', error);
}
}
module.exports = async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { email, name, involvement } = req.body;
// Validate email
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid email is required' });
}
// Validate name
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'Name is required' });
}
// Validate involvement
if (!involvement || involvement.trim() === '') {
return res.status(400).json({ error: 'Please describe your desired involvement' });
}
const emailLower = email.toLowerCase().trim();
const nameTrimmed = name.trim();
const involvementTrimmed = involvement.trim();
// Check if email already exists
const existing = await pool.query(
'SELECT id FROM waitlist WHERE email = $1',
[emailLower]
);
if (existing.rows.length > 0) {
// Update existing entry
await pool.query(
'UPDATE waitlist SET name = $1, involvement = $2 WHERE email = $3',
[nameTrimmed, involvementTrimmed, emailLower]
);
return res.status(200).json({
success: true,
message: 'Your information has been updated!'
});
}
// Insert new signup
const result = await pool.query(
`INSERT INTO waitlist (email, name, involvement) VALUES ($1, $2, $3) RETURNING id`,
[emailLower, nameTrimmed, involvementTrimmed]
);
const signup = {
id: result.rows[0].id,
email: emailLower,
name: nameTrimmed,
involvement: involvementTrimmed
};
// Send welcome email
if (process.env.RESEND_API_KEY) {
try {
const email = welcomeEmail(signup);
const { data: emailData } = await resend.emails.send({
from: process.env.EMAIL_FROM || 'Valley of the Commons <noreply@jeffemmett.com>',
to: signup.email,
subject: email.subject,
html: email.html
});
await logEmail(signup.email, signup.name, 'waitlist_welcome', email.subject, emailData?.id);
} catch (emailError) {
console.error('Failed to send welcome email:', emailError);
// Don't fail the request if email fails
}
}
return res.status(200).json({
success: true,
message: 'Successfully joined the waitlist!'
});
} catch (error) {
console.error('Waitlist error:', error);
return res.status(500).json({ error: 'Failed to join waitlist. Please try again later.' });
}
};

963
apply.html Normal file
View File

@ -0,0 +1,963 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Apply - Valley of the Commons</title>
<link rel="icon" type="image/svg+xml" href="icon.svg">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--forest: #2d5016;
--forest-light: #4a7c23;
--cream: #faf8f5;
--sand: #f5f5f0;
--charcoal: #2c2c2c;
--error: #c53030;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--cream);
color: var(--charcoal);
line-height: 1.6;
}
.header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(0,0,0,0.1);
padding: 1rem 2rem;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-family: 'Cormorant Garamond', serif;
font-size: 1.5rem;
font-weight: 400;
color: var(--forest);
}
.header a { color: var(--forest); text-decoration: none; }
.container {
max-width: 700px;
margin: 0 auto;
padding: 2rem;
}
.intro {
text-align: center;
margin-bottom: 2rem;
padding: 2rem;
background: white;
border-radius: 12px;
}
.intro h1 {
font-family: 'Cormorant Garamond', serif;
font-size: 2.25rem;
color: var(--forest);
margin-bottom: 1rem;
}
.intro p { color: #666; font-size: 0.95rem; }
.event-badge {
display: inline-block;
background: var(--forest);
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
margin-bottom: 1rem;
}
/* Progress */
.progress-container {
margin-bottom: 2rem;
}
.progress-bar {
height: 4px;
background: #ddd;
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--forest);
transition: width 0.3s ease;
}
.progress-text {
text-align: center;
font-size: 0.8rem;
color: #666;
margin-top: 0.5rem;
}
/* Form */
.form-section {
background: white;
border-radius: 12px;
padding: 2rem;
margin-bottom: 1rem;
display: none;
}
.form-section.active { display: block; animation: fadeIn 0.3s; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.question-number {
font-size: 0.75rem;
color: #999;
margin-bottom: 0.5rem;
}
.form-section h2 {
font-family: 'Cormorant Garamond', serif;
font-size: 1.5rem;
color: var(--forest);
margin-bottom: 0.5rem;
}
.form-section > p.hint {
color: #666;
font-size: 0.9rem;
margin-bottom: 1.5rem;
}
.form-group { margin-bottom: 1.25rem; }
label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
label .required { color: var(--error); }
label .optional { color: #999; font-weight: 400; font-size: 0.8rem; }
input[type="text"],
input[type="email"],
textarea,
select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 8px;
font-family: inherit;
font-size: 1rem;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--forest);
box-shadow: 0 0 0 3px rgba(45, 80, 22, 0.1);
}
textarea { min-height: 120px; resize: vertical; }
.field-hint {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
}
/* Theme ranking */
.theme-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.theme-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--sand);
border-radius: 8px;
cursor: grab;
user-select: none;
}
.theme-item:active { cursor: grabbing; }
.theme-item .emoji { font-size: 1.25rem; }
.theme-item .text { flex: 1; font-size: 0.9rem; }
.theme-item .rank {
width: 24px;
height: 24px;
background: var(--forest);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
}
/* Week cards */
.week-cards { display: flex; flex-direction: column; gap: 0.75rem; }
.week-card {
border: 2px solid #ddd;
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.week-card:hover { border-color: var(--forest-light); }
.week-card.selected { border-color: var(--forest); background: rgba(45, 80, 22, 0.05); }
.week-card input { display: none; }
.week-card h4 { font-size: 0.95rem; color: var(--forest); margin-bottom: 0.25rem; }
.week-card .dates { font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; }
.week-card .desc { font-size: 0.85rem; color: #555; }
/* Ticket options */
.ticket-section { margin-bottom: 1.5rem; }
.ticket-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: var(--charcoal); }
.ticket-options { display: flex; flex-direction: column; gap: 0.5rem; }
.ticket-card {
border: 2px solid #ddd;
border-radius: 8px;
padding: 0.875rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.ticket-card:hover { border-color: var(--forest-light); }
.ticket-card.selected { border-color: var(--forest); background: rgba(45, 80, 22, 0.05); }
.ticket-card input { display: none; }
.ticket-name { font-size: 0.9rem; }
.ticket-price { font-weight: 600; color: var(--forest); }
.ticket-note {
font-size: 0.8rem;
color: #666;
background: var(--sand);
padding: 0.75rem 1rem;
border-radius: 8px;
margin-top: 1rem;
}
/* Nav */
.form-nav {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 2rem;
}
.btn {
padding: 0.875rem 2rem;
border: none;
border-radius: 8px;
font-family: inherit;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
}
.btn-primary { background: var(--forest); color: white; }
.btn-primary:hover { background: var(--forest-light); }
.btn-secondary { background: var(--sand); color: var(--charcoal); }
.btn-secondary:hover { background: #eee; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Success */
.success-message { text-align: center; padding: 2rem; }
.success-icon {
width: 70px;
height: 70px;
background: var(--forest);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
}
.success-icon svg { width: 35px; height: 35px; stroke: white; }
.success-message h2 {
font-family: 'Cormorant Garamond', serif;
font-size: 1.75rem;
color: var(--forest);
margin-bottom: 1rem;
}
/* Error */
.error-message {
background: #fef2f2;
border: 1px solid #fecaca;
color: var(--error);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.field-error { border-color: var(--error) !important; }
.footer {
text-align: center;
padding: 2rem;
color: #666;
font-size: 0.875rem;
}
.footer a { color: var(--forest); }
@media (max-width: 600px) {
.container { padding: 1rem; }
.form-section { padding: 1.5rem; }
}
</style>
</head>
<body>
<header class="header">
<div class="header-content">
<h1><a href="/">Valley of the Commons</a></h1>
<a href="/">← Back</a>
</div>
</header>
<div class="container">
<div class="intro">
<span class="event-badge">August 24 September 20, 2026</span>
<h1>Application Form</h1>
<p>Valley of the Commons is a four-week pop-up village exploring housing, production, decision-making and ownership in community. We ask that you be thoughtful in your answers to help us understand if you are the right fit. We will not penalize you for unfamiliarity with any topic; please be honest.</p>
</div>
<div class="progress-container">
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
<div class="progress-text"><span id="progress-percent">0</span>% complete</div>
</div>
<form id="application-form">
<!-- Q1: Contact Information -->
<div class="form-section active" data-step="1">
<div class="question-number">Question 1 of 13</div>
<h2>Contact Information</h2>
<div class="form-group">
<label for="name">Name <span class="required">*</span></label>
<input type="text" id="name" name="name" required placeholder="Your full name">
</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="contact_other">Anything else? <span class="optional">(optional)</span></label>
<textarea id="contact_other" name="contact_other" rows="2" placeholder="Phone, website, etc."></textarea>
</div>
<div class="form-nav">
<div></div>
<button type="button" class="btn btn-primary" onclick="nextStep()">Continue</button>
</div>
</div>
<!-- Q2: How did you hear -->
<div class="form-section" data-step="2">
<div class="question-number">Question 2 of 13</div>
<h2>How did you hear about Valley of the Commons? <span class="required">*</span></h2>
<div class="form-group">
<textarea id="how_heard" name="how_heard" required placeholder="Social media, friend referral, newsletter, event..."></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>
</div>
</div>
<!-- Q3: Referral names -->
<div class="form-section" data-step="3">
<div class="question-number">Question 3 of 13</div>
<h2>Referral name(s) <span class="optional">(optional)</span></h2>
<p class="hint">Who can vouch for you?</p>
<div class="form-group">
<textarea id="referral_names" name="referral_names" placeholder="Names of people who know you or referred you"></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>
</div>
</div>
<!-- Q4: Affiliations -->
<div class="form-section" data-step="4">
<div class="question-number">Question 4 of 13</div>
<h2>What are your affiliations? <span class="required">*</span></h2>
<p class="hint">What projects or groups are you affiliated with?</p>
<div class="form-group">
<textarea id="affiliations" name="affiliations" required placeholder="Organizations, DAOs, cooperatives, communities, projects..."></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>
</div>
</div>
<!-- Q5: Why join -->
<div class="form-section" data-step="5">
<div class="question-number">Question 5 of 13</div>
<h2>Why would you like to join Valley of the Commons, and why are you a good fit? <span class="required">*</span></h2>
<div class="form-group">
<textarea id="why_join" name="why_join" required placeholder="What draws you to this gathering? Why are you a good fit for this community?"></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>
</div>
</div>
<!-- Q6: Current work -->
<div class="form-section" data-step="6">
<div class="question-number">Question 6 of 13</div>
<h2>What are you currently building, researching, or working on? <span class="required">*</span></h2>
<div class="form-group">
<textarea id="current_work" name="current_work" required placeholder="Tell us about your current projects, research, or focus areas..."></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>
</div>
</div>
<!-- Q7: How contribute -->
<div class="form-section" data-step="7">
<div class="question-number">Question 7 of 13</div>
<h2>How will you contribute to Valley of the Commons? <span class="required">*</span></h2>
<p class="hint">Villagers co-create their experience. You can start an interest club, lead a discussion or workshop, teach a cooking class, or more.</p>
<div class="form-group">
<textarea id="contribution" name="contribution" required placeholder="What skills, energy, workshops, or perspectives will you bring?"></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>
</div>
</div>
<!-- Q8: Rank themes -->
<div class="form-section" data-step="8">
<div class="question-number">Question 8 of 13</div>
<h2>Please rank your interest in our themes <span class="required">*</span></h2>
<p class="hint">Drag to reorder from most interested (top) to least interested (bottom).</p>
<div class="theme-list" id="theme-list">
<div class="theme-item" data-theme="valley-future">
<span class="rank">1</span>
<span class="emoji">🏞️</span>
<span class="text">Developing the Future of the Valley</span>
</div>
<div class="theme-item" data-theme="cosmo-localism">
<span class="rank">2</span>
<span class="emoji">🌐</span>
<span class="text">Cosmo-localism</span>
</div>
<div class="theme-item" data-theme="funding-token">
<span class="rank">3</span>
<span class="emoji">🪙</span>
<span class="text">Funding Models & Token Engineering</span>
</div>
<div class="theme-item" data-theme="fablabs">
<span class="rank">4</span>
<span class="emoji">👾</span>
<span class="text">Fablabs & Hackerspaces</span>
</div>
<div class="theme-item" data-theme="future-living">
<span class="rank">5</span>
<span class="emoji">🌌</span>
<span class="text">Future Living Design & Development</span>
</div>
<div class="theme-item" data-theme="network-governance">
<span class="rank">6</span>
<span class="emoji">🧑‍⚖️</span>
<span class="text">Network Societies & Decentralized Governance</span>
</div>
<div class="theme-item" data-theme="commons-theory">
<span class="rank">7</span>
<span class="emoji">⛲️</span>
<span class="text">Commons Theory & Practice</span>
</div>
<div class="theme-item" data-theme="privacy">
<span class="rank">8</span>
<span class="emoji">👤</span>
<span class="text">Privacy & Digital Sovereignty</span>
</div>
<div class="theme-item" data-theme="dacc">
<span class="rank">9</span>
<span class="emoji">🌏</span>
<span class="text">d/acc: defensive accelerationism</span>
</div>
<div class="theme-item" data-theme="rationality">
<span class="rank">10</span>
<span class="emoji">💭</span>
<span class="text">(Meta)rationality & Cognitive Sovereignty</span>
</div>
</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>
</div>
</div>
<!-- Q9: Theme familiarity -->
<div class="form-section" data-step="9">
<div class="question-number">Question 9 of 13</div>
<h2>Please explain your familiarity and interest in our themes and event overall <span class="required">*</span></h2>
<p class="hint">🏞️ Valley Future · 🌐 Cosmo-localism · 🪙 Funding & Token Engineering · 👾 Fablabs · 🌌 Future Living · 🧑‍⚖️ Network Governance · ⛲️ Commons · 👤 Privacy · 🌏 d/acc · 💭 (Meta)rationality</p>
<div class="form-group">
<textarea id="theme_familiarity" name="theme_familiarity" required placeholder="Share your experience, learning journey, or curiosity about any of these themes..."></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>
</div>
</div>
<!-- Q10: Belief update -->
<div class="form-section" data-step="10">
<div class="question-number">Question 10 of 13</div>
<h2>Describe a belief you have updated within the last 1-2 years <span class="required">*</span></h2>
<p class="hint">What was the belief and why did it change?</p>
<div class="form-group">
<textarea id="belief_update" name="belief_update" required placeholder="We value intellectual humility. Share how your thinking has evolved..."></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>
</div>
</div>
<!-- Q11: Which weeks -->
<div class="form-section" data-step="11">
<div class="question-number">Question 11 of 13</div>
<h2>Which week(s) would you like to attend? <span class="required">*</span></h2>
<div class="week-cards">
<label class="week-card" onclick="toggleWeek(this)">
<input type="checkbox" name="weeks" value="week1">
<h4>Week 1: Return to the Commons</h4>
<div class="dates">August 24 30, 2026</div>
<div class="desc">A five-day course with Michel Bauwens and Adam Arvidsson exploring the history and future of the commons.</div>
</label>
<label class="week-card" onclick="toggleWeek(this)">
<input type="checkbox" name="weeks" value="week2">
<h4>Week 2: Post-Capitalist Production</h4>
<div class="dates">August 31 September 6, 2026</div>
<div class="desc">How global knowledge commons and local production can sustain livelihoods and community resilience.</div>
</label>
<label class="week-card" onclick="toggleWeek(this)">
<input type="checkbox" name="weeks" value="week3">
<h4>Week 3: Future Living</h4>
<div class="dates">September 7 13, 2026</div>
<div class="desc">From vision to scouting: cooperative housing, mapping local resources, ecological design.</div>
</label>
<label class="week-card" onclick="toggleWeek(this)">
<input type="checkbox" name="weeks" value="week4">
<h4>Week 4: Governance & Funding Models</h4>
<div class="dates">September 14 20, 2026</div>
<div class="desc">Participatory governance, cooperative legal structures, and mechanisms for shared assets.</div>
</label>
</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>
</div>
</div>
<!-- Q12: Ticket option -->
<div class="form-section" data-step="12">
<div class="question-number">Question 12 of 13</div>
<h2>Which ticket option would you prefer? <span class="required">*</span></h2>
<p class="hint">Prices and options subject to change.</p>
<div class="ticket-section">
<h4>🏡 Full Resident (4 weeks)</h4>
<div class="ticket-options">
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="full-dorm" required>
<span class="ticket-name">Dorm (4-6 people)</span>
<span class="ticket-price">€1,500</span>
</label>
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="full-shared">
<span class="ticket-name">Shared Double</span>
<span class="ticket-price">€1,800</span>
</label>
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="full-single">
<span class="ticket-name">Single (deluxe apartment)</span>
<span class="ticket-price">€3,200</span>
</label>
</div>
</div>
<div class="ticket-section">
<h4>🗓 1-Week Visitor (max 20 per week)</h4>
<div class="ticket-options">
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="week-dorm">
<span class="ticket-name">Dorm (4-6 people)</span>
<span class="ticket-price">€425</span>
</label>
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="week-shared">
<span class="ticket-name">Shared Double</span>
<span class="ticket-price">€500</span>
</label>
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="week-single">
<span class="ticket-name">Single (deluxe apartment)</span>
<span class="ticket-price">€850</span>
</label>
</div>
</div>
<div class="ticket-section">
<h4>🎟 Non-Accommodation Pass</h4>
<div class="ticket-options">
<label class="ticket-card" onclick="selectTicket(this)">
<input type="radio" name="ticket" value="no-accom">
<span class="ticket-name">Event access only</span>
<span class="ticket-price">€300/week</span>
</label>
</div>
</div>
<div class="ticket-note">
<strong>Included:</strong> Accommodation (if applicable), venue access, event ticket<br>
<strong>Not included:</strong> Food (~€10/day)<br>
<strong>Note:</strong> +10% after June 1 (goes to event org costs)
</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>
</div>
</div>
<!-- Q13: Anything else -->
<div class="form-section" data-step="13">
<div class="question-number">Question 13 of 13</div>
<h2>Anything else you'd like to add? <span class="optional">(optional)</span></h2>
<div class="form-group">
<textarea id="anything_else" name="anything_else" placeholder="Questions, accessibility needs, dietary requirements, or anything else..."></textarea>
</div>
<div class="form-group" style="margin-top: 2rem;">
<label style="display: flex; align-items: flex-start; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="privacy_accepted" name="privacy_accepted" required style="width: 18px; height: 18px; margin-top: 0.2rem;">
<span>I agree to the <a href="privacy.html" target="_blank">privacy policy</a> and consent to my data being processed for this application <span class="required">*</span></span>
</label>
</div>
<div id="form-error" class="error-message" style="display: none;"></div>
<div class="form-nav">
<button type="button" class="btn btn-secondary" onclick="prevStep()">Back</button>
<button type="submit" class="btn btn-primary" id="submit-btn">Submit Application</button>
</div>
</div>
<!-- Success -->
<div class="form-section" data-step="success" style="display: none;">
<div class="success-message">
<div class="success-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
</div>
<h2>Application Submitted!</h2>
<p>Thank you for applying to Valley of the Commons.</p>
<p>We've sent a confirmation to <strong id="confirm-email"></strong>.</p>
<p style="margin-top: 1.5rem; color: #666;">Our team will review your application and get back to you within 2-3 weeks.</p>
<p style="margin-top: 2rem;">
<a href="/" class="btn btn-primary">Return to Homepage</a>
</p>
</div>
</div>
</form>
</div>
<footer class="footer">
<p>Valley of the Commons · <a href="https://www.commons-hub.at/">Commons Hub</a> · <a href="privacy.html">Privacy Policy</a></p>
</footer>
<script>
let currentStep = 1;
const totalSteps = 13;
function updateProgress() {
const percent = Math.round(((currentStep - 1) / totalSteps) * 100);
document.getElementById('progress-fill').style.width = percent + '%';
document.getElementById('progress-percent').textContent = percent;
}
function showStep(step) {
document.querySelectorAll('.form-section').forEach(s => s.classList.remove('active'));
const target = document.querySelector(`.form-section[data-step="${step}"]`);
if (target) {
target.classList.add('active');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
updateProgress();
}
function validateStep(step) {
const section = document.querySelector(`.form-section[data-step="${step}"]`);
const required = section.querySelectorAll('[required]');
let valid = true;
required.forEach(field => {
field.style.borderColor = '';
if (field.type === 'checkbox') {
if (!field.checked) valid = false;
} else if (!field.value.trim()) {
valid = false;
field.style.borderColor = 'var(--error)';
}
});
// Email validation
const emailField = section.querySelector('input[type="email"]');
if (emailField && emailField.value && !emailField.value.includes('@')) {
valid = false;
emailField.style.borderColor = 'var(--error)';
}
// Week selection (step 11)
if (step === 11) {
const checked = document.querySelectorAll('input[name="weeks"]:checked');
if (checked.length === 0) {
valid = false;
alert('Please select at least one week.');
}
}
return valid;
}
function nextStep() {
if (!validateStep(currentStep)) return;
if (currentStep < totalSteps) {
currentStep++;
showStep(currentStep);
}
}
function prevStep() {
if (currentStep > 1) {
currentStep--;
showStep(currentStep);
}
}
function toggleWeek(card) {
const cb = card.querySelector('input');
cb.checked = !cb.checked;
card.classList.toggle('selected', cb.checked);
}
function selectTicket(card) {
document.querySelectorAll('.ticket-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
card.querySelector('input').checked = true;
}
// Theme ranking drag & drop
const themeList = document.getElementById('theme-list');
let draggedItem = null;
themeList.addEventListener('dragstart', e => {
draggedItem = e.target.closest('.theme-item');
e.dataTransfer.effectAllowed = 'move';
});
themeList.addEventListener('dragover', e => {
e.preventDefault();
const target = e.target.closest('.theme-item');
if (target && target !== draggedItem) {
const rect = target.getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (e.clientY < mid) {
themeList.insertBefore(draggedItem, target);
} else {
themeList.insertBefore(draggedItem, target.nextSibling);
}
}
});
themeList.addEventListener('dragend', () => {
updateRanks();
draggedItem = null;
});
// Make items draggable
document.querySelectorAll('.theme-item').forEach(item => {
item.draggable = true;
});
function updateRanks() {
document.querySelectorAll('.theme-item').forEach((item, i) => {
item.querySelector('.rank').textContent = i + 1;
});
}
function getThemeRanking() {
return Array.from(document.querySelectorAll('.theme-item')).map((item, i) => ({
theme: item.dataset.theme,
rank: i + 1
}));
}
function getSelectedWeeks() {
return Array.from(document.querySelectorAll('input[name="weeks"]:checked')).map(cb => cb.value);
}
function collectFormData() {
const form = document.getElementById('application-form');
const nameParts = form.name.value.trim().split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
return {
first_name: firstName,
last_name: lastName,
email: form.email.value,
social_links: form.social_media.value,
contact_other: form.contact_other.value,
how_heard: form.how_heard.value,
referral_name: form.referral_names.value,
affiliations: form.affiliations.value,
motivation: form.why_join.value,
current_work: form.current_work.value,
contribution: form.contribution.value,
theme_ranking: JSON.stringify(getThemeRanking()),
theme_familiarity: form.theme_familiarity.value,
belief_update: form.belief_update.value,
weeks: getSelectedWeeks(),
attendance_type: getSelectedWeeks().length === 4 ? 'full' : 'partial',
contribution_amount: form.querySelector('input[name="ticket"]:checked')?.value || null,
anything_else: form.anything_else.value,
privacy_policy_accepted: form.privacy_accepted.checked,
code_of_conduct_accepted: true
};
}
document.getElementById('application-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorDiv = document.getElementById('form-error');
const submitBtn = document.getElementById('submit-btn');
if (!document.getElementById('privacy_accepted').checked) {
errorDiv.textContent = 'Please accept the privacy policy.';
errorDiv.style.display = 'block';
return;
}
errorDiv.style.display = 'none';
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
try {
const data = collectFormData();
const response = await fetch('/api/application', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
document.getElementById('confirm-email').textContent = data.email;
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"]').classList.add('active');
document.querySelector('.progress-container').style.display = 'none';
} else {
errorDiv.textContent = result.error || 'Something went wrong. Please try again.';
errorDiv.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Application';
}
} catch (error) {
console.error('Submission error:', error);
errorDiv.textContent = 'Network error. Please check your connection.';
errorDiv.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Application';
}
});
updateProgress();
</script>
</body>
</html>

137
db/schema.sql Normal file
View File

@ -0,0 +1,137 @@
-- Valley of the Commons Database Schema
-- PostgreSQL
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Waitlist table (simple interest signups)
CREATE TABLE IF NOT EXISTS waitlist (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
involvement TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
email_verified BOOLEAN DEFAULT FALSE,
subscribed BOOLEAN DEFAULT TRUE
);
CREATE INDEX idx_waitlist_email ON waitlist(email);
CREATE INDEX idx_waitlist_created ON waitlist(created_at);
-- Applications table (full event applications)
CREATE TABLE IF NOT EXISTS applications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Status tracking
status VARCHAR(50) DEFAULT 'pending', -- pending, reviewing, accepted, waitlisted, declined
submitted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
reviewed_at TIMESTAMP WITH TIME ZONE,
reviewed_by VARCHAR(255),
-- Personal Information
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
phone VARCHAR(50),
country VARCHAR(100),
city VARCHAR(100),
pronouns VARCHAR(50),
date_of_birth DATE,
-- Professional Background
occupation VARCHAR(255),
organization VARCHAR(255),
skills TEXT[], -- Array of skills
languages TEXT[], -- Array of languages spoken
website VARCHAR(500),
social_links JSONB, -- {twitter: "", linkedin: "", etc}
-- Participation Details
attendance_type VARCHAR(50), -- full (4 weeks), partial
arrival_date DATE,
departure_date DATE,
accommodation_preference VARCHAR(50), -- tent, shared-room, private-room, offsite
dietary_requirements TEXT[], -- vegetarian, vegan, gluten-free, etc
dietary_notes TEXT,
-- Motivation & Contribution
motivation TEXT NOT NULL, -- Why do you want to join?
contribution TEXT, -- What will you contribute?
projects TEXT, -- Projects you'd like to work on
workshops_offer TEXT, -- Workshops you could facilitate
-- Commons Experience
commons_experience TEXT, -- Experience with commons/cooperatives
community_experience TEXT, -- Previous community living experience
governance_interest TEXT[], -- Areas of interest: housing, production, decision-making, ownership
-- Practical
how_heard VARCHAR(255), -- How did you hear about us?
referral_name VARCHAR(255), -- Who referred you?
previous_events TEXT[], -- Previous related events attended
-- Emergency Contact
emergency_name VARCHAR(255),
emergency_phone VARCHAR(50),
emergency_relationship VARCHAR(100),
-- Agreements
code_of_conduct_accepted BOOLEAN DEFAULT FALSE,
privacy_policy_accepted BOOLEAN DEFAULT FALSE,
photo_consent BOOLEAN DEFAULT FALSE,
-- Financial
scholarship_needed BOOLEAN DEFAULT FALSE,
scholarship_reason TEXT,
contribution_amount VARCHAR(50), -- sliding scale selection
-- Admin notes
admin_notes TEXT,
-- Metadata
ip_address VARCHAR(45),
user_agent TEXT,
CONSTRAINT valid_status CHECK (status IN ('pending', 'reviewing', 'accepted', 'waitlisted', 'declined'))
);
CREATE INDEX idx_applications_email ON applications(email);
CREATE INDEX idx_applications_status ON applications(status);
CREATE INDEX idx_applications_submitted ON applications(submitted_at);
-- Email log table (track all sent emails)
CREATE TABLE IF NOT EXISTS email_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recipient_email VARCHAR(255) NOT NULL,
recipient_name VARCHAR(255),
email_type VARCHAR(100) NOT NULL, -- application_confirmation, waitlist_welcome, status_update, etc
subject VARCHAR(500),
resend_id VARCHAR(255), -- Resend API message ID
status VARCHAR(50) DEFAULT 'sent', -- sent, delivered, bounced, failed
sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
metadata JSONB
);
CREATE INDEX idx_email_log_recipient ON email_log(recipient_email);
CREATE INDEX idx_email_log_type ON email_log(email_type);
-- Admin users table
CREATE TABLE IF NOT EXISTS admin_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'reviewer', -- admin, reviewer
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP WITH TIME ZONE
);
-- Session tokens for admin auth
CREATE TABLE IF NOT EXISTS admin_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
admin_id UUID REFERENCES admin_users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_admin_sessions_token ON admin_sessions(token);

View File

@ -3,13 +3,48 @@ services:
build: .
container_name: votc
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://votc:votc_password@votc-db:5432/votc
- RESEND_API_KEY=${RESEND_API_KEY}
- EMAIL_FROM=Valley of the Commons <noreply@jeffemmett.com>
- ADMIN_API_KEY=${ADMIN_API_KEY}
- ADMIN_EMAILS=${ADMIN_EMAILS:-jeff@jeffemmett.com}
- NODE_ENV=production
depends_on:
votc-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.votc.rule=Host(`votc.jeffemmett.com`)"
- "traefik.http.services.votc.loadbalancer.server.port=3000"
networks:
- traefik-public
- votc-internal
votc-db:
image: postgres:16-alpine
container_name: votc-db
restart: unless-stopped
environment:
- POSTGRES_USER=votc
- POSTGRES_PASSWORD=votc_password
- POSTGRES_DB=votc
volumes:
- votc-postgres-data:/var/lib/postgresql/data
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U votc"]
interval: 5s
timeout: 5s
retries: 5
networks:
- votc-internal
volumes:
votc-postgres-data:
networks:
traefik-public:
external: true
votc-internal:
driver: bridge

View File

@ -53,7 +53,7 @@
<a href="#explore">Explore the Valley</a>
<a href="#waitlist" class="nav-get-involved">Get involved</a>
<a href="game.html" target="_blank" rel="noopener noreferrer" class="nav-rabbit">🐰</a>
<a href="https://blocksurvey.io/valley-of-the-commons-application-form-NVCeexCKTBunKj8xOlLJ_g?v=o" target="_blank" rel="noopener noreferrer" class="nav-cta-button">APPLY NOW</a>
<a href="/apply.html" class="nav-cta-button">APPLY NOW</a>
</nav>
</div>
</header>
@ -78,7 +78,7 @@
<h2 class="event-title">Pop-Up Event to Seed the Valley</h2>
<p class="event-dates">24 August 2026 20 September 2026</p>
</div>
<a href="https://blocksurvey.io/valley-of-the-commons-application-form-NVCeexCKTBunKj8xOlLJ_g?v=o" target="_blank" rel="noopener noreferrer" class="cta-button">APPLY NOW</a>
<a href="/apply.html" class="cta-button">APPLY NOW</a>
</section>
<!-- Main Content -->

407
package-lock.json generated
View File

@ -13,7 +13,9 @@
"@octokit/rest": "^22.0.1",
"ai": "^6.0.1",
"express": "^4.21.0",
"googleapis": "^126.0.1"
"googleapis": "^126.0.1",
"pg": "^8.13.0",
"resend": "^4.0.0"
},
"devDependencies": {
"dotenv": "^16.3.1"
@ -263,6 +265,37 @@
"node": ">=8.0.0"
}
},
"node_modules/@react-email/render": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz",
"integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==",
"license": "MIT",
"dependencies": {
"html-to-text": "^9.0.5",
"prettier": "^3.5.3",
"react-promise-suspense": "^0.3.4"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"selderee": "^0.11.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -472,6 +505,15 @@
"ms": "2.0.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -491,6 +533,61 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -542,6 +639,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -664,6 +773,12 @@
],
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@ -881,6 +996,41 @@
"node": ">= 0.4"
}
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "^0.11.0",
"deepmerge": "^4.3.1",
"dom-serializer": "^2.0.0",
"htmlparser2": "^8.0.2",
"selderee": "^0.11.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -1012,6 +1162,15 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -1140,6 +1299,19 @@
"node": ">= 0.8"
}
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
"license": "MIT",
"dependencies": {
"leac": "^0.6.0",
"peberminta": "^0.9.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -1155,6 +1327,158 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/pg": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1207,6 +1531,50 @@
"node": ">= 0.8"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"node_modules/react-promise-suspense": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
"integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^2.0.1"
}
},
"node_modules/resend": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-4.8.0.tgz",
"integrity": "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==",
"license": "MIT",
"dependencies": {
"@react-email/render": "1.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -1233,6 +1601,25 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/selderee": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
"license": "MIT",
"dependencies": {
"parseley": "^0.12.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@ -1356,6 +1743,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -1461,6 +1857,15 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",

View File

@ -11,7 +11,9 @@
"@ai-sdk/mistral": "^3.0.0",
"@octokit/rest": "^22.0.1",
"ai": "^6.0.1",
"googleapis": "^126.0.1"
"googleapis": "^126.0.1",
"pg": "^8.13.0",
"resend": "^4.0.0"
},
"devDependencies": {
"dotenv": "^16.3.1"

View File

@ -20,7 +20,8 @@ app.use((req, res, next) => {
});
// API routes - wrap Vercel serverless functions
const waitlistHandler = require('./api/waitlist');
const waitlistHandler = require('./api/waitlist-db');
const applicationHandler = require('./api/application');
const gameChatHandler = require('./api/game-chat');
const shareToGithubHandler = require('./api/share-to-github');
@ -37,6 +38,7 @@ const vercelToExpress = (handler) => async (req, res) => {
};
app.all('/api/waitlist', vercelToExpress(waitlistHandler));
app.all('/api/application', vercelToExpress(applicationHandler));
app.all('/api/game-chat', vercelToExpress(gameChatHandler));
app.all('/api/share-to-github', vercelToExpress(shareToGithubHandler));