Files
fitme3070.pt/site/assets/js/main.js
T
andreandClaude Opus 5.5 8ebf20f52f feat: horário em 3 dias, horário de funcionamento compacto, textura na equipa, estrutura/SEO
- Horário de aulas: cartões mais pequenos e estreitos; a partir de 992px mostra
  o dia anterior, hoje ao centro e o seguinte (a dar a volta à semana), com as
  horas alinhadas e os dias laterais esbatidos. O JPG continua a semana toda.
- Horário de funcionamento: cartões compactos numa <dl>, ordenados por hora.
- Equipa: fundo no cinzento da parede do ginásio com juntas de blocos e grão
  muito discretos (SVG inline).
- Tira o «FitMe 3070» do header e esconde a newsletter.
- Estrutura: um <main> em todas as páginas públicas (skip link aponta-lhe),
  h2 por secção com aria-labelledby, h3 nos itens em vez de h4, sem secções
  aninhadas, alts distintos na galeria e no h1 do CrossFit.
- robots.txt e sitemap.xml gerados com o domínio da instalação; SEO_NOINDEX
  no .env tira uma cópia de staging dos motores de pesquisa.

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

431 lines
21 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: 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. */
document.addEventListener('DOMContentLoaded', function () {
var scroller = document.querySelector('.mod-scroll');
// 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) {
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');
});
}
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');
});
}
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') {
e.preventDefault();
card.click();
} else if (e.key === 'Escape') {
card.classList.remove('is-open');
card.setAttribute('aria-expanded', 'false');
syncPause();
}
});
});
document.addEventListener('click', function () { closeAll(null); syncPause(); });
});
/* ── 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 || 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) {
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 || 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; }
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: 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'); });
});
});