diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97725b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +.env +*.log diff --git a/Dockerfile b/Dockerfile index 37fadba..99dd94f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ diff --git a/api/booking-sheet.js b/api/booking-sheet.js new file mode 100644 index 0000000..ff99496 --- /dev/null +++ b/api/booking-sheet.js @@ -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 }; diff --git a/docker-compose.yml b/docker-compose.yml index 921b5ca..c144633 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/index.html b/index.html index 9306af7..9e680ed 100644 --- a/index.html +++ b/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 @@
This is the first edition of WORLDPLAY and spaces are limited to 60 participants. Express your interest early to secure your place.
+WORLDPLAY spaces are limited to 60 participants. Complete registration and payment to secure your place.