feat(equipa): o cartão de detalhe nasce da foto, estica e segue o rato

O cartão abre por cima da própria foto (mesmo sítio, tamanho e inclinação),
endireita-se e estica para o lado — para a esquerda nas fotos da ponta
direita — com o texto a entrar por trás. Aberto, inclina-se em 3D e desliza
uns píxeis na direção do rato; ao sair volta a dobrar-se para dentro da foto.
No telemóvel voa da foto para o centro (FLIP) e volta ao fechar.

- clip-path em px calculados no JS: o Chrome não interpola inset() com
  calc(100% - …) e o recorte saltava em vez de esticar.
- O cartão é posicionado sem transição antes de abrir; senão a transição do
  estado fechado arrancava primeiro e a abertura partia de valores antigos.
- Tira o «Saber mais» do cartão da equipa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-24 21:28:32 +01:00
1 parent 1614bf327b
commit 90ee2a93bc
3 files changed
+237 -84

No files matched your search

+103 -48
View File
@@ -2962,7 +2962,7 @@ section[id] {
/* The name used to be the tile's last child and got its size from the
`.trainer-item>*:last-child` rule; pin it so it stays the same whether or
not a title / "Saber mais" follows it. */
not a title follows it. */
.trainers-section .trainer-item h4 {
font-size: .9rem;
}
@@ -2987,80 +2987,105 @@ section[id] {
outline: none;
}
.trainer-more {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: .55rem;
font-size: .72rem;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--fitme-ash);
transition: color var(--fitme-transition);
/* On tiles with a card, the card IS the hover effect: keep the photo in its
resting tilt (the card starts from exactly that pose) and hide it while the
card covers it, bringing it back only once the card has folded back in. */
#powerRotation>div:nth-child(odd) .trainer-item.has-detail .trainer-pic:hover { transform: rotate(-3deg); }
#powerRotation>div:nth-child(even) .trainer-item.has-detail .trainer-pic:hover { transform: rotate(3deg); }
.trainer-item.has-detail .trainer-pic {
transition: transform .4s cubic-bezier(.34, 1.56, .64, 1), box-shadow var(--fitme-transition), visibility 0s linear .38s;
}
.trainer-more i { font-size: .65rem; }
.trainer-item.has-detail:hover .trainer-more,
.trainer-item.has-detail:focus-visible .trainer-more,
.trainer-item.has-detail.is-open .trainer-more {
color: var(--fitme-accent);
.trainer-item.has-detail.is-open .trainer-pic {
visibility: hidden;
transition-delay: 0s;
}
@media (max-width: 767px), (hover: none) {
/* Touch: the card flies to the centre, so leave the tile photo in place. */
.trainer-item.has-detail.is-open .trainer-pic { visibility: visible; }
}
/* ── Detail card: grows out of the tile's own photo ─────────────────────────
main.js places the card so its photo column sits exactly on the tile photo
(--td-x/--td-y, size --td-pw/--td-ph) with the tile's tilt (--td-rot). Closed,
the clip-path shows only that photo square, so it reads as the tile itself;
opening straightens it and unclips it sideways — the card "stretches" out of
the photo and the text fades in behind the edge. .td-flip mirrors it for
right-hand tiles (photo on the right, card stretching left). While open, the
mouse position feeds a small 3D tilt + drift (--td-rx/--td-ry/--td-mx/--td-my).
Both transform lists have the same functions so they interpolate. */
.trainer-detail {
position: absolute;
z-index: 30;
top: -10px;
left: 50%;
width: min(560px, calc(100vw - 32px));
transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(8px);
left: var(--td-x, 0px);
top: var(--td-y, 0px);
width: var(--td-w, 560px);
min-height: var(--td-ph, 200px);
display: flex;
text-align: left;
background: #fff;
color: var(--fitme-text);
border-radius: 6px;
border-left: 4px solid var(--fitme-accent);
box-shadow: 0 24px 60px rgba(0, 0, 0, .45);
overflow: hidden;
/* px from main.js: Chrome won't interpolate inset() with calc(100% - …). */
clip-path: inset(0px var(--td-cx, 360px) var(--td-cy, 0px) 0px round 2px);
transform-origin: calc(var(--td-pw, 200px) / 2) calc(var(--td-ph, 200px) / 2);
transform: perspective(900px) rotateX(0deg) rotateY(0deg) translate(0px, 0px) rotate(var(--td-rot, 0deg));
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity .2s ease, transform .2s ease, visibility 0s linear .2s;
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;
}
.trainer-detail.td-flip {
flex-direction: row-reverse;
clip-path: inset(0px 0px var(--td-cy, 0px) var(--td-cx, 360px) round 2px);
transform-origin: calc(100% - var(--td-pw, 200px) / 2) calc(var(--td-ph, 200px) / 2);
}
.trainer-item.is-open .trainer-detail {
clip-path: inset(0px 0px 0px 0px round 6px);
transform: perspective(900px) rotateX(var(--td-rx, 0deg)) rotateY(var(--td-ry, 0deg)) translate(var(--td-mx, 0px), var(--td-my, 0px)) rotate(0deg);
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(0);
transition: opacity .2s ease, transform .2s ease, visibility 0s;
transition:
clip-path .55s cubic-bezier(.2, .9, .25, 1),
transform .6s cubic-bezier(.34, 1.35, .64, 1),
opacity 0s,
visibility 0s;
}
@media (hover: hover) and (pointer: fine) {
.trainer-item.has-detail:hover .trainer-detail,
.trainer-item.has-detail:focus-within .trainer-detail {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(0);
transition: opacity .2s ease .12s, transform .2s ease .12s, visibility 0s .12s;
}
.trainer-item.has-detail:hover { z-index: 31; }
/* After the opening move, follow the mouse with a short, soft transition. */
.trainer-item.is-open .trainer-detail.td-tracking {
transition:
clip-path .55s cubic-bezier(.2, .9, .25, 1),
transform .18s ease-out,
opacity 0s,
visibility 0s;
}
.trainer-item.is-open { z-index: 31; }
.td-photo {
flex: 0 0 38%;
max-width: 210px;
background: var(--fitme-dark-2);
flex: 0 0 var(--td-pw, 200px);
box-sizing: border-box;
border: 4px solid var(--fitme-accent);
background: var(--fitme-surface-2);
align-self: stretch;
}
.td-photo img {
width: 100%;
height: 100%;
min-height: 220px;
min-height: calc(var(--td-ph, 200px) - 8px);
object-fit: cover;
object-position: top;
display: block;
@@ -3071,6 +3096,17 @@ section[id] {
padding: 18px 22px 18px 20px;
max-height: 340px;
overflow-y: auto;
opacity: 0;
transform: translateX(-10px);
transition: opacity .12s ease, transform .12s ease;
}
.td-flip .td-body { transform: translateX(10px); }
.trainer-item.is-open .td-body {
opacity: 1;
transform: none;
transition: opacity .3s ease .18s, transform .35s ease .18s;
}
.td-name {
@@ -3148,20 +3184,32 @@ section[id] {
display: none;
}
/* Touch / small screens: a centred fixed card over a dimmed page. */
/* Touch / small screens: a centred fixed card over a dimmed page. The "grows
out of the tile" move is done in main.js with a FLIP animation instead of
the clip-path, since the card no longer sits on top of the tile. */
@media (max-width: 767px), (hover: none) {
.trainer-detail {
.trainer-detail,
.trainer-detail.td-flip {
position: fixed;
top: 50%;
top: var(--td-top, 20vh);
left: 16px;
right: 16px;
width: auto;
max-width: 560px;
min-height: 0;
margin: 0 auto;
transform: translateY(-46%);
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;
}
.trainer-item.is-open .trainer-detail {
clip-path: none;
transform: none;
transition: opacity 0s, visibility 0s;
}
.trainer-item.is-open .trainer-detail { transform: translateY(-50%); }
.trainer-item.is-open:after {
content: '';
position: fixed;
@@ -3170,17 +3218,24 @@ section[id] {
background: rgba(0, 0, 0, .55);
}
.td-close { display: block; }
.td-photo { flex-basis: 34%; }
.td-photo { flex-basis: 34%; border-width: 3px; }
.td-photo img { min-height: 180px; }
.td-body { max-height: calc(100vh - 48px); padding: 16px 16px 16px 14px; }
.td-body,
.td-flip .td-body { max-height: calc(100vh - 48px); padding: 16px 16px 16px 14px; transform: none; }
}
@media (max-width: 339px) {
.trainer-detail { flex-direction: column; }
.trainer-detail,
.trainer-detail.td-flip { flex-direction: column; }
.td-photo { max-width: none; flex-basis: auto; }
.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
══════════════════════════════════════════════════════════════════════════ */
+134 -35
View File
@@ -224,36 +224,105 @@ document.addEventListener('DOMContentLoaded', function () {
});
/* ── Equipa: horizontal detail card ─────────────────────────────────────────
Desktop (fine pointer) opens it on hover purely in CSS; this handles tap /
click / keyboard (.is-open, one at a time; Esc, the × and outside clicks
close) and nudges the absolutely-positioned card sideways (--td-shift) so an
edge column's card stays inside the viewport. On small / touch screens the
CSS turns it into a centred fixed card, so no shift is applied there. */
/* ── 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');
if (!card) return;
card.style.setProperty('--td-shift', '0px');
if (floating.matches) return;
var r = card.getBoundingClientRect();
var itemR = item.getBoundingClientRect();
// Where the card would sit centred on the item (ignoring its current transform).
var left = itemR.left + itemR.width / 2 - r.width / 2;
var right = left + r.width;
var gutter = 16, vw = document.documentElement.clientWidth, shift = 0;
if (left < gutter) shift = gutter - left;
else if (right > vw - gutter) shift = (vw - gutter) - right;
card.style.setProperty('--td-shift', Math.round(shift) + 'px');
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');
}
var section = document.getElementById('equipa');
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 || reduced.matches) 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) {
item.classList.toggle('is-open', 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) {
@@ -261,35 +330,65 @@ document.addEventListener('DOMContentLoaded', function () {
}
items.forEach(function (item) {
item.addEventListener('mouseenter', function () { place(item); });
item.addEventListener('focus', function () { place(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 || reduced.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; }
// Clicks inside an open card (e.g. scrolling / selecting text) keep it open.
if (e.target.closest('.trainer-detail') && item.classList.contains('is-open')) return;
// Tapping the dimmed backdrop (the item's ::after) closes it.
if (floating.matches && item.classList.contains('is-open') && !e.target.closest('.trainer-detail')) {
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);
place(item);
setOpen(item, !item.classList.contains('is-open'));
setOpen(item, true);
});
item.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
if (e.target !== item) return;
if (e.key === 'Escape') { setOpen(item, false); item.blur(); }
else if ((e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') && e.target === item) {
e.preventDefault();
closeAll(item); place(item);
setOpen(item, !item.classList.contains('is-open'));
} else if (e.key === 'Escape') {
setOpen(item, false);
item.blur();
}
});
});
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: pre-select the clicked card in the sign-up modal ───────── */
-1
View File
@@ -140,7 +140,6 @@ if ($team):
<h4><?= htmlspecialchars((string) $d['name']) ?></h4>
<?php if ($t_title !== ''): ?><p class="trainer-title"><?= htmlspecialchars($t_title) ?></p><?php endif; ?>
<?php if ($t_detail): ?>
<span class="trainer-more" aria-hidden="true">Saber mais <i class="fas fa-plus"></i></span>
<div class="trainer-detail" id="<?= $t_id ?>">
<button type="button" class="td-close" aria-label="Fechar">&times;</button>
<div class="td-photo"><img loading="lazy" src="<?= $t_img ?>" alt=""></div>