feat: vários ginásios (/3070/ e /<código postal>/), CRUD de ginásios, modalidades após a galeria

Estrutura multi-ginásio: as mesmas secções do site, conteúdo próprio por
ginásio, e cada um numa pasta da URL com o código postal da zona.

- migration_20260926_gyms: tabela gyms (pasta, nome, localidade, publicado,
  principal, secções visíveis, título/imagem do header, SEO, galeria, Place ID,
  página CrossFit) com o FitMe 3070 como ginásio 1 em /3070/; gym_id em team,
  modalidades (o horário segue a modalidade), work_hours, memberships,
  membership_leads e website_offer (tudo o existente → ginásio 1); events com
  gym_id NULL = todos; fitme3070_info passa a uma linha por ginásio (id = gym).
- Site: /<pasta>/, /<pasta>/crossfit, /<pasta>/agendar|inscricao. Ginásio por
  publicar → 404 para o público, pré-visualização (com faixa) para a equipa.
  / e /home → 301 para o ginásio principal (a manter a query); com mais de um
  ginásio publicado, a raiz passa a uma página para escolher. Secções
  desligadas não aparecem nem no menu; menu, canonical, JSON-LD, sitemap,
  formulários, e-mails e reviews (Place ID e cache) são do ginásio.
- Backoffice: Gestão → Ginásios (criar/editar/apagar, secções, e «começar
  com o conteúdo de» outro ginásio — cópias independentes) e o seletor
  «A editar» no topo; equipa, modalidades, horários, horário de
  funcionamento, mensalidades, pedidos, contactos e reviews trabalham sobre o
  ginásio escolhido, e um registo de outro ginásio não abre.
- Modalidades passam para logo a seguir à galeria das instalações.
- Galeria sem cantos arredondados (raio da marca).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-25 07:26:40 +01:00
1 parent 2f8f68c61a
commit 5e300fc341
33 files changed
+1146 -197

No files matched your search

+16 -3
View File
@@ -5,14 +5,27 @@
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php';
$info = site_info($pdo);
$navbar = site_navbar();
// The gym this page belongs to: ?gym=<slug> from the URL folder (/3070/…), or
// the default gym for pages outside a gym folder (privacy page, events).
// Unknown slugs — and unpublished gyms for anyone not logged in to the
// backoffice — are a plain 404.
$gym = site_gym($pdo);
if (!$gym) { require __DIR__ . '/404.php'; exit; }
$GLOBALS['_gym'] = $gym;
$info = site_info($pdo, (int) $gym['id']);
$navbar = site_navbar($gym);
$current_uri_2 = $current_uri_2 ?? '';
$GLOBALS['_view_shared'] = compact('info', 'navbar', 'current_uri_2');
$GLOBALS['_view_shared'] = compact('info', 'navbar', 'current_uri_2', 'gym');
echo view('include'); // renders view('seo') inside <head>
echo view('navbar');
echo view('messenger_chat');
// Staff previewing a gym that isn't public yet.
if (empty($gym['is_published'])) {
echo '<div class="event-preview-bar">Pré-visualização — <strong>' . htmlspecialchars($gym['name'])
. '</strong> ainda não está publicado. Só quem tem sessão iniciada no backoffice vê esta página.</div>' . "\n";
}
// Page content landmark — closed at the top of site/includes/footer.php, so
// every public page gets one <main> (and the skip link a real target).
echo '<main id="conteudo">' . "\n";
+11 -6
View File
@@ -8,16 +8,21 @@
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php';
// Only POST is meaningful here; anything else just goes home.
// The gym comes from the URL folder (/<slug>/agendar); the old /home/agendar
// route has no ?gym and falls back to the default gym.
$gym = site_gym($pdo) ?? db_gyms_default($pdo) ?? gym_fallback();
$GLOBALS['_gym'] = $gym;
// Only POST is meaningful here; anything else just goes to the gym's page.
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
header('Location: ' . base_url('home'));
header('Location: ' . gym_url($gym));
exit;
}
csrf_check(); // 403s and exits on a bad/missing token
function agendar_redirect(string $status): void {
header('Location: ' . base_url('home?agendar=' . $status . '#contactos'));
header('Location: ' . gym_url(current_gym(), '?agendar=' . $status . '#contactos'));
exit;
}
@@ -39,7 +44,7 @@ if ($data !== '') {
$data_fmt = ($d && $d->format('Y-m-d') === $data) ? $d->format('d/m/Y') : '';
}
$info = site_info($pdo);
$info = site_info($pdo, (int) $gym['id']);
$to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM;
$e = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
@@ -58,7 +63,7 @@ foreach ($rows as $label => $value) {
. ':</td><td>' . nl2br($e($value)) . '</td></tr>';
}
$body .= '</table><p style="font-family:Arial,sans-serif;font-size:13px;color:#666;margin-top:18px;">'
. 'Pedido enviado através do formulário do site FitMe 3070. Responde diretamente a '
. 'Pedido enviado através do formulário do site ' . $e($gym['name']) . '. Responde diretamente a '
. $e($email) . ' ou liga para ' . $e($telefone) . '.</p>';
$alt = "Novo pedido de aula experimental\n\n";
@@ -69,7 +74,7 @@ foreach ($rows as $label => $value) {
$id = enqueue_email([
'to_address' => $to,
'to_name' => $info['name'] !== '' ? $info['name'] : 'FitMe 3070',
'to_name' => $info['name'] !== '' ? $info['name'] : $gym['name'],
'subject' => 'Pedido de aula experimental — ' . $nome,
'body' => $body,
'alt_body' => $alt,
+2 -2
View File
@@ -4149,7 +4149,7 @@ h3.contact-title {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 6px;
border-radius: var(--fitme-radius); /* brand: square-ish corners */
position: relative;
background: var(--fitme-surface-2);
cursor: zoom-in;
@@ -4229,7 +4229,7 @@ h3.contact-title {
max-width: 100%;
max-height: calc(100vh - 110px);
object-fit: contain;
border-radius: 4px;
border-radius: var(--fitme-radius);
box-shadow: 0 20px 60px rgba(0, 0, 0, .5);
animation: gal-in .35s ease;
}
+14 -2
View File
@@ -1,7 +1,19 @@
<?php
// Was Home::crossfit.
$current_uri_2 = 'crossfit';
// Old /home/crossfit links → the default gym's CrossFit page.
if (($_GET['gym'] ?? '') === '') {
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php';
$to = db_gyms_default($pdo) ?? gym_fallback();
header('Location: ' . gym_url($to, 'crossfit'), true, 301);
exit;
}
require __DIR__ . '/_open.php';
// Only gyms with a CrossFit box have this page.
if (empty($gym['has_crossfit'])) { ob_get_level() && ob_end_clean(); require __DIR__ . '/404.php'; exit; }
$data = [
'title' => 'CrossFit',
@@ -13,13 +25,13 @@ $data = [
'header_video_video' => 'site/assets/img/crossfit.mp4',
'header_video_img' => 'site/assets/img/crossfit.jpg',
'rel_footer_h2' => 'Precisas de um<span>Personal Trainer?</span><br> Entra em contacto connosco.',
'ad_footer_link' => 'home/contactos?assunto=pt_cf',
'ad_footer_link' => rawurlencode($gym['slug']) . '/?assunto=pt_cf#contactos',
'ad_footer_text' => 'Tens objetivos específicos na área do CrossFit? Temos a solução para ti. Atinge e melhora os teus resultados com os nossos profissionais especializados na área. Todas as viagens são melhores quando vividas com a companhia certa. ',
'ad_footer_h2' => 'Precisas de um <span>Personal Trainer</span> especializado em CrossFit?',
'ad_footer_bg' => '',
'ad_footer_text_btn' => 'Entra em contacto connosco',
];
$data['data'] = site_horario($pdo, 'crossfit');
$data['data'] = site_horario($pdo, 'crossfit', (int) $gym['id']);
echo view('header_video', $data);
echo view('crossfit', $data);
+52
View File
@@ -0,0 +1,52 @@
<?php
// Root page when more than one gym is published (site/index.php includes this
// instead of redirecting): pick a gym. Self-contained — no gym is "current"
// here, so it doesn't go through _open.php. Expects $pdo and $published.
$gyms = $published ?? db_gyms_published($pdo);
$brand = 'FitMe';
?><!doctype html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($brand) ?> — Escolhe o teu ginásio</title>
<meta name="description" content="<?= htmlspecialchars($brand) ?>: <?= htmlspecialchars(implode(', ', array_map(fn($g) => $g['name'] . ($g['locality'] !== '' ? ' (' . $g['locality'] . ')' : ''), $gyms))) ?>.">
<link rel="canonical" href="<?= htmlspecialchars(base_url()) ?>">
<?php if (seo_noindex()): ?><meta name="robots" content="noindex, nofollow"><?php endif; ?>
<link href="https://fonts.googleapis.com/css?family=Roboto:400,700,900&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="<?= base_url('site/assets/img/logos/logo_black.svg') ?>">
<style>
:root { --accent:#C2D500; --dark:#0B0B0D; --dark2:#16181A; --ash:#B8B9BE; }
* { box-sizing: border-box; }
body { margin:0; min-height:100svh; font-family:Roboto,system-ui,sans-serif; color:#fff;
background:#141517 url('<?= base_url('site/assets/img/home.jpg') ?>') center/cover fixed; }
.wrap { min-height:100svh; background:rgba(11,11,13,.82); display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:48px 16px; gap:36px; }
h1 { margin:0; font-size:clamp(1.8rem,4vw,2.6rem); font-weight:900; text-transform:uppercase; text-align:center; }
h1 span { color:var(--accent); }
.gyms { display:flex; flex-wrap:wrap; gap:18px; justify-content:center; width:100%; max-width:900px; }
a.gym { flex:1 1 260px; max-width:320px; display:block; padding:28px 24px; border-radius:8px; text-decoration:none;
color:#fff; background:var(--dark2); border:2px solid rgba(255,255,255,.08);
transition:border-color .25s, transform .25s; }
a.gym:hover, a.gym:focus-visible { border-color:var(--accent); transform:translateY(-4px); outline:none; }
.gym strong { display:block; font-size:1.4rem; font-weight:900; text-transform:uppercase; }
.gym span { color:var(--ash); }
.gym em { display:inline-block; margin-top:14px; font-style:normal; font-weight:700; color:var(--accent); }
</style>
</head>
<body>
<main class="wrap">
<img src="<?= base_url('site/assets/img/logos/logo_black.svg') ?>" alt="<?= htmlspecialchars($brand) ?>" style="width:170px;filter:invert(1)">
<h1>Escolhe o teu <span>ginásio</span></h1>
<div class="gyms">
<?php foreach ($gyms as $g): ?>
<a class="gym" href="<?= htmlspecialchars(gym_url($g)) ?>">
<strong><?= htmlspecialchars($g['name']) ?></strong>
<?php if ($g['locality'] !== ''): ?><span><?= htmlspecialchars($g['locality']) ?></span><?php endif; ?>
<br><em>Entrar →</em>
</a>
<?php endforeach; ?>
</div>
</main>
</body>
</html>
+1 -1
View File
@@ -21,7 +21,7 @@
<?php endif; ?>
<ul class="footer-menu">
<?php foreach($navbar as $key => $n): ?>
<li><a href="<?= base_url($n) ?>" <?= (isset($n[0]) && $n[0] === '#') ? 'class="smooth-scroll"' : '' ?>><?= $key ?></a></li>
<li><a href="<?= htmlspecialchars($n) ?>" <?= str_contains($n, '#') ? 'class="smooth-scroll"' : '' ?>><?= $key ?></a></li>
<?php endforeach; ?>
</ul>
<p class="small footer-legal">Este estabelecimento possui livro de reclamações online em <a href="https://www.livroreclamacoes.pt/Pedido/Reclamacao" target="_blank" rel="noopener">www.livroreclamacoes.pt</a></p>
+17 -2
View File
@@ -10,10 +10,25 @@ $page_meta = [
'privacidade' => ['Política de Privacidade | FitMe 3070',
'Política de privacidade do FitMe 3070 — como recolhemos, usamos e protegemos os teus dados pessoais.'],
];
// The gym's homepage uses the gym's own SEO title/description when set
// (Gestão → Ginásios); a new gym without them gets a generic one from its name
// and locality rather than the 3070 texts above.
$gym_meta = current_gym();
if ($page === '' && ($gym_meta['seo_title'] ?? '') !== '') {
$page_meta[''] = [$gym_meta['seo_title'], ($gym_meta['seo_description'] ?? '') !== '' ? $gym_meta['seo_description'] : $page_meta[''][1]];
} elseif ($page === '' && (int) $gym_meta['id'] !== 1) {
$loc = ($gym_meta['locality'] ?? '') !== '' ? ' em ' . $gym_meta['locality'] : '';
$page_meta[''] = [$gym_meta['name'] . ' — Ginásio' . $loc,
($gym_meta['seo_description'] ?? '') !== '' ? $gym_meta['seo_description'] : 'Ginásio ' . $gym_meta['name'] . $loc . ': treino, aulas de grupo e acompanhamento.'];
}
// A dynamic page (e.g. an event landing page) can set its own title/description
// and canonical via these globals before requiring _open.php.
[$page_title, $page_desc] = $GLOBALS['_page_meta_override'] ?? $page_meta[$page] ?? $page_meta[''];
$canonical = $GLOBALS['_canonical_override'] ?? base_url($page !== '' ? 'home/' . $page : '');
$canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
'' => gym_url($gym_meta),
'crossfit' => gym_url($gym_meta, 'crossfit'),
default => base_url('home/' . $page),
};
?><!doctype html>
<html lang="pt">
<head>
@@ -34,7 +49,7 @@ $canonical = $GLOBALS['_canonical_override'] ?? base_url($page !== '' ? 'home/'
<link rel="dns-prefetch" href="https://forms.zohopublic.eu">
<?php if (($current_uri_2 ?? '') === ''): ?>
<!-- Homepage hero is the LCP image — start fetching it before the CSS/JS -->
<link rel="preload" as="image" href="<?= base_url('site/assets/img/home.jpg') ?>">
<link rel="preload" as="image" href="<?= base_url(($gym_meta['hero_image'] ?? '') !== '' ? $gym_meta['hero_image'] : 'site/assets/img/home.jpg') ?>">
<?php endif; ?>
<!-- Fonts -->
+8 -7
View File
@@ -9,18 +9,20 @@ else
?>
<header class="header-section lazy">
<nav class="navbar navbar-expand-lg navbar-light" style="padding-left:1rem; padding-top: 0; padding-bottom: 0;padding-right: 0; ">
<a class="navbar-brand" href="<?= base_url('home') ?>">
<a class="navbar-brand" href="<?= htmlspecialchars(gym_url(current_gym())) ?>">
<img loading="eager" alt="Logo FitMe3070" src="<?= $logo ?>" style="width: 150px;">
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mx-auto ">
<?php foreach($navbar as $key => $n):
// $n is an absolute URL on the gym's own page (site_navbar()).
$is_crossfit = ($key === 'CrossFit');
$is_anchor = str_contains($n, '#');
$is_active = false;
if ($is_crossfit) {
$is_active = ($current_uri_2 === 'crossfit');
} elseif ($n === '#home') {
} elseif (str_ends_with($n, '#home')) {
// Only the Home link is active by default on the homepage
$is_active = ($current_uri_2 === '');
}
@@ -28,16 +30,15 @@ else
?>
<li class="nav-item <?= $is_active ? 'active' : '' ?> main-menu-li" >
<?php if ($is_crossfit): ?>
<a class="nav-link crossfit-nav-link" href="<?= base_url($n) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
<a class="nav-link crossfit-nav-link" href="<?= htmlspecialchars($n) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
<?= $key ?>
</a>
<?php elseif (isset($n[0]) && $n[0] === '#'): ?>
<?php $href = ($current_uri_2 === 'crossfit') ? base_url('home' . $n) : base_url($n); ?>
<a class="nav-link smooth-scroll" href="<?= $href ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
<?php elseif ($is_anchor): ?>
<a class="nav-link smooth-scroll" href="<?= htmlspecialchars($n) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
<?= $key ?>
</a>
<?php else: ?>
<a class="nav-link" href="<?= base_url($n) ?>">
<a class="nav-link" href="<?= htmlspecialchars($n) ?>">
<?= $key ?>
</a>
<?php endif; ?>
+5 -4
View File
@@ -1,12 +1,13 @@
<?php
// JSON-LD structured data for the gym (rendered inside <head> by include.php).
// Built from fitme3070_info + work_hours so admin edits stay in sync.
$ld_gym = current_gym();
$ld = [
'@context' => 'https://schema.org',
'@type' => 'ExerciseGym',
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070',
'url' => base_url(),
'image' => base_url('site/assets/img/home.jpg'),
'name' => $ld_gym['name'] !== '' ? $ld_gym['name'] : (($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070'),
'url' => gym_url($ld_gym),
'image' => base_url(($ld_gym['hero_image'] ?? '') !== '' ? $ld_gym['hero_image'] : 'site/assets/img/home.jpg'),
'logo' => base_url('site/assets/img/logos/logo_black.svg'),
];
if (!empty($info['phone'])) $ld['telephone'] = site_tel($info['phone']);
@@ -15,7 +16,7 @@ if (!empty($info['adress'])) {
$ld['address'] = [
'@type' => 'PostalAddress',
'streetAddress' => $info['adress'],
'addressLocality' => 'Mira',
'addressLocality' => ($ld_gym['locality'] ?? '') !== '' ? $ld_gym['locality'] : 'Mira',
'addressCountry' => 'PT',
];
}
+94 -68
View File
@@ -2,17 +2,37 @@
// One-page site: Home + Equipa + Modalidades + Horário + Contactos.
// CrossFit is a separate page (site/crossfit.php).
$current_uri_2 = '';
require __DIR__ . '/_open.php';
// No gym in the URL (/, /home, old links): send people to a gym page. With a
// single published gym that is a 301 to it (query string kept, e.g.
// ?agendar=ok); with more, the gym chooser.
if (($_GET['gym'] ?? '') === '') {
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php';
$published = db_gyms_published($pdo);
if (count($published) > 1) { require __DIR__ . '/gym_chooser.php'; exit; }
$to = $published[0] ?? db_gyms_default($pdo) ?? gym_fallback();
$qs = $_SERVER['QUERY_STRING'] ?? '';
header('Location: ' . gym_url($to) . ($qs !== '' ? '?' . $qs : ''), true, 301);
exit;
}
require __DIR__ . '/_open.php'; // resolves $gym (404 if unknown / not visible)
// ── Load all data ───────────────────────────────────────────────────────────────
$offer = site_offer($pdo);
$team = site_team($pdo);
$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
// Everything is this gym's content; a section the gym switched off
// (Gestão → Ginásios) loads as empty, so its block below simply isn't printed.
$gid = (int) $gym['id'];
$on = fn(string $key) => gym_section_on($gym, $key);
$offer = $on('oferta') ? site_offer($pdo, $gid) : [];
$team = $on('equipa') ? site_team($pdo, $gid) : [];
$modalidades = $on('modalidades') ? site_modalidades($pdo, $gid) : [];
$horario = $on('horario') ? site_horario($pdo, '', $gid) : [];
$whours = $on('workhours') ? site_work_hours($pdo, $gid) : [];
$events = site_active_events($pdo, $gid); // time-limited events, banner below the hero
$memberships = $on('mensalidades') ? site_memberships($pdo, $gid) : [];
$reviews = $on('reviews') ? site_reviews($gym) : null; // null when not configured / nothing qualifies
$hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img/home.jpg';
// ── Hero section ────────────────────────────────────────────────────────────────
?>
@@ -27,7 +47,7 @@ $reviews = site_reviews(); // null when not configured / nothin
<!-- Hero background is inline (no data-setbg) so it paints without waiting for JS;
the set-bg class is kept for its cover/center styling. -->
<section id="home" class="hero-section set-bg fade-in" style="background-image:url('<?= base_url('site/assets/img/home.jpg') ?>')">
<section id="home" class="hero-section set-bg fade-in" style="background-image:url('<?= htmlspecialchars(base_url($hero_img), ENT_QUOTES) ?>')">
<div class="container">
<div class="hero-text">
<?php /* Eventos a decorrer — destacados no topo do header. Cada um liga
@@ -50,7 +70,7 @@ $reviews = site_reviews(); // null when not configured / nothin
<?php endforeach; ?>
</div>
<?php endif; ?>
<h1>Ginásio, Box, Aulas<br><span>&amp; Acompanhamento</span></h1>
<h1><?= htmlspecialchars($gym['hero_title'] !== '' ? $gym['hero_title'] : $gym['name']) ?><?php if ($gym['hero_subtitle'] !== ''): ?><br><span><?= htmlspecialchars($gym['hero_subtitle']) ?></span><?php endif; ?></h1>
<div class="hero-cta-row">
<a href="?assunto=insc#contactos" class="site-btn">Inscreve-te Já!</a>
<button type="button" class="site-btn sb-ghost" data-toggle="modal" data-target="#trialModal">Marca uma Aula Experimental</button>
@@ -92,8 +112,8 @@ if ($offer):
<?php
// ── Galeria (Instalações) ───────────────────────────────────────────────────────
$gallery_path = 'uploads/gallery/1/';
$gallery_files = glob($gallery_path . '*.jpg');
$gallery_path = !empty($gym['gallery_id']) ? 'uploads/gallery/' . (int) $gym['gallery_id'] . '/' : '';
$gallery_files = ($on('galeria') && $gallery_path !== '') ? glob($gallery_path . '*.jpg') : [];
if ($gallery_files && count($gallery_files) > 0):
$gallery_h2 = 'Conhece as nossas instalações';
?>
@@ -102,6 +122,59 @@ $gallery_h2 = 'Conhece as nossas instalações';
</section>
<?php endif; ?>
<?php
// ── Modalidades ─────────────────────────────────────────────────────────────────
if ($modalidades):
?>
<section id="modalidades" class="service-section" aria-labelledby="modalidades-title">
<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">Treinos</span>
<h2 id="modalidades-title">Modalidades</h2>
<p class="section-subtitle">Encontra a modalidade certa para ti.</p>
</div>
</div>
</div>
</div>
<?php /* Full-width strip of modalidade cards. It is a real horizontal
scroller (touch, trackpad/wheel, mouse drag, arrows) that also
glides on its own until someone interacts (main.js). A card with a
description opens .mod-detail — one shared panel outside the
scroller, so it isn't clipped — which grows out of the card. */ ?>
<div class="mod-strip">
<button type="button" class="mod-nav mod-nav-prev" aria-label="Modalidades anteriores"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>
<div class="mod-scroll" tabindex="0" aria-label="Modalidades — desliza para ver mais">
<?php foreach($modalidades as $d): $desc = trim(strip_tags((string) ($d['descricao'] ?? ''))) !== '' ? trim((string) $d['descricao']) : ''; ?>
<div class="icon-box-item mod-card<?= $desc !== '' ? ' has-info' : '' ?>" data-color="<?= htmlspecialchars((string) $d['cor']) ?>"<?= $desc !== '' ? ' role="button" tabindex="0" aria-haspopup="dialog" aria-label="Ver descrição de ' . htmlspecialchars((string) $d['nome']) . '"' : '' ?>>
<div class="ib-icon">
<?= modalidade_icon_html($d['icon']) /* inline animated SVG for the FitMe set, else <img> */ ?>
</div>
<h3><?= htmlspecialchars((string) $d['nome']) ?></h3>
<?php if ($desc !== ''): ?>
<?php /* descricao is admin-authored rich text — sanitised allowlist HTML */ ?>
<template class="mod-desc"><?= clean_richtext($desc) ?></template>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="mod-nav mod-nav-next" aria-label="Mais modalidades"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
</div>
<div class="mod-detail" role="dialog" aria-modal="false" aria-hidden="true">
<button type="button" class="md-close" aria-label="Fechar">&times;</button>
<div class="md-card">
<div class="ib-icon"></div>
<h3></h3>
</div>
<div class="md-body">
<strong class="md-name"></strong>
<div class="md-text"></div>
</div>
</div>
</section>
<?php endif; ?>
<?php
// ── Equipa ──────────────────────────────────────────────────────────────────────
if ($team):
@@ -166,59 +239,6 @@ if ($team):
</section>
<?php endif; ?>
<?php
// ── Modalidades ─────────────────────────────────────────────────────────────────
if ($modalidades):
?>
<section id="modalidades" class="service-section" aria-labelledby="modalidades-title">
<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">Treinos</span>
<h2 id="modalidades-title">Modalidades</h2>
<p class="section-subtitle">Encontra a modalidade certa para ti.</p>
</div>
</div>
</div>
</div>
<?php /* Full-width strip of modalidade cards. It is a real horizontal
scroller (touch, trackpad/wheel, mouse drag, arrows) that also
glides on its own until someone interacts (main.js). A card with a
description opens .mod-detail — one shared panel outside the
scroller, so it isn't clipped — which grows out of the card. */ ?>
<div class="mod-strip">
<button type="button" class="mod-nav mod-nav-prev" aria-label="Modalidades anteriores"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>
<div class="mod-scroll" tabindex="0" aria-label="Modalidades — desliza para ver mais">
<?php foreach($modalidades as $d): $desc = trim(strip_tags((string) ($d['descricao'] ?? ''))) !== '' ? trim((string) $d['descricao']) : ''; ?>
<div class="icon-box-item mod-card<?= $desc !== '' ? ' has-info' : '' ?>" data-color="<?= htmlspecialchars((string) $d['cor']) ?>"<?= $desc !== '' ? ' role="button" tabindex="0" aria-haspopup="dialog" aria-label="Ver descrição de ' . htmlspecialchars((string) $d['nome']) . '"' : '' ?>>
<div class="ib-icon">
<?= modalidade_icon_html($d['icon']) /* inline animated SVG for the FitMe set, else <img> */ ?>
</div>
<h3><?= htmlspecialchars((string) $d['nome']) ?></h3>
<?php if ($desc !== ''): ?>
<?php /* descricao is admin-authored rich text — sanitised allowlist HTML */ ?>
<template class="mod-desc"><?= clean_richtext($desc) ?></template>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="mod-nav mod-nav-next" aria-label="Mais modalidades"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
</div>
<div class="mod-detail" role="dialog" aria-modal="false" aria-hidden="true">
<button type="button" class="md-close" aria-label="Fechar">&times;</button>
<div class="md-card">
<div class="ib-icon"></div>
<h3></h3>
</div>
<div class="md-body">
<strong class="md-name"></strong>
<div class="md-text"></div>
</div>
</div>
</section>
<?php endif; ?>
<?php
// ── Horário ─────────────────────────────────────────────────────────────────────
if ($horario):
@@ -556,6 +576,7 @@ if (isset($_GET['assunto'])) {
}
$session = session();
?>
<?php if ($on('contactos')): ?>
<?php /* contacts-bg.jpg never existed — outside2.jpg is the current photo in img/contactos/ */ ?>
<section id="contactos" class="contact-section set-bg" data-setbg="<?= base_url('site/assets/img/contactos/outside2.jpg') ?>" aria-labelledby="contactos-title">
<div class="container">
@@ -663,6 +684,8 @@ $session = session();
</div>
</section>
<?php endif; /* contactos */ ?>
<?php
// ── Horário de Funcionamento ──────────────────────────────────────────────────
if ($whours):
@@ -694,6 +717,7 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
<?php
// ── Personal Trainer CTA ────────────────────────────────────────────────────────
if ($on('personal_trainer')):
?>
<section id="personal-trainer" class="classes-section" aria-labelledby="pt-title">
<div class="container">
@@ -713,6 +737,8 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
</div>
</section>
<?php endif; /* personal trainer */ ?>
<?php
// ── Aula experimental modal ─────────────────────────────────────────────────
// Queues a booking request to id.andmore.pt (centralized mail) via site/agendar.php.
@@ -726,7 +752,7 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
<p>Treina connosco sem compromisso. Deixa os teus dados e entramos em contacto para confirmar o dia e a hora.</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/agendar') ?>">
<form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'agendar')) ?>">
<?= csrf_field() ?>
<div class="form-row">
<div class="col-12 col-md-6 mb-3">
@@ -785,7 +811,7 @@ if ($memberships):
<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') ?>">
<form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'inscricao')) ?>">
<?= csrf_field() ?>
<input type="text" name="website" class="event-hp" tabindex="-1" autocomplete="off" aria-hidden="true">
<div class="form-row">
+11 -4
View File
@@ -10,15 +10,20 @@
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php';
// The gym comes from the URL folder (/<slug>/inscricao); the old /home/inscricao
// route has no ?gym and falls back to the default gym.
$gym = site_gym($pdo) ?? db_gyms_default($pdo) ?? gym_fallback();
$GLOBALS['_gym'] = $gym;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
header('Location: ' . base_url('home#mensalidades'));
header('Location: ' . gym_url($gym, '#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'));
header('Location: ' . gym_url(current_gym(), '?inscricao=' . $status . '#mensalidades'));
exit;
}
@@ -27,9 +32,11 @@ 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;
if ($membership && ($membership['deleted_at'] !== null || !$membership['is_active']
|| (int) $membership['gym_id'] !== (int) $gym['id'])) $membership = null;
$lead = [
'gym_id' => (int) $gym['id'],
'membership_id' => $membership ? (int) $membership['id'] : null,
'membership_name' => $membership ? $membership['name'] : '',
'name' => trim((string) ($_POST['nome'] ?? '')),
@@ -58,7 +65,7 @@ try {
// Notify the gym. Best effort: the request is already stored.
try {
$info = site_info($pdo);
$info = site_info($pdo, (int) $gym['id']);
$to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM;
$e = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$rows = [