PageSourceSearch

https://sessionscycle.com/script.js?v=membership-offer-anchor3

js sessionscycle.com collected 2026-10-02 00:55:40 UTC 56,918 bytes, 1,445 lines download raw bytes

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.