413 lines
16 KiB
JavaScript
413 lines
16 KiB
JavaScript
const express = require('express');
|
||
const fs = require('fs').promises;
|
||
const path = require('path');
|
||
const { google } = require('googleapis');
|
||
const { Resend } = require('resend');
|
||
|
||
const app = express();
|
||
const PORT = process.env.PORT || 3000;
|
||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||
const REGISTRATIONS_FILE = path.join(DATA_DIR, 'registrations.json');
|
||
|
||
// Initialize Resend for emails
|
||
const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;
|
||
|
||
// Google Sheets configuration
|
||
const GOOGLE_SHEET_ID = process.env.GOOGLE_SHEET_ID;
|
||
const GOOGLE_CREDENTIALS = process.env.GOOGLE_CREDENTIALS ? JSON.parse(process.env.GOOGLE_CREDENTIALS) : null;
|
||
|
||
// Initialize Google Sheets API
|
||
let sheets = null;
|
||
if (GOOGLE_CREDENTIALS && GOOGLE_SHEET_ID) {
|
||
const auth = new google.auth.GoogleAuth({
|
||
credentials: GOOGLE_CREDENTIALS,
|
||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||
});
|
||
sheets = google.sheets({ version: 'v4', auth });
|
||
console.log('Google Sheets integration enabled');
|
||
}
|
||
|
||
// Middleware
|
||
app.use(express.json());
|
||
app.use(express.static('.'));
|
||
|
||
// Ensure data directory exists
|
||
async function ensureDataDir() {
|
||
try {
|
||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||
try {
|
||
await fs.access(REGISTRATIONS_FILE);
|
||
} catch {
|
||
await fs.writeFile(REGISTRATIONS_FILE, '[]');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating data directory:', error);
|
||
}
|
||
}
|
||
|
||
// Load registrations
|
||
async function loadRegistrations() {
|
||
try {
|
||
const data = await fs.readFile(REGISTRATIONS_FILE, 'utf8');
|
||
return JSON.parse(data);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Save registrations
|
||
async function saveRegistrations(registrations) {
|
||
await fs.writeFile(REGISTRATIONS_FILE, JSON.stringify(registrations, null, 2));
|
||
}
|
||
|
||
// Append registration to Google Sheet
|
||
async function appendToGoogleSheet(registration) {
|
||
if (!sheets || !GOOGLE_SHEET_ID) {
|
||
console.log('Google Sheets not configured, skipping...');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const values = [[
|
||
registration.registeredAt,
|
||
registration.firstName,
|
||
registration.lastName,
|
||
registration.email,
|
||
registration.location,
|
||
registration.role,
|
||
Array.isArray(registration.interests) ? registration.interests.join(', ') : registration.interests,
|
||
registration.contribute,
|
||
registration.message,
|
||
registration.id
|
||
]];
|
||
|
||
await sheets.spreadsheets.values.append({
|
||
spreadsheetId: GOOGLE_SHEET_ID,
|
||
range: 'Registrations!A:J',
|
||
valueInputOption: 'USER_ENTERED',
|
||
insertDataOption: 'INSERT_ROWS',
|
||
requestBody: { values },
|
||
});
|
||
|
||
console.log(`Added registration to Google Sheet: ${registration.email}`);
|
||
} catch (error) {
|
||
console.error('Error appending to Google Sheet:', error.message);
|
||
}
|
||
}
|
||
|
||
// Send confirmation email
|
||
async function sendConfirmationEmail(registration) {
|
||
if (!resend) {
|
||
console.log('Resend not configured, skipping email...');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const interestsText = Array.isArray(registration.interests) && registration.interests.length > 0
|
||
? registration.interests.map(i => {
|
||
const labels = {
|
||
'reality': '🎭 Playing with Reality',
|
||
'fiction': '✒️ Science Fictions',
|
||
'worlding': '🛠 Guerrilla Futuring',
|
||
'games': '🎲 Game Commons',
|
||
'infrastructure': '🌱 Infrastructures'
|
||
};
|
||
return labels[i] || i;
|
||
}).join(', ')
|
||
: 'Not specified';
|
||
|
||
const roleLabels = {
|
||
'writer': 'Sci-Fi Writer / Storyteller',
|
||
'gamemaker': 'Game Designer / Maker',
|
||
'artist': 'Artist / Performer',
|
||
'larper': 'LARPer / Roleplayer',
|
||
'economist': 'Weird Economist / Commons Activist',
|
||
'futurist': 'Futurist / Speculative Designer',
|
||
'academic': 'Academic / Researcher',
|
||
'tech': 'Technologist / Developer',
|
||
'curious': 'Curious Explorer',
|
||
'other': 'Other'
|
||
};
|
||
|
||
const contributeLabels = {
|
||
'session': 'Pitch a session',
|
||
'workshop': 'Run a workshop',
|
||
'game': 'Share/playtest a game',
|
||
'performance': 'Perform/facilitate',
|
||
'collaborate': 'Collaborate on something',
|
||
'participate': 'Participate',
|
||
'unsure': 'Not sure yet'
|
||
};
|
||
|
||
await resend.emails.send({
|
||
from: 'WORLDPLAY <hello@worldplay.art>',
|
||
to: registration.email,
|
||
subject: '🎭 Welcome to WORLDPLAY – Registration Confirmed',
|
||
html: `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
</head>
|
||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #0a0a0f; color: #f0f0f5; padding: 40px 20px; margin: 0;">
|
||
<div style="max-width: 600px; margin: 0 auto;">
|
||
<div style="text-align: center; margin-bottom: 40px;">
|
||
<h1 style="font-family: monospace; font-size: 32px; background: linear-gradient(135deg, #9d4edd 0%, #ff006e 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin: 0;">WORLDPLAY</h1>
|
||
<p style="color: #a0a0b0; font-style: italic; margin-top: 8px;">: To be Defined</p>
|
||
</div>
|
||
|
||
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
|
||
<h2 style="color: #00d9ff; margin-top: 0; font-size: 20px;">Welcome, ${registration.firstName}! 🌟</h2>
|
||
|
||
<p style="color: #a0a0b0; line-height: 1.7;">
|
||
Thank you for registering your interest in <strong style="color: #f0f0f5;">WORLDPLAY</strong> – a pop-up physical hub for prefiguring and prehearsing postcapitalist futures through fiction, performance and play.
|
||
</p>
|
||
|
||
<p style="color: #a0a0b0; line-height: 1.7;">
|
||
We've received your registration and will be in touch with next steps as we finalize the programme.
|
||
</p>
|
||
</div>
|
||
|
||
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
|
||
<h3 style="color: #9d4edd; margin-top: 0; font-size: 16px; text-transform: uppercase; letter-spacing: 0.1em;">Your Registration Details</h3>
|
||
|
||
<table style="width: 100%; color: #a0a0b0; font-size: 14px;">
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Name:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.firstName} ${registration.lastName}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Email:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.email}</td>
|
||
</tr>
|
||
${registration.location ? `
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Location:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.location}</td>
|
||
</tr>
|
||
` : ''}
|
||
${registration.role ? `
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Role:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${roleLabels[registration.role] || registration.role}</td>
|
||
</tr>
|
||
` : ''}
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Interests:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${interestsText}</td>
|
||
</tr>
|
||
${registration.contribute ? `
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Contribution:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${contributeLabels[registration.contribute] || registration.contribute}</td>
|
||
</tr>
|
||
` : ''}
|
||
</table>
|
||
</div>
|
||
|
||
<div style="background-color: #12121a; border: 1px solid rgba(157, 78, 221, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
|
||
<h3 style="color: #9d4edd; margin-top: 0; font-size: 16px; text-transform: uppercase; letter-spacing: 0.1em;">Event Details</h3>
|
||
|
||
<p style="color: #a0a0b0; margin: 0;">
|
||
📅 <strong style="color: #f0f0f5;">June 7–13, 2026</strong><br>
|
||
📍 <strong style="color: #f0f0f5;">Commons Hub</strong>, Hirschwang an der Rax, Austria<br>
|
||
🏔️ Austrian Alps, ~1.5 hours from Vienna by train
|
||
</p>
|
||
</div>
|
||
|
||
<div style="text-align: center; padding: 24px 0; border-top: 1px solid rgba(157, 78, 221, 0.2);">
|
||
<p style="color: #a0a0b0; font-size: 14px; margin: 0;">
|
||
Questions? Reply to this email or visit <a href="https://worldplay.art" style="color: #00d9ff;">worldplay.art</a>
|
||
</p>
|
||
<p style="color: #9d4edd; font-size: 12px; margin-top: 16px; font-style: italic;">
|
||
Reality is a design space ✨
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`,
|
||
});
|
||
|
||
console.log(`Confirmation email sent to: ${registration.email}`);
|
||
} catch (error) {
|
||
console.error('Error sending confirmation email:', error.message);
|
||
}
|
||
}
|
||
|
||
// Registration endpoint
|
||
app.post('/api/register', async (req, res) => {
|
||
try {
|
||
const { firstName, lastName, email, location, role, interests, contribute, message } = req.body;
|
||
|
||
// Validation
|
||
if (!firstName || !lastName || !email) {
|
||
return res.status(400).json({ error: 'First name, last name, and email are required' });
|
||
}
|
||
|
||
if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
||
return res.status(400).json({ error: 'Please provide a valid email address' });
|
||
}
|
||
|
||
// Load existing registrations
|
||
const registrations = await loadRegistrations();
|
||
|
||
// Check for duplicate email
|
||
if (registrations.some(r => r.email.toLowerCase() === email.toLowerCase())) {
|
||
return res.status(400).json({ error: 'This email is already registered' });
|
||
}
|
||
|
||
// Create new registration
|
||
const registration = {
|
||
id: Date.now().toString(36) + Math.random().toString(36).substr(2),
|
||
firstName: firstName.trim(),
|
||
lastName: lastName.trim(),
|
||
email: email.toLowerCase().trim(),
|
||
location: location?.trim() || '',
|
||
role: role || '',
|
||
interests: interests || [],
|
||
contribute: contribute || '',
|
||
message: message?.trim() || '',
|
||
registeredAt: new Date().toISOString(),
|
||
ipAddress: req.ip || req.connection.remoteAddress
|
||
};
|
||
|
||
// Save registration locally
|
||
registrations.push(registration);
|
||
await saveRegistrations(registrations);
|
||
|
||
console.log(`New registration: ${registration.firstName} ${registration.lastName} <${registration.email}>`);
|
||
|
||
// Add to Google Sheet (async, don't block response)
|
||
appendToGoogleSheet(registration).catch(err => console.error('Sheet error:', err));
|
||
|
||
// Send confirmation email (async, don't block response)
|
||
sendConfirmationEmail(registration).catch(err => console.error('Email error:', err));
|
||
|
||
res.json({
|
||
success: true,
|
||
message: 'Registration successful',
|
||
id: registration.id
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Registration error:', error);
|
||
res.status(500).json({ error: 'An error occurred. Please try again.' });
|
||
}
|
||
});
|
||
|
||
// Admin endpoint to view registrations (protected by simple token)
|
||
app.get('/api/registrations', async (req, res) => {
|
||
const token = req.headers['x-admin-token'] || req.query.token;
|
||
const adminToken = process.env.ADMIN_TOKEN || 'worldplay-admin-2026';
|
||
|
||
if (token !== adminToken) {
|
||
return res.status(401).json({ error: 'Unauthorized' });
|
||
}
|
||
|
||
try {
|
||
const registrations = await loadRegistrations();
|
||
res.json({
|
||
count: registrations.length,
|
||
registrations: registrations.map(r => ({
|
||
...r,
|
||
ipAddress: undefined // Don't expose IP in admin view
|
||
}))
|
||
});
|
||
} catch (error) {
|
||
res.status(500).json({ error: 'Failed to load registrations' });
|
||
}
|
||
});
|
||
|
||
// Export registrations as CSV
|
||
app.get('/api/registrations/export', async (req, res) => {
|
||
const token = req.headers['x-admin-token'] || req.query.token;
|
||
const adminToken = process.env.ADMIN_TOKEN || 'worldplay-admin-2026';
|
||
|
||
if (token !== adminToken) {
|
||
return res.status(401).json({ error: 'Unauthorized' });
|
||
}
|
||
|
||
try {
|
||
const registrations = await loadRegistrations();
|
||
|
||
const headers = ['ID', 'First Name', 'Last Name', 'Email', 'Location', 'Role', 'Interests', 'Contribute', 'Message', 'Registered At'];
|
||
const rows = registrations.map(r => [
|
||
r.id,
|
||
r.firstName,
|
||
r.lastName,
|
||
r.email,
|
||
r.location,
|
||
r.role,
|
||
Array.isArray(r.interests) ? r.interests.join('; ') : r.interests,
|
||
r.contribute,
|
||
r.message.replace(/"/g, '""'),
|
||
r.registeredAt
|
||
]);
|
||
|
||
const csv = [
|
||
headers.join(','),
|
||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||
].join('\n');
|
||
|
||
res.setHeader('Content-Type', 'text/csv');
|
||
res.setHeader('Content-Disposition', 'attachment; filename=worldplay-registrations.csv');
|
||
res.send(csv);
|
||
} catch (error) {
|
||
res.status(500).json({ error: 'Failed to export registrations' });
|
||
}
|
||
});
|
||
|
||
// Health check
|
||
app.get('/health', (req, res) => {
|
||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||
});
|
||
|
||
// Stats endpoint
|
||
app.get('/api/stats', async (req, res) => {
|
||
try {
|
||
const registrations = await loadRegistrations();
|
||
|
||
const stats = {
|
||
totalRegistrations: registrations.length,
|
||
byRole: {},
|
||
byInterest: {},
|
||
byContribute: {}
|
||
};
|
||
|
||
registrations.forEach(r => {
|
||
// Count by role
|
||
if (r.role) {
|
||
stats.byRole[r.role] = (stats.byRole[r.role] || 0) + 1;
|
||
}
|
||
|
||
// Count by interest
|
||
if (Array.isArray(r.interests)) {
|
||
r.interests.forEach(interest => {
|
||
stats.byInterest[interest] = (stats.byInterest[interest] || 0) + 1;
|
||
});
|
||
}
|
||
|
||
// Count by contribute
|
||
if (r.contribute) {
|
||
stats.byContribute[r.contribute] = (stats.byContribute[r.contribute] || 0) + 1;
|
||
}
|
||
});
|
||
|
||
res.json(stats);
|
||
} catch (error) {
|
||
res.status(500).json({ error: 'Failed to calculate stats' });
|
||
}
|
||
});
|
||
|
||
// Start server
|
||
ensureDataDir().then(() => {
|
||
app.listen(PORT, '0.0.0.0', () => {
|
||
console.log(`WORLDPLAY server running on port ${PORT}`);
|
||
console.log(`Admin token: ${process.env.ADMIN_TOKEN || 'worldplay-admin-2026'}`);
|
||
console.log(`Google Sheets: ${sheets ? 'enabled' : 'disabled'}`);
|
||
console.log(`Email notifications: ${resend ? 'enabled' : 'disabled'}`);
|
||
});
|
||
});
|