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

+330
View File
@@ -0,0 +1,330 @@
<?php
// ─── Ginásios (multi-gym) ─────────────────────────────────────────────────────
// One site, several gyms: the same homepage sections, each gym with its own
// content. A gym lives in a URL folder named after its postal code
// (/3070/, /XXXX/) and can be unpublished — then only logged-in staff can open
// it (preview), everyone else gets a 404.
//
// Content tables carry gym_id (team, modalidades → horario, work_hours,
// memberships, membership_leads, website_offer; events: NULL = every gym) and
// fitme3070_info has one row per gym with id = gyms.id.
//
// Public site — site_gym($pdo): the gym of the current request (?gym=<slug>,
// set by .htaccess from the URL folder), default gym otherwise.
// Backoffice — admin_gym_id(): the gym picked in the header switcher
// (session), which every content CRUD page reads and writes.
// Homepage sections a gym can switch off. Order = page order.
function gym_sections(): array {
return [
'oferta' => 'A Nossa Oferta',
'galeria' => 'Galeria (instalações)',
'modalidades' => 'Modalidades',
'equipa' => 'Equipa',
'horario' => 'Horário de aulas',
'mensalidades' => 'Mensalidades',
'reviews' => 'Reviews Google',
'contactos' => 'Contactos + formulário',
'workhours' => 'Horário de funcionamento',
'personal_trainer' => 'Personal Trainer (chamada)',
];
}
// URL folders that would shadow a real path or route.
function gym_reserved_slugs(): array {
return ['home', 'site', 'views', 'api', 'assets', 'uploads', 'shared', 'config', 'database',
'includes', 'templates', 'vendor', 'logs', 'backups', 'evento', 'robots.txt', 'sitemap.xml'];
}
function gym_defaults(): array {
return [
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
'hero_image' => '', 'seo_title' => '', 'seo_description' => '', 'gallery_id' => null,
'google_place_id' => '', 'has_crossfit' => 0, 'item_order' => 0,
];
}
// ─── Reads ────────────────────────────────────────────────────────────────────
function db_gyms_list(PDO $pdo, bool $include_deleted = false): array {
try {
$sql = "SELECT * FROM gyms" . ($include_deleted ? "" : " WHERE deleted_at IS NULL")
. " ORDER BY deleted_at IS NOT NULL, item_order, id";
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; }
}
function db_gyms_get(PDO $pdo, int $id): ?array {
try {
$st = $pdo->prepare("SELECT * FROM gyms WHERE id = ? LIMIT 1");
$st->execute([$id]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) { return null; }
}
function db_gyms_by_slug(PDO $pdo, string $slug): ?array {
try {
$st = $pdo->prepare("SELECT * FROM gyms WHERE slug = ? AND deleted_at IS NULL LIMIT 1");
$st->execute([$slug]);
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) { return null; }
}
function db_gyms_default(PDO $pdo): ?array {
try {
$row = $pdo->query("SELECT * FROM gyms WHERE deleted_at IS NULL
ORDER BY is_default DESC, is_published DESC, item_order, id LIMIT 1")
->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
} catch (Throwable $e) { return null; }
}
function db_gyms_published(PDO $pdo): array {
return array_values(array_filter(db_gyms_list($pdo), fn($g) => (int) $g['is_published'] === 1));
}
// A gym row, or a stand-in for installs that haven't run the migration yet so
// the public site keeps working exactly as before.
function gym_fallback(): array {
return array_merge(gym_defaults(), [
'id' => 1, 'slug' => '3070', 'name' => 'FitMe 3070', 'locality' => 'Mira', 'postal_code' => '3070',
'is_published' => 1, 'is_default' => 1, 'hero_title' => 'Ginásio, Box, Aulas',
'hero_subtitle' => '& Acompanhamento', 'hero_image' => 'site/assets/img/home.jpg',
'gallery_id' => 1, 'has_crossfit' => 1,
]);
}
// Is a homepage section on for this gym? NULL/empty config = everything on.
function gym_section_on(array $gym, string $key): bool {
$s = $gym['sections'] ?? null;
if ($s === null || $s === '') return true;
$list = json_decode((string) $s, true);
return !is_array($list) || in_array($key, $list, true);
}
function gym_url(array $gym, string $path = ''): string {
return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/'));
}
// ─── Public request → gym ─────────────────────────────────────────────────────
// Set once per request by site/_open.php (and the POST handlers). Unknown or
// unpublished-for-this-visitor gyms return null; the caller 404s.
function site_gym(PDO $pdo, ?string $slug = null): ?array {
static $cache = [];
$slug = $slug ?? (string) ($_GET['gym'] ?? '');
if (isset($cache[$slug])) return $cache[$slug];
$gym = $slug !== '' ? db_gyms_by_slug($pdo, $slug) : db_gyms_default($pdo);
if ($gym === null && $slug === '' ) $gym = gym_fallback(); // pre-migration install
if ($gym && !(int) $gym['is_published'] && !(function_exists('is_staff') && is_staff())) $gym = null;
return $cache[$slug] = $gym;
}
// The gym the current page is rendering (set by _open.php); default gym for
// code that runs outside a gym page (privacy page, events, mail handlers).
function current_gym(): array {
return $GLOBALS['_gym'] ?? gym_fallback();
}
// ─── Backoffice: which gym am I editing? ─────────────────────────────────────
function admin_gym_id(): int {
global $pdo;
$id = (int) ($_SESSION['admin_gym_id'] ?? 0);
if ($id && $pdo instanceof PDO && db_gyms_get($pdo, $id) && !db_gyms_get($pdo, $id)['deleted_at']) return $id;
$def = ($pdo instanceof PDO) ? db_gyms_default($pdo) : null;
return $def ? (int) $def['id'] : 1;
}
function admin_gym(PDO $pdo): array {
return db_gyms_get($pdo, admin_gym_id()) ?? gym_fallback();
}
// ─── Writes ───────────────────────────────────────────────────────────────────
function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null): array {
$slug = strtolower(trim((string) ($post['slug'] ?? '')));
$data = [
'slug' => $slug,
'name' => mb_substr(trim((string) ($post['name'] ?? '')), 0, 100),
'locality' => mb_substr(trim((string) ($post['locality'] ?? '')), 0, 100),
'postal_code' => mb_substr(trim((string) ($post['postal_code'] ?? '')), 0, 20),
'is_published' => !empty($post['is_published']) ? 1 : 0,
'is_default' => !empty($post['is_default']) ? 1 : 0,
'hero_title' => mb_substr(trim((string) ($post['hero_title'] ?? '')), 0, 150),
'hero_subtitle' => mb_substr(trim((string) ($post['hero_subtitle'] ?? '')), 0, 150),
'hero_image' => mb_substr(trim((string) ($post['hero_image'] ?? '')), 0, 255),
'seo_title' => mb_substr(trim((string) ($post['seo_title'] ?? '')), 0, 160),
'seo_description' => mb_substr(trim((string) ($post['seo_description'] ?? '')), 0, 300),
'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null,
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0,
'item_order' => (int) ($post['item_order'] ?? 0),
];
$on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on);
if ($data['name'] === '') $errors[] = 'O nome é obrigatório.';
if (!preg_match('/^[a-z0-9][a-z0-9-]{1,19}$/', $slug)) {
$errors[] = 'A pasta da URL deve ter 2–20 letras minúsculas, números ou hífens (ex.: 3070).';
} elseif (in_array($slug, gym_reserved_slugs(), true)) {
$errors[] = 'Essa pasta da URL está reservada pelo site.';
} else {
$other = db_gyms_by_slug($pdo, $slug);
if ($other && (int) $other['id'] !== (int) $id) $errors[] = 'Já existe um ginásio com essa pasta da URL.';
}
if ($data['is_default'] && !$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado.';
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp)$~i', $data['hero_image'])) {
$errors[] = 'Imagem do header: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).';
}
return $data;
}
function _gyms_params(array $d): array {
return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'],
$d['sections'], $d['hero_title'], $d['hero_subtitle'], $d['hero_image'], $d['seo_title'],
$d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['item_order']];
}
function db_gyms_create(PDO $pdo, array $d): int {
$pdo->beginTransaction();
$pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections,
hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id,
google_place_id, has_crossfit, item_order, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
$id = (int) $pdo->lastInsertId();
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
// Its own contacts row (id = gym id), starting from the gym's name.
$pdo->prepare("INSERT IGNORE INTO fitme3070_info (id, name, adress, phone, telephone, whatsapp, instagram,
facebook, messenger, email, website, google_maps, google_maps_link)
VALUES (?, ?, '', '', '', '', '', '', '', '', '', '', '')")->execute([$id, $d['name']]);
$pdo->commit();
return $id;
}
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
$pdo->beginTransaction();
$params = _gyms_params($d); $params[] = $id;
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
is_default = ?, sections = ?, hero_title = ?, hero_subtitle = ?, hero_image = ?,
seo_title = ?, seo_description = ?, gallery_id = ?, google_place_id = ?,
has_crossfit = ?, item_order = ?, updated_at = NOW()
WHERE id = ?");
$st->execute($params);
$changed = $st->rowCount() > 0;
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
$pdo->commit();
return $changed;
}
// Soft delete. The default gym can't go (the root URL needs somewhere to land).
function db_gyms_delete(PDO $pdo, int $id): bool {
$st = $pdo->prepare("UPDATE gyms SET deleted_at = NOW(), is_published = 0
WHERE id = ? AND is_default = 0 AND deleted_at IS NULL");
$st->execute([$id]);
return $st->rowCount() > 0;
}
function db_gyms_restore(PDO $pdo, int $id): bool {
$st = $pdo->prepare("UPDATE gyms SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL");
$st->execute([$id]);
return $st->rowCount() > 0;
}
// ─── Start a gym from another one's content ──────────────────────────────────
// What can be copied, and a copy routine for each. Copies are independent rows:
// editing one gym never changes the other.
function gyms_copy_options(): array {
return [
'modalidades' => 'Modalidades e horário de aulas',
'work_hours' => 'Horário de funcionamento',
'memberships' => 'Mensalidades',
'offer' => 'A Nossa Oferta',
'team' => 'Equipa',
'info' => 'Contactos (redes sociais, e-mail — ajustar depois)',
];
}
function gyms_copy_content(PDO $pdo, int $from, int $to, array $what): array {
$done = [];
$pdo->beginTransaction();
try {
if (in_array('modalidades', $what, true)) {
$map = [];
$st = $pdo->prepare("SELECT * FROM modalidades WHERE gym_id = ? AND deleted_at IS NULL");
$st->execute([$from]);
$ins = $pdo->prepare("INSERT INTO modalidades (gym_id, nome, cor, icon, descricao, item_order) VALUES (?,?,?,?,?,?)");
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $m) {
$ins->execute([$to, $m['nome'], $m['cor'], $m['icon'], $m['descricao'], $m['item_order']]);
$map[(int) $m['m_id']] = (int) $pdo->lastInsertId();
}
if ($map) {
$in = implode(',', array_keys($map));
$hs = $pdo->query("SELECT * FROM horario WHERE id_modalidade IN ($in)"
. (_gyms_has_col($pdo, 'horario', 'deleted_at') ? " AND deleted_at IS NULL" : ""))
->fetchAll(PDO::FETCH_ASSOC);
$hi = $pdo->prepare("INSERT INTO horario (id_modalidade, dia_semana, hora_inicio, hora_fim, local) VALUES (?,?,?,?,?)");
foreach ($hs as $h) $hi->execute([$map[(int) $h['id_modalidade']], $h['dia_semana'], $h['hora_inicio'], $h['hora_fim'], $h['local']]);
}
$done[] = count($map) . ' modalidades';
}
if (in_array('work_hours', $what, true)) {
$n = $pdo->prepare("INSERT INTO work_hours (gym_id, week_day, open, close)
SELECT ?, week_day, open, close FROM work_hours WHERE gym_id = ?"
. (_gyms_has_col($pdo, 'work_hours', 'deleted_at') ? " AND deleted_at IS NULL" : ""));
$n->execute([$to, $from]);
$done[] = $n->rowCount() . ' horários de funcionamento';
}
if (in_array('memberships', $what, true)) {
$n = $pdo->prepare("INSERT INTO memberships (gym_id, name, subtitle, price, is_addon, price_suffix, features,
is_featured, featured_label, cta_label, item_order, is_active)
SELECT ?, name, subtitle, price, is_addon, price_suffix, features, is_featured,
featured_label, cta_label, item_order, is_active
FROM memberships WHERE gym_id = ? AND deleted_at IS NULL");
$n->execute([$to, $from]);
$done[] = $n->rowCount() . ' mensalidades';
}
if (in_array('offer', $what, true)) {
$n = $pdo->prepare("INSERT INTO website_offer (gym_id, o_img, o_title, o_description)
SELECT ?, o_img, o_title, o_description FROM website_offer
WHERE gym_id = ? AND (o_deleted_at IS NULL OR o_deleted_at = '0000-00-00 00:00:00' OR o_deleted_at = '')");
$n->execute([$to, $from]);
$done[] = $n->rowCount() . ' itens da oferta';
}
if (in_array('team', $what, true)) {
$n = $pdo->prepare("INSERT INTO team (gym_id, photo, name, title, birthday, `at`, status, description, skills, certifications, created_at, updated_at)
SELECT ?, photo, name, title, birthday, `at`, status, description, skills, certifications, NOW(), NOW()
FROM team WHERE gym_id = ? AND deleted_at IS NULL");
$n->execute([$to, $from]);
$done[] = $n->rowCount() . ' membros da equipa';
}
if (in_array('info', $what, true)) {
$src = $pdo->prepare("SELECT * FROM fitme3070_info WHERE id = ?");
$src->execute([$from]);
if ($i = $src->fetch(PDO::FETCH_ASSOC)) {
// Brand-wide channels only; address, phones and map are the new gym's own.
$pdo->prepare("UPDATE fitme3070_info SET instagram = ?, facebook = ?, messenger = ?, email = ?, website = ?
WHERE id = ?")->execute([$i['instagram'], $i['facebook'], $i['messenger'], $i['email'], $i['website'], $to]);
$done[] = 'contactos (redes sociais e e-mail)';
}
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
return $done;
}
function _gyms_has_col(PDO $pdo, string $table, string $col): bool {
static $cache = [];
$k = "$table.$col";
if (!isset($cache[$k])) {
// (SHOW COLUMNS … LIKE ? can't take a bound parameter with native prepares.)
$st = $pdo->prepare("SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?");
$st->execute([$table, $col]);
$cache[$k] = (bool) $st->fetch();
}
return $cache[$k];
}