'use strict'; /* ── Lazy background images (.set-bg / data-setbg) ────────────────────── Backgrounds are applied as their element approaches the viewport, so below-the-fold images (contactos, newsletter, footer) don't compete with the hero for bandwidth. */ (function () { var els = Array.prototype.slice.call(document.querySelectorAll('.set-bg')); function apply(el) { var bg = el.getAttribute('data-setbg'); if (bg) el.style.backgroundImage = "url('" + bg + "')"; } if ('IntersectionObserver' in window) { var io = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { apply(entry.target); io.unobserve(entry.target); } }); }, { rootMargin: '300px 0px' }); els.forEach(function (el) { io.observe(el); }); } else { els.forEach(apply); } })(); /* ── Back to top button ─────────────────────────────────────────────── */ document.addEventListener('DOMContentLoaded', function () { var btn = document.getElementById('backToTop'); if (!btn) return; function toggleBtn() { if (window.scrollY > 400) btn.classList.add('visible'); else btn.classList.remove('visible'); } window.addEventListener('scroll', toggleBtn, { passive: true }); btn.addEventListener('click', function () { window.scrollTo({ top: 0, behavior: 'smooth' }); }); }); /* Scroll-reveal: elements fade + slide up as they enter viewport */ if ('IntersectionObserver' in window) { (function () { var observer = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { entry.target.classList.add('revealed'); observer.unobserve(entry.target); } }); }, { rootMargin: '0px 0px -60px 0px', threshold: 0.05 }); // Target all cards, sections, and content blocks document.querySelectorAll( 'section[id], .icon-box-item, .trainer-item, .schedule_box, ' + '.classes-section .col-lg-6, .contact-info-warp, .rounded_frame_grey' ).forEach(function (el) { observer.observe(el); }); // Hero reveals immediately after preloader window.addEventListener('load', function () { var hero = document.querySelector('.hero-section'); if (hero) hero.classList.add('revealed'); }); })(); } /* Hide scroll hint after user scrolls the schedule */ document.addEventListener('DOMContentLoaded', function () { var panel = document.querySelector('.schedule_panel'); var hint = document.querySelector('.schedule-scroll-hint'); if (panel && hint) { panel.addEventListener('scroll', function () { hint.style.opacity = '0'; hint.style.transition = 'opacity .3s'; setTimeout(function () { hint.style.display = 'none'; }, 300); }, { once: true }); } }); /* ── Modalidades: scrollable strip + detail card ──────────────────────────── .mod-scroll is a native horizontal scroller, so touch, trackpad and shift+wheel just work; on top of that: mouse drag, arrow buttons, plain wheel → sideways, and a slow auto-glide (~20px/s) that pauses while anyone interacts and picks up again a moment later. The card set is cloned once so the glide loops: past the first copy we jump back by exactly its width. (The position is kept in a float — assigning scrollLeft += 0.3 each frame rounds to nothing — and written back every frame.) Cards with a description open .mod-detail, one shared panel outside the scroller (so overflow doesn't clip it) that grows out of the card like the team cards do, with the same 3D tilt toward the mouse; on touch it flies to the centre of the screen instead. */ document.addEventListener('DOMContentLoaded', function () { var section = document.getElementById('modalidades'); var scroller = section && section.querySelector('.mod-scroll'); if (!scroller) return; var panel = section.querySelector('.mod-detail'); var floating = window.matchMedia('(max-width: 767px), (hover: none)'); var reduced = window.matchMedia('(prefers-reduced-motion: reduce)'); // ── Loop: clone the set once ────────────────────────────────────────── var originals = Array.prototype.slice.call(scroller.querySelectorAll('.mod-card')); originals.forEach(function (c) { var clone = c.cloneNode(true); clone.setAttribute('aria-hidden', 'true'); clone.setAttribute('tabindex', '-1'); clone.classList.add('is-clone'); scroller.appendChild(clone); }); function loopWidth() { // Distance from the first original to the first clone = one full set. var first = scroller.querySelector('.mod-card'); var firstClone = scroller.querySelector('.mod-card.is-clone'); return first && firstClone ? firstClone.offsetLeft - first.offsetLeft : 0; } function wrap() { var w = loopWidth(); if (!w) return; if (scroller.scrollLeft >= w) { scroller.scrollLeft -= w; pos = scroller.scrollLeft; } else if (scroller.scrollLeft <= 0 && (performance.now() - lastUser) < IDLE) { scroller.scrollLeft += w; pos = scroller.scrollLeft; } } // ── Auto-glide ──────────────────────────────────────────────────────── var SPEED = 20; // px per second var IDLE = 2500; // ms after the last interaction before gliding again var pos = 0, last = 0, lastUser = -1e9, hovering = false; // -1e9: glide from the start function busy() { return hovering || dragging || panelOpen() || document.hidden || (performance.now() - lastUser) < IDLE; } function tick(t) { var dt = last ? Math.min(64, t - last) : 0; last = t; if (!busy()) { pos += SPEED * dt / 1000; scroller.scrollLeft = pos; wrap(); } else { pos = scroller.scrollLeft; } requestAnimationFrame(tick); } requestAnimationFrame(tick); function touched() { lastUser = performance.now(); } scroller.addEventListener('mouseenter', function () { hovering = true; }); scroller.addEventListener('mouseleave', function () { hovering = false; touched(); }); scroller.addEventListener('touchstart', touched, { passive: true }); scroller.addEventListener('touchmove', touched, { passive: true }); scroller.addEventListener('focusin', touched); scroller.addEventListener('scroll', function () { if (dragging || (performance.now() - lastUser) < IDLE) { touched(); wrap(); } if (panelOpen() && !opening) closePanel(); }, { passive: true }); // Plain vertical wheel over the strip scrolls it sideways — but only while // there is room, so the page keeps scrolling past it at the ends… which a // looping strip never has, so: only when the pointer is actually on it. scroller.addEventListener('wheel', function (e) { if (floating.matches || Math.abs(e.deltaX) > Math.abs(e.deltaY) || e.shiftKey) { touched(); return; } e.preventDefault(); touched(); scroller.scrollLeft += e.deltaY; wrap(); }, { passive: false }); // ── Mouse drag ──────────────────────────────────────────────────────── var dragging = false, dragStartX = 0, dragStartLeft = 0, dragMoved = 0, suppressClick = false; scroller.addEventListener('pointerdown', function (e) { if (e.pointerType !== 'mouse' || e.button !== 0) return; dragging = true; dragMoved = 0; dragStartX = e.clientX; dragStartLeft = scroller.scrollLeft; touched(); }); window.addEventListener('pointermove', function (e) { if (!dragging) return; var dx = e.clientX - dragStartX; dragMoved = Math.max(dragMoved, Math.abs(dx)); if (dragMoved > 5) { scroller.classList.add('is-dragging'); scroller.scrollLeft = dragStartLeft - dx; wrap(); touched(); } }); window.addEventListener('pointerup', function () { if (!dragging) return; dragging = false; suppressClick = dragMoved > 5; scroller.classList.remove('is-dragging'); touched(); }); // A drag must not also count as a click on the card under the pointer. scroller.addEventListener('click', function (e) { if (suppressClick) { e.stopPropagation(); e.preventDefault(); suppressClick = false; } }, true); // ── Arrow buttons ───────────────────────────────────────────────────── function step(dir) { var card = scroller.querySelector('.mod-card'); var by = card ? (card.offsetWidth + 8) * Math.max(1, Math.floor(scroller.clientWidth / (card.offsetWidth + 8)) - 1) : 400; touched(); if (dir < 0 && scroller.scrollLeft - by < 0) { scroller.scrollLeft += loopWidth(); } scroller.scrollBy({ left: dir * by, behavior: reduced.matches ? 'auto' : 'smooth' }); } section.querySelectorAll('.mod-nav').forEach(function (b) { b.addEventListener('click', function (e) { e.stopPropagation(); closePanel(); step(b.classList.contains('mod-nav-prev') ? -1 : 1); }); }); // ── Detail panel ────────────────────────────────────────────────────── if (!panel) return; var source = null, opening = false, trackTimer = 0, backdrop = null; var W = 540, GUTTER = 16; function panelOpen() { return panel.classList.contains('is-open'); } function fill(card) { var name = card.querySelector('h3').textContent; var tpl = card.querySelector('template.mod-desc'); // Same icon markup as the card (inline animated SVG or ), so the // panel's copy replays its animation as it opens. var icon = card.querySelector('.ib-icon'); panel.querySelector('.md-card .ib-icon').innerHTML = icon ? icon.innerHTML : ''; panel.querySelector('.md-card h3').textContent = name; panel.querySelector('.md-name').textContent = name; panel.querySelector('.md-text').innerHTML = tpl ? tpl.innerHTML : ''; panel.style.setProperty('--md-color', card.getAttribute('data-color') || ''); panel.setAttribute('aria-label', name); } // Desktop: lay the panel's left column exactly over the card. function place(card) { var sr = section.getBoundingClientRect(), cr = card.getBoundingClientRect(); var vw = document.documentElement.clientWidth; var w = Math.min(W, vw - GUTTER * 2); var flip = cr.left + w > vw - GUTTER; var x = (flip ? cr.right - w : cr.left) - sr.left; var icon = card.querySelector('.ib-icon'); panel.classList.toggle('md-flip', flip); panel.style.setProperty('--md-w', w + 'px'); panel.style.setProperty('--md-cw', cr.width + 'px'); panel.style.setProperty('--md-ch', cr.height + 'px'); panel.style.setProperty('--md-pad', (icon ? icon.offsetTop : 0) + 'px'); panel.style.setProperty('--md-x', x + 'px'); panel.style.setProperty('--md-y', (cr.top - sr.top) + 'px'); panel.style.setProperty('--md-cx', Math.max(0, w - cr.width) + 'px'); panel.style.setProperty('--md-cy', Math.max(0, panel.offsetHeight - cr.height) + 'px'); // Open, the icon + name slide from where they sit on the card to the // vertical centre of the (taller) panel column. var title = card.querySelector('h3'); var contentH = icon && title ? (title.offsetTop + title.offsetHeight) - icon.offsetTop : 0; panel.style.setProperty('--md-pad-open', Math.max(0, (panel.offsetHeight - contentH) / 2) + 'px'); } function flyFrom(card, opening) { if (!panel.animate) return null; var c = card.getBoundingClientRect(), p = panel.getBoundingClientRect(); if (!p.width || !p.height) return null; var from = 'translate(' + (c.left - p.left) + 'px,' + (c.top - p.top) + 'px) scale(' + (c.width / p.width) + ',' + (c.height / p.height) + ')'; var frames = [{ transform: from, opacity: .4 }, { transform: 'none', opacity: 1 }]; return panel.animate(opening ? frames : frames.reverse(), { duration: opening ? 420 : 260, easing: opening ? 'cubic-bezier(.2,.9,.25,1)' : 'cubic-bezier(.4,0,1,1)' }); } function openPanel(card) { if (source === card && panelOpen()) return; closePanel(true); source = card; fill(card); opening = true; if (floating.matches) { backdrop = document.createElement('div'); backdrop.className = 'mod-detail-backdrop'; backdrop.addEventListener('click', function () { closePanel(); }); section.appendChild(backdrop); panel.classList.add('is-open'); panel.style.setProperty('--md-top', Math.max(24, (window.innerHeight - panel.offsetHeight) / 2) + 'px'); flyFrom(card, true); } else { var mdCard = panel.querySelector('.md-card'); panel.style.transition = mdCard.style.transition = 'none'; place(card); void panel.offsetWidth; panel.style.transition = mdCard.style.transition = ''; void panel.offsetWidth; panel.classList.add('is-open'); card.classList.add('is-source'); trackTimer = setTimeout(function () { panel.classList.add('md-tracking'); }, 600); } panel.setAttribute('aria-hidden', 'false'); section.classList.add('has-open-detail'); setTimeout(function () { opening = false; }, 450); } function closePanel(instant) { if (!panelOpen() && !source) return; var card = source; clearTimeout(trackTimer); ['--md-rx', '--md-ry', '--md-mx', '--md-my'].forEach(function (p) { panel.style.removeProperty(p); }); panel.classList.remove('md-tracking'); var done = function () { panel.classList.remove('is-open'); if (card) card.classList.remove('is-source'); section.classList.remove('has-open-detail'); if (backdrop) { backdrop.remove(); backdrop = null; } }; var anim = (!instant && floating.matches && card) ? flyFrom(card, false) : null; if (anim) anim.onfinish = done; else { panel.classList.remove('is-open'); // Desktop: the card reappears once the panel has folded back onto it. if (card) setTimeout(function () { if (source !== card) card.classList.remove('is-source'); }, instant ? 0 : 380); section.classList.remove('has-open-detail'); if (backdrop) { backdrop.remove(); backdrop = null; } } panel.setAttribute('aria-hidden', 'true'); source = null; touched(); } // Open: hover (desktop, short intent delay) or click / tap / Enter. var hoverTimer = 0; scroller.addEventListener('mouseover', function (e) { if (floating.matches || dragging) return; var card = e.target.closest('.mod-card.has-info'); if (!card || card === source) return; clearTimeout(hoverTimer); hoverTimer = setTimeout(function () { if (!dragging) openPanel(card); }, 120); }); scroller.addEventListener('mouseout', function (e) { if (floating.matches) return; var card = e.target.closest('.mod-card.has-info'); if (card && !card.contains(e.relatedTarget)) clearTimeout(hoverTimer); }); // The panel sits over the card; leaving the panel closes it. panel.addEventListener('mouseleave', function (e) { if (floating.matches) return; if (source && source.contains(e.relatedTarget)) return; closePanel(); }); panel.addEventListener('mousemove', function (e) { if (floating.matches || !panelOpen()) return; var r = panel.getBoundingClientRect(); var dx = Math.max(-1, Math.min(1, (e.clientX - (r.left + r.width / 2)) / (r.width / 2))); var dy = Math.max(-1, Math.min(1, (e.clientY - (r.top + r.height / 2)) / (r.height / 2))); panel.style.setProperty('--md-ry', (dx * 5).toFixed(2) + 'deg'); panel.style.setProperty('--md-rx', (-dy * 5).toFixed(2) + 'deg'); panel.style.setProperty('--md-mx', (dx * 6).toFixed(1) + 'px'); panel.style.setProperty('--md-my', (dy * 6 - 6).toFixed(1) + 'px'); }); scroller.addEventListener('click', function (e) { var card = e.target.closest('.mod-card.has-info'); if (!card) return; e.stopPropagation(); openPanel(card); }); scroller.addEventListener('keydown', function (e) { var card = e.target.closest('.mod-card.has-info'); if (card && (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar')) { e.preventDefault(); openPanel(card); } }); panel.addEventListener('click', function (e) { e.stopPropagation(); if (e.target.closest('.md-close')) closePanel(); }); document.addEventListener('click', function () { if (panelOpen()) closePanel(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && panelOpen()) closePanel(); }); window.addEventListener('resize', function () { if (panelOpen()) closePanel(true); }, { passive: true }); window.addEventListener('scroll', function () { if (panelOpen() && !floating.matches && !opening) closePanel(); }, { passive: true }); }); /* ── Schedule day switcher ──────────────────────────────────────────────────── The on-screen schedule shows the chosen day (phones/tablets) or that day centred between the day before and after (desktop ≥992px, .is-3day); each header + class cell is tagged with data-day. There is no separate day-picker bar — the header itself is the switcher: prev/next arrows beside the day name on phones/tablets, and clicking a side day's header on desktop. Defaults to today when the gym is open that day, otherwise the first day. The JPG export is unaffected — its clone removes every .sched-hide and re-renders the full week. */ document.addEventListener('DOMContentLoaded', function () { // Works on the home page (#horario) and the CrossFit page (bare .schedule). var scope = document.querySelector('.schedule'); if (!scope) return; var dayheads = scope.querySelectorAll('.schedule_dayhead'); var cells = scope.querySelectorAll('.schedule_cell, .schedule_dayhead'); if (!dayheads.length) return; var labels = scope.querySelectorAll('.schedule_section_label'); // Desktop shows three days — the chosen one centred between the day before // and the day after (wrapping round the week); phones show just the one. var wide = window.matchMedia('(min-width: 992px)'); var week = Array.prototype.map.call(dayheads, function (h) { return h.getAttribute('data-day'); }); var current = null; // Toggle classes (not inline display) — .sched-hide carries !important so it // reliably wins, and the export clone simply strips it (and ignores the // .sched-pos-* columns) to show every day. function showDay(day) { current = day; var i = week.indexOf(day), n = week.length; var three = wide.matches && n >= 3; var visible = three ? [week[(i - 1 + n) % n], day, week[(i + 1) % n]] : [day]; scope.classList.toggle('is-3day', three); cells.forEach(function (c) { var pos = visible.indexOf(c.getAttribute('data-day')); c.classList.toggle('sched-hide', pos === -1); c.classList.remove('sched-pos-1', 'sched-pos-2', 'sched-pos-3', 'sched-side'); if (pos !== -1) { var col = three ? pos + 1 : 1; c.classList.add('sched-pos-' + col); if (three && col !== 2) c.classList.add('sched-side'); } }); // Show a Manhã / Tarde divider only when a visible day has a class in that block. labels.forEach(function (lbl) { var sec = lbl.getAttribute('data-section'); var has = visible.some(function (d) { return !!scope.querySelector('.schedule_cell[data-day="' + d + '"][data-section="' + sec + '"]'); }); lbl.classList.toggle('sched-hide', !has); }); } function step(dir) { var i = week.indexOf(current), n = week.length; if (i === -1 || !n) return; showDay(week[(i + dir + n) % n]); } scope.addEventListener('click', function (e) { if (e.target.closest('.sched-prev')) { step(-1); return; } if (e.target.closest('.sched-next')) { step(1); return; } // Clicking a side day's header (desktop 3-day view) centres it. var head = e.target.closest('.schedule_dayhead.sched-side'); if (head) showDay(head.getAttribute('data-day')); }); var reflow = function () { if (current) showDay(current); }; if (wide.addEventListener) wide.addEventListener('change', reflow); else if (wide.addListener) wide.addListener(reflow); // Swipe left/right on phones/tablets to change day — same effect as the // arrows. Only acts on mostly-horizontal drags, so a vertical page scroll // starting over the schedule is never hijacked. var touchStart = null; var container = scope.querySelector('.schedule_container') || scope; container.addEventListener('touchstart', function (e) { var t = e.changedTouches[0]; touchStart = { x: t.clientX, y: t.clientY }; }, { passive: true }); container.addEventListener('touchend', function (e) { if (!touchStart) return; var t = e.changedTouches[0]; var dx = t.clientX - touchStart.x, dy = t.clientY - touchStart.y; touchStart = null; if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy) * 1.5) return; step(dx < 0 ? 1 : -1); }, { passive: true }); // Pre-select today when the schedule has a header for it (PT day names), // otherwise the first day. var ptDays = { 1: 'segunda', 2: 'terça', 3: 'quarta', 4: 'quinta', 5: 'sexta', 6: 'sábado' }; var today = ptDays[new Date().getDay()]; var start = (today && week.indexOf(today) !== -1) ? today : week[0]; if (start) showDay(start); }); /* ── Horário: hide the "Ver Horário das aulas" toggle once open ───────────── The button's only job is opening the accordion — once it has, leaving it sitting above the now-visible schedule is dead weight (and confusing when the navbar link opened it without a click on the button itself). */ document.addEventListener('DOMContentLoaded', function () { document.querySelectorAll('.collapse[id^="horarioCollapse"]').forEach(function (el) { var toggles = document.querySelectorAll('[data-bs-toggle="collapse"][data-bs-target="#' + el.id + '"]'); el.addEventListener('shown.bs.collapse', function () { toggles.forEach(function (b) { b.hidden = true; }); }); el.addEventListener('hidden.bs.collapse', function () { toggles.forEach(function (b) { b.hidden = false; }); }); }); }); /* ── Equipa: detail card that grows out of the tile ───────────────────────── Desktop: hover (or keyboard focus) opens. The card is laid exactly over the tile photo, tilted like it, and the CSS unclips/straightens it sideways — to the right, or to the left (.td-flip) when there is no room. While open, the mouse drives a small 3D tilt + drift so it handles like a real card. Touch / narrow screens: tap opens a centred fixed card; a FLIP animation makes it fly out of (and back into) the tile photo. Esc, ×, outside tap close. */ document.addEventListener('DOMContentLoaded', function () { var items = document.querySelectorAll('.trainer-item.has-detail'); if (!items.length) return; var floating = window.matchMedia('(max-width: 767px), (hover: none)'); var reduced = window.matchMedia('(prefers-reduced-motion: reduce)'); var section = document.getElementById('equipa'); var CARD_W = 560, GUTTER = 16; function tileRot(item) { // #powerRotation tilts odd tiles -3deg and even ones +3deg (style.css). var col = item.parentElement, i = 0; while (col && (col = col.previousElementSibling)) i++; return (i % 2 === 1) ? 3 : -3; } // Desktop placement: photo column on top of the tile photo. function place(item) { var card = item.querySelector('.trainer-detail'); var pic = item.querySelector('.trainer-pic'); if (!card || !pic || floating.matches) return; var vw = document.documentElement.clientWidth; var w = Math.min(CARD_W, vw - GUTTER * 2); var pw = pic.offsetWidth, ph = pic.offsetHeight; var picLeft = item.getBoundingClientRect().left + pic.offsetLeft; // untransformed var flip = picLeft + w > vw - GUTTER; // no room on the right var x = flip ? pic.offsetLeft + pw - w : pic.offsetLeft; card.classList.toggle('td-flip', flip); card.style.setProperty('--td-w', w + 'px'); card.style.setProperty('--td-pw', pw + 'px'); card.style.setProperty('--td-ph', ph + 'px'); card.style.setProperty('--td-x', x + 'px'); card.style.setProperty('--td-y', pic.offsetTop + 'px'); card.style.setProperty('--td-rot', tileRot(item) + 'deg'); // Closed clip = just the photo square, in px (see style.css). card.style.setProperty('--td-cx', Math.max(0, w - pw) + 'px'); card.style.setProperty('--td-cy', Math.max(0, card.offsetHeight - ph) + 'px'); } function resetTilt(card) { ['--td-rx', '--td-ry', '--td-mx', '--td-my'].forEach(function (p) { card.style.removeProperty(p); }); card.classList.remove('td-tracking'); } // Touch: fly the fixed card out of / back into the tile photo. function flip(item, opening) { var card = item.querySelector('.trainer-detail'); var pic = item.querySelector('.trainer-pic'); if (!card || !pic || !card.animate) return null; var p = pic.getBoundingClientRect(), c = card.getBoundingClientRect(); if (!c.width || !c.height) return null; var from = 'translate(' + (p.left - c.left) + 'px,' + (p.top - c.top) + 'px) scale(' + (p.width / c.width) + ',' + (p.height / c.height) + ') rotate(' + tileRot(item) + 'deg)'; var frames = [{ transform: from, opacity: .4 }, { transform: 'none', opacity: 1 }]; return card.animate(opening ? frames : frames.reverse(), { duration: opening ? 420 : 280, easing: opening ? 'cubic-bezier(.2,.9,.25,1)' : 'cubic-bezier(.4,0,1,1)' }); } function setOpen(item, open) { var card = item.querySelector('.trainer-detail'); var wasOpen = item.classList.contains('is-open'); if (open === wasOpen) return; clearTimeout(item._tdTrack); if (open) { if (floating.matches) { item.classList.add('is-open'); card.style.setProperty('--td-top', Math.max(24, (window.innerHeight - card.offsetHeight) / 2) + 'px'); flip(item, true); } else { // Jump (no transition) to the closed pose on top of the tile, commit // it, then open — otherwise the closed state's own transition would // start moving from the previous pose and the opening would begin // from that half-way value instead of from the photo. card.style.transition = 'none'; place(item); void card.offsetWidth; card.style.transition = ''; void card.offsetWidth; item.classList.add('is-open'); item._tdTrack = setTimeout(function () { card.classList.add('td-tracking'); }, 600); } } else { resetTilt(card); var anim = floating.matches ? flip(item, false) : null; if (anim) anim.onfinish = function () { item.classList.remove('is-open'); syncSection(); }; else item.classList.remove('is-open'); } item.setAttribute('aria-expanded', open ? 'true' : 'false'); syncSection(); } function syncSection() { if (section) section.classList.toggle('has-open-detail', !!section.querySelector('.trainer-item.is-open')); } function closeAll(except) { items.forEach(function (it) { if (it !== except) setOpen(it, false); }); } items.forEach(function (item) { var card = item.querySelector('.trainer-detail'); // Desktop hover: short intent delay so sweeping across the grid // doesn't fire every card. item.addEventListener('mouseenter', function () { if (floating.matches) return; clearTimeout(item._tdHover); item._tdHover = setTimeout(function () { closeAll(item); setOpen(item, true); }, 90); }); item.addEventListener('mouseleave', function () { if (floating.matches) return; clearTimeout(item._tdHover); setOpen(item, false); }); // Tilt + drift toward the pointer while open (desktop only). item.addEventListener('mousemove', function (e) { if (floating.matches || !item.classList.contains('is-open')) return; var r = card.getBoundingClientRect(); var dx = Math.max(-1, Math.min(1, (e.clientX - (r.left + r.width / 2)) / (r.width / 2))); var dy = Math.max(-1, Math.min(1, (e.clientY - (r.top + r.height / 2)) / (r.height / 2))); card.style.setProperty('--td-ry', (dx * 5).toFixed(2) + 'deg'); card.style.setProperty('--td-rx', (-dy * 5).toFixed(2) + 'deg'); card.style.setProperty('--td-mx', (dx * 6).toFixed(1) + 'px'); card.style.setProperty('--td-my', (dy * 6).toFixed(1) + 'px'); }); item.addEventListener('focus', function () { if (!floating.matches) { closeAll(item); setOpen(item, true); } }); item.addEventListener('blur', function (e) { if (!floating.matches && !item.contains(e.relatedTarget)) setOpen(item, false); }); // Tap / click (touch, or a desktop click on a closed tile). item.addEventListener('click', function (e) { e.stopPropagation(); if (e.target.closest('.td-close')) { setOpen(item, false); return; } var inCard = !!e.target.closest('.trainer-detail'); if (item.classList.contains('is-open')) { // Tapping the dimmed backdrop (the item's ::after) closes it; // taps inside the card (scroll, select) keep it open. if (floating.matches && !inCard) setOpen(item, false); return; } closeAll(item); setOpen(item, true); }); item.addEventListener('keydown', function (e) { if (e.key === 'Escape') { setOpen(item, false); item.blur(); } else if ((e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') && e.target === item) { e.preventDefault(); setOpen(item, !item.classList.contains('is-open')); } }); }); document.addEventListener('click', function () { closeAll(null); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAll(null); }); window.addEventListener('resize', function () { closeAll(null); }, { passive: true }); }); /* ── Mensalidades: cards follow the mouse ─────────────────────────────────── While hovered, a price card tilts and drifts a few px toward the pointer (like the team cards) and the spinning conic edge is centred on it (--pc-x/--pc-y feed the gradient's `at` position). Pointer devices only; off with reduced motion. */ document.addEventListener('DOMContentLoaded', function () { var cards = document.querySelectorAll('.price-card'); if (!cards.length) return; var fine = window.matchMedia('(hover: hover) and (pointer: fine)'); var reduced = window.matchMedia('(prefers-reduced-motion: reduce)'); var VARS = ['--pc-rx', '--pc-ry', '--pc-mx', '--pc-my', '--pc-x', '--pc-y']; cards.forEach(function (card) { var t; card.addEventListener('mouseenter', function () { if (!fine.matches || reduced.matches) return; clearTimeout(t); t = setTimeout(function () { card.classList.add('pc-tracking'); }, 350); }); card.addEventListener('mousemove', function (e) { if (!fine.matches || reduced.matches) return; var r = card.getBoundingClientRect(); var x = e.clientX - r.left, y = e.clientY - r.top; var dx = Math.max(-1, Math.min(1, (x - r.width / 2) / (r.width / 2))); var dy = Math.max(-1, Math.min(1, (y - r.height / 2) / (r.height / 2))); card.style.setProperty('--pc-ry', (dx * 6).toFixed(2) + 'deg'); card.style.setProperty('--pc-rx', (-dy * 6).toFixed(2) + 'deg'); card.style.setProperty('--pc-mx', (dx * 6).toFixed(1) + 'px'); card.style.setProperty('--pc-my', (dy * 6).toFixed(1) + 'px'); // The pseudo-elements sit a few px outside the card; close enough. card.style.setProperty('--pc-x', x.toFixed(0) + 'px'); card.style.setProperty('--pc-y', y.toFixed(0) + 'px'); }); card.addEventListener('mouseleave', function () { clearTimeout(t); card.classList.remove('pc-tracking'); // spring back VARS.forEach(function (v) { card.style.removeProperty(v); }); }); }); }); /* ── Mensalidades: pre-select the clicked card in the sign-up modal ───────── */ document.addEventListener('DOMContentLoaded', function () { var select = document.querySelector('#joinModal select[name="membership_id"]'); if (!select) return; document.querySelectorAll('[data-bs-target="#joinModal"][data-membership]').forEach(function (btn) { btn.addEventListener('click', function () { select.value = btn.getAttribute('data-membership'); }); }); }); /* ── Galeria: lightbox ─────────────────────────────────────────────────────── Tiles are plain links to the full image (works without JS). With JS a click opens the instead: prev / next buttons, ← → keys, Esc, swipe on touch, and a click on the dark backdrop closes it. */ document.addEventListener('DOMContentLoaded', function () { document.querySelectorAll('.gal-masonry').forEach(function (grid) { var box = grid.parentElement.nextElementSibling; if (!box || !box.classList.contains('gal-lightbox') || typeof box.showModal !== 'function') return; var links = Array.prototype.slice.call(grid.querySelectorAll('.gal-link')); var img = box.querySelector('.gal-lb-figure img'); var count = box.querySelector('.gal-lb-count'); var current = 0; function show(i) { current = (i + links.length) % links.length; var src = links[current].querySelector('img'); img.src = links[current].getAttribute('href'); img.alt = src ? src.alt : ''; // Restart the fade-in for each photo. img.style.animation = 'none'; void img.offsetWidth; img.style.animation = ''; count.textContent = (current + 1) + ' / ' + links.length; } links.forEach(function (a, i) { a.addEventListener('click', function (e) { e.preventDefault(); show(i); box.showModal(); }); }); box.querySelector('.gal-lb-prev').addEventListener('click', function () { show(current - 1); }); box.querySelector('.gal-lb-next').addEventListener('click', function () { show(current + 1); }); box.querySelector('.gal-lb-close').addEventListener('click', function () { box.close(); }); // Clicks that land on the dialog itself (not the photo or a button) are the backdrop. box.addEventListener('click', function (e) { if (e.target === box) box.close(); }); box.addEventListener('keydown', function (e) { if (e.key === 'ArrowLeft') show(current - 1); else if (e.key === 'ArrowRight') show(current + 1); }); box.addEventListener('close', function () { links[current].focus(); }); var x0 = null; box.addEventListener('touchstart', function (e) { x0 = e.touches[0].clientX; }, { passive: true }); box.addEventListener('touchend', function (e) { if (x0 === null) return; var dx = e.changedTouches[0].clientX - x0; if (Math.abs(dx) > 40) show(current + (dx < 0 ? 1 : -1)); x0 = null; }, { passive: true }); }); }); /* ── Galeria: masonry layout ───────────────────────────────────────────────── A small hand-rolled masonry (no library): the grid is split into columns (4 / 3 / 2 by width), and each tile — at its photo's true aspect ratio, from the width/height attributes the PHP writes — is dropped into the shortest column; tiles marked .gal-w take two adjacent columns (the pair whose taller one is lowest), which is what staggers the rows. Tiles are absolutely positioned with transforms, so a relayout on resize glides into place. The first time the gallery scrolls into view the tiles rise in one by one. */ document.addEventListener('DOMContentLoaded', function () { document.querySelectorAll('.gal-masonry').forEach(function (grid) { var tiles = Array.prototype.slice.call(grid.querySelectorAll('.gal-tile')); if (!tiles.length) return; grid.classList.add('is-masonry'); function ratio(tile) { var img = tile.querySelector('img'); var w = +img.getAttribute('width') || img.naturalWidth || 4; var h = +img.getAttribute('height') || img.naturalHeight || 3; return h / w; } function layout() { var width = grid.clientWidth; var cols = width >= 900 ? 4 : width >= 520 ? 3 : 2; var gap = width >= 520 ? 12 : 8; var colW = (width - gap * (cols - 1)) / cols; var heights = []; for (var c = 0; c < cols; c++) heights.push(0); tiles.forEach(function (tile) { var span = (tile.classList.contains('gal-w') && cols > 1) ? 2 : 1; // Best column (or pair of columns) = lowest top edge. var pick = function (span) { var best = 0, bestTop = Infinity, waste = 0; for (var i = 0; i <= cols - span; i++) { var cells = heights.slice(i, i + span); var top = Math.max.apply(null, cells); if (top < bestTop - 0.5) { bestTop = top; best = i; waste = top - Math.min.apply(null, cells); } } return [best, bestTop, waste]; }; var p = pick(span); // A wide tile whose two columns end at different heights would // leave a hole under the shorter one — use a single column then. if (span === 2 && p[2] > 60) { span = 1; p = pick(1); } var best = p[0], bestTop = p[1]; var w = colW * span + gap * (span - 1); var h = Math.round(w * ratio(tile)); tile.style.width = w + 'px'; tile.style.height = h + 'px'; tile.style.transform = 'translate(' + (best * (colW + gap)) + 'px,' + bestTop + 'px)'; for (var j = best; j < best + span; j++) heights[j] = bestTop + h + gap; }); grid.style.height = Math.max(0, Math.max.apply(null, heights) - gap) + 'px'; } layout(); // Re-lay out whenever the grid's own width changes (window resize, but // also a gallery that was laid out while hidden / zero-width). var t, lastW = grid.clientWidth; var relayout = function () { if (grid.clientWidth === lastW) return; lastW = grid.clientWidth; clearTimeout(t); t = setTimeout(layout, 80); }; if ('ResizeObserver' in window) new ResizeObserver(relayout).observe(grid); window.addEventListener('resize', relayout, { passive: true }); // belt and braces; relayout() is idempotent // Re-run once images report their size, in case an attribute was missing. tiles.forEach(function (tile) { var img = tile.querySelector('img'); if (!img.getAttribute('width')) img.addEventListener('load', layout, { once: true }); }); // Staggered entrance. var enter = function () { tiles.forEach(function (tile, i) { setTimeout(function () { tile.classList.add('is-in'); }, Math.min(i, 12) * 70); }); }; if ('IntersectionObserver' in window) { var io = new IntersectionObserver(function (entries) { if (entries.some(function (e) { return e.isIntersecting; })) { enter(); io.disconnect(); } }, { rootMargin: '0px 0px -80px 0px' }); io.observe(grid); } else { enter(); } }); });