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

+1
View File
@@ -24,6 +24,7 @@ require_once __DIR__ . '/functions_queue.php'; // message_queue + enque
require_once __DIR__ . '/functions_csrf.php';
require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed)
require_once __DIR__ . '/ci_compat.php'; // CI4 view-helper shim (script_tag/link_tag/view/esc/session)
require_once __DIR__ . '/functions_gyms.php'; // multi-gym: gyms, URL folders, admin gym switcher
require_once __DIR__ . '/functions_site.php'; // public-site data (site_info/offer/team/modalidades/horario)
require_once __DIR__ . '/functions_images.php'; // upload resize/compress (gallery/team/modalidades caps)
require_once __DIR__ . '/functions_team.php'; // team CRUD
+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];
}
+4 -4
View File
@@ -10,7 +10,7 @@ function db_horario_list(PDO $pdo): array {
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim_fmt
FROM horario h
JOIN modalidades m ON m.m_id = h.id_modalidade
WHERE h.deleted_at IS NULL
WHERE h.deleted_at IS NULL AND m.gym_id = " . admin_gym_id() . "
ORDER BY FIELD(h.dia_semana,'segunda','terça','quarta','quinta','sexta','sábado','domingo'),
h.hora_inicio ASC
")->fetchAll(PDO::FETCH_ASSOC);
@@ -24,9 +24,9 @@ function db_horario_get(PDO $pdo, int $id): ?array {
m.nome AS modalidade_nome
FROM horario h
JOIN modalidades m ON m.m_id = h.id_modalidade
WHERE h.id = ? AND h.deleted_at IS NULL LIMIT 1
WHERE h.id = ? AND h.deleted_at IS NULL AND m.gym_id = ? LIMIT 1
");
$stmt->execute([$id]);
$stmt->execute([$id, admin_gym_id()]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
@@ -81,6 +81,6 @@ function db_horario_move(PDO $pdo, int $id, string $dia_semana, string $hora_ini
// Modalidades dropdown (id => nome + cor) for horario create/edit forms.
function db_modalidades_options(PDO $pdo): array {
return $pdo->query("
SELECT m_id, nome, cor FROM modalidades WHERE deleted_at IS NULL ORDER BY nome
SELECT m_id, nome, cor FROM modalidades WHERE deleted_at IS NULL AND gym_id = " . admin_gym_id() . " ORDER BY nome
")->fetchAll(PDO::FETCH_ASSOC);
}
+5 -3
View File
@@ -43,15 +43,17 @@ function info_from_post(array $post, array &$errors): array {
function db_info_save(PDO $pdo, array $data): bool {
$cols = array_keys(info_fields());
$exists = (bool) $pdo->query("SELECT COUNT(*) FROM fitme3070_info WHERE id = 1")->fetchColumn();
// One row per gym (id = gyms.id): the gym being edited in the backoffice.
$gid = admin_gym_id();
$exists = (bool) $pdo->query("SELECT COUNT(*) FROM fitme3070_info WHERE id = " . $gid)->fetchColumn();
$vals = array_map(fn($c) => $data[$c] ?? '', $cols);
if ($exists) {
$sets = implode(', ', array_map(fn($c) => "`$c` = ?", $cols));
$stmt = $pdo->prepare("UPDATE fitme3070_info SET $sets, updated_at = NOW() WHERE id = 1");
$stmt = $pdo->prepare("UPDATE fitme3070_info SET $sets, updated_at = NOW() WHERE id = " . $gid);
} else {
$names = implode(', ', array_map(fn($c) => "`$c`", $cols));
$marks = implode(', ', array_fill(0, count($cols), '?'));
$stmt = $pdo->prepare("INSERT INTO fitme3070_info (id, $names, updated_at) VALUES (1, $marks, NOW())");
$stmt = $pdo->prepare("INSERT INTO fitme3070_info (id, $names, updated_at) VALUES (" . $gid . ", $marks, NOW())");
}
$stmt->execute($vals);
return $stmt->rowCount() > 0;
+24 -15
View File
@@ -14,23 +14,27 @@ function memberships_defaults(): array {
}
function db_memberships_list(PDO $pdo, bool $include_deleted = false): array {
$sql = "SELECT * FROM memberships";
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL";
// Scoped to the gym being edited in the backoffice (functions_gyms.php).
$sql = "SELECT * FROM memberships WHERE gym_id = " . admin_gym_id();
if (!$include_deleted) $sql .= " AND deleted_at IS NULL";
$sql .= " ORDER BY deleted_at IS NOT NULL, item_order, name";
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
// Public list: active, not deleted. Defensive — the table may not exist yet on
// an install that hasn't run the migration.
function site_memberships(PDO $pdo): array {
function site_memberships(PDO $pdo, ?int $gym_id = null): array {
try {
return $pdo->query(
"SELECT * FROM memberships WHERE deleted_at IS NULL AND is_active = 1 ORDER BY item_order, name"
)->fetchAll(PDO::FETCH_ASSOC);
$st = $pdo->prepare(
"SELECT * FROM memberships WHERE deleted_at IS NULL AND is_active = 1 AND gym_id = ? ORDER BY item_order, name"
);
$st->execute([$gym_id ?? (int) current_gym()['id']]);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; }
}
function db_memberships_get(PDO $pdo, int $id): ?array {
// Any gym (the public sign-up handler checks the gym itself).
$stmt = $pdo->prepare("SELECT * FROM memberships WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
@@ -70,7 +74,11 @@ function _memberships_params(array $d): array {
}
function _memberships_clear_featured(PDO $pdo, int $except): void {
$pdo->prepare("UPDATE memberships SET is_featured = 0 WHERE id <> ? AND is_featured = 1")->execute([$except]);
// One featured card per gym.
$pdo->prepare("UPDATE memberships SET is_featured = 0
WHERE id <> ? AND is_featured = 1
AND gym_id = (SELECT g FROM (SELECT gym_id AS g FROM memberships WHERE id = ?) x)")
->execute([$except, $except]);
}
function db_memberships_create(PDO $pdo, array $d): int {
@@ -78,10 +86,10 @@ function db_memberships_create(PDO $pdo, array $d): int {
$stmt = $pdo->prepare("
INSERT INTO memberships (name, subtitle, price, is_addon, price_suffix, features,
is_featured, featured_label, cta_label, item_order, is_active,
created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
gym_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
");
$stmt->execute(_memberships_params($d));
$stmt->execute(array_merge(_memberships_params($d), [admin_gym_id()]));
$id = (int) $pdo->lastInsertId();
if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
$pdo->commit();
@@ -142,8 +150,8 @@ function membership_lead_statuses(): array {
}
function db_membership_leads_list(PDO $pdo, string $status = ''): array {
$sql = "SELECT * FROM membership_leads WHERE deleted_at IS NULL";
$args = [];
$sql = "SELECT * FROM membership_leads WHERE deleted_at IS NULL AND gym_id = ?";
$args = [admin_gym_id()];
if ($status !== '' && isset(membership_lead_statuses()[$status])) { $sql .= " AND status = ?"; $args[] = $status; }
$sql .= " ORDER BY created_at DESC, id DESC";
$stmt = $pdo->prepare($sql);
@@ -152,17 +160,18 @@ function db_membership_leads_list(PDO $pdo, string $status = ''): array {
}
function db_membership_leads_count_new(PDO $pdo): int {
try { return (int) $pdo->query("SELECT COUNT(*) FROM membership_leads WHERE status = 'novo' AND deleted_at IS NULL")->fetchColumn(); }
try { return (int) $pdo->query("SELECT COUNT(*) FROM membership_leads WHERE status = 'novo' AND deleted_at IS NULL AND gym_id = " . admin_gym_id())->fetchColumn(); }
catch (Throwable $e) { return 0; }
}
function db_membership_lead_create(PDO $pdo, array $d): int {
$stmt = $pdo->prepare("
INSERT INTO membership_leads (membership_id, membership_name, name, email, phone, message,
INSERT INTO membership_leads (gym_id, membership_id, membership_name, name, email, phone, message,
consent, consent_text, ip, user_agent, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
VALUES (?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
");
$stmt->execute([
(int) ($d['gym_id'] ?? current_gym()['id']),
$d['membership_id'] ?: null,
mb_substr((string) ($d['membership_name'] ?? ''), 0, 100),
mb_substr(trim((string) ($d['name'] ?? '')), 0, 150),
+8 -6
View File
@@ -4,24 +4,26 @@
// description, and item_order for sorting.
function db_modalidades_list(PDO $pdo, bool $include_deleted = false): array {
$sql = "SELECT * FROM modalidades";
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL";
// Scoped to the gym being edited in the backoffice (functions_gyms.php).
$sql = "SELECT * FROM modalidades WHERE gym_id = " . admin_gym_id();
if (!$include_deleted) $sql .= " AND deleted_at IS NULL";
$sql .= " ORDER BY item_order DESC, nome";
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
function db_modalidades_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM modalidades WHERE m_id = ? LIMIT 1");
$stmt->execute([$id]);
$stmt = $pdo->prepare("SELECT * FROM modalidades WHERE m_id = ? AND gym_id = ? LIMIT 1");
$stmt->execute([$id, admin_gym_id()]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
function db_modalidades_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("
INSERT INTO modalidades (nome, cor, icon, descricao, item_order)
VALUES (?, ?, ?, ?, ?)
INSERT INTO modalidades (gym_id, nome, cor, icon, descricao, item_order)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([
admin_gym_id(),
$data['nome'],
$data['cor'] ?? '#808080',
$data['icon'] ?? '',
+20 -3
View File
@@ -27,10 +27,26 @@
const REVIEWS_FIELD_MASK = 'displayName,rating,userRatingCount,googleMapsUri,reviews';
// Multi-gym: the API key and display options are shared; the Place ID (and so
// the cache) belongs to a gym (gyms.google_place_id). Which gym is taken from
// reviews_gym(): the gym the public page renders, or — on the backoffice
// Reviews page — the gym being edited (set with reviews_use_gym()).
function reviews_use_gym(array $gym): void {
$GLOBALS['_reviews_gym'] = $gym;
}
function reviews_gym(): array {
return $GLOBALS['_reviews_gym'] ?? current_gym();
}
function reviews_config(): array {
$gym = reviews_gym();
$place = trim((string) ($gym['google_place_id'] ?? ''));
// Pre-multi-gym installs kept the Place ID in settings (gym 1 only).
if ($place === '' && (int) ($gym['id'] ?? 1) === 1) $place = trim((string) setting('google_place_id', ''));
return [
'api_key' => setting_secret('google_places_api_key', 'GOOGLE_PLACES_API_KEY'),
'place_id' => trim((string) setting('google_place_id', '')),
'place_id' => $place,
'min_rating' => max(1, min(5, (int) setting('google_reviews_min_rating', 4))),
'max' => max(1, min(5, (int) setting('google_reviews_max', 3))),
'cache_hours' => max(1, (int) setting('google_reviews_cache_hours', 24)),
@@ -50,7 +66,7 @@ function reviews_configured(): bool {
}
function _reviews_cache_path(): string {
return __DIR__ . '/cache/google_reviews.cache.php';
return __DIR__ . '/cache/google_reviews_' . (int) (reviews_gym()['id'] ?? 1) . '.cache.php';
}
function reviews_cache_read(): ?array {
@@ -170,7 +186,8 @@ function reviews_get(): array {
// What the homepage renders: summary + the best qualifying reviews, or null
// when there is nothing worth showing (not configured, no qualifying review).
function site_reviews(): ?array {
function site_reviews(?array $gym = null): ?array {
if ($gym !== null) reviews_use_gym($gym);
try { $data = reviews_get(); } catch (Throwable $e) { error_log('[reviews] ' . $e->getMessage()); return null; }
if (!$data || empty($data['reviews'])) return null;
$c = reviews_config();
+70 -52
View File
@@ -3,15 +3,23 @@
// The ported website/* partials read the same shapes the CI4 models returned.
// Every query is defensive — the gym-content tables can be empty on a fresh DB.
// fitme3070_info row (id=1). Always returns every key the views read, so empty
// installs render without warnings.
function site_info(PDO $pdo): array {
// Every site_*() reader takes the gym (functions_gyms.php); by default the one
// the current page renders (current_gym()), so single-gym code paths — the
// privacy page, events, mail handlers — keep working unchanged.
function _site_gym_id(?int $gym_id): int {
return $gym_id ?? (int) current_gym()['id'];
}
// fitme3070_info row of the gym (id = gyms.id). Always returns every key the
// views read, so empty installs render without warnings.
function site_info(PDO $pdo, ?int $gym_id = null): array {
$cols = ['id','name','adress','phone','telephone','whatsapp','instagram',
'facebook','messenger','email','website','google_maps','google_maps_link'];
$row = [];
try {
$row = $pdo->query("SELECT * FROM fitme3070_info WHERE id = 1 LIMIT 1")
->fetch(PDO::FETCH_ASSOC) ?: [];
$st = $pdo->prepare("SELECT * FROM fitme3070_info WHERE id = ? LIMIT 1");
$st->execute([_site_gym_id($gym_id)]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable $e) { $row = []; }
foreach ($cols as $c) {
if (!array_key_exists($c, $row) || $row[$c] === null) $row[$c] = '';
@@ -21,55 +29,63 @@ function site_info(PDO $pdo): array {
return $row;
}
// Top navigation map (label => CI-style path). Kept identical to the original
// so navbar/footer markup is unchanged; .htaccess rewrites these to site/*.php.
function site_navbar(): array {
// One-page layout: Home, Equipa, Modalidades, Horário, Contactos are all
// anchor links on the single homepage. CrossFit is a separate page styled
// differently (see navbar.php for the 'crossfit_btn' flag).
return [
'Home' => '#home',
'Equipa' => '#equipa',
'Modalidades' => '#modalidades',
'Horário' => '#horario',
'Mensalidades' => '#mensalidades',
'Contactos' => '#contactos',
'CrossFit' => 'home/crossfit', // separate page, navbar styles it specially
];
// Top navigation (label => absolute URL): anchors on the gym's own page, only
// for the sections that gym shows, plus its CrossFit page when it has one.
function site_navbar(?array $gym = null): array {
$gym = $gym ?? current_gym();
$items = ['Home' => ['#home', null]];
foreach (['Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) {
if (gym_section_on($gym, $sec)) $items[$label] = ['#' . $sec, $sec];
}
$nav = [];
foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash);
if (!empty($gym['has_crossfit'])) $nav['CrossFit'] = gym_url($gym, 'crossfit');
return $nav;
}
function site_offer(PDO $pdo): array {
function site_offer(PDO $pdo, ?int $gym_id = null): array {
try {
return $pdo->query(
$st = $pdo->prepare(
"SELECT * FROM website_offer
WHERE o_deleted_at IS NULL OR o_deleted_at = '0000-00-00 00:00:00' OR o_deleted_at = ''"
)->fetchAll(PDO::FETCH_ASSOC);
WHERE gym_id = ? AND (o_deleted_at IS NULL OR o_deleted_at = '0000-00-00 00:00:00' OR o_deleted_at = '')"
);
$st->execute([_site_gym_id($gym_id)]);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; }
}
function site_work_hours(PDO $pdo): array {
try { return $pdo->query("SELECT * FROM work_hours WHERE deleted_at IS NULL")->fetchAll(PDO::FETCH_ASSOC); }
catch (Throwable $e) { return []; }
}
function site_team(PDO $pdo): array {
try { return $pdo->query("SELECT * FROM team WHERE deleted_at IS NULL")->fetchAll(PDO::FETCH_ASSOC); }
catch (Throwable $e) { return []; }
}
function site_modalidades(PDO $pdo): array {
function site_work_hours(PDO $pdo, ?int $gym_id = null): array {
try {
return $pdo->query(
"SELECT * FROM modalidades WHERE deleted_at IS NULL ORDER BY item_order DESC"
)->fetchAll(PDO::FETCH_ASSOC);
$st = $pdo->prepare("SELECT * FROM work_hours WHERE deleted_at IS NULL AND gym_id = ?");
$st->execute([_site_gym_id($gym_id)]);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; }
}
// Currently-visible time-limited events for the homepage banner. Thin wrapper
// over db_events_active() (functions_events.php) so the site code reads in the
// same site_*() style as everything else here.
function site_active_events(PDO $pdo): array {
return function_exists('db_events_active') ? db_events_active($pdo) : [];
function site_team(PDO $pdo, ?int $gym_id = null): array {
try {
$st = $pdo->prepare("SELECT * FROM team WHERE deleted_at IS NULL AND gym_id = ?");
$st->execute([_site_gym_id($gym_id)]);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; }
}
function site_modalidades(PDO $pdo, ?int $gym_id = null): array {
try {
$st = $pdo->prepare("SELECT * FROM modalidades WHERE deleted_at IS NULL AND gym_id = ? ORDER BY item_order DESC");
$st->execute([_site_gym_id($gym_id)]);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; }
}
// Currently-visible time-limited events for the homepage banner: the gym's own
// plus those for every gym (gym_id NULL).
function site_active_events(PDO $pdo, ?int $gym_id = null): array {
if (!function_exists('db_events_active')) return [];
$gid = _site_gym_id($gym_id);
return array_values(array_filter(db_events_active($pdo), fn($e) =>
!isset($e['gym_id']) || $e['gym_id'] === null || (int) $e['gym_id'] === $gid));
}
// ─── Image-path resolver ──────────────────────────────────────────────────────
@@ -90,15 +106,15 @@ function site_img_url($value, $base_dir): string {
// Schedule joined to modalidades (name/colour/icon), optionally filtered by
// local (e.g. 'crossfit'). Mirrors Horario_model::getAll().
function site_horario(PDO $pdo, string $local = ''): array {
function site_horario(PDO $pdo, string $local = '', ?int $gym_id = null): array {
$sql = "SELECT h.*,
TIME_FORMAT(h.hora_inicio, '%H:%i') AS hora_inicio,
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim,
m.nome, m.cor, m.icon
FROM horario h
JOIN modalidades m ON m.m_id = h.id_modalidade AND m.deleted_at IS NULL
WHERE h.deleted_at IS NULL";
$args = [];
WHERE h.deleted_at IS NULL AND m.gym_id = ?";
$args = [_site_gym_id($gym_id)];
if ($local !== '') { $sql .= " AND h.local = ?"; $args[] = $local; }
$sql .= " ORDER BY h.hora_inicio ASC, m.nome ASC";
try { $st = $pdo->prepare($sql); $st->execute($args); return $st->fetchAll(PDO::FETCH_ASSOC); }
@@ -117,12 +133,14 @@ function seo_noindex(): bool {
// Public, indexable URLs for sitemap.xml: the fixed pages plus any live event
// landing page.
function site_sitemap_urls(PDO $pdo): array {
$urls = [
['loc' => base_url(), 'priority' => '1.0'],
['loc' => base_url('home/crossfit'), 'priority' => '0.8'],
['loc' => base_url('home/privacidade'), 'priority' => '0.2'],
];
foreach (site_active_events($pdo) as $ev) {
// One entry per PUBLISHED gym (unpublished ones must not be discoverable).
$urls = [];
foreach (db_gyms_published($pdo) ?: [gym_fallback()] as $g) {
$urls[] = ['loc' => gym_url($g), 'priority' => '1.0'];
if (!empty($g['has_crossfit'])) $urls[] = ['loc' => gym_url($g, 'crossfit'), 'priority' => '0.8'];
}
$urls[] = ['loc' => base_url('home/privacidade'), 'priority' => '0.2'];
foreach (function_exists('db_events_active') ? db_events_active($pdo) : [] as $ev) {
$urls[] = ['loc' => base_url('evento/' . rawurlencode($ev['slug'])), 'priority' => '0.6'];
}
return $urls;
+8 -6
View File
@@ -4,24 +4,26 @@
// Used by views/admin/team/*.php
function db_team_list(PDO $pdo, bool $include_deleted = false): array {
$sql = "SELECT * FROM team";
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL";
// Scoped to the gym being edited in the backoffice (functions_gyms.php).
$sql = "SELECT * FROM team WHERE gym_id = " . admin_gym_id();
if (!$include_deleted) $sql .= " AND deleted_at IS NULL";
$sql .= " ORDER BY FIELD(status,'ativo','inativo'), name";
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
function db_team_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM team WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
$stmt = $pdo->prepare("SELECT * FROM team WHERE id = ? AND gym_id = ? LIMIT 1");
$stmt->execute([$id, admin_gym_id()]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
function db_team_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("
INSERT INTO team (name, title, birthday, `at`, status, description, skills, certifications, photo, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
INSERT INTO team (gym_id, name, title, birthday, `at`, status, description, skills, certifications, photo, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
");
$stmt->execute([
admin_gym_id(),
$data['name'],
$data['title'] ?? '',
$data['birthday'] ?: null,
+5 -5
View File
@@ -5,20 +5,20 @@
function db_whours_list(PDO $pdo): array {
return $pdo->query("
SELECT * FROM work_hours
WHERE deleted_at IS NULL
WHERE deleted_at IS NULL AND gym_id = " . admin_gym_id() . "
ORDER BY FIELD(week_day,'segunda','terça','quarta','quinta','sexta','sabado','domingo','feriados')
")->fetchAll(PDO::FETCH_ASSOC);
}
function db_whours_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM work_hours WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
$stmt = $pdo->prepare("SELECT * FROM work_hours WHERE id = ? AND gym_id = ? LIMIT 1");
$stmt->execute([$id, admin_gym_id()]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
function db_whours_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("INSERT INTO work_hours (week_day, open, close) VALUES (?, ?, ?)");
$stmt->execute([$data['week_day'], $data['open'], $data['close']]);
$stmt = $pdo->prepare("INSERT INTO work_hours (gym_id, week_day, open, close) VALUES (?, ?, ?, ?)");
$stmt->execute([admin_gym_id(), $data['week_day'], $data['open'], $data['close']]);
return (int) $pdo->lastInsertId();
}