Files
andre f297e128fd fix(gallery): open lightbox on click and keep mouse drag alive
Pointer capture on pointerdown redirected the click to the strip, so photos
never opened; capture now starts only after a 5px drag. Native link drag is
blocked so it no longer cancels the mouse drag.
2026-10-26 20:40:17 +00:00

875 lines
44 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 <img>), 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 <dialog> 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-marquee').forEach(function (grid) {
var box = grid.parentElement.nextElementSibling;
if (!box || !box.classList.contains('gal-lightbox') || typeof box.showModal !== 'function') return;
var all = Array.prototype.slice.call(grid.querySelectorAll('.gal-link'));
var links = all.filter(function (a) { return !a.hasAttribute('data-dup'); }); // the repeated copies open the same photo
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');
// The tile's chosen source (JXL inside <picture> where supported), else the link (fallback).
img.src = (src && src.currentSrc) || 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;
}
all.forEach(function (a) {
a.addEventListener('click', function (e) {
e.preventDefault();
show(+a.getAttribute('data-index') || 0);
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 () { if (links[current]) 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 });
});
});
/* ── Scroll "hover": what sits in the middle of the screen looks hovered ───────
Trainer photos, cards and icons get .is-centered while their box crosses the
middle of the viewport (±12% of its height); inside a sliding strip
(Modalidades) they must also cross the horizontal middle. Gallery photos are
left out: they only light up on a real hover. Throttled, paused
while the tab is hidden, skipped with "reduce motion". The CSS mirrors the
:hover rules (style.css, "In the middle of the screen"). */
document.addEventListener('DOMContentLoaded', function () {
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var items = Array.prototype.slice.call(document.querySelectorAll(
'.trainer-item .trainer-pic, .icon-box-item, .price-card')).map(function (el) {
return { el: el, strip: !!el.closest('.mod-scroll'), on: false };
});
if (!items.length) return;
var last = 0;
function tick(now) {
requestAnimationFrame(tick);
if (document.hidden || now - last < 120) return;
last = now;
var vw = window.innerWidth, vh = window.innerHeight, cy = vh / 2, band = vh * 0.12;
items.forEach(function (it) {
var r = it.el.getBoundingClientRect();
var on = r.bottom > 0 && r.top < vh &&
r.top <= cy + band && r.bottom >= cy - band &&
(!it.strip || (r.left <= vw / 2 && r.right >= vw / 2));
if (on !== it.on) { it.on = on; it.el.classList.toggle('is-centered', on); }
});
}
requestAnimationFrame(tick);
});
/* ── Galeria: auto-scroll + click-and-drag ───────────────────────────────────
The strip (.gal-wrap) is four identical copies long. It advances by itself,
follows a mouse drag (touch uses the native swipe), pauses while you hover or
hold it and resumes a moment after, and whenever the position leaves the two
middle copies it jumps by exactly one copy — invisible, since every copy is the
same. A drag longer than a few px swallows the click, so it doesn't open the
lightbox. */
document.addEventListener('DOMContentLoaded', function () {
var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
document.querySelectorAll('.gal-marquee').forEach(function (strip) {
var wrap = strip.parentElement;
var set = strip.querySelector('.gal-set');
if (!wrap || !set) return;
var loop = 0, pos = null, hold = false, resumeAt = 0, dragging = false, moved = 0, startX = 0, startLeft = 0, last = 0;
var SPEED = 40; // px per second
function measure() { loop = set.offsetWidth; }
function normalise() {
if (!loop) return;
if (wrap.scrollLeft >= loop * 2) wrap.scrollLeft -= loop;
else if (wrap.scrollLeft < loop) wrap.scrollLeft += loop;
}
function start() { measure(); wrap.scrollLeft = loop; }
start();
window.addEventListener('load', start);
window.addEventListener('resize', measure, { passive: true });
wrap.addEventListener('scroll', normalise, { passive: true });
wrap.addEventListener('mouseenter', function () { hold = true; });
wrap.addEventListener('mouseleave', function () { if (!dragging) { hold = false; resumeAt = performance.now() + 600; } });
wrap.addEventListener('focusin', function () { hold = true; });
wrap.addEventListener('focusout', function () { hold = false; });
wrap.addEventListener('touchstart', function () { hold = true; }, { passive: true });
wrap.addEventListener('touchend', function () { hold = false; resumeAt = performance.now() + 1800; }, { passive: true });
wrap.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'touch' || e.button !== 0) return;
dragging = true; moved = 0;
startX = e.clientX; startLeft = wrap.scrollLeft;
});
wrap.addEventListener('pointermove', function (e) {
if (!dragging) return;
var dx = e.clientX - startX;
moved = Math.max(moved, Math.abs(dx));
if (moved <= 5) return;
// Capture only once it is a real drag: capturing on pointerdown sends the
// click to .gal-wrap instead of the photo, so the lightbox never opened.
if (!wrap.classList.contains('is-dragging')) {
wrap.classList.add('is-dragging');
try { wrap.setPointerCapture(e.pointerId); } catch (err) {}
}
wrap.scrollLeft = startLeft - dx;
});
// The tiles are links, which the browser drags natively (cancelling our drag).
wrap.addEventListener('dragstart', function (e) { e.preventDefault(); });
function endDrag(e) {
if (!dragging) return;
dragging = false;
wrap.classList.remove('is-dragging');
try { wrap.releasePointerCapture(e.pointerId); } catch (err) {}
resumeAt = performance.now() + 1200;
}
wrap.addEventListener('pointerup', endDrag);
wrap.addEventListener('pointercancel', endDrag);
// After a real drag, the click that follows must not open the lightbox.
wrap.addEventListener('click', function (e) {
if (moved > 5) { e.preventDefault(); e.stopPropagation(); moved = 0; }
}, true);
function tick(now) {
requestAnimationFrame(tick);
var dt = last ? Math.min(now - last, 100) : 16; last = now;
if (reduce || document.hidden || dragging || hold || now < resumeAt) { pos = null; return; }
// scrollLeft only keeps whole pixels, so a ~0.6px/frame step would round
// away: carry the fraction in our own float and write it back.
if (pos === null || Math.abs(wrap.scrollLeft - pos) > 2) pos = wrap.scrollLeft;
pos += SPEED * dt / 1000;
wrap.scrollLeft = pos;
}
requestAnimationFrame(tick);
});
});