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:
1 parent
2f8f68c61a
commit
5e300fc341
33 files changed
+1146
-197
No files matched your search
@@ -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>
|
||||
@@ -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'; ?>
|
||||
@@ -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'; ?>
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user