From the crowd

Real reactions, raw emotions.

These aren't just reviews. They're moments when music connected souls and created memories that last forever.

First Name
Email
Last Name
Phone
Submit

Marcus T. — Berlin

The Breakthrough Year, 2024-2025

Day 1

"I'm tired of playing it safe. I've been DJing underground parties for eight years and watching others blow up. I know I have something special. I just need to stop being afraid to show it."

Day 30

"Posted my first mix online today. Not just to friends — publicly. My hands were shaking. Claire reminded me that vulnerability is where the magic happens. The comments are already coming in. People actually like it."

Day 90

"I've started producing my own tracks. No more just playing other people's music. It's harder than I thought, but for the first time, I feel like I'm creating something that's truly mine."

Day 180

"Got booked for my first festival. Not the main stage — a small tent at 3pm. But it's a start. My day job boss isn't happy about the time off. I'm starting to care less about that."

Day 365

"Signed with an agent yesterday. Quit my job today. The fear is still there, but now it feels like fuel instead of chains. Turns out the spotlight I was avoiding was exactly where I belonged."

"Claire creates the space where your truth emerges naturally. She doesn't push or pull — she simply holds space for your wisdom to surface."

— Sarah K., Berlin

Marcus T. — Berlin

Strategy Session, then Creative Evolution, 2023-2024

Before

"I'd been playing the same venues for seven years. It wasn't terrible — that was the issue. It wasn't bad enough to quit and wasn't exciting enough to keep going. I was stuck in limbo."

The Session

"Four hours with Alex. I was frustrated for the first hour. He didn't rush to solutions. He just listened deeply. By the end, I understood what had to happen. I'd sensed it for months, really. I just needed someone to witness me speak the truth."

Eight months later

"I left my residency. Respectfully, transparently. I built my own studio. I started producing again. I started actually feeling creative for the first time in ages. The uncertainty hasn't vanished completely — perhaps it never does. But beneath it, there's something I'd lost sight of: joy."

Marcus K. — Berlin

The Superstar Collective Programme, 2025

Week 1

"I almost bailed. Group sessions aren't my thing. I thought everyone would be killing it already. They weren't. We were all perfectly, beautifully stuck in the same place."

Month 3

"This crew. I've shared stuff I haven't even told my partner. Something about being surrounded by people keeping it real makes you drop your own mask."

Month 6

"I pitched for the headline slot I'd been dodging. Not because anyone pushed me — because I genuinely craved it. That shift changed everything. I landed it, obviously. The whole crew went wild on our video call."

"I thought I needed new music. Turns out I needed a new perspective on why I make it."

— Marcus K., Artist Evolution

"Three months in and I'm creating from joy, not desperation. My fans feel it. I feel it."

— Nina V., The Collective

"The Creative Reset changed everything. Not my sound—my relationship with success. Game changer."

— Jordan L., Creative Reset

The crowd awaits

Ready to make them move?

Lock In Your Event Date
* * Fetches config from /api/forms/config/:form_id, renders multi-step form, * handles submission, validation, and WhatsApp/SMS verification polling. * * Reads CSS variables from the page for automatic theme matching. * Supports inline, modal, and slide-in presentation modes. * Session state persisted to sessionStorage to survive modal close/reopen. */ (function() { 'use strict'; var VERIFY_POLL_MS = 3000; var VERIFY_TIMEOUT_MS = 300000; // 5 min max polling // ═══════════════════════════════════════════════════════ // Text sanitisation — prevent XSS from config data (#1) // ═══════════════════════════════════════════════════════ function escapeHtml(str) { if (!str) return ''; var div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // ═══════════════════════════════════════════════════════ // Session state — persist to sessionStorage (#4) // ═══════════════════════════════════════════════════════ function saveState(formId, state) { try { sessionStorage.setItem('rf_state_' + formId, JSON.stringify({ submissionId: state.submissionId, profileId: state.profileId, currentStep: state.currentStep, verified: state.verified })); } catch (e) { /* sessionStorage unavailable */ } } function loadState(formId) { try { var data = sessionStorage.getItem('rf_state_' + formId); return data ? JSON.parse(data) : null; } catch (e) { return null; } } function clearState(formId) { try { sessionStorage.removeItem('rf_state_' + formId); } catch (e) { /* */ } } // ═══════════════════════════════════════════════════════ // Utility // ═══════════════════════════════════════════════════════ function cssVar(name, fallback) { var val = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return val || fallback; } function isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function buildTheme(styleConfig) { return { bg: (styleConfig && styleConfig.bg_color) || cssVar('--bg-color', '#0f0d1a'), bgAlt: (styleConfig && styleConfig.bg_alt) || cssVar('--bg-alt', '#1a1730'), text: (styleConfig && styleConfig.text_color) || cssVar('--text-color', '#e2e8f0'), textLight: (styleConfig && styleConfig.text_light) || cssVar('--text-light', 'rgba(255,255,255,0.5)'), accent: (styleConfig && styleConfig.accent_color) || cssVar('--accent-color', '#7c3aed'), border: (styleConfig && styleConfig.border_color) || cssVar('--border-color', 'rgba(255,255,255,0.08)'), radius: (styleConfig && styleConfig.border_radius) || cssVar('--border-radius', '12px'), font: cssVar('--font-family', 'Inter, system-ui, sans-serif'), shadow: cssVar('--shadow-lg', '0 8px 32px rgba(0,0,0,0.3)') }; } // ═══════════════════════════════════════════════════════ // Inject scoped styles (once per page) // ═══════════════════════════════════════════════════════ var stylesInjected = false; function injectStyles(theme) { if (stylesInjected) return; stylesInjected = true; var style = document.createElement('style'); style.textContent = [ '.rf-form { font-family: ' + theme.font + '; max-width: 480px; margin: 0 auto; }', '.rf-form * { box-sizing: border-box; }', '.rf-step { display: none; }', '.rf-step.rf-active { display: block; }', '.rf-title { font-size: 22px; font-weight: 700; color: ' + theme.text + '; margin-bottom: 4px; text-align: center; }', '.rf-subtitle { font-size: 14px; color: ' + theme.textLight + '; margin-bottom: 20px; text-align: center; }', '.rf-field { margin-bottom: 12px; }', '.rf-label { display: block; font-size: 13px; color: ' + theme.textLight + '; margin-bottom: 6px; font-weight: 500; }', '.rf-input { width: 100%; padding: 14px 16px; border-radius: ' + theme.radius + '; border: 1px solid ' + theme.border + '; background: ' + theme.bgAlt + '; color: ' + theme.text + '; font-size: 15px; font-family: ' + theme.font + '; outline: none; transition: border-color 0.2s, box-shadow 0.2s; }', '.rf-input:focus { border-color: ' + theme.accent + '; box-shadow: 0 0 0 3px ' + theme.accent + '33; }', '.rf-input.rf-error { border-color: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,0.15); }', '.rf-error-text { color: #ef4444; font-size: 12px; margin-top: 4px; display: none; }', '.rf-error-text.rf-show { display: block; }', '.rf-btn { width: 100%; padding: 16px; border-radius: ' + theme.radius + '; border: none; background: ' + theme.accent + '; color: #fff; font-size: 16px; font-weight: 600; font-family: ' + theme.font + '; cursor: pointer; transition: all 0.2s; margin-top: 8px; }', '.rf-btn:hover { opacity: 0.9; transform: translateY(-1px); }', '.rf-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }', '.rf-progress { display: flex; gap: 6px; margin-bottom: 20px; justify-content: center; }', '.rf-dot { width: 8px; height: 8px; border-radius: 50%; background: ' + theme.border + '; transition: all 0.3s; }', '.rf-dot.rf-done { background: ' + theme.accent + '; }', '.rf-dot.rf-current { background: ' + theme.accent + '; transform: scale(1.3); }', '.rf-verify { text-align: center; padding: 20px 0; }', '.rf-verify-icon { width: 64px; height: 64px; border-radius: 16px; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; font-size: 32px; }', '.rf-verify-whatsapp { background: #25D366; color: #fff; box-shadow: 0 4px 16px rgba(37,211,102,0.3); }', '.rf-verify-sms { background: #3b82f6; color: #fff; box-shadow: 0 4px 16px rgba(59,130,246,0.3); }', '.rf-verify-status { font-size: 14px; color: ' + theme.textLight + '; margin-top: 12px; }', '.rf-verify-spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid ' + theme.border + '; border-top-color: ' + theme.accent + '; border-radius: 50%; animation: rf-spin 0.8s linear infinite; margin-right: 8px; vertical-align: middle; }', '@keyframes rf-spin { to { transform: rotate(360deg); } }', '.rf-success { text-align: center; padding: 20px 0; }', '.rf-success-icon { font-size: 48px; margin-bottom: 12px; }', '.rf-privacy { text-align: center; margin-top: 14px; font-size: 11px; color: ' + theme.textLight + '; }', '.rf-privacy i { margin-right: 4px; font-size: 10px; }', '.rf-error-banner { text-align: center; padding: 20px; color: #ef4444; font-size: 14px; }', '.rf-field .iti { width: 100%; }', '.rf-field .iti__tel-input { width: 100%; padding: 14px 16px; border-radius: ' + theme.radius + '; border: 1px solid ' + theme.border + '; background: ' + theme.bgAlt + '; color: ' + theme.text + '; font-size: 15px; font-family: ' + theme.font + '; }', '.rf-phone-check { display: none; color: #25D366; margin-left: 8px; }', '.rf-phone-check.rf-show { display: inline; }', '.rf-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); z-index: 10000; display: flex; align-items: center; justify-content: center; }', '.rf-modal { background: ' + theme.bg + '; border-radius: 16px; padding: 32px; max-width: 480px; width: 90%; max-height: 90vh; overflow-y: auto; box-shadow: ' + theme.shadow + '; position: relative; }', '.rf-modal-close { position: absolute; top: 12px; right: 16px; background: none; border: none; color: ' + theme.textLight + '; font-size: 24px; cursor: pointer; }', '.rf-slidein { position: fixed; bottom: 20px; right: 20px; z-index: 10000; background: ' + theme.bg + '; border-radius: 16px; padding: 24px; max-width: 400px; width: 90%; box-shadow: ' + theme.shadow + '; transform: translateX(110%); transition: transform 0.4s ease; }', '.rf-slidein.rf-open { transform: translateX(0); }', '.rf-slidein-close { position: absolute; top: 8px; right: 12px; background: none; border: none; color: ' + theme.textLight + '; font-size: 20px; cursor: pointer; }' ].join('\n'); document.head.appendChild(style); } // ═══════════════════════════════════════════════════════ // Render a single field // ═══════════════════════════════════════════════════════ function renderField(field, formId) { var wrap = document.createElement('div'); wrap.className = 'rf-field'; if (field.type === 'hidden') { var hidden = document.createElement('input'); hidden.type = 'hidden'; hidden.name = field.name; hidden.value = field.default_value || ''; wrap.appendChild(hidden); return wrap; } if (field.type === 'consent') { var consentWrap = document.createElement('label'); consentWrap.style.cssText = 'display:flex;align-items:flex-start;gap:10px;cursor:pointer;font-size:13px;color:rgba(255,255,255,0.6);'; var cb = document.createElement('input'); cb.type = 'checkbox'; cb.name = field.name; cb.id = 'rf-' + formId + '-' + field.name; if (field.required) { cb.required = true; cb.setAttribute('aria-required', 'true'); } consentWrap.appendChild(cb); var span = document.createElement('span'); // #1 XSS fix: use textContent for consent label span.textContent = field.label || 'I agree to the terms'; consentWrap.appendChild(span); wrap.appendChild(consentWrap); return wrap; } var fieldId = 'rf-' + formId + '-' + field.name; var errId = 'rf-err-' + formId + '-' + field.name; // Label if (field.label) { var label = document.createElement('label'); label.className = 'rf-label'; label.textContent = field.label; label.htmlFor = fieldId; wrap.appendChild(label); } // Input var input; if (field.type === 'message') { input = document.createElement('textarea'); input.rows = 3; } else if (field.name === 'country' || field.type === 'custom_select') { input = document.createElement('select'); var defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = field.placeholder || 'Select...'; input.appendChild(defaultOpt); if (field.options) { field.options.forEach(function(opt) { var o = document.createElement('option'); o.value = typeof opt === 'string' ? opt : opt.value; o.textContent = typeof opt === 'string' ? opt : opt.label; input.appendChild(o); }); } } else { input = document.createElement('input'); if (field.type === 'tel') input.type = 'tel'; else if (field.type === 'email') { input.type = 'email'; input.autocomplete = 'email'; input.inputMode = 'email'; } else if (field.type === 'url') input.type = 'url'; else input.type = 'text'; } input.className = 'rf-input'; input.name = field.name; input.id = fieldId; if (field.placeholder) input.placeholder = field.placeholder; if (field.required) { input.required = true; input.setAttribute('aria-required', 'true'); } input.setAttribute('aria-describedby', errId); wrap.appendChild(input); // Error text var errEl = document.createElement('div'); errEl.className = 'rf-error-text'; errEl.id = errId; errEl.setAttribute('role', 'alert'); errEl.textContent = field.error_text || 'This field is required'; wrap.appendChild(errEl); // intl-tel-input for phone fields if (field.type === 'tel' && window.intlTelInput) { setTimeout(function() { var iti = intlTelInput(input, { initialCountry: 'auto', geoIpLookup: function(success, failure) { var timeout = setTimeout(function() { success('gb'); }, 5000); fetch('/api/geo') .then(function(r) { return r.json(); }) .then(function(d) { clearTimeout(timeout); if (d && d.country) success(d.country.toLowerCase()); else success('gb'); }) .catch(function() { clearTimeout(timeout); success('gb'); }); }, countryOrder: ['gb', 'us', 'ie', 'au', 'ca'], separateDialCode: true, nationalMode: true, formatAsYouType: true }); input._iti = iti; }, 50); } return wrap; } // ═══════════════════════════════════════════════════════ // Validate fields in a step // ═══════════════════════════════════════════════════════ function validateStep(stepEl, formId) { var valid = true; var inputs = stepEl.querySelectorAll('.rf-input, select, input[type="checkbox"]'); for (var i = 0; i < inputs.length; i++) { var inp = inputs[i]; var errEl = document.getElementById('rf-err-' + formId + '-' + inp.name); inp.classList.remove('rf-error'); if (errEl) errEl.classList.remove('rf-show'); if (!inp.required) continue; var val = inp.type === 'checkbox' ? inp.checked : (inp.value || '').trim(); if (!val) { inp.classList.add('rf-error'); if (errEl) { errEl.textContent = 'This field is required'; errEl.classList.add('rf-show'); } valid = false; continue; } if (inp.type === 'email' && !isValidEmail(val)) { inp.classList.add('rf-error'); if (errEl) { errEl.textContent = 'Please enter a valid email'; errEl.classList.add('rf-show'); } valid = false; } if (inp._iti && !inp._iti.isValidNumber()) { inp.classList.add('rf-error'); if (errEl) { errEl.textContent = 'Please enter a valid phone number'; errEl.classList.add('rf-show'); } valid = false; } } return valid; } // ═══════════════════════════════════════════════════════ // Collect field values from a step // ═══════════════════════════════════════════════════════ function collectFields(stepEl) { var fields = {}; var inputs = stepEl.querySelectorAll('.rf-input, input[type="hidden"], select, input[type="checkbox"]'); for (var i = 0; i < inputs.length; i++) { var inp = inputs[i]; if (inp.type === 'checkbox') { fields[inp.name] = inp.checked; } else if (inp._iti) { fields[inp.name] = inp._iti.getNumber(); } else { fields[inp.name] = (inp.value || '').trim(); } } return fields; } function collectMetadata() { var params = new URLSearchParams(window.location.search); var meta = {}; if (params.get('utm_source')) meta._utm_source = params.get('utm_source'); if (params.get('utm_medium')) meta._utm_medium = params.get('utm_medium'); if (params.get('utm_campaign')) meta._utm_campaign = params.get('utm_campaign'); var ref = params.get('ref') || sessionStorage.getItem('referral_code'); if (ref) meta._referral_code = ref; try { meta._timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch(e) {} return meta; } // ═══════════════════════════════════════════════════════ // Main: initialise a single form container // ═══════════════════════════════════════════════════════ function initForm(container) { var formId = container.getAttribute('data-form-id'); if (!formId) return; fetch('/api/forms/config/' + formId) .then(function(r) { if (!r.ok) throw new Error('Form not found'); return r.json(); }) .then(function(config) { renderForm(container, formId, config); }) .catch(function(err) { console.error('[Form Loader] Failed to load form:', formId, err.message); var errDiv = document.createElement('div'); errDiv.className = 'rf-error-banner'; errDiv.textContent = 'Form unavailable. Please refresh the page.'; container.appendChild(errDiv); }); } // ═══════════════════════════════════════════════════════ // Render the complete form — returns { formEl, state } // ═══════════════════════════════════════════════════════ function renderForm(container, formId, config) { var theme = buildTheme(config.style); injectStyles(theme); var steps = config.steps || []; if (!steps.length) return null; var totalSteps = steps.length; // Restore state from sessionStorage var saved = loadState(formId); var state = { currentStep: saved ? saved.currentStep : 0, submissionId: saved ? saved.submissionId : null, profileId: saved ? saved.profileId : null, verified: saved ? saved.verified : false, pollTimer: null, submitting: false // #3 double-submit guard }; var formEl = document.createElement('div'); formEl.className = 'rf-form'; formEl.id = 'rf-' + formId; // Progress dots if (totalSteps > 1) { var progress = document.createElement('div'); progress.className = 'rf-progress'; progress.id = 'rf-progress-' + formId; for (var d = 0; d < totalSteps; d++) { var dot = document.createElement('div'); dot.className = 'rf-dot'; dot.setAttribute('data-step', d); progress.appendChild(dot); } formEl.appendChild(progress); } // Render each step steps.forEach(function(stepConfig, idx) { var stepEl = document.createElement('div'); stepEl.className = 'rf-step'; stepEl.id = 'rf-step-' + formId + '-' + idx; if (stepConfig.type === 'verification') { var channel = stepConfig.channel || 'whatsapp'; var isWA = channel === 'whatsapp'; // #1 XSS fix: build verification step with DOM methods, not innerHTML var verifyDiv = document.createElement('div'); verifyDiv.className = 'rf-verify'; var iconDiv = document.createElement('div'); iconDiv.className = 'rf-verify-icon ' + (isWA ? 'rf-verify-whatsapp' : 'rf-verify-sms'); var iconI = document.createElement('i'); iconI.className = isWA ? 'fab fa-whatsapp' : 'fas fa-sms'; iconDiv.appendChild(iconI); verifyDiv.appendChild(iconDiv); var vTitle = document.createElement('div'); vTitle.className = 'rf-title'; vTitle.textContent = stepConfig.title || 'Verify Your Number'; verifyDiv.appendChild(vTitle); var vSub = document.createElement('p'); vSub.className = 'rf-subtitle'; vSub.textContent = stepConfig.message || 'Reply to confirm your number'; verifyDiv.appendChild(vSub); var statusDiv = document.createElement('div'); statusDiv.className = 'rf-verify-status'; statusDiv.id = 'rf-verify-status-' + formId; var spinner = document.createElement('span'); spinner.className = 'rf-verify-spinner'; statusDiv.appendChild(spinner); statusDiv.appendChild(document.createTextNode(' Waiting for your reply...')); verifyDiv.appendChild(statusDiv); stepEl.appendChild(verifyDiv); } else { if (stepConfig.title) { var title = document.createElement('div'); title.className = 'rf-title'; title.textContent = stepConfig.title; stepEl.appendChild(title); } if (stepConfig.subtitle) { var subtitle = document.createElement('div'); subtitle.className = 'rf-subtitle'; subtitle.textContent = stepConfig.subtitle; stepEl.appendChild(subtitle); } (stepConfig.fields || []).forEach(function(field) { stepEl.appendChild(renderField(field, formId)); }); // Submit button var btn = document.createElement('button'); btn.className = 'rf-btn'; btn.type = 'button'; btn.textContent = stepConfig.button_text || 'Continue \u2192'; btn.id = 'rf-btn-' + formId + '-' + idx; btn.setAttribute('aria-label', stepConfig.button_text || 'Continue to next step'); btn.onclick = function() { handleStepSubmit(formId, config, state, idx); }; stepEl.appendChild(btn); if (idx === 0) { var privacy = document.createElement('div'); privacy.className = 'rf-privacy'; var lockIcon = document.createElement('i'); lockIcon.className = 'fas fa-lock'; privacy.appendChild(lockIcon); privacy.appendChild(document.createTextNode(' Your data is secure and never shared.')); stepEl.appendChild(privacy); } } formEl.appendChild(stepEl); }); // Success step — #1 XSS fix: DOM methods, not innerHTML var successEl = document.createElement('div'); successEl.className = 'rf-step'; successEl.id = 'rf-step-' + formId + '-success'; var successInner = document.createElement('div'); successInner.className = 'rf-success'; var successIcon = document.createElement('div'); successIcon.className = 'rf-success-icon'; successIcon.textContent = '\ud83c\udf89'; successInner.appendChild(successIcon); var successTitle = document.createElement('div'); successTitle.className = 'rf-title'; successTitle.textContent = config.success_title || "You're In!"; successInner.appendChild(successTitle); var successMsg = document.createElement('p'); successMsg.className = 'rf-subtitle'; successMsg.textContent = config.success_message || "We'll be in touch soon. Keep an eye on your messages."; successInner.appendChild(successMsg); successEl.appendChild(successInner); formEl.appendChild(successEl); // Mount based on presentation mode if (config.presentation === 'modal') { mountAsModal(container, formEl, state); } else if (config.presentation === 'slide-in') { mountAsSlideIn(container, formEl, state); } else { container.appendChild(formEl); } // Navigate to the correct step (handles resume from sessionStorage) goToStep(formId, config, state, state.currentStep); // Return state so callers (like openModal API) can reference the SAME object return { formEl: formEl, state: state }; } // ═══════════════════════════════════════════════════════ // Handle step submission — #3 double-submit guard // ═══════════════════════════════════════════════════════ function handleStepSubmit(formId, config, state, stepIdx) { // #3: Block if already submitting if (state.submitting) return; var stepEl = document.getElementById('rf-step-' + formId + '-' + stepIdx); var btn = document.getElementById('rf-btn-' + formId + '-' + stepIdx); if (!validateStep(stepEl, formId)) return; // #3: Set guard immediately after validation passes state.submitting = true; var fields = collectFields(stepEl); var originalText = btn.textContent; btn.disabled = true; btn.textContent = 'Saving...'; var body; if (!state.submissionId) { var meta = collectMetadata(); body = { form_id: formId, fields: Object.assign({}, fields, meta) }; } else { body = { submission_id: state.submissionId, step: stepIdx + 1, fields: fields }; } fetch('/api/forms/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(function(r) { if (!r.ok) return r.json().then(function(d) { throw new Error(d.error || 'Submission failed'); }); return r.json(); }) .then(function(data) { state.submitting = false; if (data.submission_id) state.submissionId = data.submission_id; if (data.profile_id) state.profileId = data.profile_id; var nextStep = stepIdx + 1; state.currentStep = nextStep; saveState(formId, state); // Phase 6.5: if the tenant enabled GDPR consent display the server // returns a consent_redirect_url. Fix #3 rec: redirect whenever the // URL is present (server only emits one when the tenant opted in), // instead of gating on last-step — this lets the consent page appear // as soon as we have a profile, regardless of how many steps the // form has after it. if (data.consent_redirect_url) { window.location.href = data.consent_redirect_url; return; } goToStep(formId, config, state, nextStep); }) .catch(function(err) { state.submitting = false; console.error('[Form Loader] Submit error:', err.message); btn.disabled = false; btn.textContent = originalText; // #4: Show error on the RELEVANT field, not always the first var errMsg = err.message || 'Submission failed'; var targetInput = null; // Match error to field by keyword if (/phone/i.test(errMsg)) { targetInput = stepEl.querySelector('input[type="tel"], input[name="phone"]'); } else if (/email/i.test(errMsg)) { targetInput = stepEl.querySelector('input[type="email"], input[name="email"]'); } // Fallback to last input (most likely the newly added field), not first if (!targetInput) { var allInputs = stepEl.querySelectorAll('.rf-input'); targetInput = allInputs[allInputs.length - 1] || allInputs[0]; } if (targetInput) { targetInput.classList.add('rf-error'); var errEl = document.getElementById('rf-err-' + formId + '-' + targetInput.name); if (errEl) { errEl.textContent = errMsg; errEl.classList.add('rf-show'); } } }); } // ═══════════════════════════════════════════════════════ // Navigate to a step // ═══════════════════════════════════════════════════════ function goToStep(formId, config, state, stepIdx) { var steps = config.steps || []; var allSteps = document.querySelectorAll('#rf-' + formId + ' .rf-step'); for (var i = 0; i < allSteps.length; i++) allSteps[i].classList.remove('rf-active'); var dots = document.querySelectorAll('#rf-progress-' + formId + ' .rf-dot'); for (var d = 0; d < dots.length; d++) { dots[d].classList.remove('rf-current', 'rf-done'); if (d < stepIdx) dots[d].classList.add('rf-done'); if (d === stepIdx) dots[d].classList.add('rf-current'); } if (stepIdx >= steps.length) { var success = document.getElementById('rf-step-' + formId + '-success'); if (success) success.classList.add('rf-active'); for (var d2 = 0; d2 < dots.length; d2++) { dots[d2].classList.remove('rf-current'); dots[d2].classList.add('rf-done'); } clearState(formId); return; } var targetStep = document.getElementById('rf-step-' + formId + '-' + stepIdx); if (targetStep) targetStep.classList.add('rf-active'); state.currentStep = stepIdx; if (steps[stepIdx] && steps[stepIdx].type === 'verification') { startVerificationPolling(formId, config, state, stepIdx); } } // ═══════════════════════════════════════════════════════ // Verification polling // ═══════════════════════════════════════════════════════ function startVerificationPolling(formId, config, state, stepIdx) { var statusEl = document.getElementById('rf-verify-status-' + formId); var startTime = Date.now(); function poll() { if (Date.now() - startTime > VERIFY_TIMEOUT_MS) { if (statusEl) { statusEl.textContent = ''; statusEl.appendChild(document.createTextNode('\u23f0 Verification timed out. ')); var retryLink = document.createElement('a'); retryLink.href = '#'; retryLink.style.color = '#7c3aed'; retryLink.textContent = 'Try again'; retryLink.onclick = function(e) { e.preventDefault(); location.reload(); }; statusEl.appendChild(retryLink); } return; } fetch('/api/forms/verify-check?submission_id=' + state.submissionId) .then(function(r) { return r.json(); }) .then(function(data) { if (data.verified) { state.verified = true; saveState(formId, state); if (statusEl) statusEl.textContent = '\u2705 Connected! Redirecting...'; setTimeout(function() { goToStep(formId, config, state, stepIdx + 1); }, 1500); } else { state.pollTimer = setTimeout(poll, VERIFY_POLL_MS); } }) .catch(function() { state.pollTimer = setTimeout(poll, VERIFY_POLL_MS); }); } poll(); } // ═══════════════════════════════════════════════════════ // Stop polling — called on modal/slide-in close // ═══════════════════════════════════════════════════════ function stopPolling(state) { if (state && state.pollTimer) { clearTimeout(state.pollTimer); state.pollTimer = null; } } // ═══════════════════════════════════════════════════════ // Focus trap for modal (#5) // ═══════════════════════════════════════════════════════ function trapFocus(modalEl) { var focusable = 'button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'; function handleTab(e) { if (e.key !== 'Tab') return; var elements = modalEl.querySelectorAll(focusable); if (!elements.length) return; var first = elements[0]; var last = elements[elements.length - 1]; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } } modalEl.addEventListener('keydown', handleTab); // Focus first focusable element on open setTimeout(function() { var elements = modalEl.querySelectorAll(focusable); if (elements.length) elements[0].focus(); }, 100); // Return cleanup function return function() { modalEl.removeEventListener('keydown', handleTab); }; } // ═══════════════════════════════════════════════════════ // Presentation: Modal — #2 single state, #5 focus trap + Escape // ═══════════════════════════════════════════════════════ function mountAsModal(container, formEl, state) { var triggerBtn = document.createElement('button'); triggerBtn.className = 'rf-btn'; triggerBtn.textContent = container.getAttribute('data-trigger-text') || 'Apply Now \u2192'; triggerBtn.setAttribute('aria-label', 'Open signup form'); triggerBtn.onclick = function() { openModal(formEl, state); }; container.appendChild(triggerBtn); } function openModal(formEl, state) { var overlay = document.createElement('div'); overlay.className = 'rf-modal-overlay'; var cleanupTrap; function closeModal() { stopPolling(state); if (cleanupTrap) cleanupTrap(); document.removeEventListener('keydown', escHandler); overlay.remove(); } // #5: Escape key closes modal function escHandler(e) { if (e.key === 'Escape') closeModal(); } document.addEventListener('keydown', escHandler); overlay.onclick = function(e) { if (e.target === overlay) closeModal(); }; var modal = document.createElement('div'); modal.className = 'rf-modal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-label', 'Signup form'); var closeBtn = document.createElement('button'); closeBtn.className = 'rf-modal-close'; closeBtn.textContent = '\u00d7'; closeBtn.setAttribute('aria-label', 'Close form'); closeBtn.onclick = closeModal; modal.appendChild(closeBtn); modal.appendChild(formEl); overlay.appendChild(modal); document.body.appendChild(overlay); // #5: Trap focus inside modal cleanupTrap = trapFocus(modal); } // ═══════════════════════════════════════════════════════ // Presentation: Slide-in // ═══════════════════════════════════════════════════════ function mountAsSlideIn(container, formEl, state) { var panel = document.createElement('div'); panel.className = 'rf-slidein'; var closeBtn = document.createElement('button'); closeBtn.className = 'rf-slidein-close'; closeBtn.textContent = '\u00d7'; closeBtn.setAttribute('aria-label', 'Close form'); closeBtn.onclick = function() { stopPolling(state); panel.classList.remove('rf-open'); }; panel.appendChild(closeBtn); panel.appendChild(formEl); document.body.appendChild(panel); var delay = parseInt(container.getAttribute('data-delay')) || 5000; setTimeout(function() { panel.classList.add('rf-open'); }, delay); } // ═══════════════════════════════════════════════════════ // API: Allow external code to open a form as modal // #2 fix: use the SAME state object from renderForm // ═══════════════════════════════════════════════════════ window.ReplicantsForm = { openModal: function(formId) { fetch('/api/forms/config/' + formId) .then(function(r) { return r.json(); }) .then(function(config) { var theme = buildTheme(config.style); injectStyles(theme); var tempContainer = document.createElement('div'); // renderForm now returns { formEl, state } var result = renderForm(tempContainer, formId, Object.assign({}, config, { presentation: 'inline' })); if (result) { // #2: Use the SAME state object that renderForm created openModal(result.formEl, result.state); } }); } }; // ═══════════════════════════════════════════════════════ // Auto-init // ═══════════════════════════════════════════════════════ function autoInit() { var containers = document.querySelectorAll('[data-form-id]'); for (var i = 0; i < containers.length; i++) { initForm(containers[i]); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', autoInit); } else { autoInit(); } })();