feat: add Mollie payment pipeline with two-step registration flow
CI/CD / deploy (push) Failing after 1m12s Details

Convert single-step registration into a two-step flow:
- Step 1: Registration info → saves to JSON + Google Sheet with awaiting_payment status
- Step 2: Accommodation selection + price summary → creates Mollie checkout → redirects

On payment completion, Mollie webhook updates status, assigns bed on booking sheet,
sends confirmation email, and adds to Listmonk.

New files: api/booking-sheet.js, payment-success.html
New routes: /api/create-checkout-session, /api/mollie/webhook, /api/payment-status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-10 14:36:40 -04:00
parent 1b3f7faa62
commit dc95bf4819
9 changed files with 3007 additions and 137 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
data/
.env
*.log

View File

@ -24,6 +24,7 @@ COPY --from=builder /app/node_modules ./node_modules
# Copy application files
COPY package*.json ./
COPY server.js ./
COPY api/ ./api/
COPY *.html ./
COPY images/ ./images/

255
api/booking-sheet.js Normal file
View File

@ -0,0 +1,255 @@
// Booking sheet integration for WORLDPLAY
// Manages bed assignments on a Google Sheets booking sheet
// Adapted from VotC booking-sheet.js for single-week event
const fs = require('fs');
let sheetsClient = null;
// Accommodation criteria mapping — maps accommodation_type to sheet matching rules
const ACCOMMODATION_CRITERIA = {
'ch-shared': {
venue: 'Commons Hub',
bedTypes: ['Bunk up', 'Bunk down', 'Single'],
},
'ch-double': {
venue: 'Commons Hub',
bedTypes: ['Double'],
},
'hh-living': {
venue: 'Herrnhof Villa',
bedTypes: ['Living room', 'Sofa bed', 'Daybed'],
},
'hh-triple': {
venue: 'Herrnhof Villa',
bedTypes: ['Triple'],
},
'hh-twin': {
venue: 'Herrnhof Villa',
bedTypes: ['Twin', 'Double (separate)'],
},
'hh-single': {
venue: 'Herrnhof Villa',
bedTypes: ['Single'],
},
'hh-couple': {
venue: 'Herrnhof Villa',
bedTypes: ['Double (shared)', 'Double', 'Couple'],
},
};
// Single week for WORLDPLAY (June 7-13)
const WEEK_COLUMNS = ['Week 1'];
function getCredentials() {
const filePath = process.env.GOOGLE_SERVICE_ACCOUNT_FILE;
if (filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (err) {
console.error('[Booking Sheet] Failed to read credentials file:', err.message);
return null;
}
}
const raw = process.env.GOOGLE_SERVICE_ACCOUNT;
if (!raw) return null;
try {
return JSON.parse(raw.trim());
} catch {
console.error('[Booking Sheet] Failed to parse service account JSON');
return null;
}
}
async function getSheetsClient() {
if (sheetsClient) return sheetsClient;
const creds = getCredentials();
if (!creds) return null;
const { google } = require('googleapis');
const auth = new google.auth.GoogleAuth({
credentials: creds,
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
sheetsClient = google.sheets({ version: 'v4', auth });
return sheetsClient;
}
/**
* Read and parse the booking sheet.
* Expected structure per venue section:
* Row: "Commons Hub" or "Herrnhof Villa" (venue header)
* Row: Room | Bed Type | Week 1 (column headers)
* Row: 5 | Bunk up | (bed rows)
* ...
* Empty row (section separator)
*
* Returns array of bed objects:
* { venue, room, bedType, rowIndex, weekColumns: { 'Week 1': colIndex }, occupancy: { 'Week 1': 'Guest Name' | null } }
*/
async function parseBookingSheet() {
const sheets = await getSheetsClient();
if (!sheets) {
console.log('[Booking Sheet] No credentials configured — skipping');
return null;
}
const sheetId = process.env.BOOKING_SHEET_ID;
if (!sheetId) {
console.log('[Booking Sheet] No BOOKING_SHEET_ID configured — skipping');
return null;
}
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
const response = await sheets.spreadsheets.values.get({
spreadsheetId: sheetId,
range: `${sheetName}!A:G`,
});
const rows = response.data.values || [];
const beds = [];
let currentVenue = null;
let weekColIndexes = {};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row || row.length === 0 || row.every(cell => !cell || !cell.toString().trim())) {
currentVenue = null;
weekColIndexes = {};
continue;
}
const firstCell = (row[0] || '').toString().trim();
// Check if this is a venue header
if (firstCell === 'Commons Hub' || firstCell === 'Herrnhof Villa') {
currentVenue = firstCell;
weekColIndexes = {};
continue;
}
// Check if this is the column header row (contains "Room" and week columns)
if (firstCell.toLowerCase() === 'room' && currentVenue) {
for (let c = 0; c < row.length; c++) {
const header = (row[c] || '').toString().trim();
if (WEEK_COLUMNS.includes(header)) {
weekColIndexes[header] = c;
}
}
continue;
}
// If we have a venue and week columns, this is a bed row
if (currentVenue && Object.keys(weekColIndexes).length > 0 && firstCell) {
const room = firstCell;
const bedType = (row[1] || '').toString().trim();
if (!bedType) continue;
const occupancy = {};
for (const [week, colIdx] of Object.entries(weekColIndexes)) {
const cellValue = (row[colIdx] || '').toString().trim();
occupancy[week] = cellValue || null;
}
beds.push({
venue: currentVenue,
room,
bedType,
rowIndex: i,
weekColumns: { ...weekColIndexes },
occupancy,
});
}
}
return beds;
}
/**
* Find an available bed matching the accommodation criteria.
* A bed is "available" if the Week 1 column is empty.
*/
function findAvailableBed(beds, accommodationType) {
const criteria = ACCOMMODATION_CRITERIA[accommodationType];
if (!criteria) {
console.error(`[Booking Sheet] Unknown accommodation type: ${accommodationType}`);
return null;
}
for (const bed of beds) {
if (bed.venue !== criteria.venue) continue;
const bedTypeLower = bed.bedType.toLowerCase();
const matchesBedType = criteria.bedTypes.some(bt => bedTypeLower.includes(bt.toLowerCase()));
if (!matchesBedType) continue;
// Check Week 1 is empty
if (!bed.occupancy['Week 1']) {
return bed;
}
}
return null;
}
/**
* Assign a guest to a bed on the booking sheet.
* Writes guest name to the Week 1 column for the matched bed row.
*
* @param {string} guestName - Full name of the guest
* @param {string} accommodationType - e.g. 'ch-shared', 'hh-single'
* @returns {object} Result with success status, venue, room, bedType
*/
async function assignBooking(guestName, accommodationType) {
if (!accommodationType) {
return { success: false, reason: 'Missing accommodation type' };
}
try {
const beds = await parseBookingSheet();
if (!beds) {
return { success: false, reason: 'Booking sheet not configured' };
}
const bed = findAvailableBed(beds, accommodationType);
if (!bed) {
console.warn(`[Booking Sheet] No available bed for ${accommodationType}`);
return { success: false, reason: 'No available bed matching criteria' };
}
const sheets = await getSheetsClient();
const sheetId = process.env.BOOKING_SHEET_ID;
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet';
const weekCol = bed.weekColumns['Week 1'];
if (weekCol === undefined) {
return { success: false, reason: 'Week 1 column not found' };
}
const colLetter = String.fromCharCode(65 + weekCol);
const rowNum = bed.rowIndex + 1; // Sheets is 1-indexed
await sheets.spreadsheets.values.update({
spreadsheetId: sheetId,
range: `${sheetName}!${colLetter}${rowNum}`,
valueInputOption: 'USER_ENTERED',
requestBody: { values: [[guestName]] },
});
console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType})`);
return {
success: true,
venue: bed.venue,
room: bed.room,
bedType: bed.bedType,
};
} catch (error) {
console.error('[Booking Sheet] Assignment failed:', error);
return { success: false, reason: error.message };
}
}
module.exports = { assignBooking, parseBookingSheet, findAvailableBed, ACCOMMODATION_CRITERIA };

View File

@ -23,6 +23,13 @@ services:
- LISTMONK_DB_USER=listmonk
- LISTMONK_DB_PASS=listmonk_secure_2025
- LISTMONK_LIST_ID=20
# Mollie payment integration
- MOLLIE_API_KEY=${MOLLIE_API_KEY}
- BASE_URL=${BASE_URL:-https://worldplay.art}
# Booking sheet (occupancy/bed assignment)
- BOOKING_SHEET_ID=${BOOKING_SHEET_ID:-1LGEjsYpaHQn7em-dJPutL-C3t47UYSbqLEsGUVD8aIc}
- BOOKING_SHEET_TAB=${BOOKING_SHEET_TAB:-Booking Sheet}
- EMAIL_TEAM=${EMAIL_TEAM:-hello@worldplay.art}
volumes:
- worldplay-data:/app/data
- ./google-service-account.json:/app/secrets/google-service-account.json:ro

View File

@ -943,6 +943,227 @@
color: var(--accent-magenta);
}
/* Step indicator */
.step-indicator {
display: flex;
justify-content: center;
gap: 0;
margin-bottom: 2rem;
}
.step-indicator .step {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
font-size: 0.85rem;
color: var(--text-secondary);
opacity: 0.5;
transition: all 0.3s;
}
.step-indicator .step.active {
opacity: 1;
color: var(--accent-cyan);
}
.step-indicator .step.completed {
opacity: 0.8;
color: var(--accent-green);
}
.step-indicator .step-number {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid currentColor;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
}
.step-indicator .step.completed .step-number {
background: var(--accent-green);
border-color: var(--accent-green);
color: #0a0a0f;
}
.step-indicator .step-divider {
width: 40px;
height: 2px;
background: rgba(157, 78, 221, 0.3);
align-self: center;
}
/* Form step visibility */
.form-step { display: none; }
.form-step.active { display: block; }
/* Accommodation toggle */
.accom-toggle {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.accom-toggle-btn {
flex: 1;
padding: 1rem;
background: var(--bg-input);
border: 2px solid rgba(157, 78, 221, 0.2);
border-radius: 10px;
color: var(--text-secondary);
font-family: 'Space Grotesk', sans-serif;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s;
text-align: center;
}
.accom-toggle-btn:hover {
border-color: var(--accent-purple);
}
.accom-toggle-btn.selected {
border-color: var(--accent-cyan);
background: rgba(0, 217, 255, 0.08);
color: var(--text-primary);
}
/* Accommodation cards */
.accom-cards {
display: none;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.accom-cards.visible { display: flex; }
.accom-venue-label {
font-size: 0.8rem;
color: var(--accent-purple);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
}
.accom-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
background: var(--bg-input);
border: 2px solid rgba(157, 78, 221, 0.15);
border-radius: 10px;
cursor: pointer;
transition: all 0.3s;
}
.accom-card:hover {
border-color: var(--accent-purple);
background: rgba(157, 78, 221, 0.08);
}
.accom-card.selected {
border-color: var(--accent-cyan);
background: rgba(0, 217, 255, 0.08);
}
.accom-card .accom-name {
font-size: 0.95rem;
color: var(--text-primary);
}
.accom-card .accom-price {
font-family: 'Space Mono', monospace;
font-size: 0.9rem;
color: var(--accent-cyan);
white-space: nowrap;
}
.accom-card .accom-note {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 2px;
}
/* Price summary box */
.price-summary {
background: rgba(157, 78, 221, 0.08);
border: 1px solid rgba(157, 78, 221, 0.3);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.price-summary h4 {
font-size: 0.85rem;
color: var(--accent-purple);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 1rem;
}
.price-line {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
.price-line.total {
border-top: 1px solid rgba(157, 78, 221, 0.3);
margin-top: 0.5rem;
padding-top: 0.75rem;
font-weight: 700;
font-size: 1.1rem;
color: var(--text-primary);
}
.price-line.total .price-amount {
color: var(--accent-green);
}
.price-amount {
font-family: 'Space Mono', monospace;
}
/* Food disclaimer info box */
.info-box {
background: rgba(255, 149, 0, 0.08);
border: 1px solid rgba(255, 149, 0, 0.3);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.6;
}
.info-box strong {
color: var(--accent-orange);
}
/* Back button */
.btn-back {
background: transparent;
color: var(--text-secondary);
border: 1px solid rgba(157, 78, 221, 0.3);
margin-bottom: 1rem;
font-size: 0.85rem;
padding: 0.6rem 1.25rem;
}
.btn-back:hover {
color: var(--text-primary);
border-color: var(--accent-purple);
}
/* Call to Action */
.cta-section {
text-align: center;
@ -1491,8 +1712,8 @@
<div class="container">
<div class="section-header">
<span class="section-tag">Join Us</span>
<h2 class="section-title">Register Interest</h2>
<p class="section-subtitle">This is the first edition of WORLDPLAY and spaces are limited to 60 participants. Express your interest early to secure your place.</p>
<h2 class="section-title">Register & Pay</h2>
<p class="section-subtitle">WORLDPLAY spaces are limited to 60 participants. Complete registration and payment to secure your place.</p>
</div>
<div class="register-content">
@ -1523,132 +1744,252 @@
</div>
<form class="register-form" id="registration-form">
<div class="form-header">
<h3>Express Interest</h3>
<p>We'll be in touch with next steps</p>
<div class="step-indicator">
<div class="step active" id="step-ind-1">
<span class="step-number">1</span>
<span>Your Info</span>
</div>
<div class="step-divider"></div>
<div class="step" id="step-ind-2">
<span class="step-number">2</span>
<span>Accommodation & Payment</span>
</div>
</div>
<div class="form-row">
<!-- STEP 1: Registration Info -->
<div class="form-step active" id="step-1">
<div class="form-header">
<h3>Your Details</h3>
<p>Tell us about yourself</p>
</div>
<div class="form-row">
<div class="form-group">
<label>First Name <span class="required">*</span></label>
<input type="text" name="firstName" required placeholder="Your first name">
</div>
<div class="form-group">
<label>Last Name <span class="required">*</span></label>
<input type="text" name="lastName" required placeholder="Your last name">
</div>
</div>
<div class="form-group">
<label>First Name <span class="required">*</span></label>
<input type="text" name="firstName" required placeholder="Your first name">
<label>Email <span class="required">*</span></label>
<input type="email" name="email" required placeholder="your@email.com">
</div>
<div class="form-group">
<label>Last Name <span class="required">*</span></label>
<input type="text" name="lastName" required placeholder="Your last name">
<label>Location</label>
<input type="text" name="location" placeholder="City, Country">
</div>
</div>
<div class="form-group">
<label>Email <span class="required">*</span></label>
<input type="email" name="email" required placeholder="your@email.com">
</div>
<div class="form-group">
<label>How would you describe yourself?</label>
<select name="role" id="role-select">
<option value="">Select one...</option>
<option value="writer">Sci-Fi Writer / Storyteller</option>
<option value="gamemaker">Game Designer / Maker</option>
<option value="artist">Artist / Performer</option>
<option value="larper">LARPer / Roleplayer</option>
<option value="economist">Weird Economist / Commons Activist</option>
<option value="futurist">Futurist / Speculative Designer</option>
<option value="academic">Academic / Researcher</option>
<option value="tech">Technologist / Developer</option>
<option value="curious">Curious Explorer</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label>Location</label>
<input type="text" name="location" placeholder="City, Country">
</div>
<div class="form-group" id="other-role-group" style="display: none;">
<label>Please describe yourself</label>
<input type="text" name="otherRole" id="other-role-input" placeholder="How would you describe yourself?">
</div>
<div class="form-group">
<label>How would you describe yourself?</label>
<select name="role" id="role-select">
<option value="">Select one...</option>
<option value="writer">Sci-Fi Writer / Storyteller</option>
<option value="gamemaker">Game Designer / Maker</option>
<option value="artist">Artist / Performer</option>
<option value="larper">LARPer / Roleplayer</option>
<option value="economist">Weird Economist / Commons Activist</option>
<option value="futurist">Futurist / Speculative Designer</option>
<option value="academic">Academic / Researcher</option>
<option value="tech">Technologist / Developer</option>
<option value="curious">Curious Explorer</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label>Which threads interest you most?</label>
<div class="checkbox-group">
<label class="checkbox-item">
<input type="checkbox" name="interests" value="reality">
<span>🎭 Playing with Reality</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="interests" value="fiction">
<span>✒️ Econ Sci-Fi</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="interests" value="worlding">
<span>🛠 Guerrilla Futuring</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="interests" value="games">
<span>🎲 Eutopian Games</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="interests" value="infrastructure">
<span>🌱 Infrastructures</span>
</label>
</div>
</div>
<div class="form-group" id="other-role-group" style="display: none;">
<label>Please describe yourself</label>
<input type="text" name="otherRole" id="other-role-input" placeholder="How would you describe yourself?">
</div>
<div class="form-group">
<label>Would you like to contribute to the programme? (select all that apply)</label>
<div class="checkbox-group">
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="session">
<span>Propose a session (talk / discussion / panel / other)</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="workshop">
<span>Host a workshop (co-writing / game co-design / other)</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="game">
<span>Bring a game to play or playtest (tabletop / digital / hybrid)</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="larp">
<span>Run a LARP or other participatory format</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="project">
<span>Co-create a project or publication</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="other">
<span>Contribute in another way (please share below)</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="attend">
<span>Attend only</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="unsure">
<span>Not sure yet</span>
</label>
</div>
</div>
<div class="form-group">
<label>Which threads interest you most?</label>
<div class="checkbox-group">
<label class="checkbox-item">
<input type="checkbox" name="interests" value="reality">
<span>🎭 Playing with Reality</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="interests" value="fiction">
<span>✒️ Econ Sci-Fi</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="interests" value="worlding">
<span>🛠 Guerrilla Futuring</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="interests" value="games">
<span>🎲 Eutopian Games</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="interests" value="infrastructure">
<span>🌱 Infrastructures</span>
<div class="form-group">
<label>Anything else you'd like to share?</label>
<textarea name="message" placeholder="Tell us about your work, what excites you about WORLDPLAY, or any questions you have..."></textarea>
</div>
<div class="form-group">
<label class="checkbox-item newsletter-checkbox">
<input type="checkbox" name="newsletter" value="yes">
<span>Subscribe to the WORLDPLAY newsletter for updates</span>
</label>
</div>
</div>
<div class="form-group">
<label>Would you like to contribute to the programme? (select all that apply)</label>
<div class="checkbox-group">
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="session">
<span>Propose a session (talk / discussion / panel / other)</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="workshop">
<span>Host a workshop (co-writing / game co-design / other)</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="game">
<span>Bring a game to play or playtest (tabletop / digital / hybrid)</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="larp">
<span>Run a LARP or other participatory format</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="project">
<span>Co-create a project or publication</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="other">
<span>Contribute in another way (please share below)</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="attend">
<span>Attend only</span>
</label>
<label class="checkbox-item">
<input type="checkbox" name="contribute" value="unsure">
<span>Not sure yet</span>
</label>
<div class="form-submit">
<button type="button" class="btn btn-primary" id="btn-to-step-2">Continue to Accommodation & Payment</button>
</div>
<div class="form-message" id="form-message-step1"></div>
</div>
<div class="form-group">
<label>Anything else you'd like to share?</label>
<textarea name="message" placeholder="Tell us about your work, what excites you about WORLDPLAY, or any questions you have..."></textarea>
</div>
<!-- STEP 2: Accommodation & Payment -->
<div class="form-step" id="step-2">
<div class="form-header">
<h3>Accommodation & Payment</h3>
<p>Choose your accommodation and complete payment</p>
</div>
<div class="form-group">
<label class="checkbox-item newsletter-checkbox">
<input type="checkbox" name="newsletter" value="yes">
<span>Subscribe to the WORLDPLAY newsletter for updates</span>
</label>
</div>
<button type="button" class="btn btn-back" id="btn-back-step1">&larr; Back to details</button>
<div class="form-submit">
<button type="submit" class="btn btn-primary">Register Interest</button>
<div class="form-group">
<label>Do you need on-site accommodation? (7 nights, June 713)</label>
<div class="accom-toggle">
<button type="button" class="accom-toggle-btn selected" data-accom="no">No, I'll arrange my own</button>
<button type="button" class="accom-toggle-btn" data-accom="yes">Yes, book a room</button>
</div>
</div>
<div class="accom-cards" id="accom-cards">
<div class="accom-venue-label">Commons Hub</div>
<div class="accom-card" data-type="ch-shared" data-price="275">
<div>
<div class="accom-name">Shared Room</div>
<div class="accom-note">Bunk beds / shared room</div>
</div>
<div class="accom-price">&euro;275</div>
</div>
<div class="accom-card" data-type="ch-double" data-price="350">
<div>
<div class="accom-name">Double Room</div>
<div class="accom-note">Double bed, private or shared</div>
</div>
<div class="accom-price">&euro;350</div>
</div>
<div class="accom-venue-label">Herrnhof Villa</div>
<div class="accom-card" data-type="hh-living" data-price="315">
<div>
<div class="accom-name">Living Room</div>
<div class="accom-note">Sofa bed / daybed in shared living area</div>
</div>
<div class="accom-price">&euro;315</div>
</div>
<div class="accom-card" data-type="hh-triple" data-price="350">
<div>
<div class="accom-name">Triple Room</div>
<div class="accom-note">Shared with two others</div>
</div>
<div class="accom-price">&euro;350</div>
</div>
<div class="accom-card" data-type="hh-twin" data-price="420">
<div>
<div class="accom-name">Twin Room</div>
<div class="accom-note">Two separate beds, shared with one other</div>
</div>
<div class="accom-price">&euro;420</div>
</div>
<div class="accom-card" data-type="hh-single" data-price="665">
<div>
<div class="accom-name">Single Room</div>
<div class="accom-note">Private room for one</div>
</div>
<div class="accom-price">&euro;665</div>
</div>
<div class="accom-card" data-type="hh-couple" data-price="700">
<div>
<div class="accom-name">Couple Room</div>
<div class="accom-note">Double bed, private room for two</div>
</div>
<div class="accom-price">&euro;700</div>
</div>
</div>
<div class="price-summary" id="price-summary">
<h4>Price Summary</h4>
<div class="price-line">
<span>Participation fee</span>
<span class="price-amount">&euro;50.00</span>
</div>
<div class="price-line" id="price-accom-line" style="display: none;">
<span id="price-accom-label">Accommodation</span>
<span class="price-amount" id="price-accom-amount">&euro;0.00</span>
</div>
<div class="price-line">
<span>Processing fee (2%)</span>
<span class="price-amount" id="price-processing">&euro;1.00</span>
</div>
<div class="price-line total">
<span>Total</span>
<span class="price-amount" id="price-total">&euro;51.00</span>
</div>
</div>
<div class="info-box">
<strong>About food:</strong> Food is not included in this payment. Expect approximately &euro;1520 per person per day. We'll be in touch about food choices and dietary preferences before the event.
</div>
<div class="form-submit">
<button type="button" class="btn btn-primary" id="btn-pay">Pay via Mollie</button>
</div>
<div class="form-message" id="form-message-step2"></div>
</div>
<div class="form-message" id="form-message"></div>
@ -1662,7 +2003,7 @@
<h2>Ready to <span class="highlight-text">Hijack Reality</span>?</h2>
<p>Join fellow dreamers, makers, and reality-benders in prefiguring postcapitalist futures.</p>
<div class="cta-group">
<a href="#register" class="btn btn-primary">Register Now</a>
<a href="#register" class="btn btn-primary">Register & Pay</a>
<a href="mailto:hello@worldplay.art" class="btn btn-secondary">Get in Touch</a>
</div>
</div>
@ -1897,16 +2238,52 @@
observer.observe(el);
});
// Form submission
// ===== Two-step registration + payment flow =====
const form = document.getElementById('registration-form');
const formMessage = document.getElementById('form-message');
const step1 = document.getElementById('step-1');
const step2 = document.getElementById('step-2');
const stepInd1 = document.getElementById('step-ind-1');
const stepInd2 = document.getElementById('step-ind-2');
const msgStep1 = document.getElementById('form-message-step1');
const msgStep2 = document.getElementById('form-message-step2');
const btnToStep2 = document.getElementById('btn-to-step-2');
const btnBack = document.getElementById('btn-back-step1');
const btnPay = document.getElementById('btn-pay');
form.addEventListener('submit', async (e) => {
e.preventDefault();
let registrationId = null;
let selectedAccom = null; // null = no accommodation
function showMsg(el, text, type) {
el.textContent = text;
el.className = 'form-message ' + type;
}
function clearMsg(el) {
el.textContent = '';
el.className = 'form-message';
}
// Step 1 → Step 2 transition
btnToStep2.addEventListener('click', async () => {
clearMsg(msgStep1);
// Validate required fields
const firstName = form.querySelector('[name="firstName"]').value.trim();
const lastName = form.querySelector('[name="lastName"]').value.trim();
const email = form.querySelector('[name="email"]').value.trim();
if (!firstName || !lastName || !email) {
showMsg(msgStep1, 'Please fill in all required fields.', 'error');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
showMsg(msgStep1, 'Please enter a valid email address.', 'error');
return;
}
// Collect form data
const formData = new FormData(form);
const data = {};
formData.forEach((value, key) => {
if (key === 'interests' || key === 'contribute') {
if (!data[key]) data[key] = [];
@ -1916,30 +2293,150 @@
}
});
btnToStep2.disabled = true;
btnToStep2.textContent = 'Saving...';
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
formMessage.textContent = '✓ Thank you! We\'ll be in touch soon with next steps.';
formMessage.className = 'form-message success';
form.reset();
} else {
throw new Error(result.error || 'Something went wrong');
if (!response.ok) {
throw new Error(result.error || 'Registration failed');
}
registrationId = result.id;
// Transition to step 2
step1.classList.remove('active');
step2.classList.add('active');
stepInd1.classList.remove('active');
stepInd1.classList.add('completed');
stepInd2.classList.add('active');
// Scroll to form top
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (error) {
formMessage.textContent = '✗ ' + error.message;
formMessage.className = 'form-message error';
showMsg(msgStep1, error.message, 'error');
} finally {
btnToStep2.disabled = false;
btnToStep2.textContent = 'Continue to Accommodation & Payment';
}
});
// Back button
btnBack.addEventListener('click', () => {
step2.classList.remove('active');
step1.classList.add('active');
stepInd2.classList.remove('active');
stepInd1.classList.remove('completed');
stepInd1.classList.add('active');
});
// Accommodation toggle (yes/no)
const accomCards = document.getElementById('accom-cards');
document.querySelectorAll('.accom-toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.accom-toggle-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
if (btn.dataset.accom === 'yes') {
accomCards.classList.add('visible');
} else {
accomCards.classList.remove('visible');
selectedAccom = null;
document.querySelectorAll('.accom-card').forEach(c => c.classList.remove('selected'));
updatePriceSummary();
}
});
});
// Accommodation card selection
document.querySelectorAll('.accom-card').forEach(card => {
card.addEventListener('click', () => {
document.querySelectorAll('.accom-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
selectedAccom = card.dataset.type;
updatePriceSummary();
});
});
// Live price calculator
function updatePriceSummary() {
const participation = 50;
const accomLine = document.getElementById('price-accom-line');
const accomLabel = document.getElementById('price-accom-label');
const accomAmount = document.getElementById('price-accom-amount');
const processingEl = document.getElementById('price-processing');
const totalEl = document.getElementById('price-total');
let accomPrice = 0;
if (selectedAccom) {
const card = document.querySelector(`.accom-card[data-type="${selectedAccom}"]`);
accomPrice = parseInt(card.dataset.price);
accomLabel.textContent = card.querySelector('.accom-name').textContent + ' (7 nights)';
accomAmount.textContent = '\u20AC' + accomPrice.toFixed(2);
accomLine.style.display = 'flex';
} else {
accomLine.style.display = 'none';
}
const subtotal = participation + accomPrice;
const processing = Math.round(subtotal * 0.02 * 100) / 100;
const total = subtotal + processing;
processingEl.textContent = '\u20AC' + processing.toFixed(2);
totalEl.textContent = '\u20AC' + total.toFixed(2);
}
// Pay button — create Mollie checkout
btnPay.addEventListener('click', async () => {
clearMsg(msgStep2);
// If accommodation is toggled to "yes" but no card selected
const accomToggled = document.querySelector('.accom-toggle-btn[data-accom="yes"]').classList.contains('selected');
if (accomToggled && !selectedAccom) {
showMsg(msgStep2, 'Please select an accommodation option.', 'error');
return;
}
btnPay.disabled = true;
btnPay.textContent = 'Creating payment...';
try {
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
registrationId,
accommodationType: selectedAccom || null,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to create payment');
}
// Redirect to Mollie checkout
window.location.href = result.checkoutUrl;
} catch (error) {
showMsg(msgStep2, error.message, 'error');
btnPay.disabled = false;
btnPay.textContent = 'Pay via Mollie';
}
});
// Prevent default form submit (we handle it via buttons)
form.addEventListener('submit', (e) => e.preventDefault());
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function(e) {

1405
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@
"author": "WORLDPLAY Collective",
"license": "MIT",
"dependencies": {
"@mollie/api-client": "^4.5.0",
"express": "^4.18.2",
"googleapis": "^144.0.0",
"nodemailer": "^6.9.0",

330
payment-success.html Normal file
View File

@ -0,0 +1,330 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Confirmation — WORLDPLAY</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌍</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
<style>
:root {
--bg-dark: #0a0a0f;
--bg-card: #12121a;
--text-primary: #f0f0f5;
--text-secondary: #a0a0b0;
--accent-purple: #9d4edd;
--accent-cyan: #00d9ff;
--accent-green: #00ff88;
--accent-orange: #ff9500;
--gradient-1: linear-gradient(135deg, #9d4edd 0%, #ff006e 100%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Space Grotesk', sans-serif;
background-color: var(--bg-dark);
color: var(--text-primary);
line-height: 1.7;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.container {
max-width: 600px;
width: 100%;
}
.logo {
text-align: center;
margin-bottom: 2rem;
}
.logo h1 {
font-family: 'Space Mono', monospace;
font-size: 2rem;
background: var(--gradient-1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.logo p {
color: var(--text-secondary);
font-style: italic;
margin-top: 4px;
}
.card {
background: var(--bg-card);
border: 1px solid rgba(157, 78, 221, 0.3);
border-radius: 16px;
padding: 2.5rem;
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: var(--gradient-1);
}
.status-icon {
text-align: center;
font-size: 4rem;
margin-bottom: 1rem;
}
.status-title {
text-align: center;
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.status-subtitle {
text-align: center;
color: var(--text-secondary);
margin-bottom: 2rem;
font-size: 0.95rem;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid var(--accent-purple);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 8px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.details-table {
width: 100%;
margin-top: 1.5rem;
}
.details-table .row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
font-size: 0.9rem;
color: var(--text-secondary);
border-bottom: 1px solid rgba(157, 78, 221, 0.1);
}
.details-table .row:last-child {
border-bottom: none;
}
.details-table .row.total {
border-top: 1px solid rgba(157, 78, 221, 0.3);
border-bottom: none;
margin-top: 0.5rem;
padding-top: 0.75rem;
font-weight: 700;
font-size: 1.05rem;
color: var(--text-primary);
}
.details-table .row.total .val {
color: var(--accent-green);
}
.details-table .val {
font-family: 'Space Mono', monospace;
}
.booking-info {
margin-top: 1.5rem;
padding: 1rem 1.25rem;
background: rgba(0, 217, 255, 0.06);
border: 1px solid rgba(0, 217, 255, 0.2);
border-radius: 10px;
font-size: 0.9rem;
color: var(--text-secondary);
}
.booking-info strong {
color: var(--accent-cyan);
}
.food-note {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: rgba(255, 149, 0, 0.08);
border: 1px solid rgba(255, 149, 0, 0.2);
border-radius: 10px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.food-note strong { color: var(--accent-orange); }
.back-link {
display: block;
text-align: center;
margin-top: 2rem;
color: var(--accent-cyan);
text-decoration: none;
font-size: 0.9rem;
}
.back-link:hover { text-decoration: underline; }
.status-paid .status-icon::after { content: ''; }
.status-pending .status-icon::after { content: ''; }
.status-failed .status-icon::after { content: ''; }
#content-paid, #content-failed { display: none; }
</style>
</head>
<body>
<div class="container">
<div class="logo">
<h1>WORLDPLAY</h1>
<p>To be Defined</p>
</div>
<div class="card">
<!-- Pending state (polling) -->
<div id="content-pending">
<div class="status-icon">
<span class="spinner" style="width:48px;height:48px;border-width:4px;"></span>
</div>
<h2 class="status-title">Confirming Payment...</h2>
<p class="status-subtitle">Waiting for payment confirmation from Mollie. This usually takes a few seconds.</p>
</div>
<!-- Paid state -->
<div id="content-paid">
<div class="status-icon">&#10003;</div>
<h2 class="status-title" style="color: var(--accent-green);">Payment Confirmed!</h2>
<p class="status-subtitle">You're in, <strong id="guest-name"></strong>! A confirmation email is on its way.</p>
<div class="details-table" id="payment-details"></div>
<div id="booking-section"></div>
<div class="food-note">
<strong>About food:</strong> Food is not included. Expect ~&euro;1520/person/day. We'll be in touch about food choices before the event.
</div>
</div>
<!-- Failed/expired state -->
<div id="content-failed">
<div class="status-icon">&#10007;</div>
<h2 class="status-title" style="color: #ff006e;">Payment Not Completed</h2>
<p class="status-subtitle" id="failed-message">Your payment was not completed. You can return to the registration page to try again.</p>
</div>
</div>
<a href="/" class="back-link">&larr; Back to worldplay.art</a>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const regId = params.get('id');
const contentPending = document.getElementById('content-pending');
const contentPaid = document.getElementById('content-paid');
const contentFailed = document.getElementById('content-failed');
if (!regId) {
contentPending.style.display = 'none';
contentFailed.style.display = 'block';
document.getElementById('failed-message').textContent = 'No registration ID found. Please return to the registration page.';
} else {
let attempts = 0;
const maxAttempts = 60; // 2 minutes at 2s intervals
async function pollStatus() {
try {
const res = await fetch(`/api/payment-status?id=${encodeURIComponent(regId)}`);
if (!res.ok) throw new Error('Failed to fetch status');
const data = await res.json();
if (data.paymentStatus === 'paid') {
showPaid(data);
return;
}
if (data.paymentStatus === 'failed' || data.paymentStatus === 'canceled' || data.paymentStatus === 'expired') {
showFailed(data.paymentStatus);
return;
}
// Still pending
attempts++;
if (attempts < maxAttempts) {
setTimeout(pollStatus, 2000);
} else {
showFailed('timeout');
}
} catch (err) {
attempts++;
if (attempts < maxAttempts) {
setTimeout(pollStatus, 3000);
} else {
showFailed('error');
}
}
}
function showPaid(data) {
contentPending.style.display = 'none';
contentPaid.style.display = 'block';
document.getElementById('guest-name').textContent = `${data.firstName} ${data.lastName}`;
let rows = `
<div class="row"><span>Participation fee</span><span class="val">&euro;${data.participationFee.toFixed(2)}</span></div>
`;
if (data.accommodationPrice > 0) {
rows += `<div class="row"><span>${data.accommodationLabel} (7 nights)</span><span class="val">&euro;${data.accommodationPrice.toFixed(2)}</span></div>`;
}
rows += `
<div class="row"><span>Processing fee (2%)</span><span class="val">&euro;${data.processingFee.toFixed(2)}</span></div>
<div class="row total"><span>Total paid</span><span class="val">&euro;${data.total.toFixed(2)}</span></div>
`;
document.getElementById('payment-details').innerHTML = rows;
if (data.bookingResult && data.bookingResult.success) {
document.getElementById('booking-section').innerHTML = `
<div class="booking-info">
<strong>Your accommodation:</strong> ${data.bookingResult.venue} &mdash; Room ${data.bookingResult.room} (${data.bookingResult.bedType})
</div>
`;
}
}
function showFailed(reason) {
contentPending.style.display = 'none';
contentFailed.style.display = 'block';
const messages = {
failed: 'Your payment was declined. Please try again or use a different payment method.',
canceled: 'You cancelled the payment. You can return to the registration page to try again.',
expired: 'Your payment session expired. Please return to the registration page to try again.',
timeout: 'We couldn\'t confirm your payment yet. If you completed payment, please check your email — confirmation may arrive shortly.',
error: 'Something went wrong checking your payment status. Please check your email for confirmation.',
};
document.getElementById('failed-message').textContent = messages[reason] || messages.error;
}
pollStatus();
}
</script>
</body>
</html>

392
server.js
View File

@ -4,6 +4,8 @@ const path = require('path');
const { google } = require('googleapis');
const nodemailer = require('nodemailer');
const { Pool } = require('pg');
const { createMollieClient } = require('@mollie/api-client');
const { assignBooking } = require('./api/booking-sheet');
const app = express();
const PORT = process.env.PORT || 3000;
@ -32,6 +34,24 @@ const listmonkPool = process.env.LISTMONK_DB_HOST ? new Pool({
password: process.env.LISTMONK_DB_PASS || 'listmonk_secure_2025',
}) : null;
// Initialize Mollie payment client
const mollieClient = process.env.MOLLIE_API_KEY ? createMollieClient({ apiKey: process.env.MOLLIE_API_KEY }) : null;
const BASE_URL = process.env.BASE_URL || 'https://worldplay.art';
const EMAIL_TEAM = process.env.EMAIL_TEAM || 'hello@worldplay.art';
// Pricing configuration
const PARTICIPATION_FEE = 50;
const PROCESSING_FEE_RATE = 0.02; // 2%
const ACCOMMODATION_OPTIONS = {
'ch-shared': { label: 'Commons Hub — Shared Room', price: 275 },
'ch-double': { label: 'Commons Hub — Double Room', price: 350 },
'hh-living': { label: 'Herrnhof Villa — Living Room', price: 315 },
'hh-triple': { label: 'Herrnhof Villa — Triple Room', price: 350 },
'hh-twin': { label: 'Herrnhof Villa — Twin Room', price: 420 },
'hh-single': { label: 'Herrnhof Villa — Single Room', price: 665 },
'hh-couple': { label: 'Herrnhof Villa — Couple Room', price: 700 },
};
// Google Sheets configuration
const GOOGLE_SHEET_ID = process.env.GOOGLE_SHEET_ID;
function loadGoogleCredentials() {
@ -117,12 +137,15 @@ async function appendToGoogleSheet(registration) {
Array.isArray(registration.contribute) ? registration.contribute.join(', ') : registration.contribute,
registration.message,
registration.newsletter ? 'Yes' : 'No',
registration.id
registration.id,
registration.paymentStatus || '',
registration.molliePaymentId || '',
registration.accommodationType ? (ACCOMMODATION_OPTIONS[registration.accommodationType]?.label || registration.accommodationType) : ''
]];
await sheets.spreadsheets.values.append({
spreadsheetId: GOOGLE_SHEET_ID,
range: 'Registrations!A:K',
range: 'Registrations!A:N',
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
requestBody: { values },
@ -360,7 +383,162 @@ async function addToListmonk(registration) {
}
}
// Registration endpoint
// Update payment status on Google Sheet (find row by registration ID in column K)
async function updateRegistrationPaymentStatus(registrationId, paymentStatus, molliePaymentId, accommodationType) {
if (!sheets || !GOOGLE_SHEET_ID) return;
try {
// Read column K to find the row with matching registration ID
const response = await sheets.spreadsheets.values.get({
spreadsheetId: GOOGLE_SHEET_ID,
range: 'Registrations!K:K',
});
const rows = response.data.values || [];
let rowIndex = -1;
for (let i = 0; i < rows.length; i++) {
if (rows[i][0] === registrationId) {
rowIndex = i + 1; // 1-indexed
break;
}
}
if (rowIndex === -1) {
console.error(`Registration ${registrationId} not found in sheet`);
return;
}
// Update columns L, M, N
await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: GOOGLE_SHEET_ID,
resource: {
valueInputOption: 'USER_ENTERED',
data: [
{ range: `Registrations!L${rowIndex}`, values: [[paymentStatus]] },
{ range: `Registrations!M${rowIndex}`, values: [[molliePaymentId || '']] },
{ range: `Registrations!N${rowIndex}`, values: [[accommodationType ? (ACCOMMODATION_OPTIONS[accommodationType]?.label || accommodationType) : '']] },
],
},
});
console.log(`Updated sheet payment status for ${registrationId}: ${paymentStatus}`);
} catch (error) {
console.error('Error updating sheet payment status:', error.message);
}
}
// Send payment confirmation email with booking details
async function sendPaymentConfirmationEmail(registration) {
if (!smtp) {
console.log('SMTP not configured, skipping payment confirmation email...');
return;
}
try {
const accomLabel = registration.accommodationType
? (ACCOMMODATION_OPTIONS[registration.accommodationType]?.label || 'None')
: 'None (participation only)';
const accomPrice = registration.accommodationType
? (ACCOMMODATION_OPTIONS[registration.accommodationType]?.price || 0)
: 0;
const subtotal = PARTICIPATION_FEE + accomPrice;
const processingFee = Math.round(subtotal * PROCESSING_FEE_RATE * 100) / 100;
const total = subtotal + processingFee;
const bookingInfo = registration.bookingResult?.success
? `<tr><td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);"><strong style="color: #f0f0f5;">Room:</strong></td><td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${registration.bookingResult.venue} — Room ${registration.bookingResult.room} (${registration.bookingResult.bedType})</td></tr>`
: '';
await smtp.sendMail({
from: process.env.EMAIL_FROM || 'WORLDPLAY <newsletter@worldplay.art>',
to: registration.email,
cc: EMAIL_TEAM,
subject: 'WORLDPLAY — Payment Confirmed & Booking Details',
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; font-size: 18px; font-weight: 600;">To be Defined</p>
</div>
<div style="background-color: #12121a; border: 1px solid rgba(0, 255, 136, 0.3); border-radius: 12px; padding: 32px; margin-bottom: 24px;">
<h2 style="color: #00ff88; margin-top: 0; font-size: 20px;">Payment Confirmed!</h2>
<p style="color: #a0a0b0; line-height: 1.7;">
Thank you, <strong style="color: #f0f0f5;">${registration.firstName}</strong>! Your registration and payment for WORLDPLAY have been confirmed. You're officially in.
</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;">Payment Breakdown</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);">Participation fee</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${PARTICIPATION_FEE.toFixed(2)}</td>
</tr>
${accomPrice > 0 ? `
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">${accomLabel} (7 nights)</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${accomPrice.toFixed(2)}</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1);">Processing fee (2%)</td>
<td style="padding: 8px 0; border-bottom: 1px solid rgba(157, 78, 221, 0.1); text-align: right;">&euro;${processingFee.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0;"><strong style="color: #f0f0f5;">Total paid</strong></td>
<td style="padding: 8px 0; text-align: right;"><strong style="color: #00ff88;">&euro;${total.toFixed(2)}</strong></td>
</tr>
</table>
</div>
${bookingInfo ? `
<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;">Accommodation</h3>
<table style="width: 100%; color: #a0a0b0; font-size: 14px;">
${bookingInfo}
</table>
</div>
` : ''}
<div style="background-color: #1a1520; border: 1px solid rgba(255, 149, 0, 0.3); border-radius: 12px; padding: 24px; margin-bottom: 24px;">
<p style="color: #ff9500; margin: 0 0 8px; font-weight: 600;">About Food</p>
<p style="color: #a0a0b0; margin: 0; font-size: 14px; line-height: 1.6;">
Food is not included in this payment. Expect approximately &euro;1520 per person per day. We'll be in touch about food choices and dietary preferences before the event.
</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;">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 contact <a href="mailto:${EMAIL_TEAM}" style="color: #00d9ff;">${EMAIL_TEAM}</a>
</p>
</div>
</div>
</body>
</html>
`,
});
console.log(`Payment confirmation email sent to: ${registration.email}`);
} catch (error) {
console.error('Error sending payment confirmation email:', error.message);
}
}
// Registration endpoint (Step 1 — saves registration with awaiting_payment status)
app.post('/api/register', async (req, res) => {
try {
const { firstName, lastName, email, location, role, otherRole, interests, contribute, message, newsletter } = req.body;
@ -382,7 +560,7 @@ app.post('/api/register', async (req, res) => {
return res.status(400).json({ error: 'This email is already registered' });
}
// Create new registration
// Create new registration with awaiting_payment status
const registration = {
id: Date.now().toString(36) + Math.random().toString(36).substr(2),
firstName: firstName.trim(),
@ -394,6 +572,7 @@ app.post('/api/register', async (req, res) => {
contribute: contribute || [],
message: message?.trim() || '',
newsletter: newsletter === 'yes',
paymentStatus: 'awaiting_payment',
registeredAt: new Date().toISOString(),
ipAddress: req.ip || req.connection.remoteAddress
};
@ -402,20 +581,16 @@ app.post('/api/register', async (req, res) => {
registrations.push(registration);
await saveRegistrations(registrations);
console.log(`New registration: ${registration.firstName} ${registration.lastName} <${registration.email}>`);
console.log(`New registration (awaiting payment): ${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));
// Add to Listmonk for newsletter management (async, don't block response)
addToListmonk(registration).catch(err => console.error('Listmonk error:', err));
// NOTE: Confirmation email and Listmonk subscription happen AFTER payment
res.json({
success: true,
message: 'Registration successful',
message: 'Registration saved',
id: registration.id
});
@ -425,6 +600,200 @@ app.post('/api/register', async (req, res) => {
}
});
// Create Mollie checkout session (Step 2)
app.post('/api/create-checkout-session', async (req, res) => {
if (!mollieClient) {
return res.status(503).json({ error: 'Payment system not configured' });
}
try {
const { registrationId, accommodationType } = req.body;
if (!registrationId) {
return res.status(400).json({ error: 'Registration ID is required' });
}
// Find registration
const registrations = await loadRegistrations();
const registration = registrations.find(r => r.id === registrationId);
if (!registration) {
return res.status(404).json({ error: 'Registration not found' });
}
if (registration.paymentStatus === 'paid') {
return res.status(400).json({ error: 'This registration has already been paid' });
}
// Calculate price
let accomPrice = 0;
let accomLabel = 'Participation only';
if (accommodationType && ACCOMMODATION_OPTIONS[accommodationType]) {
accomPrice = ACCOMMODATION_OPTIONS[accommodationType].price;
accomLabel = ACCOMMODATION_OPTIONS[accommodationType].label;
}
const subtotal = PARTICIPATION_FEE + accomPrice;
const processingFee = Math.round(subtotal * PROCESSING_FEE_RATE * 100) / 100;
const total = subtotal + processingFee;
// Save accommodation choice to registration
registration.accommodationType = accommodationType || null;
registration.paymentAmount = total;
await saveRegistrations(registrations);
// Create Mollie payment
const payment = await mollieClient.payments.create({
amount: {
currency: 'EUR',
value: total.toFixed(2),
},
description: `WORLDPLAY 2026 — ${registration.firstName} ${registration.lastName}${accommodationType ? ` + ${accomLabel}` : ''}`,
redirectUrl: `${BASE_URL}/payment-success.html?id=${registrationId}`,
webhookUrl: `${BASE_URL}/api/mollie/webhook`,
metadata: {
registrationId,
accommodationType: accommodationType || 'none',
},
});
// Save Mollie payment ID
registration.molliePaymentId = payment.id;
await saveRegistrations(registrations);
// Update sheet with pending payment info
updateRegistrationPaymentStatus(registrationId, 'awaiting_payment', payment.id, accommodationType).catch(err => console.error('Sheet update error:', err));
console.log(`Mollie checkout created for ${registration.email}: ${payment.id} (€${total.toFixed(2)})`);
res.json({
success: true,
checkoutUrl: payment.getCheckoutUrl(),
});
} catch (error) {
console.error('Checkout session error:', error);
res.status(500).json({ error: 'Failed to create payment session' });
}
});
// Mollie webhook — called when payment status changes
app.post('/api/mollie/webhook', async (req, res) => {
if (!mollieClient) {
return res.status(503).send('Payment system not configured');
}
try {
const paymentId = req.body.id;
if (!paymentId) {
return res.status(400).send('Missing payment ID');
}
const payment = await mollieClient.payments.get(paymentId);
const registrationId = payment.metadata?.registrationId;
if (!registrationId) {
console.error(`Mollie webhook: no registrationId in metadata for ${paymentId}`);
return res.status(200).send('OK');
}
const registrations = await loadRegistrations();
const registration = registrations.find(r => r.id === registrationId);
if (!registration) {
console.error(`Mollie webhook: registration ${registrationId} not found`);
return res.status(200).send('OK');
}
console.log(`Mollie webhook: payment ${paymentId} status=${payment.status} for ${registration.email}`);
if (payment.status === 'paid' && registration.paymentStatus !== 'paid') {
// Update registration
registration.paymentStatus = 'paid';
registration.molliePaymentId = paymentId;
registration.paidAt = new Date().toISOString();
await saveRegistrations(registrations);
// Update Google Sheet
updateRegistrationPaymentStatus(
registrationId, 'paid', paymentId, registration.accommodationType
).catch(err => console.error('Sheet update error:', err));
// Assign bed on booking sheet if accommodation was selected
if (registration.accommodationType) {
const guestName = `${registration.firstName} ${registration.lastName}`;
const bookingResult = await assignBooking(guestName, registration.accommodationType);
registration.bookingResult = bookingResult;
await saveRegistrations(registrations);
if (bookingResult.success) {
console.log(`Bed assigned for ${registration.email}: ${bookingResult.venue} Room ${bookingResult.room}`);
} else {
console.warn(`Bed assignment failed for ${registration.email}: ${bookingResult.reason}`);
}
}
// Send payment confirmation email
sendPaymentConfirmationEmail(registration).catch(err => console.error('Payment email error:', err));
// Add to Listmonk (now that payment is confirmed)
addToListmonk(registration).catch(err => console.error('Listmonk error:', err));
} else if (payment.status === 'failed' || payment.status === 'canceled' || payment.status === 'expired') {
registration.paymentStatus = payment.status;
await saveRegistrations(registrations);
updateRegistrationPaymentStatus(
registrationId, payment.status, paymentId, registration.accommodationType
).catch(err => console.error('Sheet update error:', err));
}
res.status(200).send('OK');
} catch (error) {
console.error('Mollie webhook error:', error);
res.status(200).send('OK'); // Always return 200 to Mollie
}
});
// Payment status polling endpoint (for frontend)
app.get('/api/payment-status', async (req, res) => {
try {
const { id } = req.query;
if (!id) {
return res.status(400).json({ error: 'Registration ID required' });
}
const registrations = await loadRegistrations();
const registration = registrations.find(r => r.id === id);
if (!registration) {
return res.status(404).json({ error: 'Registration not found' });
}
const accomType = registration.accommodationType;
const accomPrice = accomType ? (ACCOMMODATION_OPTIONS[accomType]?.price || 0) : 0;
const subtotal = PARTICIPATION_FEE + accomPrice;
const processingFee = Math.round(subtotal * PROCESSING_FEE_RATE * 100) / 100;
const total = subtotal + processingFee;
res.json({
paymentStatus: registration.paymentStatus,
firstName: registration.firstName,
lastName: registration.lastName,
email: registration.email,
accommodationType: accomType || null,
accommodationLabel: accomType ? (ACCOMMODATION_OPTIONS[accomType]?.label || null) : null,
participationFee: PARTICIPATION_FEE,
accommodationPrice: accomPrice,
processingFee,
total,
bookingResult: registration.bookingResult || null,
});
} catch (error) {
console.error('Payment status error:', error);
res.status(500).json({ error: 'Failed to check payment status' });
}
});
// 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;
@ -547,5 +916,6 @@ ensureDataDir().then(() => {
console.log(`Google Sheets: ${sheets ? 'enabled' : 'disabled'}`);
console.log(`Email notifications: ${smtp ? 'enabled (Mailcow SMTP)' : 'disabled (no SMTP_PASS)'}`);
console.log(`Listmonk newsletter sync: ${listmonkPool ? 'enabled' : 'disabled'} (list ID: ${LISTMONK_LIST_ID})`);
console.log(`Mollie payments: ${mollieClient ? 'enabled' : 'disabled (no MOLLIE_API_KEY)'}`);
});
});