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

+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');