worldplay-website/server.js

425 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
Array.isArray(registration.contribute) ? registration.contribute.join(', ') : registration.contribute,
registration.message,
registration.newsletter ? 'Yes' : 'No',
registration.id
]];
await sheets.spreadsheets.values.append({
spreadsheetId: GOOGLE_SHEET_ID,
range: 'Registrations!A:K',
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': 'Propose a session',
'workshop': 'Host a workshop',
'game': 'Bring a game to play or playtest',
'larp': 'Run a LARP or participatory format',
'project': 'Co-create a project or publication',
'other': 'Contribute in another way',
'attend': 'Attend only',
'unsure': 'Not sure yet'
};
const contributeText = Array.isArray(registration.contribute) && registration.contribute.length > 0
? registration.contribute.map(c => contributeLabels[c] || c).join(', ')
: 'Not specified';
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>
${(Array.isArray(registration.contribute) && registration.contribute.length > 0) || 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);">${contributeText}</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 713, 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, otherRole, interests, contribute, message, newsletter } = 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 === 'other' && otherRole ? `Other: ${otherRole.trim()}` : (role || ''),
interests: interests || [],
contribute: contribute || [],
message: message?.trim() || '',
newsletter: newsletter === 'yes',
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', 'Newsletter', '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,
Array.isArray(r.contribute) ? r.contribute.join('; ') : r.contribute,
r.message.replace(/"/g, '""'),
r.newsletter ? 'Yes' : 'No',
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 (Array.isArray(r.contribute)) {
r.contribute.forEach(contrib => {
stats.byContribute[contrib] = (stats.byContribute[contrib] || 0) + 1;
});
} else 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'}`);
});
});