1const menuToggle = document.querySelector('.menu-toggle'); 2const navLinks = document.querySelector('.nav-links'); 3 4if (menuToggle && navLinks) { 5 menuToggle.setAttribute('aria-label', 'Open main menu'); 6 menuToggle.addEventListener('click', () => { 7 const isOpen = navLinks.classList.toggle('open'); 8 menuToggle.setAttribute('aria-expanded', String(isOpen)); 9 menuToggle.setAttribute('aria-label', isOpen ? 'Close main menu' : 'Open main menu'); 10 }); 11} 12 13let activeFounderTrigger = null; 14 15function openFounderModal(key, trigger) { 16 const dialog = document.getElementById(`founder-modal-${key}`); 17 if (!dialog) return; 18 activeFounderTrigger = trigger || null; 19 dialog.classList.add('is-open'); 20 document.body.classList.add('modal-open'); 21 if (typeof dialog.showModal === 'function' && !dialog.open) { 22 dialog.showModal(); 23 } else { 24 dialog.setAttribute('open', ''); 25 } 26} 27 28function closeFounderModal(dialog) { 29 if (!dialog) return; 30 dialog.classList.remove('is-open'); 31 document.body.classList.remove('modal-open'); 32 if (typeof dialog.close === 'function' && dialog.open) { 33 dialog.close(); 34 } else { 35 dialog.removeAttribute('open'); 36 } 37 activeFounderTrigger?.focus?.(); 38 activeFounderTrigger = null; 39} 40 41document.addEventListener('click', (event) => { 42 const registerLink = event.target.closest('[data-register-link]'); 43 if (registerLink instanceof HTMLAnchorElement) { 44 event.preventDefault(); 45 const accountPath = registerLink.getAttribute('href')?.replace(/\/?$/, '/') || '/account/'; 46 window.location.href = `${accountPath}?_mt=%2Faccount%2Fcreate%3FredirectURL%3D%252Faccount`; 47 return; 48 } 49 50 const openButton = event.target.closest('[data-founder-modal]'); 51 if (openButton) { 52 event.preventDefault(); 53 openFounderModal(openButton.dataset.founderModal, openButton); 54 return; 55 } 56 57 const closeButton = event.target.closest('[data-founder-close]'); 58 if (closeButton) { 59 event.preventDefault(); 60 closeFounderModal(document.getElementById(`founder-modal-${closeButton.dataset.founderClose}`)); 61 return; 62 } 63 64 if (event.target.matches?.('.founder-modal')) { 65 closeFounderModal(event.target); 66 } 67}); 68 69document.addEventListener('keydown', (event) => { 70 if (event.key !== 'Escape') return; 71 const dialog = document.querySelector('.founder-modal[open]'); 72 if (dialog) closeFounderModal(dialog); 73}); 74 75const content = window.SESSIONS_CONTENT; 76const isSpanishPage = document.documentElement.lang?.toLowerCase().startsWith('es'); 77 78function setText(selector, value, root = document) { 79 const el = root.querySelector(selector); 80 if (el && value != null) el.textContent = value; 81} 82 83function setHref(selector, value, root = document) { 84 const el = root.querySelector(selector); 85 if (el && value) el.setAttribute('href', value); 86} 87 88function setImage(selector, src, alt, root = document) { 89 const el = root.querySelector(selector); 90 if (!el || !src) return; 91 el.setAttribute('src', src); 92 if (alt) el.setAttribute('alt', alt); 93} 94 95function setHeroImage(selector, src) { 96 const el = document.querySelector(selector); 97 if (el && src) el.style.setProperty('--hero-image', `url("${src}")`); 98} 99 100function pageKey() { 101 const path = location.pathname.toLowerCase().replace(/\/+$/, '') || '/'; 102 if (path.includes('/instructors/')) return 'instructors'; 103 const name = (path.split('/').pop() || 'index.html').toLowerCase(); 104 if (name.includes('book-now')) return 'book'; 105 if (name.includes('membership')) return 'membership'; 106 if (name.includes('instructors')) return 'instructors'; 107 if (name.includes('about')) return 'about'; 108 if (name.includes('contact')) return 'contact'; 109 if (name.includes('account')) return 'account'; 110 return 'home'; 111} 112 113function applyCommon() { 114 if (!content?.common) return; 115 const common = content.common; 116 document.querySelectorAll('.topline a[href^="tel:"]').forEach((el) => { 117 el.textContent = common.phone; 118 el.href = common.phoneHref; 119 }); 120 document.querySelectorAll('.topline a[href^="mailto:"]').forEach((el) => { 121 el.textContent = common.email; 122 el.href = common.emailHref; 123 }); 124 document.querySelectorAll('.brand img').forEach((img) => { 125 img.src = common.logo; 126 img.alt = 'Sessions Cycle'; 127 }); 128 document.querySelectorAll('.footer-brand img').forEach((img) => { 129 img.src = common.footerLogo; 130 img.alt = 'Sessions Cycle'; 131 }); 132 document.querySelectorAll('.footer-brand p').forEach((el) => { el.textContent = common.copyright; }); 133 134 const app = common.app; 135 if (!app) return; 136 document.querySelectorAll('.app-band').forEach((band) => { 137 setText('.eyebrow', app.eyebrow, band); 138 setText('h2', app.title, band); 139 setImage('.phone', app.phoneImage, 'Sessions Cycle mobile apps', band); 140 const appStore = band.querySelector('.store-links a:nth-of-type(1)'); 141 const google = band.querySelector('.store-links a:nth-of-type(2)'); 142 if (appStore) { 143 appStore.href = app.appStoreHref; 144 setImage('img', app.appStoreImage, 'Apple Store', appStore); 145 } 146 if (google) { 147 google.href = app.googlePlayHref; 148 setImage('img', app.googlePlayImage, 'Google Play Store', google); 149 } 150 }); 151} 152 153function applyHome(page) { 154 if (!page) return; 155 const hero = document.querySelector('.home-hero'); 156 if (hero) { 157 const media = hero.querySelector('.hero-media'); 158 if (media && page.heroImage) { 159 media.style.backgroundImage = `linear-gradient(rgba(39,37,34,.16), rgba(39,37,34,.22)), url("${page.heroImage}")`; 160 } 161 setText('.eyebrow', page.hero?.eyebrow, hero); 162 const heading = hero.querySelector('h1'); 163 if (heading && page.hero?.title === 'Elevated Movement') { 164 heading.innerHTML = 'Elevated <span>Movement</span>'; 165 } else { 166 setText('h1', page.hero?.title, hero); 167 } 168 setText('.hero-line', page.hero?.line, hero); 169 const meta = hero.querySelector('.hero-meta'); 170 if (meta && page.hero?.meta) meta.innerHTML = page.hero.meta.map((item) => `<span>${item}</span>`).join(''); 171 setText('.button', page.hero?.cta, hero); 172 } 173 174 const intro = document.querySelector('.split-section'); 175 if (intro) { 176 setText('.eyebrow', page.intro?.eyebrow, intro); 177 setText('h2', page.intro?.title, intro); 178 const ps = intro.querySelectorAll('.copy p'); 179 if (ps[0]) ps[0].textContent = page.intro?.lead || ps[0].textContent; 180 if (ps[1]) ps[1].textContent = page.intro?.body || ps[1].textContent; 181 } 182 183 const philosophy = document.querySelector('.statement-band'); 184 if (philosophy) { 185 setText('.eyebrow', page.philosophy?.eyebrow, philosophy); 186 setText('h2', page.philosophy?.title, philosophy); 187 const body = philosophy.querySelector(':scope > p:not(.eyebrow)'); 188 if (body && page.philosophy?.body) body.textContent = page.philosophy.body; 189 const mantra = philosophy.querySelector('.mantra'); 190 if (mantra && page.philosophy?.mantra) mantra.innerHTML = page.philosophy.mantra.map((item) => `<span>${item}</span>`).join(''); 191 } 192 193 const studio = document.querySelector('.image-copy'); 194 if (studio) { 195 setImage('img', page.studio?.image, 'Sessions Cycle studio', studio); 196 setText('.eyebrow', page.studio?.eyebrow, studio); 197 setText('h2', page.studio?.title, studio); 198 const ps = studio.querySelectorAll('div p:not(.eyebrow)');
199 (page.studio?.body || []).forEach((text, index) => { 200 if (ps[index]) ps[index].textContent = text; 201 }); 202 } 203 204 const highlights = document.querySelector('.highlights'); 205 if (highlights) { 206 setText('.section-heading .eyebrow', page.highlights?.eyebrow, highlights); 207 setText('.section-heading h2', page.highlights?.title, highlights); 208 highlights.querySelectorAll('.highlight-grid article').forEach((article, index) => { 209 const item = page.highlights?.items?.[index]; 210 if (!item) return; 211 setText('strong', item[0], article); 212 setText('span:not(.highlight-icon)', item[1], article); 213 }); 214 } 215} 216 217function applySubpage(key, page) { 218 if (!page) return; 219 const hero = document.querySelector('.subhero'); 220 if (hero) { 221 setHeroImage('.subhero', page.heroImage); 222 setText('.eyebrow', page.hero?.eyebrow, hero); 223 setText('h1', page.hero?.title, hero); 224 setText('p:last-child', page.hero?.line, hero); 225 } 226 227 if (key === 'book') { 228 const section = document.querySelector('.split-section'); 229 setText('.eyebrow', page.rhythm?.eyebrow, section); 230 setText('h2', page.rhythm?.title, section); 231 const ps = section?.querySelectorAll('.copy p') || []; 232 (page.rhythm?.body || []).forEach((text, index) => { if (ps[index]) ps[index].textContent = text; }); 233 const tags = section?.querySelector('.class-tags'); 234 if (tags && page.rhythm?.tags) tags.innerHTML = page.rhythm.tags.map((tag) => `<span>${tag}</span>`).join(''); 235 } 236 237 if (key === 'membership') { 238 document.querySelectorAll('.pricing article').forEach((article, index) => { 239 const item = page.cards?.[index]; 240 if (!item) return; 241 setText('.eyebrow', item[0], article); 242 setText('h2', item[1], article); 243 setText('p:not(.eyebrow)', item[2], article); 244 }); 245 } 246 247 if (key === 'about') { 248 setImage('.image-copy.reverse img', page.teamImage, 'Sessions Cycle team'); 249 document.querySelectorAll('.amenity-grid article').forEach((article, index) => { 250 const item = page.amenities?.[index]; 251 if (!item) return; 252 setImage('img', item[0], item[1], article); 253 setText('h3', item[1], article); 254 setText('p', item[2], article); 255 }); 256 } 257 258 if (key === 'contact') { 259 setText('.contact-details address strong', page.address); 260 setText('.contact-details address span', page.cityStateZip); 261 setText('.contact-details address a[href^="tel:"]', page.phone); 262 setText('.contact-details address a[href^="mailto:"]', page.email); 263 } 264 265 if (key === 'account') { 266 setText('.account-panel h1', page.title); 267 setText('.account-panel p:not(.eyebrow)', page.body); 268 } 269} 270 271function applyEditableContent() { 272 if (isSpanishPage) return; 273 if (!content) return; 274 applyCommon(); 275 const key = pageKey(); 276 if (key === 'instructors') return; 277 if (key === 'home') applyHome(content.pages.home); 278 else applySubpage(key, content.pages[key]); 279} 280 281applyEditableContent(); 282 283function initBackToTop() { 284 const button = document.createElement('button'); 285 button.className = 'back-to-top'; 286 button.type = 'button'; 287 button.setAttribute('aria-label', 'Return to top'); 288 button.innerHTML = '<span aria-hidden="true">â</span>'; 289 document.body.appendChild(button); 290 291 const update = () => { 292 button.classList.toggle('is-visible', window.scrollY > 520); 293 }; 294 295 button.addEventListener('click', () => { 296 window.scrollTo({ top: 0, behavior: 'smooth' }); 297 }); 298 window.addEventListener('scroll', update, { passive: true }); 299 update(); 300} 301 302initBackToTop(); 303 304function initHeroVideo() { 305 const isSpanish = document.documentElement.lang?.toLowerCase().startsWith('es'); 306 const labels = isSpanish 307 ? { 308 play: 'Reproducir', 309 pause: 'Pausar', 310 soundOn: 'Sonido', 311 soundOff: 'Silenciar', 312 playAria: 'Reproducir video', 313 pauseAria: 'Pausar video', 314 soundOnAria: 'Activar sonido', 315 soundOffAria: 'Silenciar video', 316 fullscreen: 'Pantalla completa', 317 fullscreenAria: 'Ver video en pantalla completa', 318 } 319 : { 320 play: 'Play', 321 pause: 'Pause', 322 soundOn: 'Sound On', 323 soundOff: 'Sound Off', 324 playAria: 'Play video', 325 pauseAria: 'Pause video', 326 soundOnAria: 'Turn sound on', 327 soundOffAria: 'Turn sound off', 328 fullscreen: 'Fullscreen', 329 fullscreenAria: 'View video fullscreen', 330 }; 331 332 document.querySelectorAll('.home-hero').forEach((hero) => { 333 const video = hero.querySelector('.hero-video[data-start]'); 334 if (!video || video.dataset.heroControlsReady === 'true') return; 335 video.dataset.heroControlsReady = 'true'; 336
337 const playButton = hero.querySelector('[data-video-action="toggle-play"]'); 338 const soundButton = hero.querySelector('[data-video-action="toggle-sound"]'); 339 const fullscreenButton = hero.querySelector('[data-video-action="fullscreen"]'); 340 341 const canFullscreen = Boolean( 342 video.requestFullscreen || 343 video.webkitRequestFullscreen || 344 video.webkitEnterFullscreen 345 ); 346 if (fullscreenButton && !canFullscreen) fullscreenButton.hidden = true; 347 348 const syncControls = () => { 349 if (playButton) { 350 const playLabel = playButton.querySelector('.hero-video-label'); 351 if (playLabel) playLabel.textContent = video.paused ? labels.play : labels.pause; 352 playButton.dataset.videoState = video.paused ? 'paused' : 'playing'; 353 playButton.setAttribute('aria-label', video.paused ? labels.playAria : labels.pauseAria); 354 } 355 if (soundButton) { 356 const soundLabel = soundButton.querySelector('.hero-video-label'); 357 if (soundLabel) soundLabel.textContent = video.muted ? labels.soundOn : labels.soundOff; 358 soundButton.dataset.videoState = video.muted ? 'muted' : 'unmuted'; 359 soundButton.setAttribute('aria-label', video.muted ? labels.soundOnAria : labels.soundOffAria); 360 } 361 if (fullscreenButton) { 362 const fullscreenLabel = fullscreenButton.querySelector('.hero-video-label'); 363 if (fullscreenLabel) fullscreenLabel.textContent = labels.fullscreen; 364 fullscreenButton.setAttribute('aria-label', labels.fullscreenAria); 365 } 366 }; 367 368 const startAt = Number(video.dataset.start || 0); 369 const seekToStart = () => { 370 if (!Number.isFinite(startAt) || startAt <= 0 || video.dataset.started === 'true') return; 371 video.dataset.started = 'true'; 372 try { 373 const duration = Number.isFinite(video.duration) ? video.duration : startAt + 1; 374 video.currentTime = Math.min(startAt, Math.max(0, duration - 1)); 375 const playAttempt = video.play(); 376 if (playAttempt && typeof playAttempt.catch === 'function') playAttempt.catch(() => {}); 377 } catch (error) { 378 // If the browser blocks seeking during load, the video still plays from the beginning. 379 } 380 syncControls(); 381 }; 382 if (video.readyState >= 1) seekToStart(); 383 else video.addEventListener('loadedmetadata', seekToStart, { once: true }); 384 385 playButton?.addEventListener('click', (event) => { 386 event.preventDefault(); 387 event.stopPropagation(); 388 if (video.paused) { 389 if (video.ended) video.currentTime = 0; 390 const playAttempt = video.play(); 391 if (playAttempt && typeof playAttempt.catch === 'function') playAttempt.catch(syncControls); 392 } else { 393 video.pause(); 394 } 395 syncControls(); 396 }); 397 398 soundButton?.addEventListener('click', (event) => { 399 event.preventDefault(); 400 event.stopPropagation(); 401 video.muted = !video.muted; 402 if (!video.muted && video.paused) { 403 const playAttempt = video.play(); 404 if (playAttempt && typeof playAttempt.catch === 'function') playAttempt.catch(syncControls); 405 } 406 syncControls(); 407 }); 408 409 fullscreenButton?.addEventListener('click', (event) => { 410 event.preventDefault(); 411 event.stopPropagation(); 412 if (video.paused) { 413 const playAttempt = video.play(); 414 if (playAttempt && typeof playAttempt.catch === 'function') playAttempt.catch(() => {}); 415 } 416 if (typeof video.requestFullscreen === 'function') { 417 video.requestFullscreen().catch?.(() => {}); 418 } else if (typeof video.webkitRequestFullscreen === 'function') { 419 video.webkitRequestFullscreen(); 420 } else if (typeof video.webkitEnterFullscreen === 'function') { 421 video.webkitEnterFullscreen(); 422 } 423 }); 424 425 video.addEventListener('play', syncControls); 426 video.addEventListener('pause', syncControls); 427 video.addEventListener('ended', syncControls); 428 video.addEventListener('volumechange', syncControls); 429 syncControls(); 430 }); 431} 432 433initHeroVideo(); 434 435function getDeviceType() { 436 const ua = navigator.userAgent || ''; 437 if (/tablet|ipad|playbook|silk/i.test(ua)) return 'tablet'; 438 if (/mobile|iphone|ipod|android|blackberry|phone/i.test(ua)) return 'mobile'; 439 return 'desktop'; 440} 441 442function initXplEmailForms() { 443 const formServiceUrl = 'https://api.prd.xplorcs.com/forms-v2/forms'; 444 const forms = document.querySelectorAll('form.newsletter[name]'); 445 if (!forms.length) return; 446 447 const newsletterMessage = (form, message, type = 'info') => { 448 const alert = form?.querySelector('[data-newsletter-alert], #alert'); 449 if (!alert) return; 450 alert.textContent = message; 451 alert.dataset.status = type; 452 }; 453 454 const setNewsletterBusy = (form, isBusy) => { 455 const button = form?.querySelector('button[type="submit"]'); 456 if (!button) return; 457 button.disabled = isBusy; 458 button.dataset.originalText ||= button.textContent || ''; 459 button.textContent = isBusy ? 'Sending...' : button.dataset.originalText; 460 }; 461 462 const logEmailSignup = async (form, uuid) => { 463 const email = form?.querySelector('input[type="email"]')?.value?.trim(); 464 if (!email) return Promise.resolve(false); 465 466 const payload = JSON.stringify({ 467 type: 'email_signup', 468 email, 469 form_uuid: uuid || '', 470 language: document.documentElement.lang?.toLowerCase().startsWith('es') ? 'es' : 'en', 471 page_url: window.location.pathname + window.location.search + window.location.hash, 472 device_type: getDeviceType(), 473 }); 474 475 return fetch('/submission-log.php', { 476 method: 'POST', 477 headers: { 'Content-Type': 'application/json' }, 478 body: payload, 479 keepalive: true, 480 credentials: 'same-origin', 481 }) 482 .then(() => true) 483 .catch(() => { 484 if (navigator.sendBeacon) { 485 const blob = new Blob([payload], { type: 'application/json' }); 486 return navigator.sendBeacon('/submission-log.php', blob); 487 } 488 return false;
489 }); 490 }; 491 492 const fallbackSignup = async (form, uuid) => { 493 return logEmailSignup(form, uuid).then((logged) => { 494 if (logged) { 495 newsletterMessage(form, 'Thanks. Sessions received your email directly.', 'success'); 496 form.reset(); 497 } else { 498 newsletterMessage(form, 'Please try again, or email [email protected].', 'error'); 499 } 500 }); 501 }; 502 503 const submitAndLog = async (xplForm, form, uuid, recaptchaResponse) => { 504 let submitResult; 505 try { 506 submitResult = recaptchaResponse ? xplForm.submit(recaptchaResponse) : xplForm.submit(); 507 } catch (error) { 508 return fallbackSignup(form, uuid); 509 } 510 511 return Promise.resolve(submitResult) 512 .then(() => logEmailSignup(form, uuid)) 513 .then(() => { 514 newsletterMessage(form, '', 'success'); 515 form.reset(); 516 }) 517 .catch(() => fallbackSignup(form, uuid)); 518 }; 519 520 forms.forEach((form) => { 521 const uuid = form.getAttribute('name') || form.querySelector('input[name="uuid"]')?.value; 522 if (!uuid) return; 523 524 let uuidInput = form.querySelector('input[name="uuid"]'); 525 if (!uuidInput) { 526 uuidInput = document.createElement('input'); 527 uuidInput.type = 'hidden'; 528 uuidInput.name = 'uuid'; 529 form.prepend(uuidInput); 530 } 531 uuidInput.value = uuid; 532 }); 533 534 window.onXplFormSubmit = async (event) => { 535 event.preventDefault(); 536 event.stopPropagation(); 537 538 const form = event.target; 539 const uuid = form?.getAttribute('name') || form?.querySelector('input[name="uuid"]')?.value; 540 const uuidInput = form?.querySelector('input[name="uuid"]'); 541 if (uuidInput && uuid) uuidInput.value = uuid; 542 543 newsletterMessage(form, 'Sending...', 'info'); 544 setNewsletterBusy(form, true); 545 546 if (typeof window.__sessionsNewsletterReady === 'function') { 547 await window.__sessionsNewsletterReady(); 548 } 549 550 if (!uuid || !window.XplFormAPI || !window.XplFormContainer) { 551 await fallbackSignup(form, uuid); 552 setNewsletterBusy(form, false); 553 return; 554 } 555 556 try { 557 const formContainer = new window.XplFormContainer(formServiceUrl); 558 await formContainer.load(uuid); 559 const xplForm = new window.XplFormAPI(formServiceUrl, form, formContainer); 560 const recaptchaKey = xplForm.getRecaptchaKey(); 561 562 if (recaptchaKey && window.grecaptcha) { 563 await new Promise((resolve) => { 564 window.grecaptcha.ready(() => { 565 window.grecaptcha 566 .execute(recaptchaKey, { action: 'submit' }) 567 .then((recaptchaResponse) => submitAndLog(xplForm, form, uuid, recaptchaResponse)) 568 .then(resolve) 569 .catch(() => fallbackSignup(form, uuid).then(resolve)); 570 }); 571 }); 572 } else { 573 await submitAndLog(xplForm, form, uuid); 574 } 575 } catch (error) { 576 await fallbackSignup(form, uuid); 577 } finally { 578 setNewsletterBusy(form, false); 579 } 580 }; 581} 582 583initXplEmailForms(); 584 585function formatLocalDate(date = new Date()) { 586 const locale = document.documentElement.lang || 'en-US'; 587 return new Intl.DateTimeFormat(locale, { 588 month: 'short', 589 day: 'numeric', 590 year: 'numeric', 591 }).format(date); 592} 593 594function formatNumericDate(date = new Date()) {
595 const month = String(date.getMonth() + 1).padStart(2, '0'); 596 const day = String(date.getDate()).padStart(2, '0'); 597 const year = String(date.getFullYear()); 598 return `${month}/${day}/${year}`; 599} 600 601function formatDateInput(value) { 602 const digits = String(value || '').replace(/\D/g, '').slice(0, 8); 603 if (digits.length <= 2) return digits; 604 if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`; 605 return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`; 606} 607 608function initReleaseSignaturePads() { 609 const forms = document.querySelectorAll('form.release-form'); 610 if (!forms.length) return; 611 612 forms.forEach((form) => { 613 const dateInput = form.querySelector('input[name="signature_date"]'); 614 if (dateInput) dateInput.value = formatNumericDate(); 615 616 const birthDateInput = form.querySelector('input[name="date_of_birth"]'); 617 if (birthDateInput) { 618 const syncBirthDate = () => { 619 const formatted = formatDateInput(birthDateInput.value); 620 birthDateInput.value = formatted; 621 }; 622 birthDateInput.addEventListener('input', syncBirthDate); 623 birthDateInput.addEventListener('blur', syncBirthDate); 624 birthDateInput.addEventListener('paste', () => { 625 window.setTimeout(syncBirthDate, 0); 626 }); 627 } 628 629 const phoneInput = form.querySelector('input[name="phone"]'); 630 if (phoneInput) { 631 phoneInput.addEventListener('input', () => { 632 phoneInput.value = phoneInput.value.replace(/\D/g, '').slice(0, 15); 633 }); 634 } 635 636 const startedAt = form.querySelector('input[name="form_started_at"]'); 637 if (startedAt && !startedAt.value) startedAt.value = Math.floor(Date.now() / 1000).toString(); 638 639 const canvas = form.querySelector('.signature-canvas'); 640 const hidden = form.querySelector('input[name="signature_drawn"]'); 641 const clearButton = form.querySelector('.signature-clear'); 642 const hint = form.querySelector('.signature-hint'); 643 if (!canvas || !hidden) return; 644 645 const context = canvas.getContext('2d'); 646 if (!context) return; 647 648 let drawing = false; 649 let hasInk = false; 650 const defaultHint = hint?.textContent || 'Use your finger, mouse, or trackpad to sign.'; 651 652 const setSignatureMessage = (message, isError = false) => { 653 if (!hint) return; 654 hint.textContent = message; 655 hint.classList.toggle('is-error', isError); 656 }; 657 658 const resizeCanvas = () => { 659 const rect = canvas.getBoundingClientRect(); 660 const width = Math.max(320, Math.round(rect.width)); 661 const height = Math.max(180, Math.round(rect.height)); 662 const snapshot = hasInk ? canvas.toDataURL('image/png') : null; 663 canvas.width = width; 664 canvas.height = height; 665 context.lineJoin = 'round'; 666 context.lineCap = 'round'; 667 context.strokeStyle = '#272522'; 668 context.lineWidth = 2.5; 669 context.clearRect(0, 0, canvas.width, canvas.height); 670 if (snapshot) { 671 const image = new Image(); 672 image.onload = () => context.drawImage(image, 0, 0, canvas.width, canvas.height); 673 image.src = snapshot; 674 } 675 }; 676 677 const getPoint = (event) => { 678 const rect = canvas.getBoundingClientRect(); 679 return { 680 x: ((event.clientX - rect.left) / rect.width) * canvas.width, 681 y: ((event.clientY - rect.top) / rect.height) * canvas.height, 682 }; 683 }; 684 685 const drawPoint = (event) => { 686 if (!drawing) return; 687 const point = getPoint(event); 688 context.lineTo(point.x, point.y); 689 context.stroke(); 690 context.beginPath(); 691 context.moveTo(point.x, point.y); 692 hidden.value = canvas.toDataURL('image/png'); 693 hasInk = true; 694 setSignatureMessage(defaultHint, false); 695 }; 696 697 resizeCanvas(); 698 window.addEventListener('resize', resizeCanvas); 699 700 canvas.addEventListener('pointerdown', (event) => { 701 event.preventDefault(); 702 canvas.setPointerCapture(event.pointerId); 703 drawing = true; 704 const point = getPoint(event); 705 context.beginPath(); 706 context.moveTo(point.x, point.y); 707 drawPoint(event); 708 }); 709 710 canvas.addEventListener('pointermove', drawPoint); 711 712 const stopDrawing = (event) => { 713 if (event) event.preventDefault(); 714 drawing = false;
715 context.beginPath(); 716 }; 717 718 canvas.addEventListener('pointerup', stopDrawing); 719 canvas.addEventListener('pointercancel', stopDrawing); 720 canvas.addEventListener('pointerleave', stopDrawing); 721 722 clearButton?.addEventListener('click', () => { 723 hidden.value = ''; 724 hasInk = false; 725 context.clearRect(0, 0, canvas.width, canvas.height); 726 resizeCanvas(); 727 setSignatureMessage(defaultHint, false); 728 }); 729 730 form.addEventListener('submit', () => { 731 if (!dateInput || !dateInput.value) { 732 dateInput.value = formatNumericDate(); 733 } 734 if (birthDateInput && birthDateInput.value) { 735 birthDateInput.value = formatDateInput(birthDateInput.value); 736 } 737 if (hasInk && !hidden.value) { 738 hidden.value = canvas.toDataURL('image/png'); 739 } 740 }); 741 742 window.validateReleaseSignature = window.validateReleaseSignature || ((targetForm) => { 743 const targetHidden = targetForm.querySelector('input[name="signature_drawn"]'); 744 const targetHint = targetForm.querySelector('.signature-hint'); 745 if (targetHidden && targetHidden.value) { 746 if (targetHint) { 747 targetHint.textContent = defaultHint; 748 targetHint.classList.remove('is-error'); 749 } 750 return true; 751 } 752 if (targetHint) { 753 targetHint.textContent = 'Please add your handwritten signature before submitting.'; 754 targetHint.classList.add('is-error'); 755 } 756 return false; 757 }); 758 }); 759} 760 761initReleaseSignaturePads(); 762 763function initProtectedForms() { 764 const forms = document.querySelectorAll('form[data-recaptcha-action][data-recaptcha-site-key]'); 765 if (!forms.length) return; 766 767 forms.forEach((form) => { 768 const startedAt = form.querySelector('input[name="form_started_at"]'); 769 if (startedAt && !startedAt.value) startedAt.value = Math.floor(Date.now() / 1000).toString(); 770 771 form.addEventListener('submit', (event) => { 772 const tokenInput = form.querySelector('input[name="recaptcha_token"]'); 773 const siteKey = form.dataset.recaptchaSiteKey; 774 const action = form.dataset.recaptchaAction || 'submit'; 775 if (!tokenInput || tokenInput.value || !siteKey || !window.grecaptcha) return; 776 777 event.preventDefault(); 778 window.grecaptcha.ready(() => { 779 window.grecaptcha.execute(siteKey, { action }).then((token) => { 780 tokenInput.value = token; 781 form.submit(); 782 }); 783 }); 784 }); 785 }); 786} 787 788function initFormStatusMessages() { 789 const params = new URLSearchParams(window.location.search); 790 const success = params.get('submitted') === '1'; 791 const error = params.get('error') === '1'; 792 const reason = params.get('reason') || 'default'; 793 const isSpanish = document.documentElement.lang?.toLowerCase().startsWith('es'); 794 const messages = isSpanish 795 ? { 796 'missing-fields': 'Por favor completa todos los campos requeridos e intenta de nuevo.', 797 'invalid-email': 'Por favor ingresa un email valido e intenta de nuevo.', 798 verification: 'No pudimos verificar el envio. Espera unos segundos e intenta de nuevo.', 799 'recaptcha-missing': 'No se completo la verificacion de seguridad. Actualiza la pagina e intenta de nuevo.', 800 'recaptcha-unavailable': 'La verificacion de seguridad no esta disponible en este momento. Intenta de nuevo mas tarde o escribenos directamente.', 801 recaptcha: 'La verificacion de seguridad no paso. Actualiza la pagina e intenta de nuevo.', 802 mail: 'Tu mensaje no se pudo enviar por el servidor de correo. Por favor escribenos directamente a [email protected].', 803 default: 'No se pudo enviar tu mensaje. Intenta de nuevo o escribenos directamente a [email protected].', 804 } 805 : { 806 'missing-fields': 'Please complete all required fields and try again.', 807 'invalid-email': 'Please enter a valid email address and try again.', 808 verification: 'We could not verify the submission. Please wait a few seconds and try again.', 809 'recaptcha-missing': 'The security check did not complete. Please refresh the page and try again.', 810 'recaptcha-unavailable': 'The security check is not available right now. Please try again later or email us directly.', 811 recaptcha: 'The security check did not pass. Please refresh the page and try again.', 812 mail: 'Your message could not be sent by the mail server. Please email us directly at [email protected].', 813 default: 'Sorry, your message was not sent. Please try again or email us directly at [email protected].', 814 }; 815 document.querySelectorAll('[data-form-success]').forEach((message) => { 816 message.hidden = !success; 817 if (success) { 818 window.requestAnimationFrame(() => { 819 message.scrollIntoView({ block: 'center', behavior: 'smooth' }); 820 }); 821 } 822 }); 823 document.querySelectorAll('[data-form-error]').forEach((message) => { 824 if (error) message.textContent = messages[reason] || messages.default; 825 message.hidden = !error; 826 }); 827} 828 829initProtectedForms(); 830initFormStatusMessages(); 831 832function initMobileCarouselButtons() { 833 document.querySelectorAll('.instructor-grid').forEach((track) => {
834 if (track.closest('.mobile-carousel-shell')) return; 835 836 const shell = document.createElement('div'); 837 shell.className = 'mobile-carousel-shell instructor-carousel-shell'; 838 track.parentNode?.insertBefore(shell, track); 839 shell.appendChild(track); 840 841 const button = document.createElement('button'); 842 button.className = 'mobile-carousel-cue'; 843 button.type = 'button'; 844 button.setAttribute('aria-label', document.documentElement.lang === 'es' ? 'Desplazar instructores' : 'Scroll instructors'); 845 button.setAttribute('data-mobile-carousel-next', ''); 846 button.textContent = 'â'; 847 shell.appendChild(button); 848 }); 849 850 const buttons = document.querySelectorAll('[data-mobile-carousel-next]'); 851 if (!buttons.length) return; 852 853 buttons.forEach((button) => { 854 const shell = button.closest('.mobile-carousel-shell'); 855 const track = shell?.querySelector('.visual-story, .studio-gallery, .amenity-grid, .highlight-grid, .instagram-grid, .instructor-grid, .faq-list, .mantra, .local-city-grid'); 856 const firstItem = track?.firstElementChild; 857 if (!track || !firstItem) return; 858 const defaultLabel = button.getAttribute('aria-label') || 'Scroll carousel'; 859 const resetLabel = document.documentElement.lang === 'es' ? 'Volver al primer grupo' : 'Back to first group'; 860 861 const updateButtonState = () => { 862 const canScroll = track.scrollWidth > track.clientWidth + 12; 863 const trackRect = track.getBoundingClientRect(); 864 const hasNextItem = Array.from(track.children).some((item) => item.getBoundingClientRect().right > trackRect.right + 12); 865 866 button.hidden = !canScroll; 867 button.classList.toggle('is-at-end', canScroll && !hasNextItem); 868 button.setAttribute('aria-label', canScroll && !hasNextItem ? resetLabel : defaultLabel); 869 }; 870 871 let lastAdvanceTime = 0; 872 873 const advanceCarousel = (event) => { 874 event?.preventDefault(); 875 event?.stopPropagation(); 876 877 const now = Date.now(); 878 if (now - lastAdvanceTime < 250) return; 879 lastAdvanceTime = now; 880 881 const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth); 882 const trackRect = track.getBoundingClientRect(); 883 const nextItem = Array.from(track.children).find((item) => item.getBoundingClientRect().right > trackRect.right + 12); 884 const nextPosition = nextItem 885 ? Math.min(track.scrollLeft + nextItem.getBoundingClientRect().left - trackRect.left, maxScroll) 886 : 0; 887 888 track.scrollTo({ 889 left: nextPosition, 890 behavior: 'smooth', 891 }); 892 }; 893 894 button.addEventListener('pointerup', advanceCarousel); 895 button.addEventListener('click', advanceCarousel); 896 897 track.addEventListener('scroll', updateButtonState, { passive: true }); 898 window.addEventListener('resize', updateButtonState, { passive: true }); 899 updateButtonState(); 900 }); 901} 902 903initMobileCarouselButtons(); 904 905function initUpcomingRidesWidget() { 906 const root = document.querySelector('[data-upcoming-rides]'); 907 if (!root) return; 908 909 const grid = root.querySelector('[data-upcoming-rides-grid]'); 910 const dateLabel = root.querySelector('[data-upcoming-rides-date]'); 911 const syncLabel = root.querySelector('[data-upcoming-rides-sync]'); 912 const nextButton = root.querySelector('[data-upcoming-rides-next]'); 913 const isSpanish = document.documentElement.lang?.toLowerCase().startsWith('es'); 914 const scheduleHref = root.dataset.scheduleHref || (isSpanish ? '/es/book-now#schedule' : '/book-now#schedule'); 915 const labels = isSpanish 916 ? { 917 book: 'Reservar una clase', 918 joinWaitlist: 'Lista de espera', 919 waitlist: 'Lista de espera', 920 fullSchedule: 'Calendario completo', 921 fullScheduleTitle: 'Ver calendario completo', 922 fullScheduleBody: 'Ve todas las fechas, horarios, instructores y bicis disponibles.', 923 noRidesTitle: 'No hay proximas clases disponibles', 924 noRidesBody: 'Consulta el horario completo para ver las nuevas clases cuando se publiquen.', 925 errorTitle: 'Horario no disponible', 926 errorBody: 'El horario en vivo no se pudo cargar en este momento.',
927 instructorFallback: 'Instructor por confirmar', 928 classFallback: 'Clase de cycling', 929 updated: 'Actualizado', 930 live: 'Horario en vivo', 931 } 932 : { 933 book: 'Book a Class', 934 joinWaitlist: 'Join Waitlist', 935 waitlist: 'Waitlist', 936 fullSchedule: 'Full Calendar', 937 fullScheduleTitle: 'View Full Calendar', 938 fullScheduleBody: 'See every date, time, instructor, and available bike.', 939 noRidesTitle: 'No upcoming rides available', 940 noRidesBody: 'Check the full schedule for newly posted classes.', 941 errorTitle: 'Schedule unavailable', 942 errorBody: 'The live schedule could not be loaded right now.', 943 instructorFallback: 'Instructor TBA', 944 classFallback: 'Cycling Class', 945 updated: 'Updated', 946 live: 'Live schedule', 947 }; 948 949 const instructorImages = { 950 ally: '/instructors/Allyinstructor01.jpg', 951 'ally rayne': '/instructors/Allyinstructor01.jpg', 952 danny: '/instructors/Dannyinstructor01.jpg', 953 fabe: '/instructors/Fabeinstructor01.jpeg', 954 fabian: '/instructors/Fabeinstructor01.jpeg', 955 kat: '/instructors/KatInstructor01.jpg', 956 maisy: '/instructors/Maisyinstructor01.jpeg', 957 megan: '/instructors/MegCofounder01.jpg', 958 tatania: '/instructors/TataniaCofounder01.jpg', 959 'tiff d': '/instructors/TiffanyInstructor01.jpg', 960 tiffany: '/instructors/TiffanyInstructor01.jpg', 961 }; 962 963 function setUpcomingDate(timezone) { 964 if (!dateLabel) return; 965 dateLabel.textContent = new Intl.DateTimeFormat(isSpanish ? 'es-US' : 'en-US', { 966 timeZone: timezone || 'America/Los_Angeles', 967 weekday: 'long', 968 month: 'short', 969 day: 'numeric', 970 }).format(new Date()); 971 } 972 973 function cleanRideValue(value, fallback = '') { 974 return typeof value === 'string' && value.trim() ? value.trim() : fallback; 975 } 976 977 function rideAvailability(ride) { 978 const status = cleanRideValue(ride.status).toLowerCase(); 979 if (status.includes('waitlist')) return labels.waitlist; 980 981 if (typeof ride.spots_available === 'number') { 982 if (ride.spots_available <= 0) return labels.waitlist; 983 if (isSpanish) return ride.spots_available === 1 ? '1 asiento' : `${ride.spots_available} asientos`; 984 return ride.spots_available === 1 ? '1 seat left' : `${ride.spots_available} seats left`; 985 } 986 987 return cleanRideValue(ride.availability_label, labels.book); 988 } 989 990 function rideActionLabel(ride) { 991 const status = cleanRideValue(ride.status || ride.availability_label).toLowerCase(); 992 if (status.includes('waitlist') || ride.spots_available === 0) return labels.joinWaitlist; 993 return labels.book; 994 } 995 996 function instructorImage(ride) { 997 const feedImage = cleanRideValue(ride.instructor_photo_url || ride.instructor_photo); 998 if (feedImage) return feedImage; 999 1000 const instructor = ride.instructor; 1001 const key = cleanRideValue(instructor).toLowerCase(); 1002 if (instructorImages[key]) return instructorImages[key]; 1003 1004 const partialKey = Object.keys(instructorImages).find((name) => key.includes(name) || name.includes(key)); 1005 return partialKey ? instructorImages[partialKey] : '/assets/images/06.jpg'; 1006 } 1007 1008 function isLocalPreview() { 1009 return ['localhost', '127.0.0.1'].includes(window.location.hostname); 1010 } 1011 1012 function previewRides() { 1013 return [ 1014 { name: 'SIGNATURE SESSION', instructor: 'MEGAN', date_label: isSpanish ? 'Mie, ago 19' : 'Wed, Aug 19', time_label: '6:00 AM', spots_available: 0, status: 'Waitlist Only', booking_url: scheduleHref }, 1015 { name: 'SIGNATURE SESSION', instructor: 'DANNY', date_label: isSpanish ? 'Mie, ago 19' : 'Wed, Aug 19', time_label: '8:30 AM', spots_available: 33, booking_url: scheduleHref }, 1016 { name: 'SIGNATURE SESSION', instructor: 'MEGAN', date_label: isSpanish ? 'Mie, ago 19' : 'Wed, Aug 19', time_label: '5:30 PM', spots_available: 28, booking_url: scheduleHref }, 1017 { name: 'SIGNATURE SESSION', instructor: 'DANNY', date_label: isSpanish ? 'Jue, ago 20' : 'Thu, Aug 20', time_label: '6:00 AM', spots_available: 28, booking_url: scheduleHref }, 1018 { name: 'SIGNATURE SESSION', instructor: 'MEGAN', date_label: isSpanish ? 'Jue, ago 20' : 'Thu, Aug 20', time_label: '8:30 AM', spots_available: 23, booking_url: scheduleHref }, 1019 { name: 'SIGNATURE SESSION', instructor: 'MEGAN', date_label: isSpanish ? 'Jue, ago 20' : 'Thu, Aug 20', time_label: '5:30 PM', spots_available: 26, booking_url: scheduleHref }, 1020 { name: 'SIGNATURE SESSION', instructor: 'FABE', date_label: isSpanish ? 'Jue, ago 20' : 'Thu, Aug 20', time_label: '6:30 PM', spots_available: 33, booking_url: scheduleHref }, 1021 { name: 'SIGNATURE SESSION', instructor: 'MAISY', date_label: isSpanish ? 'Vie, ago 21' : 'Fri, Aug 21', time_label: '6:00 AM', spots_available: 26, booking_url: scheduleHref }, 1022 { name: 'SIGNATURE SESSION', instructor: 'MAISY', date_label: isSpanish ? 'Vie, ago 21' : 'Fri, Aug 21', time_label: '7:30 AM', spots_available: 33, booking_url: scheduleHref }, 1023 { name: 'SIGNATURE SESSION', instructor: 'TIFF D', date_label: isSpanish ? 'Vie, ago 21' : 'Fri, Aug 21', time_label: '8:30 AM', spots_available: 32, booking_url: scheduleHref }, 1024 { name: 'SIGNATURE SESSION', instructor: 'Ally Rayne', date_label: isSpanish ? 'Vie, ago 21' : 'Fri, Aug 21', time_label: '5:30 PM', spots_available: 35, booking_url: scheduleHref }, 1025 { name: 'SIGNATURE SESSION', instructor: 'Ally Rayne', date_label: isSpanish ? 'Vie, ago 21' : 'Fri, Aug 21', time_label: '6:30 PM', spots_available: 34, booking_url: scheduleHref }, 1026 { name: 'SIGNATURE SESSION', instructor: 'DANNY', date_label: isSpanish ? 'Sab, ago 22' : 'Sat, Aug 22', time_label: '7:30 AM', spots_available: 34, booking_url: scheduleHref }, 1027 { name: 'SIGNATURE SESSION', instructor: 'MAISY', date_label: isSpanish ? 'Sab, ago 22' : 'Sat, Aug 22', time_label: '8:30 AM', spots_available: 34, booking_url: scheduleHref }, 1028 { name: 'ENCORE SESSION', instructor: 'DALIA', date_label: isSpanish ? 'Sab, ago 22' : 'Sat, Aug 22', time_label: '9:30 AM', spots_available: 0, status: 'Waitlist Only', booking_url: scheduleHref }, 1029 { name: 'SIGNATURE SESSION', instructor: 'DANNY', date_label: isSpanish ? 'Dom, ago 23' : 'Sun, Aug 23', time_label: '7:30 AM', spots_available: 36, booking_url: scheduleHref }, 1030 { name: 'ENCORE SESSION', instructor: 'MEGAN', date_label: isSpanish ? 'Dom, ago 23' : 'Sun, Aug 23', time_label: '9:30 AM', spots_available: 2, booking_url: scheduleHref }, 1031 { name: 'SIGNATURE SESSION', instructor: 'MAISY', date_label: isSpanish ? 'Dom, ago 23' : 'Sun, Aug 23', time_label: '10:45 AM', spots_available: 38, booking_url: scheduleHref }, 1032 ]; 1033 } 1034 1035 function renderUpcomingRide(ride) { 1036 const card = document.createElement('article'); 1037 card.className = 'upcoming-ride-card'; 1038 const instructorKey = cleanRideValue(ride.instructor).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); 1039 if (instructorKey) { 1040 card.classList.add(`upcoming-ride-instructor-${instructorKey}`); 1041 } 1042 1043 const imageWrap = document.createElement('figure'); 1044 imageWrap.className = 'upcoming-ride-photo'; 1045 const image = document.createElement('img'); 1046 image.src = instructorImage(ride); 1047 image.alt = cleanRideValue(ride.instructor, labels.instructorFallback); 1048 image.loading = 'lazy'; 1049 image.decoding = 'async'; 1050 imageWrap.append(image); 1051 1052 const info = document.createElement('div'); 1053 info.className = 'upcoming-ride-info'; 1054 1055 const time = document.createElement('div'); 1056 time.className = 'upcoming-ride-time'; 1057 const datePart = cleanRideValue(ride.date_label, isSpanish ? 'Hoy' : 'Today'); 1058 const timePart = cleanRideValue(ride.time_label || ride.time, ''); 1059 time.textContent = `${datePart} ${timePart}`.trim(); 1060 1061 const details = document.createElement('div'); 1062 details.className = 'upcoming-ride-details'; 1063 1064 const name = document.createElement('h3'); 1065 name.textContent = cleanRideValue(ride.name, labels.classFallback); 1066 1067 const instructor = document.createElement('p'); 1068 instructor.textContent = cleanRideValue(ride.instructor, labels.instructorFallback); 1069 1070 const room = document.createElement('span'); 1071 room.textContent = rideAvailability(ride); 1072 1073 details.append(name, instructor, room); 1074 1075 const link = document.createElement('a'); 1076 link.className = 'button primary upcoming-ride-book'; 1077 link.href = cleanRideValue(ride.booking_url, scheduleHref); 1078 link.textContent = rideActionLabel(ride); 1079 1080 info.append(time, details, link); 1081 card.append(imageWrap, info); 1082 return card; 1083 } 1084 1085 function renderUpcomingState(title, body) { 1086 if (!grid) return; 1087 grid.textContent = ''; 1088 const state = document.createElement('div'); 1089 state.className = 'upcoming-rides-state'; 1090 const heading = document.createElement('h3'); 1091 heading.textContent = title; 1092 const copy = document.createElement('p'); 1093 copy.textContent = body; 1094 const link = document.createElement('a'); 1095 link.className = 'button primary'; 1096 link.href = scheduleHref; 1097 link.textContent = labels.fullSchedule; 1098 state.append(heading, copy, link); 1099 grid.append(state); 1100 } 1101 1102 function renderFullScheduleCard() { 1103 const card = document.createElement('a'); 1104 card.className = 'upcoming-ride-card upcoming-rides-full-card'; 1105 card.href = scheduleHref; 1106
1107 const kicker = document.createElement('span'); 1108 kicker.textContent = isSpanish ? 'Calendario' : 'Calendar'; 1109 1110 const heading = document.createElement('strong'); 1111 heading.textContent = labels.fullScheduleTitle; 1112 1113 const copy = document.createElement('p'); 1114 copy.textContent = labels.fullScheduleBody; 1115 1116 const action = document.createElement('em'); 1117 action.textContent = labels.fullSchedule; 1118 1119 card.append(kicker, heading, copy, action); 1120 return card; 1121 } 1122 1123 async function loadUpcomingRides() { 1124 if (!grid) return; 1125 try { 1126 const response = await fetch(`/ask-sessy-schedule.php?view=homepage&v=${Date.now()}`, { cache: 'no-store' }); 1127 if (!response.ok) throw new Error('Schedule request failed'); 1128 const data = await response.json(); 1129 setUpcomingDate(data.timezone); 1130 1131 if (syncLabel) { 1132 syncLabel.textContent = data.last_synced_at ? `${labels.updated} ${data.last_synced_at}` : labels.live; 1133 } 1134 1135 if (!data.ok || !Array.isArray(data.classes) || data.classes.length === 0) { 1136 renderUpcomingState(labels.noRidesTitle, labels.noRidesBody); 1137 return; 1138 } 1139 1140 grid.textContent = ''; 1141 data.classes.forEach((ride) => { 1142 grid.append(renderUpcomingRide(ride)); 1143 }); 1144 grid.append(renderFullScheduleCard()); 1145 updateCarouselButton(); 1146 } catch { 1147 setUpcomingDate('America/Los_Angeles'); 1148 if (syncLabel) syncLabel.textContent = labels.live; 1149 if (isLocalPreview()) { 1150 grid.textContent = ''; 1151 previewRides().forEach((ride) => { 1152 grid.append(renderUpcomingRide(ride)); 1153 }); 1154 grid.append(renderFullScheduleCard()); 1155 updateCarouselButton(); 1156 return; 1157 } 1158 renderUpcomingState(labels.errorTitle, labels.errorBody); 1159 } 1160 } 1161 1162 setUpcomingDate('America/Los_Angeles'); 1163 function updateCarouselButton() { 1164 if (!nextButton || !grid) return; 1165 nextButton.hidden = grid.scrollWidth <= grid.clientWidth + 8; 1166 } 1167 1168 if (nextButton && grid) { 1169 nextButton.addEventListener('click', () => { 1170 const firstCard = grid.querySelector('.upcoming-ride-card'); 1171 const cardStep = firstCard ? firstCard.getBoundingClientRect().width + 14 : 320; 1172 const nextLeft = grid.scrollLeft + cardStep; 1173 const maxLeft = Math.max(0, grid.scrollWidth - grid.clientWidth); 1174 grid.scrollTo({ left: nextLeft >= maxLeft - 16 ? 0 : nextLeft, behavior: 'smooth' }); 1175 }); 1176 grid.addEventListener('scroll', updateCarouselButton, { passive: true }); 1177 window.addEventListener('resize', updateCarouselButton, { passive: true }); 1178 } 1179 1180 loadUpcomingRides(); 1181 window.setInterval(loadUpcomingRides, 5 * 60 * 1000); 1182} 1183 1184initUpcomingRidesWidget(); 1185 1186function initInstructorScheduleCards() { 1187 const sections = Array.from(document.querySelectorAll('[data-instructor-schedule]')); 1188 if (!sections.length) return; 1189 1190 const isSpanish = document.documentElement.lang?.toLowerCase().startsWith('es'); 1191 const labels = isSpanish 1192 ? { 1193 loading: 'Cargando clases...', 1194 empty: 'No hay clases próximas para este instructor en este momento.', 1195 error: 'El horario en vivo no se pudo cargar en este momento.', 1196 book: 'Reservar', 1197 waitlist: 'Lista de espera', 1198 openBikes: 'Bicis disponibles', 1199 fullSchedule: 'Ver horario completo', 1200 } 1201 : { 1202 loading: 'Loading classes...', 1203 empty: 'No upcoming classes for this instructor right now.', 1204 error: 'The live schedule could not load right now.', 1205 book: 'Book', 1206 waitlist: 'Waitlist', 1207 openBikes: 'Open bikes', 1208 fullSchedule: 'View full schedule', 1209 }; 1210 1211 function clean(value, fallback = '') { 1212 return typeof value === 'string' && value.trim() ? value.trim() : fallback; 1213 } 1214 1215 function normalized(value) { 1216 return clean(value).toLowerCase().replace(/[^a-z0-9]+/g, ''); 1217 } 1218 1219 function classMatchesInstructor(ride, instructorId, instructorName) { 1220 if (instructorId && String(ride.instructor_id || '') === instructorId) return true; 1221 if (!instructorName) return false; 1222
1223 const expected = normalized(instructorName); 1224 const actual = normalized(ride.instructor); 1225 if (!expected || !actual) return false; 1226 1227 return actual.includes(expected) || expected.includes(actual); 1228 } 1229 1230 function availabilityLabel(ride) { 1231 const status = clean(ride.status || ride.availability_label).toLowerCase(); 1232 if (status.includes('waitlist') || ride.spots_available === 0) return labels.waitlist; 1233 if (typeof ride.spots_available === 'number') return `${ride.spots_available} ${labels.openBikes}`; 1234 return clean(ride.availability_label, labels.book); 1235 } 1236 1237 function actionLabel(ride) { 1238 const status = clean(ride.status || ride.availability_label).toLowerCase(); 1239 return status.includes('waitlist') || ride.spots_available === 0 ? labels.waitlist : labels.book; 1240 } 1241 1242 function isLocalPreview() { 1243 return ['localhost', '127.0.0.1'].includes(window.location.hostname); 1244 } 1245 1246 function previewClasses() { 1247 const scheduleHref = isSpanish ? '/es/book-now#schedule' : '/book-now#schedule'; 1248 return [ 1249 { name: 'SIGNATURE SESSION', instructor: 'MEGAN', instructor_id: '6701', date_label: 'Wed, Aug 19', time_label: '6:00 AM', spots_available: 0, status: 'Waitlist Only', booking_url: scheduleHref }, 1250 { name: 'SIGNATURE SESSION', instructor: 'DANNY', instructor_id: '6470', date_label: 'Wed, Aug 19', time_label: '8:30 AM', spots_available: 33, booking_url: scheduleHref }, 1251 { name: 'SIGNATURE SESSION', instructor: 'MEGAN', instructor_id: '6701', date_label: 'Wed, Aug 19', time_label: '5:30 PM', spots_available: 24, booking_url: scheduleHref }, 1252 { name: 'SIGNATURE SESSION', instructor: 'FABE', instructor_id: '6767', date_label: 'Thu, Aug 20', time_label: '6:30 PM', spots_available: 34, booking_url: scheduleHref }, 1253 { name: 'SIGNATURE SESSION', instructor: 'MAISY', instructor_id: '6768', date_label: 'Fri, Aug 21', time_label: '6:00 AM', spots_available: 26, booking_url: scheduleHref }, 1254 { name: 'SIGNATURE SESSION', instructor: 'TIFF D', instructor_id: '6770', date_label: 'Fri, Aug 21', time_label: '8:30 AM', spots_available: 32, booking_url: scheduleHref }, 1255 { name: 'SIGNATURE SESSION', instructor: 'Ally Rayne', instructor_id: '6771', date_label: 'Fri, Aug 21', time_label: '5:30 PM', spots_available: 35, booking_url: scheduleHref }, 1256 { name: 'ENCORE SESSION', instructor: 'DALIA', instructor_id: '35830', date_label: 'Sat, Aug 22', time_label: '9:30 AM', spots_available: 0, status: 'Waitlist Only', booking_url: scheduleHref }, 1257 { name: 'ENCORE SESSION', instructor: 'MEGAN', instructor_id: '6701', date_label: 'Sun, Aug 23', time_label: '9:30 AM', spots_available: 2, booking_url: scheduleHref }, 1258 ]; 1259 } 1260 1261 function renderCard(ride, scheduleHref) { 1262 const card = document.createElement('article'); 1263 card.className = 'instructor-class-card'; 1264 1265 const meta = document.createElement('p'); 1266 meta.className = 'instructor-class-time'; 1267 meta.textContent = `${clean(ride.date_label)} ${clean(ride.time_label || ride.time)}`.trim(); 1268 1269 const title = document.createElement('h3'); 1270 title.textContent = clean(ride.name, 'SESSION'); 1271 1272 const spots = document.createElement('p'); 1273 spots.className = 'instructor-class-spots'; 1274 spots.textContent = availabilityLabel(ride); 1275 1276 const link = document.createElement('a'); 1277 link.className = 'button primary instructor-class-book'; 1278 link.href = clean(ride.booking_url, scheduleHref); 1279 link.textContent = actionLabel(ride); 1280 1281 card.append(meta, title, spots, link); 1282 return card; 1283 } 1284 1285 function renderMessage(target, text, scheduleHref) { 1286 target.textContent = ''; 1287 const message = document.createElement('div'); 1288 message.className = 'instructor-schedule-message'; 1289 const copy = document.createElement('p'); 1290 copy.textContent = text; 1291 const link = document.createElement('a'); 1292 link.className = 'button ghost'; 1293 link.href = scheduleHref; 1294 link.textContent = labels.fullSchedule; 1295 message.append(copy, link); 1296 target.append(message); 1297 } 1298 1299 sections.forEach(async (section) => { 1300 const target = section.querySelector('[data-instructor-schedule-fallback]'); 1301 if (!target) return; 1302 if (section.querySelector('[data-mariana-integrations]')) { 1303 target.textContent = ''; 1304 return; 1305 } 1306 1307 const instructorId = clean(section.dataset.instructorId); 1308 const instructorName = clean(section.dataset.instructorName); 1309 const scheduleHref = clean(section.dataset.scheduleHref, isSpanish ? '/es/book-now#schedule' : '/book-now#schedule'); 1310 1311 target.textContent = labels.loading; 1312 try { 1313 const response = await fetch(`/ask-sessy-schedule.php?view=upcoming&v=${Date.now()}`, { cache: 'no-store' }); 1314 if (!response.ok) throw new Error('Schedule request failed'); 1315 const data = await response.json(); 1316 const classes = Array.isArray(data.classes) ? data.classes : []; 1317 const matches = classes 1318 .filter((ride) => classMatchesInstructor(ride, instructorId, instructorName)) 1319 .slice(0, 8); 1320 1321 if (!matches.length) { 1322 renderMessage(target, labels.empty, scheduleHref); 1323 return; 1324 } 1325 1326 target.textContent = ''; 1327 const grid = document.createElement('div'); 1328 grid.className = 'instructor-class-grid';
1329 matches.forEach((ride) => { 1330 grid.append(renderCard(ride, scheduleHref)); 1331 }); 1332 1333 const fullSchedule = document.createElement('a'); 1334 fullSchedule.className = 'button ghost instructor-class-all'; 1335 fullSchedule.href = scheduleHref; 1336 fullSchedule.textContent = labels.fullSchedule; 1337 1338 target.append(grid, fullSchedule); 1339 } catch { 1340 if (isLocalPreview()) { 1341 const matches = previewClasses() 1342 .filter((ride) => classMatchesInstructor(ride, instructorId, instructorName)) 1343 .slice(0, 8); 1344 1345 if (matches.length) { 1346 target.textContent = ''; 1347 const grid = document.createElement('div'); 1348 grid.className = 'instructor-class-grid'; 1349 matches.forEach((ride) => { 1350 grid.append(renderCard(ride, scheduleHref)); 1351 }); 1352 target.append(grid); 1353 return; 1354 } 1355 } 1356 renderMessage(target, labels.error, scheduleHref); 1357 } 1358 }); 1359} 1360 1361initInstructorScheduleCards(); 1362 1363function initMembershipOfferScroll() { 1364 const targetHash = '#clock-out-clip-in'; 1365 const targetText = 'clock out clip in'; 1366 1367 function normalize(value) { 1368 return String(value || '') 1369 .toLowerCase() 1370 .replace(/[^a-z0-9]+/g, ' ') 1371 .trim(); 1372 } 1373 1374 function findOfferElement(root) { 1375 const candidates = root.querySelectorAll('article, li, section, div, header, h1, h2, h3, h4, p, span'); 1376 for (const element of candidates) { 1377 if (!normalize(element.textContent).includes(targetText)) continue; 1378 return element.closest('article, li, section, [class*="card"], [class*="package"], [class*="product"], [class*="item"]') || element; 1379 } 1380 return null; 1381 } 1382 1383 function run() { 1384 const params = new URLSearchParams(window.location.search); 1385 const shouldFindOffer = window.location.hash === targetHash; 1386 const shouldWakeMembershipEmbed = shouldFindOffer || params.has('_mt'); 1387 if (!shouldWakeMembershipEmbed) return; 1388 1389 const embed = document.getElementById('membership-options') || document.querySelector('.membership-embed'); 1390 if (!embed) return; 1391 1392 let finished = false; 1393 let attempts = 0; 1394 let observer = null; 1395 let interval = null; 1396 1397 embed.scrollIntoView({ behavior: 'smooth', block: 'start' }); 1398 1399 function finish(element) { 1400 if (finished || !element) return; 1401 finished = true; 1402 if (observer) observer.disconnect(); 1403 if (interval) window.clearInterval(interval); 1404 element.scrollIntoView({ behavior: 'smooth', block: 'start' }); 1405 } 1406 1407 function tryScroll() { 1408 if (!shouldFindOffer) { 1409 finished = true; 1410 return true; 1411 } 1412 1413 const offer = findOfferElement(embed); 1414 if (offer) { 1415 finish(offer); 1416 return true; 1417 } 1418 return false; 1419 } 1420 1421 if (tryScroll()) return; 1422 1423 observer = new MutationObserver(tryScroll); 1424 observer.observe(embed, { childList: true, subtree: true, characterData: true }); 1425 1426 interval = window.setInterval(() => { 1427 attempts += 1; 1428 if (tryScroll()) return; 1429 if (attempts >= 30) { 1430 finished = true; 1431 if (observer) observer.disconnect(); 1432 if (interval) window.clearInterval(interval); 1433 } 1434 }, 300); 1435 } 1436 1437 if (document.readyState === 'loading') { 1438 document.addEventListener('DOMContentLoaded', run); 1439 } else { 1440 run(); 1441 } 1442 window.addEventListener('hashchange', run); 1443} 1444 1445initMembershipOfferScroll();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.