Files
fitme3070.pt/site/assets/js/main.js
T
andreandClaude Opus 5.5 cb0e4540f2 feat(modalidades): 6 ícones em falta, ícones animados, ícone centrado no cartão aberto
- Ícones novos (Bootcamp, YourFit Core, Run3070, Total Workout, On Ramp, Jump)
  do mesmo conjunto; só o LPO continua sem ícone no ficheiro de design.
- Os ícones fitme-*.svg passam a ir embutidos (modalidade_icon_html): cada
  traço tem pathLength=1 e desenha-se ao aparecer, no hover e ao abrir o
  cartão; cada família tem um movimento (levantar, soco, respirar, saltar,
  chama, pedalar, correr). Outros ícones continuam <img>.
- No cartão de detalhe (desktop) o ícone e o nome deslizam até ao centro
  vertical da coluna.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:02:58 +01:00

698 lines
34 KiB
JavaScript
Raw 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 + 16) * Math.max(1, Math.floor(scroller.clientWidth / (card.offsetWidth + 16)) - 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 filter ─────────────────────────────────────────────────────
The on-screen schedule shows the chosen day (phones) or that day centred
between the day before and after (desktop ≥992px); a day-picker bar switches
days (each header + class cell is tagged with data-day). 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 filter = scope.querySelector('.schedule-day-filter');
var cells = scope.querySelectorAll('.schedule_cell, .schedule_dayhead');
if (!filter || !cells.length) return;
var buttons = filter.querySelectorAll('.sdf-btn');
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(buttons, function (b) { return b.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');
}
});
buttons.forEach(function (b) {
b.classList.toggle('active', b.getAttribute('data-day') === day);
});
// 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);
});
}
filter.addEventListener('click', function (e) {
var btn = e.target.closest('.sdf-btn');
if (btn) showDay(btn.getAttribute('data-day'));
});
// Clicking a side day's header centres it.
scope.addEventListener('click', function (e) {
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);
// Pre-select today when the schedule has a button 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 todayBtn = today ? filter.querySelector('.sdf-btn[data-day="' + today + '"]') : null;
var start = todayBtn || buttons[0];
if (start) showDay(start.getAttribute('data-day'));
});
/* ── Contact form: stretch the message box to meet the map ───────────────────
On desktop (≥992px, where the info+map column sits beside the form) the
Mensagem textarea grows so its bottom lines up with the bottom of the compact
map; the consent checkbox and submit button then follow just below it. Off on
mobile, where everything is stacked. */
document.addEventListener('DOMContentLoaded', function () {
var ta = document.querySelector('#contactos .contact-form textarea');
var map = document.querySelector('#contactos .map-side');
if (!ta || !map) return;
var mq = window.matchMedia('(min-width: 992px)');
function sync() {
if (!mq.matches) { ta.style.height = ''; return; }
ta.style.height = 'auto'; // reset before measuring
var taTop = ta.getBoundingClientRect().top;
var mapBottom = map.getBoundingClientRect().bottom;
ta.style.height = Math.max(150, Math.round(mapBottom - taTop)) + 'px';
}
sync();
window.addEventListener('load', sync); // re-measure once fonts/layout settle
if (mq.addEventListener) mq.addEventListener('change', sync);
var t;
window.addEventListener('resize', function () { clearTimeout(t); t = setTimeout(sync, 150); }, { passive: true });
});
/* ── 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-target="#joinModal"][data-membership]').forEach(function (btn) {
btn.addEventListener('click', function () { select.value = btn.getAttribute('data-membership'); });
});
});