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:
andreandClaude Opus 5.5 committed 2026-09-24 14:58:57 +01:00
1 parent 8a494d59a5
commit f95c9ebc52
27 files changed
+2063 -16

No files matched your search

+543 -1
View File
@@ -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;
}
+78
View File
@@ -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
View File
@@ -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">&times;</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">&times;</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');
+93
View File
@@ -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');