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>
This commit is contained in:
1 parent
8a494d59a5
commit
f95c9ebc52
27 files changed
+2063
-16
No files matched your search
+543
-1
@@ -2204,6 +2204,11 @@ section[id] {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
/* Short titles (e.g. "Modalidades") would otherwise sit on the eyebrow's line. */
|
||||
.section-title.deco .section-eyebrow {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section-title.deco h2 {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -2913,4 +2918,541 @@ section[id] {
|
||||
.event-consent a { color: var(--fitme-accent-dark); text-decoration: underline; }
|
||||
@media (max-width: 575px) {
|
||||
.event-form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
Equipa — título + cartão de detalhe horizontal
|
||||
Default card = photo + name + title. The detail card is laid OVER the grid
|
||||
(absolute), so opening it never reflows the team wall. Desktop (fine pointer)
|
||||
opens it on hover / keyboard focus; touch opens it on tap (.is-open, main.js),
|
||||
where it becomes a centred fixed card with its own close button.
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
/* content-visibility:auto implies paint containment, which would clip the
|
||||
overlay at the section edge and trap position:fixed inside the section. */
|
||||
#equipa.trainers-section {
|
||||
content-visibility: visible;
|
||||
contain: none;
|
||||
position: relative;
|
||||
z-index: 3; /* hover card may overhang the next section */
|
||||
}
|
||||
|
||||
/* While a card is open on touch, lift the whole section over the navbar and
|
||||
the mobile CTA bar so the dimmed backdrop covers everything. */
|
||||
#equipa.trainers-section.has-open-detail {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
/* The global scroll-reveal keeps transform:translateY(0) applied (fill
|
||||
forwards), and any transform makes an element the containing block for
|
||||
position:fixed descendants — which would pin the mobile detail card inside
|
||||
its own tile. Reveal the team with opacity only. */
|
||||
#equipa.revealed,
|
||||
#equipa .trainer-item.revealed {
|
||||
animation-name: fitmeFadeOnly;
|
||||
}
|
||||
|
||||
@keyframes fitmeFadeOnly {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.trainer-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.trainers-section .trainer-item h4 {
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
/* …and keep that same rule (white, .9rem) off the detail card when it is the
|
||||
last child. */
|
||||
.trainers-section .trainer-item>.trainer-detail {
|
||||
color: var(--fitme-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.trainers-section .trainer-item p.trainer-title {
|
||||
margin: 0;
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .04em;
|
||||
color: var(--fitme-accent);
|
||||
}
|
||||
|
||||
.trainer-item.has-detail {
|
||||
cursor: pointer;
|
||||
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);
|
||||
}
|
||||
|
||||
.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-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);
|
||||
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;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity .2s ease, transform .2s ease, visibility 0s linear .2s;
|
||||
}
|
||||
|
||||
.trainer-item.is-open .trainer-detail {
|
||||
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;
|
||||
}
|
||||
|
||||
@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; }
|
||||
}
|
||||
|
||||
.trainer-item.is-open { z-index: 31; }
|
||||
|
||||
.td-photo {
|
||||
flex: 0 0 38%;
|
||||
max-width: 210px;
|
||||
background: var(--fitme-dark-2);
|
||||
}
|
||||
|
||||
.td-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.td-body {
|
||||
flex: 1 1 auto;
|
||||
padding: 18px 22px 18px 20px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.td-name {
|
||||
display: block;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
color: var(--fitme-dark);
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.td-title {
|
||||
display: block;
|
||||
font-size: .82rem;
|
||||
font-weight: 700;
|
||||
color: var(--fitme-accent-dark);
|
||||
margin-bottom: .6rem;
|
||||
}
|
||||
|
||||
.td-bio {
|
||||
font-size: .88rem;
|
||||
color: var(--fitme-text-soft);
|
||||
margin-bottom: .6rem;
|
||||
}
|
||||
|
||||
.td-bio p:last-child { margin-bottom: 0; }
|
||||
|
||||
.td-block + .td-block { margin-top: .55rem; }
|
||||
|
||||
.td-label {
|
||||
display: block;
|
||||
font-size: .68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fitme-text-muted);
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
.td-block ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.td-block li {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
font-size: .87rem;
|
||||
line-height: 1.45;
|
||||
color: var(--fitme-text);
|
||||
}
|
||||
|
||||
.td-block li:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: .55em;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 1px;
|
||||
background: var(--fitme-accent);
|
||||
}
|
||||
|
||||
.td-close {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
color: var(--fitme-text-muted);
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Touch / small screens: a centred fixed card over a dimmed page. */
|
||||
@media (max-width: 767px), (hover: none) {
|
||||
.trainer-detail {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
width: auto;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
transform: translateY(-46%);
|
||||
max-height: calc(100vh - 48px);
|
||||
}
|
||||
.trainer-item.is-open .trainer-detail { transform: translateY(-50%); }
|
||||
.trainer-item.is-open:after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 29;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
}
|
||||
.td-close { display: block; }
|
||||
.td-photo { flex-basis: 34%; }
|
||||
.td-photo img { min-height: 180px; }
|
||||
.td-body { max-height: calc(100vh - 48px); padding: 16px 16px 16px 14px; }
|
||||
}
|
||||
|
||||
@media (max-width: 339px) {
|
||||
.trainer-detail { flex-direction: column; }
|
||||
.td-photo { max-width: none; flex-basis: auto; }
|
||||
.td-photo img { min-height: 0; height: 160px; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
Mensalidades — cartões de preço
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
.pricing-section {
|
||||
padding: 90px 0 70px;
|
||||
background: var(--fitme-surface);
|
||||
}
|
||||
|
||||
.pricing-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.price-card {
|
||||
position: relative;
|
||||
flex: 0 1 270px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid var(--fitme-surface-2);
|
||||
border-radius: 6px;
|
||||
padding: 30px 26px 26px;
|
||||
box-shadow: var(--fitme-shadow);
|
||||
transition: transform var(--fitme-transition), box-shadow var(--fitme-transition);
|
||||
}
|
||||
|
||||
.price-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--fitme-shadow-lg);
|
||||
}
|
||||
|
||||
.price-card.is-featured {
|
||||
background: var(--fitme-dark-2);
|
||||
border: 2px solid var(--fitme-accent);
|
||||
color: var(--fitme-ash);
|
||||
box-shadow: 0 18px 50px rgba(var(--fitme-dark-rgb), .28);
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.price-card.is-featured { transform: scale(1.04); }
|
||||
.price-card.is-featured:hover { transform: scale(1.04) translateY(-4px); }
|
||||
}
|
||||
|
||||
.pc-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
background: var(--fitme-accent);
|
||||
color: var(--fitme-dark);
|
||||
font-size: .72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.pc-badge i { font-size: .65rem; }
|
||||
|
||||
.price-card .pc-name {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 900;
|
||||
margin: 0 0 .35rem;
|
||||
color: var(--fitme-dark);
|
||||
}
|
||||
|
||||
.price-card.is-featured .pc-name { color: #fff; }
|
||||
|
||||
.pc-sub {
|
||||
font-size: .9rem;
|
||||
line-height: 1.45;
|
||||
color: var(--fitme-text-muted);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.price-card.is-featured .pc-sub { color: var(--fitme-ash); }
|
||||
|
||||
.pc-price {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 8px;
|
||||
padding: .9rem 0 1rem;
|
||||
border-top: 1px solid var(--fitme-surface-2);
|
||||
border-bottom: 1px solid var(--fitme-surface-2);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.price-card.is-featured .pc-price { border-color: rgba(255, 255, 255, .12); }
|
||||
|
||||
.pc-amount {
|
||||
font-size: 2.3rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
color: var(--fitme-dark);
|
||||
}
|
||||
|
||||
.pc-amount.is-text { font-size: 1.35rem; }
|
||||
|
||||
.price-card.is-featured .pc-amount { color: var(--fitme-accent); }
|
||||
|
||||
.pc-suffix {
|
||||
font-size: .88rem;
|
||||
color: var(--fitme-text-muted);
|
||||
}
|
||||
|
||||
.price-card.is-featured .pc-suffix { color: var(--fitme-ash); }
|
||||
|
||||
.pc-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.4rem;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.pc-features li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: .92rem;
|
||||
line-height: 1.4;
|
||||
padding: 4px 0;
|
||||
color: var(--fitme-text-soft);
|
||||
}
|
||||
|
||||
.price-card.is-featured .pc-features li { color: #fff; }
|
||||
|
||||
.pc-features i {
|
||||
color: var(--fitme-accent-dark);
|
||||
margin-top: 3px;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.price-card.is-featured .pc-features i { color: var(--fitme-accent); }
|
||||
|
||||
.price-card .pc-cta {
|
||||
width: 100%;
|
||||
border: none;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.price-card:not(.is-featured) .pc-cta {
|
||||
background: var(--fitme-dark-2);
|
||||
color: var(--fitme-accent);
|
||||
}
|
||||
|
||||
.price-card:not(.is-featured) .pc-cta:hover {
|
||||
background: var(--fitme-dark-3);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.pricing-section { padding: 60px 0 50px; }
|
||||
.price-card { flex-basis: 100%; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
Reviews Google
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
.reviews-section {
|
||||
padding: 90px 0 60px;
|
||||
background: var(--fitme-dark);
|
||||
color: var(--fitme-ash);
|
||||
}
|
||||
|
||||
.reviews-section .section-title h2 { color: #fff; }
|
||||
|
||||
.reviews-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px 12px;
|
||||
margin: 1rem 0 0;
|
||||
color: var(--fitme-ash);
|
||||
}
|
||||
|
||||
.reviews-summary strong {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 900;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rv-stars {
|
||||
color: #FBBC04;
|
||||
letter-spacing: 2px;
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
.reviews-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.review-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
background: var(--fitme-dark-2);
|
||||
border: 1px solid var(--fitme-dark-3);
|
||||
border-radius: 6px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.rv-text {
|
||||
flex: 1 1 auto;
|
||||
margin: .8rem 0 1.2rem;
|
||||
font-size: .95rem;
|
||||
line-height: 1.6;
|
||||
color: #fff;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 7;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rv-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rv-author img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.rv-author span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rv-author a { color: #fff; }
|
||||
.rv-author a:hover { color: var(--fitme-accent); }
|
||||
|
||||
.rv-author small {
|
||||
font-weight: 400;
|
||||
color: var(--fitme-ash);
|
||||
}
|
||||
|
||||
.reviews-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.reviews-section .site-btn.sb-line {
|
||||
border-color: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.reviews-section .site-btn.sb-line:hover {
|
||||
background: #fff;
|
||||
color: var(--fitme-dark);
|
||||
}
|
||||
|
||||
.reviews-attribution {
|
||||
margin: 1.2rem 0 0;
|
||||
text-align: center;
|
||||
font-size: .75rem;
|
||||
color: var(--fitme-ash);
|
||||
opacity: .8;
|
||||
}
|
||||
@@ -222,3 +222,81 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
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'); });
|
||||
});
|
||||
});
|
||||
+200
-6
@@ -11,6 +11,8 @@ $modalidades = site_modalidades($pdo);
|
||||
$horario = site_horario($pdo);
|
||||
$whours = site_work_hours($pdo);
|
||||
$events = site_active_events($pdo); // time-limited events, banner below the hero
|
||||
$memberships = site_memberships($pdo);
|
||||
$reviews = site_reviews(); // null when not configured / nothing qualifies
|
||||
|
||||
// ── Hero section ────────────────────────────────────────────────────────────────
|
||||
?>
|
||||
@@ -118,15 +120,46 @@ if ($team):
|
||||
</div>
|
||||
<div class="section-title text-center">
|
||||
<div class="row auto-grid" id="powerRotation">
|
||||
<?php foreach ($team as $d): if ($d['status'] == 'ativo'): ?>
|
||||
<?php foreach ($team as $d): if ($d['status'] == 'ativo'):
|
||||
// Default card: photo + name + title. The horizontal detail card
|
||||
// (hover on desktop, tap on touch) only exists when there is
|
||||
// something extra to show, so a bare member stays a plain card.
|
||||
$t_title = trim((string) ($d['title'] ?? ''));
|
||||
$t_skills = text_lines($d['skills'] ?? '');
|
||||
$t_certs = text_lines($d['certifications'] ?? '');
|
||||
$t_bio = trim(strip_tags((string) ($d['description'] ?? ''))) !== '' ? clean_richtext((string) $d['description']) : '';
|
||||
$t_detail = $t_skills || $t_certs || $t_bio !== '';
|
||||
$t_img = site_img_url($d['photo'], 'uploads/team/foto');
|
||||
$t_id = 'td-' . (int) $d['id'];
|
||||
?>
|
||||
<div class="col-6 col-md-4 col-lg-3" style="margin-top: 20px;">
|
||||
<div class="trainer-item">
|
||||
<div class="trainer-item<?= $t_detail ? ' has-detail' : '' ?>"<?= $t_detail ? ' tabindex="0" role="button" aria-expanded="false" aria-controls="' . $t_id . '" aria-label="Ver detalhes de ' . htmlspecialchars((string) $d['name']) . '"' : '' ?>>
|
||||
<div class="trainer-pic">
|
||||
<img loading="lazy" src="<?= site_img_url($d['photo'], 'uploads/team/foto') ?>" alt="<?= htmlspecialchars((string) $d['name']) ?>">
|
||||
<img loading="lazy" src="<?= $t_img ?>" alt="<?= htmlspecialchars((string) $d['name']) ?>">
|
||||
</div>
|
||||
<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">×</button>
|
||||
<div class="td-photo"><img loading="lazy" src="<?= $t_img ?>" alt=""></div>
|
||||
<div class="td-body">
|
||||
<strong class="td-name"><?= htmlspecialchars((string) $d['name']) ?></strong>
|
||||
<?php if ($t_title !== ''): ?><span class="td-title"><?= htmlspecialchars($t_title) ?></span><?php endif; ?>
|
||||
<?php if ($t_bio !== ''): ?><div class="td-bio"><?= $t_bio ?></div><?php endif; ?>
|
||||
<?php if ($t_skills): ?>
|
||||
<div class="td-block"><span class="td-label">Aptidões / Funções</span>
|
||||
<ul><?php foreach ($t_skills as $x): ?><li><?= htmlspecialchars($x) ?></li><?php endforeach; ?></ul></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($t_certs): ?>
|
||||
<div class="td-block"><span class="td-label">Formações</span>
|
||||
<ul><?php foreach ($t_certs as $x): ?><li><?= htmlspecialchars($x) ?></li><?php endforeach; ?></ul></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?= htmlspecialchars((string) $d['description']) ?>
|
||||
</div>
|
||||
<?php endif; endforeach; ?>
|
||||
</div>
|
||||
@@ -387,6 +420,108 @@ if (!in_array($default_day, $week_values, true)) { $default_day = $week_values[0
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// ── Mensalidades ────────────────────────────────────────────────────────────────
|
||||
// Cards come from the backoffice (Gestão → Mensalidades); one may be featured.
|
||||
// "Inscrever" opens #joinModal pre-set to that card (site/inscricao.php stores
|
||||
// the request as a lead and emails the gym).
|
||||
if ($memberships):
|
||||
?>
|
||||
<section id="mensalidades" class="pricing-section">
|
||||
<div class="container">
|
||||
<div class="row justify-content-md-center mb-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="section-title text-center deco">
|
||||
<span class="section-eyebrow">Mensalidades</span>
|
||||
<h2>Escolhe o teu plano</h2>
|
||||
<p class="section-subtitle">Encontra a mensalidade que encaixa na tua rotina.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (($_GET['inscricao'] ?? '') === 'ok'): ?>
|
||||
<div class="alert alert-success text-center" role="alert">Pedido de inscrição enviado! Vamos entrar em contacto contigo muito em breve.</div>
|
||||
<?php elseif (($_GET['inscricao'] ?? '') === 'erro'): ?>
|
||||
<div class="alert alert-danger text-center" role="alert">Não foi possível enviar o pedido. Verifica os dados e tenta novamente.</div>
|
||||
<?php endif; ?>
|
||||
<div class="pricing-grid">
|
||||
<?php foreach ($memberships as $m): $features = text_lines($m['features']); ?>
|
||||
<article class="price-card<?= $m['is_featured'] ? ' is-featured' : '' ?>">
|
||||
<?php if ($m['is_featured']): ?><span class="pc-badge"><i class="fas fa-star"></i> <?= htmlspecialchars($m['featured_label']) ?></span><?php endif; ?>
|
||||
<h3 class="pc-name"><?= htmlspecialchars($m['name']) ?></h3>
|
||||
<?php if ($m['subtitle'] !== ''): ?><p class="pc-sub"><?= htmlspecialchars($m['subtitle']) ?></p><?php endif; ?>
|
||||
<div class="pc-price">
|
||||
<span class="pc-amount<?= $m['price'] === null ? ' is-text' : '' ?>"><?= htmlspecialchars(membership_price_label($m)) ?></span>
|
||||
<?php if ($m['price'] !== null && $m['price_suffix'] !== ''): ?><span class="pc-suffix"><?= htmlspecialchars($m['price_suffix']) ?></span><?php endif; ?>
|
||||
</div>
|
||||
<?php if ($features): ?>
|
||||
<ul class="pc-features">
|
||||
<?php foreach ($features as $f): ?><li><i class="fas fa-check" aria-hidden="true"></i><?= htmlspecialchars($f) ?></li><?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="site-btn pc-cta" data-toggle="modal" data-target="#joinModal"
|
||||
data-membership="<?= (int) $m['id'] ?>"><?= htmlspecialchars($m['cta_label']) ?></button>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// ── Reviews Google ──────────────────────────────────────────────────────────────
|
||||
// Live from the Places API (functions_reviews.php), cached; only reviews at or
|
||||
// above the backoffice minimum rating. Hidden when nothing qualifies.
|
||||
if ($reviews):
|
||||
$stars = static function (float $r): string {
|
||||
$out = '';
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$cls = $r >= $i ? 'fas fa-star' : ($r >= $i - 0.5 ? 'fas fa-star-half-alt' : 'far fa-star');
|
||||
$out .= '<i class="' . $cls . '" aria-hidden="true"></i>';
|
||||
}
|
||||
return $out;
|
||||
};
|
||||
?>
|
||||
<section id="reviews" class="reviews-section">
|
||||
<div class="container">
|
||||
<div class="row justify-content-md-center mb-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="section-title text-center deco">
|
||||
<span class="section-eyebrow">Reviews</span>
|
||||
<h2>O que dizem de nós</h2>
|
||||
<?php if ($reviews['rating'] !== null): ?>
|
||||
<p class="reviews-summary">
|
||||
<strong><?= number_format((float) $reviews['rating'], 1, ',', '') ?></strong>
|
||||
<span class="rv-stars" role="img" aria-label="<?= number_format((float) $reviews['rating'], 1, ',', '') ?> de 5 estrelas"><?= $stars((float) $reviews['rating']) ?></span>
|
||||
<span><?= (int) $reviews['total'] ?> avaliações no Google</span>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reviews-grid">
|
||||
<?php foreach ($reviews['reviews'] as $r): ?>
|
||||
<figure class="review-card">
|
||||
<div class="rv-stars" role="img" aria-label="<?= (int) $r['rating'] ?> de 5 estrelas"><?= $stars((float) $r['rating']) ?></div>
|
||||
<blockquote class="rv-text"><?= nl2br(htmlspecialchars($r['text'])) ?></blockquote>
|
||||
<figcaption class="rv-author">
|
||||
<?php if ($r['author_photo'] !== ''): ?><img src="<?= htmlspecialchars($r['author_photo']) ?>" alt="" loading="lazy" referrerpolicy="no-referrer" width="40" height="40"><?php endif; ?>
|
||||
<span>
|
||||
<?php if ($r['author_uri'] !== ''): ?><a href="<?= htmlspecialchars($r['author_uri']) ?>" target="_blank" rel="noopener nofollow"><?= htmlspecialchars($r['author']) ?></a><?php else: ?><?= htmlspecialchars($r['author']) ?><?php endif; ?>
|
||||
<small><?= htmlspecialchars($r['relative']) ?></small>
|
||||
</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="reviews-actions">
|
||||
<?php if ($reviews['maps_uri'] !== ''): ?><a class="site-btn sb-line" href="<?= htmlspecialchars($reviews['maps_uri']) ?>" target="_blank" rel="noopener">Ver todas no Google</a><?php endif; ?>
|
||||
<a class="site-btn" href="<?= htmlspecialchars($reviews['write_uri']) ?>" target="_blank" rel="noopener">Deixa a tua review</a>
|
||||
</div>
|
||||
<p class="reviews-attribution">Reviews via Google Maps</p>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// ── Contactos + Horário de Funcionamento + Mapa ────────────────────────────────
|
||||
$assunto = '';
|
||||
@@ -436,9 +571,11 @@ $session = session();
|
||||
<a href="tel:<?= htmlspecialchars(str_replace(' ', '', $info['phone'] ?? '')) ?>">
|
||||
<p><?= htmlspecialchars($info['phone'] ?? '') ?></p>
|
||||
</a>
|
||||
<a href="tel:<?= htmlspecialchars(str_replace(' ', '', $info['telephone'] ?? '')) ?>">
|
||||
<p><?= htmlspecialchars($info['telephone'] ?? '') ?></p>
|
||||
<?php if (($info['telephone'] ?? '') !== ''): ?>
|
||||
<a href="tel:<?= htmlspecialchars(str_replace(' ', '', $info['telephone'])) ?>">
|
||||
<p><?= htmlspecialchars($info['telephone']) ?></p>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -618,6 +755,63 @@ $work_hours_days = ['Segunda-Sexta'=>'segunda','Sábado'=>'sabado','Domingo/Feri
|
||||
<script>document.addEventListener('DOMContentLoaded',function(){if(window.jQuery){jQuery('#trialModal').modal('show');}});</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// ── Inscrição (mensalidades) modal ──────────────────────────────────────────
|
||||
// The select is pre-set by main.js from the clicked card's data-membership;
|
||||
// without JS it still works as a plain chooser.
|
||||
if ($memberships):
|
||||
?>
|
||||
<div class="modal fade trial-modal-wrap" id="joinModal" tabindex="-1" role="dialog" aria-labelledby="joinModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content trial-modal">
|
||||
<div class="trial-modal-head">
|
||||
<span class="section-eyebrow"><?= htmlspecialchars($info['name'] !== '' ? $info['name'] : APP_NAME) ?></span>
|
||||
<h3 id="joinModalLabel">Inscreve-te</h3>
|
||||
<p>Deixa os teus contactos e entramos em contacto para concluir a inscrição.</p>
|
||||
<button type="button" class="trial-close" data-dismiss="modal" aria-label="Fechar">×</button>
|
||||
</div>
|
||||
<form class="trial-form" method="post" action="<?= base_url('home/inscricao') ?>">
|
||||
<?= csrf_field() ?>
|
||||
<input type="text" name="website" class="event-hp" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||
<div class="form-row">
|
||||
<div class="col-12 mb-3">
|
||||
<select name="membership_id" aria-label="Mensalidade" required>
|
||||
<?php foreach ($memberships as $m): ?>
|
||||
<option value="<?= (int) $m['id'] ?>"<?= $m['is_featured'] ? ' selected' : '' ?>><?= htmlspecialchars($m['name'] . ' — ' . membership_price_label($m)) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<input type="text" name="nome" required maxlength="150" placeholder="Nome *" aria-label="Nome">
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<input type="tel" name="telefone" required maxlength="40" placeholder="Telemóvel *" aria-label="Telemóvel">
|
||||
</div>
|
||||
<div class="col-12 mb-3">
|
||||
<input type="email" name="email" maxlength="190" placeholder="E-mail" aria-label="E-mail">
|
||||
</div>
|
||||
<div class="col-12 mb-3">
|
||||
<textarea name="mensagem" rows="3" maxlength="1000" placeholder="Alguma dúvida ou objetivo? (opcional)" aria-label="Mensagem"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="consent-check">
|
||||
<input type="checkbox" name="consent" value="1" required>
|
||||
<span>Autorizo o contacto e o tratamento dos meus dados para efeitos desta inscrição, nos termos da <a href="<?= base_url('home/privacidade') ?>" target="_blank" rel="noopener">Política de Privacidade</a>.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="site-btn w-100">Enviar pedido</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (($_GET['inscricao'] ?? '') === 'erro'): ?>
|
||||
<script>document.addEventListener('DOMContentLoaded',function(){if(window.jQuery){jQuery('#joinModal').modal('show');}});</script>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// ── Newsletter + Footer ───────────────────────────────────────────────────────
|
||||
echo view('newsletter');
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
// POST handler for the membership "Inscrever" modal on the homepage.
|
||||
// Stores the request in membership_leads (backoffice: Mensalidades → Pedidos de
|
||||
// inscrição), emails the gym like the trial-class modal does, then redirects
|
||||
// back to #mensalidades with ?inscricao=ok|erro for the homepage to render.
|
||||
// The lead is saved before the email is attempted, so a mail failure never
|
||||
// loses a request.
|
||||
//
|
||||
// Reached via .htaccess: ^home/inscricao/?$ → site/inscricao.php
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../config/functions.php';
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
header('Location: ' . base_url('home#mensalidades'));
|
||||
exit;
|
||||
}
|
||||
|
||||
csrf_check(); // 403s and exits on a bad/missing token
|
||||
|
||||
function inscricao_redirect(string $status): void {
|
||||
header('Location: ' . base_url('home?inscricao=' . $status . '#mensalidades'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Honeypot: a filled "website" field means a bot — pretend success, store nothing.
|
||||
if (trim((string) ($_POST['website'] ?? '')) !== '') inscricao_redirect('ok');
|
||||
|
||||
$consent_text = 'Autorizo o contacto e o tratamento dos meus dados para efeitos desta inscrição, nos termos da Política de Privacidade.';
|
||||
$membership = db_memberships_get($pdo, (int) ($_POST['membership_id'] ?? 0));
|
||||
if ($membership && ($membership['deleted_at'] !== null || !$membership['is_active'])) $membership = null;
|
||||
|
||||
$lead = [
|
||||
'membership_id' => $membership ? (int) $membership['id'] : null,
|
||||
'membership_name' => $membership ? $membership['name'] : '',
|
||||
'name' => trim((string) ($_POST['nome'] ?? '')),
|
||||
'phone' => trim((string) ($_POST['telefone'] ?? '')),
|
||||
'email' => trim((string) ($_POST['email'] ?? '')),
|
||||
'message' => trim((string) ($_POST['mensagem'] ?? '')),
|
||||
'consent' => !empty($_POST['consent']) ? 1 : 0,
|
||||
'consent_text' => $consent_text,
|
||||
// First hop of X-Forwarded-For (behind the reverse proxy), else peer.
|
||||
'ip' => trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))[0]) ?: ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
||||
];
|
||||
|
||||
if ($lead['name'] === '' || $lead['phone'] === '' || !$lead['consent']
|
||||
|| ($lead['email'] !== '' && !filter_var($lead['email'], FILTER_VALIDATE_EMAIL))) {
|
||||
inscricao_redirect('erro');
|
||||
}
|
||||
|
||||
try {
|
||||
db_membership_lead_create($pdo, $lead);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[inscricao] ' . $e->getMessage());
|
||||
inscricao_redirect('erro');
|
||||
}
|
||||
|
||||
// Notify the gym. Best effort: the request is already stored.
|
||||
try {
|
||||
$info = site_info($pdo);
|
||||
$to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM;
|
||||
$e = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
$rows = [
|
||||
'Mensalidade' => $lead['membership_name'],
|
||||
'Nome' => $lead['name'],
|
||||
'Telemóvel' => $lead['phone'],
|
||||
'E-mail' => $lead['email'],
|
||||
'Mensagem' => $lead['message'],
|
||||
];
|
||||
$body = '<h2 style="margin:0 0 16px;">Novo pedido de inscrição</h2><table cellpadding="6" style="border-collapse:collapse;font-family:Arial,sans-serif;font-size:14px;">';
|
||||
$alt = "Novo pedido de inscrição\n\n";
|
||||
foreach ($rows as $label => $value) {
|
||||
if (trim((string) $value) === '') continue;
|
||||
$body .= '<tr><td style="font-weight:bold;vertical-align:top;">' . $e($label) . ':</td><td>' . nl2br($e($value)) . '</td></tr>';
|
||||
$alt .= "$label: $value\n";
|
||||
}
|
||||
$body .= '</table><p style="font-family:Arial,sans-serif;font-size:13px;color:#666;margin-top:18px;">'
|
||||
. 'Pedido guardado no backoffice (Mensalidades → Pedidos de inscrição).</p>';
|
||||
|
||||
$id = enqueue_email([
|
||||
'to_address' => $to,
|
||||
'to_name' => $info['name'] !== '' ? $info['name'] : APP_NAME,
|
||||
'subject' => 'Pedido de inscrição' . ($lead['membership_name'] !== '' ? ' (' . $lead['membership_name'] . ')' : '') . ' — ' . $lead['name'],
|
||||
'body' => $body,
|
||||
'alt_body' => $alt,
|
||||
'context_type' => 'fitme_inscricao',
|
||||
]);
|
||||
flush_message($id);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[inscricao] email: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
inscricao_redirect('ok');
|
||||
Reference in new issue
Block a user