feat(modalidades): faixa rolável, cartão de detalhe como o da equipa, ícones novos

- A faixa de modalidades passa a ser um scroller a sério: toque, trackpad,
  roda do rato, arrastar e setas; continua a deslizar sozinha (~20px/s) e
  pára enquanto alguém interage, retomando 2,5s depois. Loop infinito sobre
  uma cópia do conjunto (a posição é mantida em float — scrollLeft += 0.3
  arredondava para zero).
- Em vez do «Saber mais», o cartão com descrição abre um painel que nasce do
  próprio cartão e estica para o lado (para a esquerda junto à margem
  direita), com inclinação 3D a seguir o rato; no telemóvel voa para o
  centro com o fundo escurecido. O painel vive fora do scroller para o
  overflow não o cortar.
- 14 ícones novos (SVG, traço 3) em uploads/modalidades/icons/fitme-*.svg.
- Títulos dos cartões em <h3>, h2#modalidades-title + aria-labelledby; sem
  ícone não se gera <img> vazio.
- #modalidades entra só com opacidade: o transform deixado pela animação de
  entrada prendia o fundo escurecido (position:fixed) dentro da secção.
- prefers-reduced-motion: os cartões de detalhe (equipa e modalidades)
  ficam fora da regra global que reduzia as transições a 0,01ms — com ela
  (ativa por defeito em Remote Desktop / Windows sem animações) o cartão
  aparecia de repente em vez de esticar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-24 21:47:17 +01:00
1 parent 8ebf20f52f
commit ee3fe69afb
17 files changed
+586 -170

No files matched your search

+266 -103
View File
@@ -2248,20 +2248,17 @@ h3.contact-title {
opacity: 1;
}
*,
/* The team / modalidade detail cards are exempt: reduced-motion is on by
default over Remote Desktop and with Windows "animation effects" off, and
this nuke turned their grow-out-of-the-card move into a jarring pop. They
only move when the visitor asks (hover / tap) and settle in ~0.6s. */
*:not(.trainer-detail):not(.td-body):not(.mod-detail):not(.md-body),
*:before,
*:after {
animation-duration: .01ms !important;
transition-duration: .01ms !important;
}
/* Exempt the modalidades marquee: the .01ms nuke turns its infinite loop
into a strobing blur (reduced-motion is on by default over Remote Desktop
and with Windows "animation effects" off). Keep its gentle glide instead;
JS re-asserts the measured duration inline with !important. */
.mod-track {
animation-duration: 220s !important;
}
}
/* ── Decorated section title (eyebrow + accent underline + subtitle) ────────── */
@@ -2307,136 +2304,305 @@ h3.contact-title {
font-size: 1rem;
}
/* ── Modalidades: tap card to reveal its description ───────────────────────── */
/* No separate icon — the whole card (icon + name) is the trigger. The
description reveals as a clean panel laid over the card footprint, so the
grid never reflows. Click-only (no hover), per request. */
.mod-card {
/* ── Modalidades: scrollable strip + detail card ─────────────────────────────
.mod-scroll is a real horizontal scroller (touch, trackpad, wheel, mouse
drag, arrow buttons). main.js glides it on its own until someone interacts,
and loops it seamlessly over a cloned second set of cards. */
/* content-visibility:auto implies paint containment, which would clip the
detail card at the section edge and trap position:fixed on touch. */
#modalidades.service-section {
content-visibility: visible;
contain: none;
position: relative;
z-index: 2;
}
#modalidades.has-open-detail { z-index: 1100; }
.mod-strip {
position: relative;
}
.mod-card.has-info {
cursor: pointer;
/* Soft fades at both ends, under the arrow buttons. */
.mod-strip:before,
.mod-strip:after {
content: '';
position: absolute;
top: 0;
bottom: 0;
z-index: 2;
width: 90px;
pointer-events: none;
}
.mod-strip:before { left: 0; background: linear-gradient(90deg, var(--fitme-white), rgba(255, 255, 255, 0)); }
.mod-strip:after { right: 0; background: linear-gradient(270deg, var(--fitme-white), rgba(255, 255, 255, 0)); }
@media (max-width: 767px), (hover: none) {
.mod-strip:before, .mod-strip:after { width: 28px; }
}
.mod-scroll {
display: flex;
overflow-x: auto;
overflow-y: hidden;
padding: 10px 0 14px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
cursor: grab;
outline: none;
}
.mod-card.has-info .ib-icon,
.mod-card.has-info h4 {
transition: opacity var(--fitme-transition);
.mod-scroll::-webkit-scrollbar { display: none; }
.mod-scroll.is-dragging { cursor: grabbing; user-select: none; }
.mod-scroll.is-dragging .mod-card { pointer-events: none; }
/* Trailing margin (not flex gap) keeps both copies the same width, so the
loop jump from the clone back to the original is invisible. */
.mod-scroll .mod-card {
flex: 0 0 auto;
width: 180px;
min-height: 200px;
margin: 0 1rem 0 0;
position: relative;
}
.mod-card.has-info:hover h4 {
color: var(--fitme-accent-dark);
}
.mod-scroll .mod-card:first-child { margin-left: 1rem; }
.mod-card.has-info { cursor: pointer; outline: none; }
.mod-card.has-info:hover h3 { color: var(--fitme-accent-dark); }
.mod-card.has-info:focus-visible {
box-shadow: 0 0 0 3px rgba(var(--fitme-accent-rgb), .35);
border-radius: var(--fitme-radius);
}
/* Subtle "tap for more" affordance under the name */
.mod-card.has-info h4:after {
content: 'Saber mais';
display: block;
margin-top: .35rem;
font-size: .68rem;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--fitme-accent-dark);
opacity: .85;
}
/* The card whose detail is open is covered by the panel — hide it so the
panel reads as the card itself. */
.mod-card.is-source { visibility: hidden; }
.mod-tooltip {
/* Arrow buttons (desktop). Faded edges hint there is more to either side. */
.mod-nav {
position: absolute;
inset: -10px -6px auto -6px;
z-index: 6;
min-height: calc(100% + 20px);
top: 50%;
z-index: 3;
width: 44px;
height: 44px;
margin-top: -30px;
border: 0;
border-radius: 50%;
background: var(--fitme-dark-2);
color: var(--fitme-accent);
box-shadow: var(--fitme-shadow-lg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
color: var(--fitme-text-soft);
border: 1px solid var(--fitme-surface-2);
border-top: 3px solid var(--fitme-accent);
border-radius: var(--fitme-radius);
box-shadow: var(--fitme-shadow-lg);
padding: 1rem 1.1rem;
font-size: .85rem;
line-height: 1.5;
text-align: center;
opacity: 0;
visibility: hidden;
transform: scale(.96);
opacity: .9;
transition: opacity var(--fitme-transition), transform var(--fitme-transition);
}
.mod-tooltip span {
display: block;
.mod-nav:hover { opacity: 1; transform: scale(1.06); }
.mod-nav-prev { left: 14px; }
.mod-nav-next { right: 14px; }
@media (max-width: 767px), (hover: none) {
.mod-nav { display: none; }
.mod-scroll { cursor: auto; }
}
.mod-card.is-open .mod-tooltip {
/* ── Detail panel: grows out of the card ───────────────────────────────────
Same idea as the team card: main.js lays the panel so its left column
(.md-card, a replica of the card) sits exactly on the card, clipped to it;
opening unclips it sideways to reveal the description (.md-flip mirrors it
near the right edge). While open, the mouse drives a small 3D tilt. */
.mod-detail {
position: absolute;
z-index: 30;
left: var(--md-x, 0px);
top: var(--md-y, 0px);
width: var(--md-w, 540px);
min-height: var(--md-ch, 200px);
display: flex;
text-align: left;
background: #fff;
border-radius: 6px;
box-shadow: 0 24px 60px rgba(var(--fitme-dark-rgb), .28);
overflow: hidden;
clip-path: inset(0px var(--md-cx, 360px) var(--md-cy, 0px) 0px round 2px);
transform-origin: calc(var(--md-cw, 180px) / 2) 50%;
transform: perspective(900px) rotateX(0deg) rotateY(0deg) translate(0px, 0px) scale(1);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
clip-path .38s cubic-bezier(.4, 0, .2, 1),
transform .38s cubic-bezier(.4, 0, .2, 1),
opacity .12s ease .26s,
visibility 0s linear .38s;
}
.mod-detail.md-flip {
flex-direction: row-reverse;
clip-path: inset(0px 0px var(--md-cy, 0px) var(--md-cx, 360px) round 2px);
transform-origin: calc(100% - var(--md-cw, 180px) / 2) 50%;
}
.mod-detail.is-open {
clip-path: inset(0px 0px 0px 0px round 6px);
transform: perspective(900px) rotateX(var(--md-rx, 0deg)) rotateY(var(--md-ry, 0deg)) translate(var(--md-mx, 0px), var(--md-my, -6px)) scale(1.02);
opacity: 1;
visibility: visible;
transform: scale(1);
pointer-events: auto;
transition:
clip-path .55s cubic-bezier(.2, .9, .25, 1),
transform .6s cubic-bezier(.34, 1.35, .64, 1),
opacity 0s,
visibility 0s;
}
.mod-card.is-open .ib-icon,
.mod-card.is-open h4 {
opacity: 0;
.mod-detail.is-open.md-tracking {
transition:
clip-path .55s cubic-bezier(.2, .9, .25, 1),
transform .18s ease-out,
opacity 0s,
visibility 0s;
}
/* Full-width auto-scrolling modalidade marquee */
.mod-scroll {
overflow: hidden;
padding: 8px 0 10px;
}
.mod-track {
/* Left column: looks exactly like the card it came from. */
.md-card {
flex: 0 0 var(--md-cw, 180px);
min-height: var(--md-ch, 200px);
box-sizing: border-box;
display: flex;
width: max-content;
animation: modMarquee 220s linear infinite;
/* slow fallback; JS tunes it to ~18px/s */
will-change: transform;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: var(--md-pad, 0px);
text-align: center;
background: #fff;
border-right: 4px solid var(--md-color, var(--fitme-accent));
}
/* Pause while hovering, focusing inside, or reading an open card */
.mod-scroll:hover .mod-track,
.mod-scroll:focus-within .mod-track,
.mod-scroll.is-paused .mod-track {
animation-play-state: paused;
.md-flip .md-card { border-right: 0; border-left: 4px solid var(--md-color, var(--fitme-accent)); }
.md-card .ib-icon {
width: 72px;
height: 72px;
border-radius: var(--fitme-radius);
display: flex;
align-items: center;
justify-content: center;
background: var(--fitme-accent);
margin: 0 auto 1.5rem;
}
/* Trailing margin (not flex gap) keeps every copy the same width, so a
translateX(-50%) of the duplicated track loops perfectly seamlessly. */
.mod-scroll .mod-card {
flex: 0 0 auto;
width: 180px;
min-height: 200px;
margin: 0 1rem 0 0;
.md-card .ib-icon img { max-width: 44px; max-height: 44px; }
.md-card h3 {
font-size: 1.15rem;
margin: 0 .5rem;
color: var(--fitme-accent-dark);
}
@keyframes modMarquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
/* Keep the reveal panel inside the card footprint so it isn't clipped by the
scroller's overflow (which can't be visible on one axis only). Top-align +
scroll so long descriptions don't get their first lines hidden by vertical
centering (you can't scroll back above a centered overflowing flex item). */
.mod-scroll .mod-tooltip {
inset: 0;
min-height: 0;
.md-body {
flex: 1 1 auto;
padding: 20px 24px 20px 22px;
max-height: 320px;
overflow-y: auto;
align-items: flex-start;
opacity: 0;
transform: translateX(-10px);
transition: opacity .12s ease, transform .12s ease;
}
.md-flip .md-body { transform: translateX(10px); }
.mod-detail.is-open .md-body {
opacity: 1;
transform: none;
transition: opacity .3s ease .18s, transform .35s ease .18s;
}
.md-name {
display: block;
font-size: 1.2rem;
font-weight: 900;
text-transform: uppercase;
color: var(--fitme-dark);
margin-bottom: .5rem;
padding-right: 18px;
}
.md-text {
font-size: .9rem;
line-height: 1.55;
color: var(--fitme-text-soft);
}
.md-text p:last-child { margin-bottom: 0; }
.md-close {
position: absolute;
top: 6px;
right: 10px;
z-index: 2;
background: transparent;
border: 0;
font-size: 1.6rem;
line-height: 1;
color: var(--fitme-text-muted);
cursor: pointer;
display: none;
}
/* Touch / narrow: a centred fixed card over a dimmed page; main.js flies it
out of the card with a FLIP animation. */
@media (max-width: 767px), (hover: none) {
.mod-detail,
.mod-detail.md-flip {
position: fixed;
top: var(--md-top, 20vh);
left: 16px;
right: 16px;
width: auto;
max-width: 540px;
min-height: 0;
margin: 0 auto;
max-height: calc(100vh - 48px);
flex-direction: row;
clip-path: none;
transform: none;
transform-origin: top left;
transition: opacity .15s ease, visibility 0s linear .15s;
}
.mod-detail.is-open {
clip-path: none;
transform: none;
transition: opacity 0s, visibility 0s;
}
.mod-detail-backdrop {
position: fixed;
inset: 0;
z-index: 29;
background: rgba(0, 0, 0, .55);
}
.md-close { display: block; }
.md-card { flex-basis: 120px; min-height: 0; padding: 18px 6px; justify-content: center; }
.md-flip .md-card { border-left: 0; border-right: 4px solid var(--md-color, var(--fitme-accent)); }
.md-card .ib-icon { width: 60px; height: 60px; margin-bottom: .8rem; }
.md-card h3 { font-size: .95rem; }
.md-body,
.md-flip .md-body { max-height: calc(100vh - 48px); padding: 16px 16px 16px 14px; transform: none; }
}
@media (max-width: 339px) {
.mod-detail, .mod-detail.md-flip { flex-direction: column; }
.md-card { flex-basis: auto; border-right: 0; border-bottom: 4px solid var(--md-color, var(--fitme-accent)); }
}
/* ── Horário "save as JPG" button + capture-clone export board ─────────────── */
.schedule-dl-btn {
display: inline-flex;
@@ -3110,7 +3276,8 @@ h3.contact-title {
position:fixed descendants — which would pin the mobile detail card inside
its own tile. Reveal the team with opacity only. */
#equipa.revealed,
#equipa .trainer-item.revealed {
#equipa .trainer-item.revealed,
#modalidades.revealed {
animation-name: fitmeFadeOnly;
}
@@ -3395,10 +3562,6 @@ h3.contact-title {
.td-photo img { min-height: 0; height: 160px; }
}
@media (prefers-reduced-motion: reduce) {
.trainer-detail,
.trainer-item.is-open .trainer-detail { transition-duration: .01ms !important; }
}
/* ══════════════════════════════════════════════════════════════════════════
Mensalidades — cartões de preço
+277 -57
View File
@@ -80,75 +80,295 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
/* ── Modalidades: clickable cards + CSS-animated marquee ────────────────────
Cards reveal their description on click (one open at a time; Esc / outside
click closes; Enter/Space for keyboard users). For the auto-scroll we move
the cards into an inner .mod-track, duplicate the set once, and let a CSS
transform animation glide it from 0 to -50% forever — a GPU-driven loop that
actually moves (the old scrollLeft approach was defeated by sub-pixel
rounding). It pauses on hover / focus and while a card is open. */
/* ── 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 scroller = document.querySelector('.mod-scroll');
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)');
// Build the animated track: move originals in, then append a hidden clone set.
if (scroller && !scroller.querySelector('.mod-track')) {
var track = document.createElement('div');
track.className = 'mod-track';
Array.prototype.slice.call(scroller.querySelectorAll('.mod-card'))
.forEach(function (c) { track.appendChild(c); });
Array.prototype.slice.call(track.children).forEach(function (c) {
// ── 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.removeAttribute('id');
track.appendChild(clone);
});
scroller.appendChild(track);
// Gentle, constant ~18px/s regardless of how many modalidades there are.
// Measure after a frame so layout (card widths) is settled. Set with
// !important so the reduced-motion .01ms!important override (active
// over Remote Desktop / Windows "animation effects" off) can't shrink
// the duration and turn the loop into a strobing blur.
requestAnimationFrame(function () {
var oneCopy = track.scrollWidth / 2;
var dur = oneCopy > 0 ? Math.max(60, Math.round(oneCopy / 18)) : 220;
track.style.setProperty('animation-duration', dur + 's', 'important');
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; }
}
var cards = document.querySelectorAll('.mod-card.has-info');
if (!cards.length) return;
function anyOpen() { return !!document.querySelector('.mod-card.is-open'); }
function syncPause() { if (scroller) scroller.classList.toggle('is-paused', anyOpen()); }
function closeAll(except) {
cards.forEach(function (card) {
if (card === except) return;
card.classList.remove('is-open');
card.setAttribute('aria-expanded', 'false');
});
// ── 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(); }
cards.forEach(function (card) {
card.addEventListener('click', function (e) {
e.stopPropagation();
var open = card.classList.toggle('is-open');
card.setAttribute('aria-expanded', open ? 'true' : 'false');
closeAll(card);
syncPause();
});
card.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
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();
card.click();
} else if (e.key === 'Escape') {
card.classList.remove('is-open');
card.setAttribute('aria-expanded', 'false');
syncPause();
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();
});
document.addEventListener('click', function () { closeAll(null); syncPause(); });
// 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 img = card.querySelector('.ib-icon img');
var name = card.querySelector('h3').textContent;
var tpl = card.querySelector('template.mod-desc');
var pImg = panel.querySelector('.md-card img');
pImg.hidden = !img;
pImg.src = img ? img.src : '';
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');
}
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 {
panel.style.transition = 'none';
place(card);
void panel.offsetWidth;
panel.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 ─────────────────────────────────────────────────────
@@ -307,7 +527,7 @@ document.addEventListener('DOMContentLoaded', function () {
function flip(item, opening) {
var card = item.querySelector('.trainer-detail');
var pic = item.querySelector('.trainer-pic');
if (!card || !pic || !card.animate || reduced.matches) return null;
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(' +
@@ -376,7 +596,7 @@ document.addEventListener('DOMContentLoaded', function () {
// Tilt + drift toward the pointer while open (desktop only).
item.addEventListener('mousemove', function (e) {
if (floating.matches || reduced.matches || !item.classList.contains('is-open')) return;
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)));
+29 -10
View File
@@ -170,33 +170,52 @@ if ($team):
// ── Modalidades ─────────────────────────────────────────────────────────────────
if ($modalidades):
?>
<section id="modalidades" class="service-section">
<section id="modalidades" class="service-section" aria-labelledby="modalidades-title">
<div class="container">
<div class="row justify-content-md-center mb-4">
<div class="col-lg-8">
<div class="section-title text-center deco">
<span class="section-eyebrow">Treinos</span>
<h2>Modalidades</h2>
<h2 id="modalidades-title">Modalidades</h2>
<p class="section-subtitle">Encontra a modalidade certa para ti.</p>
</div>
</div>
</div>
</div>
<!-- Full-width horizontal scroll of modalidade cards -->
<div class="mod-scroll">
<?php foreach($modalidades as $d): $desc = trim((string) ($d['descricao'] ?? '')); ?>
<div class="icon-box-item mod-card<?= $desc !== '' ? ' has-info' : '' ?>"<?= $desc !== '' ? ' role="button" tabindex="0" aria-expanded="false" aria-label="Ver descrição de ' . htmlspecialchars((string) $d['nome']) . '"' : '' ?>>
<div class="ib-icon fade-in">
<img loading="lazy" src="<?= site_img_url($d['icon'], 'uploads/modalidades/icons') ?>" alt="<?= htmlspecialchars((string) $d['nome']) ?>">
<?php /* Full-width strip of modalidade cards. It is a real horizontal
scroller (touch, trackpad/wheel, mouse drag, arrows) that also
glides on its own until someone interacts (main.js). A card with a
description opens .mod-detail — one shared panel outside the
scroller, so it isn't clipped — which grows out of the card. */ ?>
<div class="mod-strip">
<button type="button" class="mod-nav mod-nav-prev" aria-label="Modalidades anteriores"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>
<div class="mod-scroll" tabindex="0" aria-label="Modalidades — desliza para ver mais">
<?php foreach($modalidades as $d): $desc = trim(strip_tags((string) ($d['descricao'] ?? ''))) !== '' ? trim((string) $d['descricao']) : ''; ?>
<div class="icon-box-item mod-card<?= $desc !== '' ? ' has-info' : '' ?>" data-color="<?= htmlspecialchars((string) $d['cor']) ?>"<?= $desc !== '' ? ' role="button" tabindex="0" aria-haspopup="dialog" aria-label="Ver descrição de ' . htmlspecialchars((string) $d['nome']) . '"' : '' ?>>
<div class="ib-icon">
<?php if (trim((string) $d['icon']) !== ''): ?><img loading="lazy" draggable="false" src="<?= site_img_url($d['icon'], 'uploads/modalidades/icons') ?>" alt=""><?php endif; ?>
</div>
<h4><?= htmlspecialchars((string) $d['nome']) ?></h4>
<h3><?= htmlspecialchars((string) $d['nome']) ?></h3>
<?php if ($desc !== ''): ?>
<?php /* descricao is admin-authored rich text — sanitised allowlist HTML */ ?>
<div class="mod-tooltip" role="tooltip"><span><?= clean_richtext($desc) ?></span></div>
<template class="mod-desc"><?= clean_richtext($desc) ?></template>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="mod-nav mod-nav-next" aria-label="Mais modalidades"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
</div>
<div class="mod-detail" role="dialog" aria-modal="false" aria-hidden="true">
<button type="button" class="md-close" aria-label="Fechar">&times;</button>
<div class="md-card">
<div class="ib-icon"><img src="" alt="" hidden></div>
<h3></h3>
</div>
<div class="md-body">
<strong class="md-name"></strong>
<div class="md-text"></div>
</div>
</div>
</section>
<?php endif; ?>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="M15 21V8h18v13"></path><path fill="currentColor" fill-rule="evenodd" d="M19 17h10l7 7v10l-7 7H19l-7-7V24zM16.5 22h15v7h-4v9h-7v-9h-4z"></path></svg>

After

Width:  |  Height:  |  Size: 356 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="M15 21V8h18v13"></path><path fill="currentColor" d="M19 17h10l7 7v10l-7 7H19l-7-7V24z"></path></svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><circle cx="34" cy="30" r="7"></circle><circle fill="currentColor" cx="34" cy="30" r="2"></circle><path d="M13 12h10M18 12v18h16M18 20h13M31 12l3 18M30 12h6M18 30l-6 11M34 30l5 11M6 41h36"></path></svg>

After

Width:  |  Height:  |  Size: 401 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="M18 24h12"></path><path fill="currentColor" d="M12 18h6v12h-6zM30 18h6v12h-6z"></path><path d="M10 11V7h28v7M34 11l4 4 4-4"></path><path d="M38 37v4H10v-7M6 37l4-4 4 4"></path></svg>

After

Width:  |  Height:  |  Size: 390 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="M22 4l14 17v10l-8 10h-8l-8-10V20l6 5z"></path><path fill="currentColor" d="M24 27l4 6-1 8h-6l-1-8z"></path></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="M18 13h12l7 7v12l-7 7H18l-7-7V20z"></path><path fill="currentColor" d="M20 4h8v4h-8z"></path><path d="M24 8v5M24 26l5-5M37 12l3-3"></path></svg>

After

Width:  |  Height:  |  Size: 352 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><circle fill="currentColor" cx="11" cy="25" r="3.5"></circle><path d="M24 38l-9-8M24 38l8-11 8 11M16 29l9-3M4 41h40"></path></svg>

After

Width:  |  Height:  |  Size: 329 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><circle fill="currentColor" cx="12" cy="19" r="3.5"></circle><path d="M22 37l-7-12M22 37l16-15M15 26l11-9M4 41h40"></path></svg>

After

Width:  |  Height:  |  Size: 327 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><circle fill="currentColor" cx="24" cy="8" r="3.5"></circle><path d="M6 17h36M24 14v14M24 28l-11 13M24 28l9 3v10"></path></svg>

After

Width:  |  Height:  |  Size: 326 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="M14 8h16l6 6v14l-5 6H14l-4-4V12z"></path><path d="M10 21h8l3 5"></path><path fill="currentColor" d="M14 34h18v6H14z"></path></svg>

After

Width:  |  Height:  |  Size: 338 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path fill="currentColor" d="M24 8l6 11-6 11-6-11z"></path><path d="M19 30h-8L8 18l11 6"></path><path d="M29 30h8l3-12-11 6"></path><path d="M8 37h32"></path></svg>

After

Width:  |  Height:  |  Size: 363 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path fill="currentColor" d="M5 30h38l-3 6H8z"></path><path d="M11 36v5M37 36v5"></path><path d="M4 19h9l3-7 5 13 4-9 2 3h17"></path></svg>

After

Width:  |  Height:  |  Size: 338 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="M3 24h5M18 24h12M40 24h5"></path><path d="M8 17h5v14H8zM35 17h5v14h-5z"></path><path fill="currentColor" d="M13 12h5v24h-5zM30 12h5v24h-5z"></path></svg>

After

Width:  |  Height:  |  Size: 361 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><circle fill="currentColor" cx="21" cy="8" r="3.5"></circle><path d="M22 14l3 13M23 18l9-6 4-8M23 18l-10 5-3-6M25 27l-4 14M25 27l10 3 4 8"></path></svg>

After

Width:  |  Height:  |  Size: 351 B