Files
fitme3070.pt/site/assets/js/main.js
T
andreandClaude Opus 5.5 f95c9ebc52 feat: contactos editáveis, equipa com título/aptidões, mensalidades e reviews Google
- 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>
2026-09-24 14:58:57 +01:00

303 lines
14 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 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'); });
});
});