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

+9
View File
@@ -37,6 +37,15 @@ include __DIR__ . '/../../includes/header.php';
<div class="card shadow-sm mb-4"><div class="card-body">
<h2 class="h5">Gestão de Conteúdos</h2>
<div class="row g-3 mt-1">
<div class="col-sm-6 col-lg-4">
<a class="card text-decoration-none border-primary shadow-sm h-100" href="<?= base_url('views/admin/gyms/index.php') ?>">
<div class="card-body">
<i class="bi bi-buildings-fill fs-3 text-primary mb-2 d-block"></i>
<strong>Ginásios</strong>
<p class="text-muted small mb-0">A editar: <?= htmlspecialchars(admin_gym($pdo)['name']) ?> — criar e gerir ginásios</p>
</div>
</a>
</div>
<div class="col-sm-6 col-lg-4">
<a class="card text-decoration-none border-primary shadow-sm h-100" href="<?= base_url('views/admin/team/index.php') ?>">
<div class="card-body">
+104
View File
@@ -0,0 +1,104 @@
<?php
// Shared gym form fields (create.php / edit.php). Expects $data (gym row or
// gym_defaults()) and $galleries (id => name).
$sections_on = [];
foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $sections_on[] = $k;
?>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Nome <span class="text-danger">*</span></label>
<input class="form-control" name="name" required maxlength="100" placeholder="ex.: FitMe 3070" value="<?= htmlspecialchars($data['name'] ?? '') ?>">
</div>
<div class="col-md-3">
<label class="form-label">Pasta da URL <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">/</span>
<input class="form-control font-monospace" name="slug" required maxlength="20" pattern="[a-z0-9][a-z0-9-]{1,19}" placeholder="3070" value="<?= htmlspecialchars($data['slug'] ?? '') ?>">
<span class="input-group-text">/</span>
</div>
<div class="form-text">O código postal da zona.</div>
</div>
<div class="col-md-3">
<label class="form-label">Ordem</label>
<input type="number" class="form-control" name="item_order" value="<?= (int) ($data['item_order'] ?? 0) ?>">
</div>
<div class="col-md-6">
<label class="form-label">Localidade</label>
<input class="form-control" name="locality" maxlength="100" placeholder="ex.: Mira" value="<?= htmlspecialchars($data['locality'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">Código postal</label>
<input class="form-control" name="postal_code" maxlength="20" placeholder="ex.: 3070-xxx" value="<?= htmlspecialchars($data['postal_code'] ?? '') ?>">
</div>
<div class="col-12"><hr class="my-1"><strong>Página</strong></div>
<div class="col-md-6">
<label class="form-label">Título do header</label>
<input class="form-control" name="hero_title" maxlength="150" placeholder="Ginásio, Box, Aulas" value="<?= htmlspecialchars($data['hero_title'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">Segunda linha do título</label>
<input class="form-control" name="hero_subtitle" maxlength="150" placeholder="& Acompanhamento" value="<?= htmlspecialchars($data['hero_subtitle'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">Imagem do header</label>
<input class="form-control font-monospace" name="hero_image" maxlength="255" placeholder="site/assets/img/home.jpg" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
<div class="form-text">Caminho de uma imagem do site. Vazio = a do ginásio principal.</div>
</div>
<div class="col-md-6">
<label class="form-label">Galeria das instalações</label>
<select class="form-select" name="gallery_id">
<option value="">— nenhuma —</option>
<?php foreach ($galleries as $gid => $gname): ?>
<option value="<?= (int) $gid ?>" <?= (int) ($data['gallery_id'] ?? 0) === (int) $gid ? 'selected' : '' ?>><?= htmlspecialchars($gname) ?></option>
<?php endforeach; ?>
</select>
<div class="form-text">As galerias gerem-se em Gestão → Galerias.</div>
</div>
<div class="col-md-6">
<label class="form-label">Título para o Google (SEO)</label>
<input class="form-control" name="seo_title" maxlength="160" placeholder="FitMe 3070 — Ginásio & CrossFit em Mira" value="<?= htmlspecialchars($data['seo_title'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">Place ID Google (reviews)</label>
<input class="form-control font-monospace" name="google_place_id" maxlength="120" placeholder="ChIJ…" value="<?= htmlspecialchars($data['google_place_id'] ?? '') ?>">
<div class="form-text">A chave da API é comum (Gestão → Reviews Google).</div>
</div>
<div class="col-12">
<label class="form-label">Descrição para o Google (SEO)</label>
<textarea class="form-control" name="seo_description" rows="2" maxlength="300"><?= htmlspecialchars($data['seo_description'] ?? '') ?></textarea>
</div>
<div class="col-12"><hr class="my-1"><strong>Secções visíveis</strong>
<div class="form-text mt-0">O layout é o mesmo em todos os ginásios; aqui escolhes quais aparecem.</div></div>
<?php foreach (gym_sections() as $key => $label): ?>
<div class="col-sm-6 col-lg-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="sections[]" value="<?= $key ?>" id="sec_<?= $key ?>" <?= in_array($key, $sections_on, true) ? 'checked' : '' ?>>
<label class="form-check-label" for="sec_<?= $key ?>"><?= htmlspecialchars($label) ?></label>
</div>
</div>
<?php endforeach; ?>
<div class="col-sm-6 col-lg-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="has_crossfit" value="1" id="has_crossfit" <?= !empty($data['has_crossfit']) ? 'checked' : '' ?>>
<label class="form-check-label" for="has_crossfit">Página CrossFit</label>
</div>
</div>
<div class="col-12"><hr class="my-1"><strong>Estado</strong></div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_published" value="1" id="is_published" <?= !empty($data['is_published']) ? 'checked' : '' ?>>
<label class="form-check-label" for="is_published">Publicado</label>
</div>
<div class="form-text">Por publicar, só quem tem sessão iniciada no backoffice vê a página (pré-visualização).</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_default" value="1" id="is_default" <?= !empty($data['is_default']) ? 'checked' : '' ?>>
<label class="form-check-label" for="is_default">Ginásio principal</label>
</div>
<div class="form-text">É para onde vão os endereços antigos (/home). Só pode haver um.</div>
</div>
</div>
+80
View File
@@ -0,0 +1,80 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
$errors = [];
$data = gym_defaults();
$galleries = $pdo->query("SELECT id, name FROM galleries" . (_gyms_has_col($pdo, 'galleries', 'deleted_at') ? " WHERE deleted_at IS NULL" : "") . " ORDER BY id")
->fetchAll(PDO::FETCH_KEY_PAIR);
$sources = db_gyms_list($pdo);
$copy_from = (int) ($_POST['copy_from'] ?? 0);
$copy_what = (array) ($_POST['copy'] ?? array_keys(gyms_copy_options()));
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$data = gyms_from_post($pdo, $_POST, $errors);
if (!$errors) {
$id = db_gyms_create($pdo, $data);
$msg = 'Ginásio criado.';
if ($copy_from && db_gyms_get($pdo, $copy_from)) {
$what = array_intersect(array_keys(gyms_copy_options()), $copy_what);
try {
$done = gyms_copy_content($pdo, $copy_from, $id, $what);
if ($done) $msg .= ' Copiado: ' . implode(', ', $done) . '.';
} catch (Throwable $e) {
error_log('[gyms] copy: ' . $e->getMessage());
$msg .= ' A cópia do conteúdo falhou — o ginásio foi criado vazio.';
}
}
$_SESSION['admin_gym_id'] = $id; // start editing the new gym straight away
session()->setFlashdata('success', $msg . ' Estás agora a editar este ginásio.');
header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id));
exit;
}
}
$page_title = 'Novo ginásio';
include __DIR__ . '/../../../includes/header.php';
?>
<div class="container" style="max-width:900px;">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Novo ginásio</h1>
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
</div>
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
<form method="POST" class="card shadow-sm"><div class="card-body">
<?= csrf_field() ?>
<?php include __DIR__ . '/_form.php'; ?>
<?php if ($sources): ?>
<hr>
<strong>Começar com conteúdo de outro ginásio</strong>
<div class="form-text mb-2">Cria cópias independentes — depois é só ajustar. Editar um ginásio nunca altera o outro.</div>
<div class="row g-2">
<div class="col-md-5">
<select class="form-select" name="copy_from">
<option value="0">— não copiar, começar vazio —</option>
<?php foreach ($sources as $s): ?>
<option value="<?= (int) $s['id'] ?>" <?= $copy_from === (int) $s['id'] ? 'selected' : '' ?>><?= htmlspecialchars($s['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-7">
<?php foreach (gyms_copy_options() as $k => $label): ?>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="copy[]" value="<?= $k ?>" id="copy_<?= $k ?>" <?= in_array($k, $copy_what, true) ? 'checked' : '' ?>>
<label class="form-check-label" for="copy_<?= $k ?>"><?= htmlspecialchars($label) ?></label>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div class="mt-4 d-flex gap-2">
<button type="submit" class="btn btn-primary">Criar</button>
<a class="btn btn-outline-secondary" href="index.php">Cancelar</a>
</div>
</div></form>
</div>
<?php include __DIR__ . '/../../../includes/footer.php'; ?>
+60
View File
@@ -0,0 +1,60 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
$id = (int) ($_GET['id'] ?? 0);
$item = $id ? db_gyms_get($pdo, $id) : null;
if (!$item || $item['deleted_at'] !== null) { header('Location: index.php'); exit; }
$errors = [];
$data = $item;
$galleries = $pdo->query("SELECT id, name FROM galleries" . (_gyms_has_col($pdo, 'galleries', 'deleted_at') ? " WHERE deleted_at IS NULL" : "") . " ORDER BY id")
->fetchAll(PDO::FETCH_KEY_PAIR);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$data = gyms_from_post($pdo, $_POST, $errors, $id);
// Unsetting "principal" here would leave no default gym — keep it.
if ($item['is_default'] && !$data['is_default']) {
$data['is_default'] = 1;
if (!$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado. Para despublicá-lo, torna outro ginásio principal primeiro.';
}
if (!$errors) {
$changed = db_gyms_update($pdo, $id, $data);
session()->setFlashdata($changed ? 'success' : 'warning', $changed ? 'Ginásio atualizado.' : 'Nenhuma alteração.');
header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id));
exit;
}
}
$page_title = 'Editar ginásio';
include __DIR__ . '/../../../includes/header.php';
?>
<div class="container" style="max-width:900px;">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-4">
<h1 class="h3 mb-0"><?= htmlspecialchars($item['name']) ?></h1>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars(gym_url($item)) ?>" target="_blank" rel="noopener">
<i class="bi bi-box-arrow-up-right me-1"></i><?= $item['is_published'] ? 'Ver página' : 'Pré-visualizar' ?></a>
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
</div>
</div>
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
<?php if (admin_gym_id() !== $id): ?>
<form method="POST" action="<?= base_url('views/admin/gyms/switch.php') ?>" class="alert alert-info d-flex justify-content-between align-items-center gap-2">
<?= csrf_field() ?><input type="hidden" name="gym_id" value="<?= $id ?>"><input type="hidden" name="back" value="views/admin/dashboard.php">
<span>Para editar a equipa, modalidades, horários, mensalidades e contactos deste ginásio, muda o ginásio em edição.</span>
<button class="btn btn-sm btn-primary text-nowrap">Editar conteúdo deste</button>
</form>
<?php endif; ?>
<form method="POST" class="card shadow-sm"><div class="card-body">
<?= csrf_field() ?>
<?php include __DIR__ . '/_form.php'; ?>
<div class="mt-4 d-flex gap-2">
<button type="submit" class="btn btn-primary">Atualizar</button>
<a class="btn btn-outline-secondary" href="index.php">Cancelar</a>
</div>
</div></form>
</div>
<?php include __DIR__ . '/../../../includes/footer.php'; ?>
+69
View File
@@ -0,0 +1,69 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
if (isset($_POST['delete'])) {
$ok = db_gyms_delete($pdo, (int) $_POST['delete']);
session()->setFlashdata($ok ? 'success' : 'warning', $ok ? 'Ginásio apagado.' : 'O ginásio principal não pode ser apagado.');
} elseif (isset($_POST['restore'])) {
db_gyms_restore($pdo, (int) $_POST['restore']);
session()->setFlashdata('success', 'Ginásio restaurado (por publicar).');
}
header('Location: ' . base_url('views/admin/gyms/index.php'));
exit;
}
$gyms = db_gyms_list($pdo, true);
$editing = admin_gym_id();
$page_title = 'Ginásios';
include __DIR__ . '/../../../includes/header.php';
?>
<div class="container">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h1 class="h3 mb-0">Ginásios</h1>
<a class="btn btn-primary" href="<?= base_url('views/admin/gyms/create.php') ?>"><i class="bi bi-plus-lg me-1"></i>Adicionar ginásio</a>
</div>
<p class="text-muted small">Cada ginásio usa as mesmas secções do site com o seu próprio conteúdo (equipa, modalidades e horário, mensalidades, contactos…). O conteúdo que editas nas outras páginas é o do ginásio escolhido no topo («A editar»).</p>
<div class="card shadow-sm"><div class="card-body p-0">
<form method="POST" class="mb-0"><?= csrf_field() ?>
<table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr>
<th>Ginásio</th><th>Endereço</th><th>Estado</th><th>Secções</th><th style="width:150px">Ações</th>
</tr></thead>
<tbody>
<?php if (!$gyms): ?>
<tr><td colspan="5" class="text-center text-muted py-4">Sem ginásios — falta aplicar a migração (Sistema → Atualizar plataforma).</td></tr>
<?php else: foreach ($gyms as $g): $deleted = $g['deleted_at'] !== null;
$n_on = count(array_filter(array_keys(gym_sections()), fn($k) => gym_section_on($g, $k))); ?>
<tr class="<?= $deleted ? 'text-muted' : '' ?>">
<td><strong><?= htmlspecialchars($g['name']) ?></strong>
<?php if ($g['is_default']): ?><span class="badge bg-primary ms-1">Principal</span><?php endif; ?>
<?php if ((int) $g['id'] === $editing && !$deleted): ?><span class="badge bg-info text-dark ms-1">A editar</span><?php endif; ?>
<?php if ($g['locality'] !== ''): ?><div class="small text-muted"><?= htmlspecialchars($g['locality']) ?></div><?php endif; ?></td>
<td><a href="<?= htmlspecialchars(gym_url($g)) ?>" target="_blank" rel="noopener" class="font-monospace">/<?= htmlspecialchars($g['slug']) ?>/</a></td>
<td><?php if ($deleted): ?><span class="badge bg-secondary">Apagado</span>
<?php elseif ($g['is_published']): ?><span class="badge bg-success">Publicado</span>
<?php else: ?><span class="badge bg-warning text-dark" title="Só a equipa vê a página">Por publicar</span><?php endif; ?></td>
<td class="small"><?= $n_on ?> / <?= count(gym_sections()) ?></td>
<td class="text-nowrap">
<?php if ($deleted): ?>
<button class="btn btn-sm btn-outline-info" name="restore" value="<?= (int) $g['id'] ?>">Restaurar</button>
<?php else: ?>
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= (int) $g['id'] ?>" title="Editar"><i class="bi bi-pencil"></i></a>
<?php if (!$g['is_default']): ?>
<button class="btn btn-sm btn-outline-danger" name="delete" value="<?= (int) $g['id'] ?>" title="Apagar" onclick="return confirm('Apagar este ginásio? O conteúdo dele fica guardado.')"><i class="bi bi-trash"></i></button>
<?php endif; ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>
</form>
</div></div>
</div>
<?php include __DIR__ . '/../../../includes/footer.php';
+22
View File
@@ -0,0 +1,22 @@
<?php
// Backoffice gym switcher: sets which gym the content pages edit (session),
// then goes back to where the admin was. POST + CSRF only.
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$gym = db_gyms_get($pdo, (int) ($_POST['gym_id'] ?? 0));
if ($gym && $gym['deleted_at'] === null) {
$_SESSION['admin_gym_id'] = (int) $gym['id'];
session()->setFlashdata('success', 'A editar: ' . $gym['name'] . '.');
}
}
// Only same-site backoffice paths as the return target.
$back = (string) ($_POST['back'] ?? '');
if (!preg_match('~^views/[A-Za-z0-9_/.-]+\.php(\?[A-Za-z0-9_=&%.-]*)?$~', $back) || str_contains($back, '..')) {
$back = 'views/admin/dashboard.php';
}
header('Location: ' . base_url($back));
exit;
+1 -1
View File
@@ -4,7 +4,7 @@ require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
$errors = [];
$data = site_info($pdo);
$data = site_info($pdo, admin_gym_id()); // the gym being edited
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
+2 -1
View File
@@ -5,7 +5,8 @@ $user = require_admin();
$id = (int) ($_GET['id'] ?? 0);
$item = $id ? db_memberships_get($pdo, $id) : null;
if (!$item) { header('Location: index.php'); exit; }
// Only the gym being edited (a stale link after switching gyms goes back to the list).
if (!$item || (int) $item['gym_id'] !== admin_gym_id()) { header('Location: index.php'); exit; }
$errors = [];
$data = $item;
+6 -2
View File
@@ -2,6 +2,9 @@
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
// The Place ID and cache are per gym: this page works on the gym being edited.
$rgym = admin_gym($pdo);
reviews_use_gym($rgym);
$search = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -18,7 +21,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} elseif ($key !== '') {
db_update_setting($pdo, 'google_places_api_key', secret_encrypt($key), $uid);
}
db_update_setting($pdo, 'google_place_id', trim((string) ($_POST['google_place_id'] ?? '')), $uid);
$pdo->prepare("UPDATE gyms SET google_place_id = ?, updated_at = NOW() WHERE id = ?")
->execute([mb_substr(trim((string) ($_POST['google_place_id'] ?? '')), 0, 120), (int) $rgym['id']]);
db_update_setting($pdo, 'google_reviews_min_rating', (string) max(1, min(5, (int) ($_POST['min_rating'] ?? 4))), $uid);
db_update_setting($pdo, 'google_reviews_max', (string) max(1, min(5, (int) ($_POST['max'] ?? 3))), $uid);
db_update_setting($pdo, 'google_reviews_cache_hours', (string) max(1, (int) ($_POST['cache_hours'] ?? 24)), $uid);
@@ -54,7 +58,7 @@ $cfg = reviews_config();
$env_key = (string) env('GOOGLE_PLACES_API_KEY', '') !== '';
$cache = reviews_cache_read();
$preview = site_reviews();
$info = site_info($pdo);
$info = site_info($pdo, (int) $rgym['id']);
$page_title = 'Reviews Google';
include __DIR__ . '/../../../includes/header.php';
?>