Add Google Sheets and Resend email integration
- Registration data sent to Google Sheet (all fields) - Confirmation email sent via Resend - Beautiful HTML email template matching site design - Both integrations are optional (gracefully disabled if not configured) Environment variables needed: - RESEND_API_KEY: Resend API key for emails - GOOGLE_SHEET_ID: Google Sheet ID for registrations - GOOGLE_CREDENTIALS: Service account JSON credentials Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5a9fb1786a
commit
196ca4589d
|
|
@ -10,6 +10,9 @@ services:
|
|||
- PORT=3000
|
||||
- DATA_DIR=/app/data
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN:-worldplay-admin-2026}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY}
|
||||
- GOOGLE_SHEET_ID=${GOOGLE_SHEET_ID}
|
||||
- GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS}
|
||||
volumes:
|
||||
- worldplay-data:/app/data
|
||||
labels:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@
|
|||
"author": "WORLDPLAY Collective",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
"express": "^4.18.2",
|
||||
"googleapis": "^144.0.0",
|
||||
"resend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
|
|
|||
206
server.js
206
server.js
|
|
@ -1,12 +1,32 @@
|
|||
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('.'));
|
||||
|
|
@ -40,6 +60,182 @@ 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 {
|
||||
|
|
@ -77,12 +273,18 @@ app.post('/api/register', async (req, res) => {
|
|||
ipAddress: req.ip || req.connection.remoteAddress
|
||||
};
|
||||
|
||||
// Save registration
|
||||
// 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',
|
||||
|
|
@ -204,5 +406,7 @@ 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'}`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue