feat: add Mollie payment pipeline with two-step registration flow
CI/CD / deploy (push) Failing after 1m12s
Details
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:
parent
1b3f7faa62
commit
dc95bf4819
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
541
index.html
541
index.html
|
|
@ -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,9 +1744,23 @@
|
|||
</div>
|
||||
|
||||
<form class="register-form" id="registration-form">
|
||||
<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>
|
||||
|
||||
<!-- STEP 1: Registration Info -->
|
||||
<div class="form-step active" id="step-1">
|
||||
<div class="form-header">
|
||||
<h3>Express Interest</h3>
|
||||
<p>We'll be in touch with next steps</p>
|
||||
<h3>Your Details</h3>
|
||||
<p>Tell us about yourself</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
|
|
@ -1648,7 +1883,113 @@
|
|||
</div>
|
||||
|
||||
<div class="form-submit">
|
||||
<button type="submit" class="btn btn-primary">Register Interest</button>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<button type="button" class="btn btn-back" id="btn-back-step1">← Back to details</button>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Do you need on-site accommodation? (7 nights, June 7–13)</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">€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">€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">€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">€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">€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">€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">€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">€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">€0.00</span>
|
||||
</div>
|
||||
<div class="price-line">
|
||||
<span>Processing fee (2%)</span>
|
||||
<span class="price-amount" id="price-processing">€1.00</span>
|
||||
</div>
|
||||
<div class="price-line total">
|
||||
<span>Total</span>
|
||||
<span class="price-amount" id="price-total">€51.00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>About food:</strong> Food is not included in this payment. Expect approximately €15–20 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) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">✓</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 ~€15–20/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">✗</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">← 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">€${data.participationFee.toFixed(2)}</span></div>
|
||||
`;
|
||||
if (data.accommodationPrice > 0) {
|
||||
rows += `<div class="row"><span>${data.accommodationLabel} (7 nights)</span><span class="val">€${data.accommodationPrice.toFixed(2)}</span></div>`;
|
||||
}
|
||||
rows += `
|
||||
<div class="row"><span>Processing fee (2%)</span><span class="val">€${data.processingFee.toFixed(2)}</span></div>
|
||||
<div class="row total"><span>Total paid</span><span class="val">€${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} — 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
392
server.js
|
|
@ -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;">€${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;">€${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;">€${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;">€${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 €15–20 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 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 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)'}`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue