- Contactos: nova página no backoffice (fitme3070_info) e 964 201 137 como telefone principal; o telefone secundário só aparece quando existe. - Equipa: título, aptidões/funções e formações; o cartão mostra foto, nome e título e abre um cartão horizontal de detalhe (hover no desktop, toque no telemóvel) sem mexer na grelha. - Mensalidades: cartões editáveis com um destaque, modal de inscrição que guarda o pedido (RGPD + estado + campos reservados para o CRM) e avisa o ginásio por e-mail; lista de pedidos com estados, notas e CSV. - Reviews Google via Places API (New), filtradas por classificação mínima e em cache configurável; página de configuração com pesquisa do Place ID. - Assets com ?v=<mtime>: site/assets é servido com cache imutável de 1 ano. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
14 KiB
JavaScript
303 lines
14 KiB
JavaScript
'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 one day at a time; 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');
|
||
|
||
// Toggle a class (not inline display) — .sched-hide carries !important so it
|
||
// reliably wins, and the export clone simply strips it to show every day.
|
||
function showDay(day) {
|
||
cells.forEach(function (c) {
|
||
c.classList.toggle('sched-hide', c.getAttribute('data-day') !== day);
|
||
});
|
||
buttons.forEach(function (b) {
|
||
b.classList.toggle('active', b.getAttribute('data-day') === day);
|
||
});
|
||
// Show a Manhã / Tarde divider only when the day has a class in that block.
|
||
labels.forEach(function (lbl) {
|
||
var sec = lbl.getAttribute('data-section');
|
||
var has = !!scope.querySelector('.schedule_cell[data-day="' + day + '"][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'));
|
||
});
|
||
|
||
// 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: 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. */
|
||
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)');
|
||
|
||
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 section = document.getElementById('equipa');
|
||
function setOpen(item, open) {
|
||
item.classList.toggle('is-open', open);
|
||
item.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
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) {
|
||
item.addEventListener('mouseenter', function () { place(item); });
|
||
item.addEventListener('focus', function () { place(item); });
|
||
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;
|
||
}
|
||
closeAll(item);
|
||
place(item);
|
||
setOpen(item, !item.classList.contains('is-open'));
|
||
});
|
||
item.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
||
if (e.target !== item) return;
|
||
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); });
|
||
});
|
||
|
||
/* ── 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'); });
|
||
});
|
||
});
|