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:
Jeff Emmett 2026-01-22 14:35:15 +01:00
parent 5a9fb1786a
commit 196ca4589d
3 changed files with 211 additions and 2 deletions

View File

@ -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:

View File

@ -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
View File

@ -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 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 {
@ -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'}`);
});
});