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>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-24 22:02:58 +01:00
1 parent 42f5919915
commit cb0e4540f2
10 files changed
+186 -8

No files matched your search

+39
View File
@@ -137,3 +137,42 @@ function site_tel(?string $phone): string {
if ($p[0] !== '+' && str_starts_with($p, '00')) return '+' . substr($p, 2); if ($p[0] !== '+' && str_starts_with($p, '00')) return '+' . substr($p, 2);
return $p; return $p;
} }
// ─── Modalidade icon markup ───────────────────────────────────────────────────
// The FitMe icon set (uploads/modalidades/icons/fitme-*.svg, shipped in the repo
// — backoffice uploads are always named upl_*) is inlined so CSS can animate
// the strokes: every drawable element gets pathLength="1" for the draw-in, and
// the <svg> carries data-anim, a per-family movement (see style.css). Anything
// else (legacy PNGs, uploaded SVGs) stays a plain <img>.
function modalidade_icon_anim(string $slug): string {
$map = [
'lift' => ['yourfit-pump', 'crossfit', 'crossfit-teen-s', 'fbb', 'yourfit-core', 'total-workout'],
'punch' => ['yourfit-fight', 'bootcamp'],
'breathe' => ['yourfit-mind', 'yoga', 'pilates'],
'bounce' => ['zumba', 'jump', 'yourfit-pulse', 'hybrid'],
'flicker' => ['fitburn'],
'ride' => ['cycling'],
'run' => ['run3070', 'on-ramp', 'localizada'],
];
foreach ($map as $anim => $slugs) if (in_array($slug, $slugs, true)) return $anim;
return 'lift';
}
function modalidade_icon_html(?string $icon): string {
$icon = trim((string) $icon);
if ($icon === '') return '';
if (preg_match('/^fitme-([a-z0-9-]+)\.svg$/', $icon, $m)) {
$file = dirname(__DIR__) . '/uploads/modalidades/icons/' . $icon;
$svg = is_file($file) ? (string) file_get_contents($file) : '';
if (str_starts_with(ltrim($svg), '<svg')) {
// Belt and braces: no scripts / event handlers, whatever the file says.
$svg = preg_replace('~<script\b.*?</script>~is', '', $svg);
$svg = preg_replace('~\son\w+\s*=\s*("[^"]*"|\'[^\']*\')~i', '', $svg);
$svg = preg_replace('~\scolor="[^"]*"~', '', $svg, 1); // colour comes from CSS
$svg = preg_replace('~<(path|circle|rect|line|polyline|polygon|ellipse)\b~', '<$1 pathLength="1"', $svg);
$svg = preg_replace('~^\s*<svg\b~', '<svg class="mi" data-anim="' . modalidade_icon_anim($m[1]) . '" aria-hidden="true" focusable="false"', $svg, 1);
return $svg;
}
}
return '<img loading="lazy" draggable="false" src="' . htmlspecialchars(site_img_url($icon, 'uploads/modalidades/icons')) . '" alt="">';
}
+127
View File
@@ -3968,3 +3968,130 @@ h3.contact-title {
color: var(--fitme-ash); color: var(--fitme-ash);
opacity: .8; opacity: .8;
} }
/* ══════════════════════════════════════════════════════════════════════════
Modalidades — animated icons
The FitMe icon set is inlined as <svg class="mi" data-anim="…"> (see
modalidade_icon_html()). Every drawable element has pathLength="1", so a
stroke-dasharray of 1 lets the lines draw themselves: once when the card
scrolls into view, again on hover / focus, and in the detail panel as it
opens. On top of that each family has its own small movement:
lift — weights going up (Pump, CrossFit, FBB, Core, Total Workout)
punch — a jab (Fight, Bootcamp)
breathe — slow in / out (Mind, Yoga, Pilates)
bounce — hop with squash (Zumba, Jump, Pulse, Hybrid)
flicker — flame (FitBurn)
ride — pedalling rock (Cycling)
run — running bob (Run3070, On Ramp, Localizada)
══════════════════════════════════════════════════════════════════════════ */
.ib-icon svg.mi {
width: 44px;
height: 44px;
color: #141414;
overflow: visible;
transform-box: fill-box;
transform-origin: 50% 50%;
}
.mi [pathLength] {
stroke-dasharray: 1;
stroke-dashoffset: 0;
}
@keyframes mi-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@keyframes mi-draw-h { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
/* Draw-in once as the card appears (.revealed comes from the scroll observer). */
.mod-card.revealed .mi [pathLength] {
animation: mi-draw .9s cubic-bezier(.5, 0, .2, 1) both;
}
.mod-card.revealed .mi [pathLength]:nth-child(2) { animation-delay: .12s; }
.mod-card.revealed .mi [pathLength]:nth-child(3) { animation-delay: .24s; }
.mod-card.revealed .mi [pathLength]:nth-child(n+4) { animation-delay: .34s; }
/* Hover / focus / open panel: redraw (a second keyframe name restarts it). */
.mod-card:hover .mi [pathLength],
.mod-card:focus-visible .mi [pathLength],
.mod-detail.is-open .mi [pathLength] {
animation: mi-draw-h .6s cubic-bezier(.5, 0, .2, 1) both;
}
.mod-card:hover .mi [pathLength]:nth-child(2),
.mod-detail.is-open .mi [pathLength]:nth-child(2) { animation-delay: .08s; }
.mod-card:hover .mi [pathLength]:nth-child(n+3),
.mod-detail.is-open .mi [pathLength]:nth-child(n+3) { animation-delay: .16s; }
@keyframes mi-lift {
0%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
55% { transform: translateY(1px); }
75% { transform: translateY(-2px); }
}
@keyframes mi-punch {
0%, 100% { transform: translateX(0) rotate(0); }
25% { transform: translateX(-3px) rotate(-6deg); }
45% { transform: translateX(7px) rotate(4deg); }
65% { transform: translateX(-1px) rotate(0); }
}
@keyframes mi-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.13); }
}
@keyframes mi-bounce {
0%, 100% { transform: translateY(0) scale(1, 1); }
18% { transform: translateY(0) scale(1.12, .86); }
50% { transform: translateY(-9px) scale(.94, 1.07); }
80% { transform: translateY(0) scale(1.06, .94); }
}
@keyframes mi-flicker {
0%, 100% { transform: scale(1) skewX(0); }
20% { transform: scale(1.06, .93) skewX(-4deg); }
40% { transform: scale(.95, 1.09) skewX(3deg); }
60% { transform: scale(1.04, .97) skewX(-2deg); }
80% { transform: scale(.98, 1.05) skewX(2deg); }
}
@keyframes mi-ride {
0%, 100% { transform: translateX(0) rotate(0); }
25% { transform: translateX(-2px) rotate(-5deg); }
75% { transform: translateX(3px) rotate(3deg); }
}
@keyframes mi-run {
0%, 100% { transform: translateY(0) rotate(0); }
25% { transform: translateY(-4px) rotate(-6deg); }
50% { transform: translateY(0) rotate(0); }
75% { transform: translateY(-4px) rotate(6deg); }
}
.mod-card:hover .mi, .mod-card:focus-visible .mi, .mod-detail.is-open .mi {
animation-duration: .9s;
animation-timing-function: cubic-bezier(.45, 0, .25, 1);
animation-fill-mode: both;
animation-delay: .1s;
}
.mod-card:hover .mi[data-anim="lift"], .mod-card:focus-visible .mi[data-anim="lift"], .mod-detail.is-open .mi[data-anim="lift"] { animation-name: mi-lift; }
.mod-card:hover .mi[data-anim="punch"], .mod-card:focus-visible .mi[data-anim="punch"], .mod-detail.is-open .mi[data-anim="punch"] { animation-name: mi-punch; animation-duration: .7s; animation-iteration-count: 2; }
.mod-card:hover .mi[data-anim="breathe"], .mod-card:focus-visible .mi[data-anim="breathe"], .mod-detail.is-open .mi[data-anim="breathe"] { animation-name: mi-breathe; animation-duration: 1.8s; animation-iteration-count: 2; }
.mod-card:hover .mi[data-anim="bounce"], .mod-card:focus-visible .mi[data-anim="bounce"], .mod-detail.is-open .mi[data-anim="bounce"] { animation-name: mi-bounce; animation-duration: .75s; animation-iteration-count: 2; }
.mod-card:hover .mi[data-anim="flicker"], .mod-card:focus-visible .mi[data-anim="flicker"], .mod-detail.is-open .mi[data-anim="flicker"] { animation-name: mi-flicker; animation-iteration-count: 3; transform-origin: 50% 100%; }
.mod-card:hover .mi[data-anim="ride"], .mod-card:focus-visible .mi[data-anim="ride"], .mod-detail.is-open .mi[data-anim="ride"] { animation-name: mi-ride; animation-duration: .6s; animation-iteration-count: 3; }
.mod-card:hover .mi[data-anim="run"], .mod-card:focus-visible .mi[data-anim="run"], .mod-detail.is-open .mi[data-anim="run"] { animation-name: mi-run; animation-duration: .6s; animation-iteration-count: 3; }
/* Detail panel (desktop): icon + name start where they sit on the card, then
glide to the vertical centre of the taller panel column as it opens
(--md-pad-open from main.js). Phones centre it with their own layout. */
@media (min-width: 768px) and (hover: hover) {
.md-card {
transition: padding-top .38s cubic-bezier(.4, 0, .2, 1);
}
.mod-detail.is-open .md-card {
padding-top: var(--md-pad-open, var(--md-pad, 0px));
transition: padding-top .55s cubic-bezier(.2, .9, .25, 1) .05s;
}
}
@media (max-width: 767px) {
.ib-icon svg.mi { width: 34px; height: 34px; }
}
@media (prefers-reduced-motion: reduce) {
.mi, .mi [pathLength] { animation: none !important; }
}
+12 -6
View File
@@ -220,12 +220,12 @@ document.addEventListener('DOMContentLoaded', function () {
function panelOpen() { return panel.classList.contains('is-open'); } function panelOpen() { return panel.classList.contains('is-open'); }
function fill(card) { function fill(card) {
var img = card.querySelector('.ib-icon img');
var name = card.querySelector('h3').textContent; var name = card.querySelector('h3').textContent;
var tpl = card.querySelector('template.mod-desc'); var tpl = card.querySelector('template.mod-desc');
var pImg = panel.querySelector('.md-card img'); // Same icon markup as the card (inline animated SVG or <img>), so the
pImg.hidden = !img; // panel's copy replays its animation as it opens.
pImg.src = img ? img.src : ''; 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-card h3').textContent = name;
panel.querySelector('.md-name').textContent = name; panel.querySelector('.md-name').textContent = name;
panel.querySelector('.md-text').innerHTML = tpl ? tpl.innerHTML : ''; panel.querySelector('.md-text').innerHTML = tpl ? tpl.innerHTML : '';
@@ -250,6 +250,11 @@ document.addEventListener('DOMContentLoaded', function () {
panel.style.setProperty('--md-y', (cr.top - sr.top) + '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-cx', Math.max(0, w - cr.width) + 'px');
panel.style.setProperty('--md-cy', Math.max(0, panel.offsetHeight - cr.height) + '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) { function flyFrom(card, opening) {
@@ -280,10 +285,11 @@ document.addEventListener('DOMContentLoaded', function () {
panel.style.setProperty('--md-top', Math.max(24, (window.innerHeight - panel.offsetHeight) / 2) + 'px'); panel.style.setProperty('--md-top', Math.max(24, (window.innerHeight - panel.offsetHeight) / 2) + 'px');
flyFrom(card, true); flyFrom(card, true);
} else { } else {
panel.style.transition = 'none'; var mdCard = panel.querySelector('.md-card');
panel.style.transition = mdCard.style.transition = 'none';
place(card); place(card);
void panel.offsetWidth; void panel.offsetWidth;
panel.style.transition = ''; panel.style.transition = mdCard.style.transition = '';
void panel.offsetWidth; void panel.offsetWidth;
panel.classList.add('is-open'); panel.classList.add('is-open');
card.classList.add('is-source'); card.classList.add('is-source');
+2 -2
View File
@@ -193,7 +193,7 @@ if ($modalidades):
<?php foreach($modalidades as $d): $desc = trim(strip_tags((string) ($d['descricao'] ?? ''))) !== '' ? trim((string) $d['descricao']) : ''; ?> <?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="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"> <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; ?> <?= modalidade_icon_html($d['icon']) /* inline animated SVG for the FitMe set, else <img> */ ?>
</div> </div>
<h3><?= htmlspecialchars((string) $d['nome']) ?></h3> <h3><?= htmlspecialchars((string) $d['nome']) ?></h3>
<?php if ($desc !== ''): ?> <?php if ($desc !== ''): ?>
@@ -208,7 +208,7 @@ if ($modalidades):
<div class="mod-detail" role="dialog" aria-modal="false" aria-hidden="true"> <div class="mod-detail" role="dialog" aria-modal="false" aria-hidden="true">
<button type="button" class="md-close" aria-label="Fechar">&times;</button> <button type="button" class="md-close" aria-label="Fechar">&times;</button>
<div class="md-card"> <div class="md-card">
<div class="ib-icon"><img src="" alt="" hidden></div> <div class="ib-icon"></div>
<h3></h3> <h3></h3>
</div> </div>
<div class="md-body"> <div class="md-body">
@@ -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="M12 6h12v17l14 5v6H12z"></path><path fill="currentColor" d="M10 36h30v5H10z"></path><path d="M17 12h7M17 18h7"></path></svg>

After

Width:  |  Height:  |  Size: 331 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="5" r="3.5"></circle><path d="M24 11v9M24 13l-7-5M24 13l7-5M24 20l-5 6M24 20l5 6"></path><path fill="currentColor" d="M5 31h38v3H5z"></path><path d="M9 34l-3 7M39 34l3 7M20 38h8"></path></svg>

After

Width:  |  Height:  |  Size: 429 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="M6 41l36-20v20z"></path><path d="M6 27L25 11M17 11h8v8"></path></svg>

After

Width:  |  Height:  |  Size: 296 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="31" cy="8" r="3.5"></circle><path d="M29 14l-6 13M28 17l7 5 3-4M28 17l-8-1-4 5M23 27l9 4-2 10M23 27l-6 8-8-1M4 14h8M3 21h7"></path></svg>

After

Width:  |  Height:  |  Size: 367 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="24" cy="11" r="3.5"></circle><path d="M24 17v11M24 20l-9-8M24 20l9-8M24 28l-8 11M24 28l8 11"></path><path d="M4 12V4h8M36 4h8v8M44 36v8h-8M12 44H4v-8"></path></svg>

After

Width:  |  Height:  |  Size: 394 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="M11 6h26l-3 14 2 22H12l2-22z"></path><path fill="currentColor" stroke="none" d="M17.5 10h5.5v5h-5.5zM25 10h5.5v5H25zM17.5 18h5.5v5h-5.5zM25 18h5.5v5H25zM17.5 26h5.5v5h-5.5zM25 26h5.5v5H25z"></path></svg>

After

Width:  |  Height:  |  Size: 410 B