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

+8
View File
@@ -61,6 +61,14 @@ RewriteRule ^home/inscricao/?$ site/inscricao.php [L,QSA]
RewriteRule ^home/crossfit/?$ site/crossfit.php [L,QSA] RewriteRule ^home/crossfit/?$ site/crossfit.php [L,QSA]
RewriteRule ^home/privacidade/?$ site/privacidade.php [L,QSA] RewriteRule ^home/privacidade/?$ site/privacidade.php [L,QSA]
RewriteRule ^evento/([a-zA-Z0-9-]+)/?$ site/evento.php?slug=$1 [L,QSA] RewriteRule ^evento/([a-zA-Z0-9-]+)/?$ site/evento.php?slug=$1 [L,QSA]
# ── Ginásios: /<pasta>/ (código postal da zona, ex.: /3070/) ─────────────────
# Uma pasta por ginásio (Gestão → Ginásios); pastas desconhecidas ou por publicar
# dão 404 no PHP. Reais ficheiros/pastas têm prioridade.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([a-z0-9][a-z0-9-]{1,19})/?$ site/index.php?gym=$1 [L,QSA]
RewriteRule ^([a-z0-9][a-z0-9-]{1,19})/crossfit/?$ site/crossfit.php?gym=$1 [L,QSA]
RewriteRule ^([a-z0-9][a-z0-9-]{1,19})/(agendar|inscricao)/?$ site/$2.php?gym=$1 [L,QSA]
RewriteRule ^robots\.txt$ site/seo_files.php?f=robots [L] RewriteRule ^robots\.txt$ site/seo_files.php?f=robots [L]
RewriteRule ^sitemap\.xml$ site/seo_files.php?f=sitemap [L] RewriteRule ^sitemap\.xml$ site/seo_files.php?f=sitemap [L]
+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_csrf.php';
require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed) 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__ . '/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_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_images.php'; // upload resize/compress (gallery/team/modalidades caps)
require_once __DIR__ . '/functions_team.php'; // team CRUD 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 TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim_fmt
FROM horario h FROM horario h
JOIN modalidades m ON m.m_id = h.id_modalidade 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'), ORDER BY FIELD(h.dia_semana,'segunda','terça','quarta','quinta','sexta','sábado','domingo'),
h.hora_inicio ASC h.hora_inicio ASC
")->fetchAll(PDO::FETCH_ASSOC); ")->fetchAll(PDO::FETCH_ASSOC);
@@ -24,9 +24,9 @@ function db_horario_get(PDO $pdo, int $id): ?array {
m.nome AS modalidade_nome m.nome AS modalidade_nome
FROM horario h FROM horario h
JOIN modalidades m ON m.m_id = h.id_modalidade 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; 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. // Modalidades dropdown (id => nome + cor) for horario create/edit forms.
function db_modalidades_options(PDO $pdo): array { function db_modalidades_options(PDO $pdo): array {
return $pdo->query(" 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); ")->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 { function db_info_save(PDO $pdo, array $data): bool {
$cols = array_keys(info_fields()); $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); $vals = array_map(fn($c) => $data[$c] ?? '', $cols);
if ($exists) { if ($exists) {
$sets = implode(', ', array_map(fn($c) => "`$c` = ?", $cols)); $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 { } else {
$names = implode(', ', array_map(fn($c) => "`$c`", $cols)); $names = implode(', ', array_map(fn($c) => "`$c`", $cols));
$marks = implode(', ', array_fill(0, count($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); $stmt->execute($vals);
return $stmt->rowCount() > 0; 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 { function db_memberships_list(PDO $pdo, bool $include_deleted = false): array {
$sql = "SELECT * FROM memberships"; // Scoped to the gym being edited in the backoffice (functions_gyms.php).
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL"; $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"; $sql .= " ORDER BY deleted_at IS NOT NULL, item_order, name";
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
} }
// Public list: active, not deleted. Defensive — the table may not exist yet on // Public list: active, not deleted. Defensive — the table may not exist yet on
// an install that hasn't run the migration. // 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 { try {
return $pdo->query( $st = $pdo->prepare(
"SELECT * FROM memberships WHERE deleted_at IS NULL AND is_active = 1 ORDER BY item_order, name" "SELECT * FROM memberships WHERE deleted_at IS NULL AND is_active = 1 AND gym_id = ? ORDER BY item_order, name"
)->fetchAll(PDO::FETCH_ASSOC); );
$st->execute([$gym_id ?? (int) current_gym()['id']]);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; } } catch (Throwable $e) { return []; }
} }
function db_memberships_get(PDO $pdo, int $id): ?array { 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 = $pdo->prepare("SELECT * FROM memberships WHERE id = ? LIMIT 1");
$stmt->execute([$id]); $stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; 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 { 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 { 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(" $stmt = $pdo->prepare("
INSERT INTO memberships (name, subtitle, price, is_addon, price_suffix, features, INSERT INTO memberships (name, subtitle, price, is_addon, price_suffix, features,
is_featured, featured_label, cta_label, item_order, is_active, is_featured, featured_label, cta_label, item_order, is_active,
created_at, updated_at) gym_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW()) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
"); ");
$stmt->execute(_memberships_params($d)); $stmt->execute(array_merge(_memberships_params($d), [admin_gym_id()]));
$id = (int) $pdo->lastInsertId(); $id = (int) $pdo->lastInsertId();
if ($d['is_featured']) _memberships_clear_featured($pdo, $id); if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
$pdo->commit(); $pdo->commit();
@@ -142,8 +150,8 @@ function membership_lead_statuses(): array {
} }
function db_membership_leads_list(PDO $pdo, string $status = ''): array { function db_membership_leads_list(PDO $pdo, string $status = ''): array {
$sql = "SELECT * FROM membership_leads WHERE deleted_at IS NULL"; $sql = "SELECT * FROM membership_leads WHERE deleted_at IS NULL AND gym_id = ?";
$args = []; $args = [admin_gym_id()];
if ($status !== '' && isset(membership_lead_statuses()[$status])) { $sql .= " AND status = ?"; $args[] = $status; } if ($status !== '' && isset(membership_lead_statuses()[$status])) { $sql .= " AND status = ?"; $args[] = $status; }
$sql .= " ORDER BY created_at DESC, id DESC"; $sql .= " ORDER BY created_at DESC, id DESC";
$stmt = $pdo->prepare($sql); $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 { 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; } catch (Throwable $e) { return 0; }
} }
function db_membership_lead_create(PDO $pdo, array $d): int { function db_membership_lead_create(PDO $pdo, array $d): int {
$stmt = $pdo->prepare(" $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) consent, consent_text, ip, user_agent, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?, NOW(), NOW()) VALUES (?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
"); ");
$stmt->execute([ $stmt->execute([
(int) ($d['gym_id'] ?? current_gym()['id']),
$d['membership_id'] ?: null, $d['membership_id'] ?: null,
mb_substr((string) ($d['membership_name'] ?? ''), 0, 100), mb_substr((string) ($d['membership_name'] ?? ''), 0, 100),
mb_substr(trim((string) ($d['name'] ?? '')), 0, 150), mb_substr(trim((string) ($d['name'] ?? '')), 0, 150),
+8 -6
View File
@@ -4,24 +4,26 @@
// description, and item_order for sorting. // description, and item_order for sorting.
function db_modalidades_list(PDO $pdo, bool $include_deleted = false): array { function db_modalidades_list(PDO $pdo, bool $include_deleted = false): array {
$sql = "SELECT * FROM modalidades"; // Scoped to the gym being edited in the backoffice (functions_gyms.php).
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL"; $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"; $sql .= " ORDER BY item_order DESC, nome";
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
} }
function db_modalidades_get(PDO $pdo, int $id): ?array { function db_modalidades_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM modalidades WHERE m_id = ? LIMIT 1"); $stmt = $pdo->prepare("SELECT * FROM modalidades WHERE m_id = ? AND gym_id = ? LIMIT 1");
$stmt->execute([$id]); $stmt->execute([$id, admin_gym_id()]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
} }
function db_modalidades_create(PDO $pdo, array $data): int { function db_modalidades_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
INSERT INTO modalidades (nome, cor, icon, descricao, item_order) INSERT INTO modalidades (gym_id, nome, cor, icon, descricao, item_order)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
"); ");
$stmt->execute([ $stmt->execute([
admin_gym_id(),
$data['nome'], $data['nome'],
$data['cor'] ?? '#808080', $data['cor'] ?? '#808080',
$data['icon'] ?? '', $data['icon'] ?? '',
+20 -3
View File
@@ -27,10 +27,26 @@
const REVIEWS_FIELD_MASK = 'displayName,rating,userRatingCount,googleMapsUri,reviews'; 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 { 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 [ return [
'api_key' => setting_secret('google_places_api_key', 'GOOGLE_PLACES_API_KEY'), '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))), 'min_rating' => max(1, min(5, (int) setting('google_reviews_min_rating', 4))),
'max' => max(1, min(5, (int) setting('google_reviews_max', 3))), 'max' => max(1, min(5, (int) setting('google_reviews_max', 3))),
'cache_hours' => max(1, (int) setting('google_reviews_cache_hours', 24)), 'cache_hours' => max(1, (int) setting('google_reviews_cache_hours', 24)),
@@ -50,7 +66,7 @@ function reviews_configured(): bool {
} }
function _reviews_cache_path(): string { 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 { function reviews_cache_read(): ?array {
@@ -170,7 +186,8 @@ function reviews_get(): array {
// What the homepage renders: summary + the best qualifying reviews, or null // What the homepage renders: summary + the best qualifying reviews, or null
// when there is nothing worth showing (not configured, no qualifying review). // 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; } try { $data = reviews_get(); } catch (Throwable $e) { error_log('[reviews] ' . $e->getMessage()); return null; }
if (!$data || empty($data['reviews'])) return null; if (!$data || empty($data['reviews'])) return null;
$c = reviews_config(); $c = reviews_config();
+70 -52
View File
@@ -3,15 +3,23 @@
// The ported website/* partials read the same shapes the CI4 models returned. // 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. // 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 // Every site_*() reader takes the gym (functions_gyms.php); by default the one
// installs render without warnings. // the current page renders (current_gym()), so single-gym code paths — the
function site_info(PDO $pdo): array { // 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', $cols = ['id','name','adress','phone','telephone','whatsapp','instagram',
'facebook','messenger','email','website','google_maps','google_maps_link']; 'facebook','messenger','email','website','google_maps','google_maps_link'];
$row = []; $row = [];
try { try {
$row = $pdo->query("SELECT * FROM fitme3070_info WHERE id = 1 LIMIT 1") $st = $pdo->prepare("SELECT * FROM fitme3070_info WHERE id = ? LIMIT 1");
->fetch(PDO::FETCH_ASSOC) ?: []; $st->execute([_site_gym_id($gym_id)]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable $e) { $row = []; } } catch (Throwable $e) { $row = []; }
foreach ($cols as $c) { foreach ($cols as $c) {
if (!array_key_exists($c, $row) || $row[$c] === null) $row[$c] = ''; if (!array_key_exists($c, $row) || $row[$c] === null) $row[$c] = '';
@@ -21,55 +29,63 @@ function site_info(PDO $pdo): array {
return $row; return $row;
} }
// Top navigation map (label => CI-style path). Kept identical to the original // Top navigation (label => absolute URL): anchors on the gym's own page, only
// so navbar/footer markup is unchanged; .htaccess rewrites these to site/*.php. // for the sections that gym shows, plus its CrossFit page when it has one.
function site_navbar(): array { function site_navbar(?array $gym = null): array {
// One-page layout: Home, Equipa, Modalidades, Horário, Contactos are all $gym = $gym ?? current_gym();
// anchor links on the single homepage. CrossFit is a separate page styled $items = ['Home' => ['#home', null]];
// differently (see navbar.php for the 'crossfit_btn' flag). foreach (['Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
return [ 'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) {
'Home' => '#home', if (gym_section_on($gym, $sec)) $items[$label] = ['#' . $sec, $sec];
'Equipa' => '#equipa', }
'Modalidades' => '#modalidades', $nav = [];
'Horário' => '#horario', foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash);
'Mensalidades' => '#mensalidades', if (!empty($gym['has_crossfit'])) $nav['CrossFit'] = gym_url($gym, 'crossfit');
'Contactos' => '#contactos', return $nav;
'CrossFit' => 'home/crossfit', // separate page, navbar styles it specially
];
} }
function site_offer(PDO $pdo): array { function site_offer(PDO $pdo, ?int $gym_id = null): array {
try { try {
return $pdo->query( $st = $pdo->prepare(
"SELECT * FROM website_offer "SELECT * FROM website_offer
WHERE o_deleted_at IS NULL OR o_deleted_at = '0000-00-00 00:00:00' OR o_deleted_at = ''" WHERE gym_id = ? AND (o_deleted_at IS NULL OR o_deleted_at = '0000-00-00 00:00:00' OR o_deleted_at = '')"
)->fetchAll(PDO::FETCH_ASSOC); );
$st->execute([_site_gym_id($gym_id)]);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; } } catch (Throwable $e) { return []; }
} }
function site_work_hours(PDO $pdo): array { function site_work_hours(PDO $pdo, ?int $gym_id = null): 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 {
try { try {
return $pdo->query( $st = $pdo->prepare("SELECT * FROM work_hours WHERE deleted_at IS NULL AND gym_id = ?");
"SELECT * FROM modalidades WHERE deleted_at IS NULL ORDER BY item_order DESC" $st->execute([_site_gym_id($gym_id)]);
)->fetchAll(PDO::FETCH_ASSOC); return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; } } catch (Throwable $e) { return []; }
} }
// Currently-visible time-limited events for the homepage banner. Thin wrapper function site_team(PDO $pdo, ?int $gym_id = null): array {
// over db_events_active() (functions_events.php) so the site code reads in the try {
// same site_*() style as everything else here. $st = $pdo->prepare("SELECT * FROM team WHERE deleted_at IS NULL AND gym_id = ?");
function site_active_events(PDO $pdo): array { $st->execute([_site_gym_id($gym_id)]);
return function_exists('db_events_active') ? db_events_active($pdo) : []; 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 ────────────────────────────────────────────────────── // ─── 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 // Schedule joined to modalidades (name/colour/icon), optionally filtered by
// local (e.g. 'crossfit'). Mirrors Horario_model::getAll(). // 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.*, $sql = "SELECT h.*,
TIME_FORMAT(h.hora_inicio, '%H:%i') AS hora_inicio, TIME_FORMAT(h.hora_inicio, '%H:%i') AS hora_inicio,
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim, TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim,
m.nome, m.cor, m.icon m.nome, m.cor, m.icon
FROM horario h FROM horario h
JOIN modalidades m ON m.m_id = h.id_modalidade AND m.deleted_at IS NULL JOIN modalidades m ON m.m_id = h.id_modalidade AND m.deleted_at IS NULL
WHERE h.deleted_at IS NULL"; WHERE h.deleted_at IS NULL AND m.gym_id = ?";
$args = []; $args = [_site_gym_id($gym_id)];
if ($local !== '') { $sql .= " AND h.local = ?"; $args[] = $local; } if ($local !== '') { $sql .= " AND h.local = ?"; $args[] = $local; }
$sql .= " ORDER BY h.hora_inicio ASC, m.nome ASC"; $sql .= " ORDER BY h.hora_inicio ASC, m.nome ASC";
try { $st = $pdo->prepare($sql); $st->execute($args); return $st->fetchAll(PDO::FETCH_ASSOC); } 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 // Public, indexable URLs for sitemap.xml: the fixed pages plus any live event
// landing page. // landing page.
function site_sitemap_urls(PDO $pdo): array { function site_sitemap_urls(PDO $pdo): array {
$urls = [ // One entry per PUBLISHED gym (unpublished ones must not be discoverable).
['loc' => base_url(), 'priority' => '1.0'], $urls = [];
['loc' => base_url('home/crossfit'), 'priority' => '0.8'], foreach (db_gyms_published($pdo) ?: [gym_fallback()] as $g) {
['loc' => base_url('home/privacidade'), 'priority' => '0.2'], $urls[] = ['loc' => gym_url($g), 'priority' => '1.0'];
]; if (!empty($g['has_crossfit'])) $urls[] = ['loc' => gym_url($g, 'crossfit'), 'priority' => '0.8'];
foreach (site_active_events($pdo) as $ev) { }
$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']; $urls[] = ['loc' => base_url('evento/' . rawurlencode($ev['slug'])), 'priority' => '0.6'];
} }
return $urls; return $urls;
+8 -6
View File
@@ -4,24 +4,26 @@
// Used by views/admin/team/*.php // Used by views/admin/team/*.php
function db_team_list(PDO $pdo, bool $include_deleted = false): array { function db_team_list(PDO $pdo, bool $include_deleted = false): array {
$sql = "SELECT * FROM team"; // Scoped to the gym being edited in the backoffice (functions_gyms.php).
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL"; $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"; $sql .= " ORDER BY FIELD(status,'ativo','inativo'), name";
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
} }
function db_team_get(PDO $pdo, int $id): ?array { function db_team_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM team WHERE id = ? LIMIT 1"); $stmt = $pdo->prepare("SELECT * FROM team WHERE id = ? AND gym_id = ? LIMIT 1");
$stmt->execute([$id]); $stmt->execute([$id, admin_gym_id()]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
} }
function db_team_create(PDO $pdo, array $data): int { function db_team_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
INSERT INTO team (name, title, birthday, `at`, status, description, skills, certifications, photo, created_at, updated_at) INSERT INTO team (gym_id, name, title, birthday, `at`, status, description, skills, certifications, photo, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
"); ");
$stmt->execute([ $stmt->execute([
admin_gym_id(),
$data['name'], $data['name'],
$data['title'] ?? '', $data['title'] ?? '',
$data['birthday'] ?: null, $data['birthday'] ?: null,
+5 -5
View File
@@ -5,20 +5,20 @@
function db_whours_list(PDO $pdo): array { function db_whours_list(PDO $pdo): array {
return $pdo->query(" return $pdo->query("
SELECT * FROM work_hours 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') ORDER BY FIELD(week_day,'segunda','terça','quarta','quinta','sexta','sabado','domingo','feriados')
")->fetchAll(PDO::FETCH_ASSOC); ")->fetchAll(PDO::FETCH_ASSOC);
} }
function db_whours_get(PDO $pdo, int $id): ?array { function db_whours_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM work_hours WHERE id = ? LIMIT 1"); $stmt = $pdo->prepare("SELECT * FROM work_hours WHERE id = ? AND gym_id = ? LIMIT 1");
$stmt->execute([$id]); $stmt->execute([$id, admin_gym_id()]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
} }
function db_whours_create(PDO $pdo, array $data): int { function db_whours_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("INSERT INTO work_hours (week_day, open, close) VALUES (?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO work_hours (gym_id, week_day, open, close) VALUES (?, ?, ?, ?)");
$stmt->execute([$data['week_day'], $data['open'], $data['close']]); $stmt->execute([admin_gym_id(), $data['week_day'], $data['open'], $data['close']]);
return (int) $pdo->lastInsertId(); return (int) $pdo->lastInsertId();
} }
+54
View File
@@ -0,0 +1,54 @@
-- ─── Vários ginásios ─────────────────────────────────────────────────────────
-- O site passa a servir mais do que um ginásio com as mesmas secções e só o
-- conteúdo a mudar. Cada ginásio vive numa pasta da URL (o código postal da
-- zona: /3070/, /XXXX/) e pode estar por publicar (só a equipa o vê).
--
-- gyms: identidade, pasta, secções visíveis e textos próprios de cada um.
-- gym_id: o conteúdo existente passa a pertencer a um ginásio. Tudo o que já
-- existe fica no ginásio 1 (FitMe 3070). O horário de aulas segue a
-- modalidade (horario.id_modalidade), por isso não precisa de coluna própria.
-- fitme3070_info: uma linha por ginásio, com id = gyms.id.
-- Safe to re-run.
CREATE TABLE IF NOT EXISTS `gyms` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`slug` VARCHAR(20) NOT NULL, -- pasta da URL, ex.: 3070
`name` VARCHAR(100) NOT NULL, -- ex.: FitMe 3070
`locality` VARCHAR(100) NOT NULL DEFAULT '', -- ex.: Mira
`postal_code` VARCHAR(20) NOT NULL DEFAULT '',
`is_published` TINYINT(1) NOT NULL DEFAULT 0,
`is_default` TINYINT(1) NOT NULL DEFAULT 0, -- para onde vai a raiz / e /home
`sections` TEXT DEFAULT NULL, -- JSON: secções visíveis (NULL = todas)
`hero_title` VARCHAR(150) NOT NULL DEFAULT '',
`hero_subtitle` VARCHAR(150) NOT NULL DEFAULT '',
`hero_image` VARCHAR(255) NOT NULL DEFAULT '',
`seo_title` VARCHAR(160) NOT NULL DEFAULT '',
`seo_description` VARCHAR(300) NOT NULL DEFAULT '',
`gallery_id` INT(11) DEFAULT NULL, -- galleries.id → uploads/gallery/<id>/
`google_place_id` VARCHAR(120) NOT NULL DEFAULT '',
`has_crossfit` TINYINT(1) NOT NULL DEFAULT 0, -- mostra a página /<pasta>/crossfit
`item_order` INT(11) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`deleted_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_gyms_slug` (`slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- O ginásio atual. Place ID das reviews e galeria vêm do que já existe.
INSERT INTO `gyms` (`id`, `slug`, `name`, `locality`, `postal_code`, `is_published`, `is_default`,
`hero_title`, `hero_subtitle`, `hero_image`, `gallery_id`, `google_place_id`, `has_crossfit`)
SELECT 1, '3070', 'FitMe 3070', 'Mira', '3070', 1, 1,
'Ginásio, Box, Aulas', '& Acompanhamento', 'site/assets/img/home.jpg', 1,
COALESCE((SELECT `setting_value` FROM `system_settings` WHERE `setting_key` = 'google_place_id'), ''), 1
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `gyms`);
ALTER TABLE `team` ADD COLUMN IF NOT EXISTS `gym_id` INT(11) NOT NULL DEFAULT 1 AFTER `id`, ADD KEY IF NOT EXISTS `idx_team_gym` (`gym_id`);
ALTER TABLE `modalidades` ADD COLUMN IF NOT EXISTS `gym_id` INT(11) NOT NULL DEFAULT 1 AFTER `m_id`, ADD KEY IF NOT EXISTS `idx_modalidades_gym` (`gym_id`);
ALTER TABLE `work_hours` ADD COLUMN IF NOT EXISTS `gym_id` INT(11) NOT NULL DEFAULT 1 AFTER `id`, ADD KEY IF NOT EXISTS `idx_work_hours_gym` (`gym_id`);
ALTER TABLE `memberships` ADD COLUMN IF NOT EXISTS `gym_id` INT(11) NOT NULL DEFAULT 1 AFTER `id`, ADD KEY IF NOT EXISTS `idx_memberships_gym` (`gym_id`);
ALTER TABLE `membership_leads` ADD COLUMN IF NOT EXISTS `gym_id` INT(11) NOT NULL DEFAULT 1 AFTER `id`, ADD KEY IF NOT EXISTS `idx_membership_leads_gym` (`gym_id`);
ALTER TABLE `website_offer` ADD COLUMN IF NOT EXISTS `gym_id` INT(11) NOT NULL DEFAULT 1 AFTER `o_id`, ADD KEY IF NOT EXISTS `idx_website_offer_gym` (`gym_id`);
-- Eventos: NULL = todos os ginásios.
ALTER TABLE `events` ADD COLUMN IF NOT EXISTS `gym_id` INT(11) DEFAULT NULL AFTER `e_id`, ADD KEY IF NOT EXISTS `idx_events_gym` (`gym_id`);
+25
View File
@@ -106,6 +106,10 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<?php if (is_admin()): ?> <?php if (is_admin()): ?>
<div class="sidebar-section">Gestão</div> <div class="sidebar-section">Gestão</div>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/gyms/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/gyms/index.php') ?>"><i class="bi bi-buildings-fill"></i>Ginásios</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/team/') !== false) ? ' active' : '' ?>" <a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/team/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/team/index.php') ?>"><i class="bi bi-people-fill"></i>Equipa</a> href="<?= base_url('views/admin/team/index.php') ?>"><i class="bi bi-people-fill"></i>Equipa</a>
@@ -192,6 +196,27 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<button type="button" class="menu-toggle" id="menuToggle" aria-label="Abrir menu" aria-controls="sidebar"> <button type="button" class="menu-toggle" id="menuToggle" aria-label="Abrir menu" aria-controls="sidebar">
<i class="bi bi-list"></i> <i class="bi bi-list"></i>
</button> </button>
<?php
// Which gym the content pages edit (functions_gyms.php). Switching goes back
// to the section's list — an open record may not exist in the other gym.
if (is_admin() && function_exists('db_gyms_list') && ($hdr_gyms = db_gyms_list($pdo))):
$hdr_cur = admin_gym_id();
$hdr_dir = trim(str_replace(chr(92), '/', dirname($_SERVER['SCRIPT_NAME'] ?? '')), '/');
$hdr_back = (str_starts_with($hdr_dir, 'views/') && is_file(dirname(__DIR__) . '/' . $hdr_dir . '/index.php'))
? $hdr_dir . '/index.php' : 'views/admin/dashboard.php';
?>
<form method="POST" action="<?= base_url('views/admin/gyms/switch.php') ?>" class="gym-switch d-flex align-items-center gap-2 ms-2">
<?= csrf_field() ?>
<input type="hidden" name="back" value="<?= htmlspecialchars($hdr_back) ?>">
<label for="gymSwitch" class="small text-muted text-nowrap mb-0"><i class="bi bi-buildings"></i> A editar</label>
<select id="gymSwitch" name="gym_id" class="form-select form-select-sm fw-semibold" style="max-width:200px" onchange="this.form.submit()"<?= count($hdr_gyms) < 2 ? ' disabled title="Só há um ginásio"' : '' ?>>
<?php foreach ($hdr_gyms as $hg): ?>
<option value="<?= (int) $hg['id'] ?>" <?= (int) $hg['id'] === $hdr_cur ? 'selected' : '' ?>><?= htmlspecialchars($hg['name']) ?><?= $hg['is_published'] ? '' : ' (por publicar)' ?></option>
<?php endforeach; ?>
</select>
<noscript><button class="btn btn-sm btn-outline-secondary">Mudar</button></noscript>
</form>
<?php endif; ?>
<div class="user-dropdown"> <div class="user-dropdown">
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>"> <span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>">
<?= htmlspecialchars(role_label($user['role'])) ?> <?= htmlspecialchars(role_label($user['role'])) ?>
+16 -3
View File
@@ -5,14 +5,27 @@
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php'; require_once __DIR__ . '/../config/functions.php';
$info = site_info($pdo); // The gym this page belongs to: ?gym=<slug> from the URL folder (/3070/…), or
$navbar = site_navbar(); // the default gym for pages outside a gym folder (privacy page, events).
// Unknown slugs — and unpublished gyms for anyone not logged in to the
// backoffice — are a plain 404.
$gym = site_gym($pdo);
if (!$gym) { require __DIR__ . '/404.php'; exit; }
$GLOBALS['_gym'] = $gym;
$info = site_info($pdo, (int) $gym['id']);
$navbar = site_navbar($gym);
$current_uri_2 = $current_uri_2 ?? ''; $current_uri_2 = $current_uri_2 ?? '';
$GLOBALS['_view_shared'] = compact('info', 'navbar', 'current_uri_2'); $GLOBALS['_view_shared'] = compact('info', 'navbar', 'current_uri_2', 'gym');
echo view('include'); // renders view('seo') inside <head> echo view('include'); // renders view('seo') inside <head>
echo view('navbar'); echo view('navbar');
echo view('messenger_chat'); echo view('messenger_chat');
// Staff previewing a gym that isn't public yet.
if (empty($gym['is_published'])) {
echo '<div class="event-preview-bar">Pré-visualização — <strong>' . htmlspecialchars($gym['name'])
. '</strong> ainda não está publicado. Só quem tem sessão iniciada no backoffice vê esta página.</div>' . "\n";
}
// Page content landmark — closed at the top of site/includes/footer.php, so // Page content landmark — closed at the top of site/includes/footer.php, so
// every public page gets one <main> (and the skip link a real target). // every public page gets one <main> (and the skip link a real target).
echo '<main id="conteudo">' . "\n"; echo '<main id="conteudo">' . "\n";
+11 -6
View File
@@ -8,16 +8,21 @@
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php'; require_once __DIR__ . '/../config/functions.php';
// Only POST is meaningful here; anything else just goes home. // The gym comes from the URL folder (/<slug>/agendar); the old /home/agendar
// route has no ?gym and falls back to the default gym.
$gym = site_gym($pdo) ?? db_gyms_default($pdo) ?? gym_fallback();
$GLOBALS['_gym'] = $gym;
// Only POST is meaningful here; anything else just goes to the gym's page.
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
header('Location: ' . base_url('home')); header('Location: ' . gym_url($gym));
exit; exit;
} }
csrf_check(); // 403s and exits on a bad/missing token csrf_check(); // 403s and exits on a bad/missing token
function agendar_redirect(string $status): void { function agendar_redirect(string $status): void {
header('Location: ' . base_url('home?agendar=' . $status . '#contactos')); header('Location: ' . gym_url(current_gym(), '?agendar=' . $status . '#contactos'));
exit; exit;
} }
@@ -39,7 +44,7 @@ if ($data !== '') {
$data_fmt = ($d && $d->format('Y-m-d') === $data) ? $d->format('d/m/Y') : ''; $data_fmt = ($d && $d->format('Y-m-d') === $data) ? $d->format('d/m/Y') : '';
} }
$info = site_info($pdo); $info = site_info($pdo, (int) $gym['id']);
$to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM; $to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM;
$e = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); $e = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
@@ -58,7 +63,7 @@ foreach ($rows as $label => $value) {
. ':</td><td>' . nl2br($e($value)) . '</td></tr>'; . ':</td><td>' . nl2br($e($value)) . '</td></tr>';
} }
$body .= '</table><p style="font-family:Arial,sans-serif;font-size:13px;color:#666;margin-top:18px;">' $body .= '</table><p style="font-family:Arial,sans-serif;font-size:13px;color:#666;margin-top:18px;">'
. 'Pedido enviado através do formulário do site FitMe 3070. Responde diretamente a ' . 'Pedido enviado através do formulário do site ' . $e($gym['name']) . '. Responde diretamente a '
. $e($email) . ' ou liga para ' . $e($telefone) . '.</p>'; . $e($email) . ' ou liga para ' . $e($telefone) . '.</p>';
$alt = "Novo pedido de aula experimental\n\n"; $alt = "Novo pedido de aula experimental\n\n";
@@ -69,7 +74,7 @@ foreach ($rows as $label => $value) {
$id = enqueue_email([ $id = enqueue_email([
'to_address' => $to, 'to_address' => $to,
'to_name' => $info['name'] !== '' ? $info['name'] : 'FitMe 3070', 'to_name' => $info['name'] !== '' ? $info['name'] : $gym['name'],
'subject' => 'Pedido de aula experimental — ' . $nome, 'subject' => 'Pedido de aula experimental — ' . $nome,
'body' => $body, 'body' => $body,
'alt_body' => $alt, 'alt_body' => $alt,
+2 -2
View File
@@ -4149,7 +4149,7 @@ h3.contact-title {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
border-radius: 6px; border-radius: var(--fitme-radius); /* brand: square-ish corners */
position: relative; position: relative;
background: var(--fitme-surface-2); background: var(--fitme-surface-2);
cursor: zoom-in; cursor: zoom-in;
@@ -4229,7 +4229,7 @@ h3.contact-title {
max-width: 100%; max-width: 100%;
max-height: calc(100vh - 110px); max-height: calc(100vh - 110px);
object-fit: contain; object-fit: contain;
border-radius: 4px; border-radius: var(--fitme-radius);
box-shadow: 0 20px 60px rgba(0, 0, 0, .5); box-shadow: 0 20px 60px rgba(0, 0, 0, .5);
animation: gal-in .35s ease; animation: gal-in .35s ease;
} }
+14 -2
View File
@@ -1,7 +1,19 @@
<?php <?php
// Was Home::crossfit. // Was Home::crossfit.
$current_uri_2 = 'crossfit'; $current_uri_2 = 'crossfit';
// Old /home/crossfit links → the default gym's CrossFit page.
if (($_GET['gym'] ?? '') === '') {
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php';
$to = db_gyms_default($pdo) ?? gym_fallback();
header('Location: ' . gym_url($to, 'crossfit'), true, 301);
exit;
}
require __DIR__ . '/_open.php'; require __DIR__ . '/_open.php';
// Only gyms with a CrossFit box have this page.
if (empty($gym['has_crossfit'])) { ob_get_level() && ob_end_clean(); require __DIR__ . '/404.php'; exit; }
$data = [ $data = [
'title' => 'CrossFit', 'title' => 'CrossFit',
@@ -13,13 +25,13 @@ $data = [
'header_video_video' => 'site/assets/img/crossfit.mp4', 'header_video_video' => 'site/assets/img/crossfit.mp4',
'header_video_img' => 'site/assets/img/crossfit.jpg', 'header_video_img' => 'site/assets/img/crossfit.jpg',
'rel_footer_h2' => 'Precisas de um<span>Personal Trainer?</span><br> Entra em contacto connosco.', 'rel_footer_h2' => 'Precisas de um<span>Personal Trainer?</span><br> Entra em contacto connosco.',
'ad_footer_link' => 'home/contactos?assunto=pt_cf', 'ad_footer_link' => rawurlencode($gym['slug']) . '/?assunto=pt_cf#contactos',
'ad_footer_text' => 'Tens objetivos específicos na área do CrossFit? Temos a solução para ti. Atinge e melhora os teus resultados com os nossos profissionais especializados na área. Todas as viagens são melhores quando vividas com a companhia certa. ', 'ad_footer_text' => 'Tens objetivos específicos na área do CrossFit? Temos a solução para ti. Atinge e melhora os teus resultados com os nossos profissionais especializados na área. Todas as viagens são melhores quando vividas com a companhia certa. ',
'ad_footer_h2' => 'Precisas de um <span>Personal Trainer</span> especializado em CrossFit?', 'ad_footer_h2' => 'Precisas de um <span>Personal Trainer</span> especializado em CrossFit?',
'ad_footer_bg' => '', 'ad_footer_bg' => '',
'ad_footer_text_btn' => 'Entra em contacto connosco', 'ad_footer_text_btn' => 'Entra em contacto connosco',
]; ];
$data['data'] = site_horario($pdo, 'crossfit'); $data['data'] = site_horario($pdo, 'crossfit', (int) $gym['id']);
echo view('header_video', $data); echo view('header_video', $data);
echo view('crossfit', $data); echo view('crossfit', $data);
+52
View File
@@ -0,0 +1,52 @@
<?php
// Root page when more than one gym is published (site/index.php includes this
// instead of redirecting): pick a gym. Self-contained — no gym is "current"
// here, so it doesn't go through _open.php. Expects $pdo and $published.
$gyms = $published ?? db_gyms_published($pdo);
$brand = 'FitMe';
?><!doctype html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($brand) ?> — Escolhe o teu ginásio</title>
<meta name="description" content="<?= htmlspecialchars($brand) ?>: <?= htmlspecialchars(implode(', ', array_map(fn($g) => $g['name'] . ($g['locality'] !== '' ? ' (' . $g['locality'] . ')' : ''), $gyms))) ?>.">
<link rel="canonical" href="<?= htmlspecialchars(base_url()) ?>">
<?php if (seo_noindex()): ?><meta name="robots" content="noindex, nofollow"><?php endif; ?>
<link href="https://fonts.googleapis.com/css?family=Roboto:400,700,900&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="<?= base_url('site/assets/img/logos/logo_black.svg') ?>">
<style>
:root { --accent:#C2D500; --dark:#0B0B0D; --dark2:#16181A; --ash:#B8B9BE; }
* { box-sizing: border-box; }
body { margin:0; min-height:100svh; font-family:Roboto,system-ui,sans-serif; color:#fff;
background:#141517 url('<?= base_url('site/assets/img/home.jpg') ?>') center/cover fixed; }
.wrap { min-height:100svh; background:rgba(11,11,13,.82); display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:48px 16px; gap:36px; }
h1 { margin:0; font-size:clamp(1.8rem,4vw,2.6rem); font-weight:900; text-transform:uppercase; text-align:center; }
h1 span { color:var(--accent); }
.gyms { display:flex; flex-wrap:wrap; gap:18px; justify-content:center; width:100%; max-width:900px; }
a.gym { flex:1 1 260px; max-width:320px; display:block; padding:28px 24px; border-radius:8px; text-decoration:none;
color:#fff; background:var(--dark2); border:2px solid rgba(255,255,255,.08);
transition:border-color .25s, transform .25s; }
a.gym:hover, a.gym:focus-visible { border-color:var(--accent); transform:translateY(-4px); outline:none; }
.gym strong { display:block; font-size:1.4rem; font-weight:900; text-transform:uppercase; }
.gym span { color:var(--ash); }
.gym em { display:inline-block; margin-top:14px; font-style:normal; font-weight:700; color:var(--accent); }
</style>
</head>
<body>
<main class="wrap">
<img src="<?= base_url('site/assets/img/logos/logo_black.svg') ?>" alt="<?= htmlspecialchars($brand) ?>" style="width:170px;filter:invert(1)">
<h1>Escolhe o teu <span>ginásio</span></h1>
<div class="gyms">
<?php foreach ($gyms as $g): ?>
<a class="gym" href="<?= htmlspecialchars(gym_url($g)) ?>">
<strong><?= htmlspecialchars($g['name']) ?></strong>
<?php if ($g['locality'] !== ''): ?><span><?= htmlspecialchars($g['locality']) ?></span><?php endif; ?>
<br><em>Entrar →</em>
</a>
<?php endforeach; ?>
</div>
</main>
</body>
</html>
+1 -1
View File
@@ -21,7 +21,7 @@
<?php endif; ?> <?php endif; ?>
<ul class="footer-menu"> <ul class="footer-menu">
<?php foreach($navbar as $key => $n): ?> <?php foreach($navbar as $key => $n): ?>
<li><a href="<?= base_url($n) ?>" <?= (isset($n[0]) && $n[0] === '#') ? 'class="smooth-scroll"' : '' ?>><?= $key ?></a></li> <li><a href="<?= htmlspecialchars($n) ?>" <?= str_contains($n, '#') ? 'class="smooth-scroll"' : '' ?>><?= $key ?></a></li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
<p class="small footer-legal">Este estabelecimento possui livro de reclamações online em <a href="https://www.livroreclamacoes.pt/Pedido/Reclamacao" target="_blank" rel="noopener">www.livroreclamacoes.pt</a></p> <p class="small footer-legal">Este estabelecimento possui livro de reclamações online em <a href="https://www.livroreclamacoes.pt/Pedido/Reclamacao" target="_blank" rel="noopener">www.livroreclamacoes.pt</a></p>
+17 -2
View File
@@ -10,10 +10,25 @@ $page_meta = [
'privacidade' => ['Política de Privacidade | FitMe 3070', 'privacidade' => ['Política de Privacidade | FitMe 3070',
'Política de privacidade do FitMe 3070 — como recolhemos, usamos e protegemos os teus dados pessoais.'], 'Política de privacidade do FitMe 3070 — como recolhemos, usamos e protegemos os teus dados pessoais.'],
]; ];
// The gym's homepage uses the gym's own SEO title/description when set
// (Gestão → Ginásios); a new gym without them gets a generic one from its name
// and locality rather than the 3070 texts above.
$gym_meta = current_gym();
if ($page === '' && ($gym_meta['seo_title'] ?? '') !== '') {
$page_meta[''] = [$gym_meta['seo_title'], ($gym_meta['seo_description'] ?? '') !== '' ? $gym_meta['seo_description'] : $page_meta[''][1]];
} elseif ($page === '' && (int) $gym_meta['id'] !== 1) {
$loc = ($gym_meta['locality'] ?? '') !== '' ? ' em ' . $gym_meta['locality'] : '';
$page_meta[''] = [$gym_meta['name'] . ' — Ginásio' . $loc,
($gym_meta['seo_description'] ?? '') !== '' ? $gym_meta['seo_description'] : 'Ginásio ' . $gym_meta['name'] . $loc . ': treino, aulas de grupo e acompanhamento.'];
}
// A dynamic page (e.g. an event landing page) can set its own title/description // A dynamic page (e.g. an event landing page) can set its own title/description
// and canonical via these globals before requiring _open.php. // and canonical via these globals before requiring _open.php.
[$page_title, $page_desc] = $GLOBALS['_page_meta_override'] ?? $page_meta[$page] ?? $page_meta['']; [$page_title, $page_desc] = $GLOBALS['_page_meta_override'] ?? $page_meta[$page] ?? $page_meta[''];
$canonical = $GLOBALS['_canonical_override'] ?? base_url($page !== '' ? 'home/' . $page : ''); $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
'' => gym_url($gym_meta),
'crossfit' => gym_url($gym_meta, 'crossfit'),
default => base_url('home/' . $page),
};
?><!doctype html> ?><!doctype html>
<html lang="pt"> <html lang="pt">
<head> <head>
@@ -34,7 +49,7 @@ $canonical = $GLOBALS['_canonical_override'] ?? base_url($page !== '' ? 'home/'
<link rel="dns-prefetch" href="https://forms.zohopublic.eu"> <link rel="dns-prefetch" href="https://forms.zohopublic.eu">
<?php if (($current_uri_2 ?? '') === ''): ?> <?php if (($current_uri_2 ?? '') === ''): ?>
<!-- Homepage hero is the LCP image — start fetching it before the CSS/JS --> <!-- Homepage hero is the LCP image — start fetching it before the CSS/JS -->
<link rel="preload" as="image" href="<?= base_url('site/assets/img/home.jpg') ?>"> <link rel="preload" as="image" href="<?= base_url(($gym_meta['hero_image'] ?? '') !== '' ? $gym_meta['hero_image'] : 'site/assets/img/home.jpg') ?>">
<?php endif; ?> <?php endif; ?>
<!-- Fonts --> <!-- Fonts -->
+8 -7
View File
@@ -9,18 +9,20 @@ else
?> ?>
<header class="header-section lazy"> <header class="header-section lazy">
<nav class="navbar navbar-expand-lg navbar-light" style="padding-left:1rem; padding-top: 0; padding-bottom: 0;padding-right: 0; "> <nav class="navbar navbar-expand-lg navbar-light" style="padding-left:1rem; padding-top: 0; padding-bottom: 0;padding-right: 0; ">
<a class="navbar-brand" href="<?= base_url('home') ?>"> <a class="navbar-brand" href="<?= htmlspecialchars(gym_url(current_gym())) ?>">
<img loading="eager" alt="Logo FitMe3070" src="<?= $logo ?>" style="width: 150px;"> <img loading="eager" alt="Logo FitMe3070" src="<?= $logo ?>" style="width: 150px;">
</a> </a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button>
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mx-auto "> <ul class="navbar-nav mx-auto ">
<?php foreach($navbar as $key => $n): <?php foreach($navbar as $key => $n):
// $n is an absolute URL on the gym's own page (site_navbar()).
$is_crossfit = ($key === 'CrossFit'); $is_crossfit = ($key === 'CrossFit');
$is_anchor = str_contains($n, '#');
$is_active = false; $is_active = false;
if ($is_crossfit) { if ($is_crossfit) {
$is_active = ($current_uri_2 === 'crossfit'); $is_active = ($current_uri_2 === 'crossfit');
} elseif ($n === '#home') { } elseif (str_ends_with($n, '#home')) {
// Only the Home link is active by default on the homepage // Only the Home link is active by default on the homepage
$is_active = ($current_uri_2 === ''); $is_active = ($current_uri_2 === '');
} }
@@ -28,16 +30,15 @@ else
?> ?>
<li class="nav-item <?= $is_active ? 'active' : '' ?> main-menu-li" > <li class="nav-item <?= $is_active ? 'active' : '' ?> main-menu-li" >
<?php if ($is_crossfit): ?> <?php if ($is_crossfit): ?>
<a class="nav-link crossfit-nav-link" href="<?= base_url($n) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>> <a class="nav-link crossfit-nav-link" href="<?= htmlspecialchars($n) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
<?= $key ?> <?= $key ?>
</a> </a>
<?php elseif (isset($n[0]) && $n[0] === '#'): ?> <?php elseif ($is_anchor): ?>
<?php $href = ($current_uri_2 === 'crossfit') ? base_url('home' . $n) : base_url($n); ?> <a class="nav-link smooth-scroll" href="<?= htmlspecialchars($n) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
<a class="nav-link smooth-scroll" href="<?= $href ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
<?= $key ?> <?= $key ?>
</a> </a>
<?php else: ?> <?php else: ?>
<a class="nav-link" href="<?= base_url($n) ?>"> <a class="nav-link" href="<?= htmlspecialchars($n) ?>">
<?= $key ?> <?= $key ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
+5 -4
View File
@@ -1,12 +1,13 @@
<?php <?php
// JSON-LD structured data for the gym (rendered inside <head> by include.php). // JSON-LD structured data for the gym (rendered inside <head> by include.php).
// Built from fitme3070_info + work_hours so admin edits stay in sync. // Built from fitme3070_info + work_hours so admin edits stay in sync.
$ld_gym = current_gym();
$ld = [ $ld = [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
'@type' => 'ExerciseGym', '@type' => 'ExerciseGym',
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070', 'name' => $ld_gym['name'] !== '' ? $ld_gym['name'] : (($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070'),
'url' => base_url(), 'url' => gym_url($ld_gym),
'image' => base_url('site/assets/img/home.jpg'), 'image' => base_url(($ld_gym['hero_image'] ?? '') !== '' ? $ld_gym['hero_image'] : 'site/assets/img/home.jpg'),
'logo' => base_url('site/assets/img/logos/logo_black.svg'), 'logo' => base_url('site/assets/img/logos/logo_black.svg'),
]; ];
if (!empty($info['phone'])) $ld['telephone'] = site_tel($info['phone']); if (!empty($info['phone'])) $ld['telephone'] = site_tel($info['phone']);
@@ -15,7 +16,7 @@ if (!empty($info['adress'])) {
$ld['address'] = [ $ld['address'] = [
'@type' => 'PostalAddress', '@type' => 'PostalAddress',
'streetAddress' => $info['adress'], 'streetAddress' => $info['adress'],
'addressLocality' => 'Mira', 'addressLocality' => ($ld_gym['locality'] ?? '') !== '' ? $ld_gym['locality'] : 'Mira',
'addressCountry' => 'PT', 'addressCountry' => 'PT',
]; ];
} }
+94 -68
View File
@@ -2,17 +2,37 @@
// One-page site: Home + Equipa + Modalidades + Horário + Contactos. // One-page site: Home + Equipa + Modalidades + Horário + Contactos.
// CrossFit is a separate page (site/crossfit.php). // CrossFit is a separate page (site/crossfit.php).
$current_uri_2 = ''; $current_uri_2 = '';
require __DIR__ . '/_open.php';
// No gym in the URL (/, /home, old links): send people to a gym page. With a
// single published gym that is a 301 to it (query string kept, e.g.
// ?agendar=ok); with more, the gym chooser.
if (($_GET['gym'] ?? '') === '') {
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php';
$published = db_gyms_published($pdo);
if (count($published) > 1) { require __DIR__ . '/gym_chooser.php'; exit; }
$to = $published[0] ?? db_gyms_default($pdo) ?? gym_fallback();
$qs = $_SERVER['QUERY_STRING'] ?? '';
header('Location: ' . gym_url($to) . ($qs !== '' ? '?' . $qs : ''), true, 301);
exit;
}
require __DIR__ . '/_open.php'; // resolves $gym (404 if unknown / not visible)
// ── Load all data ─────────────────────────────────────────────────────────────── // ── Load all data ───────────────────────────────────────────────────────────────
$offer = site_offer($pdo); // Everything is this gym's content; a section the gym switched off
$team = site_team($pdo); // (Gestão → Ginásios) loads as empty, so its block below simply isn't printed.
$modalidades = site_modalidades($pdo); $gid = (int) $gym['id'];
$horario = site_horario($pdo); $on = fn(string $key) => gym_section_on($gym, $key);
$whours = site_work_hours($pdo); $offer = $on('oferta') ? site_offer($pdo, $gid) : [];
$events = site_active_events($pdo); // time-limited events, banner below the hero $team = $on('equipa') ? site_team($pdo, $gid) : [];
$memberships = site_memberships($pdo); $modalidades = $on('modalidades') ? site_modalidades($pdo, $gid) : [];
$reviews = site_reviews(); // null when not configured / nothing qualifies $horario = $on('horario') ? site_horario($pdo, '', $gid) : [];
$whours = $on('workhours') ? site_work_hours($pdo, $gid) : [];
$events = site_active_events($pdo, $gid); // time-limited events, banner below the hero
$memberships = $on('mensalidades') ? site_memberships($pdo, $gid) : [];
$reviews = $on('reviews') ? site_reviews($gym) : null; // null when not configured / nothing qualifies
$hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img/home.jpg';
// ── Hero section ──────────────────────────────────────────────────────────────── // ── Hero section ────────────────────────────────────────────────────────────────
?> ?>
@@ -27,7 +47,7 @@ $reviews = site_reviews(); // null when not configured / nothin
<!-- Hero background is inline (no data-setbg) so it paints without waiting for JS; <!-- Hero background is inline (no data-setbg) so it paints without waiting for JS;
the set-bg class is kept for its cover/center styling. --> the set-bg class is kept for its cover/center styling. -->
<section id="home" class="hero-section set-bg fade-in" style="background-image:url('<?= base_url('site/assets/img/home.jpg') ?>')"> <section id="home" class="hero-section set-bg fade-in" style="background-image:url('<?= htmlspecialchars(base_url($hero_img), ENT_QUOTES) ?>')">
<div class="container"> <div class="container">
<div class="hero-text"> <div class="hero-text">
<?php /* Eventos a decorrer — destacados no topo do header. Cada um liga <?php /* Eventos a decorrer — destacados no topo do header. Cada um liga
@@ -50,7 +70,7 @@ $reviews = site_reviews(); // null when not configured / nothin
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<h1>Ginásio, Box, Aulas<br><span>&amp; Acompanhamento</span></h1> <h1><?= htmlspecialchars($gym['hero_title'] !== '' ? $gym['hero_title'] : $gym['name']) ?><?php if ($gym['hero_subtitle'] !== ''): ?><br><span><?= htmlspecialchars($gym['hero_subtitle']) ?></span><?php endif; ?></h1>
<div class="hero-cta-row"> <div class="hero-cta-row">
<a href="?assunto=insc#contactos" class="site-btn">Inscreve-te Já!</a> <a href="?assunto=insc#contactos" class="site-btn">Inscreve-te Já!</a>
<button type="button" class="site-btn sb-ghost" data-toggle="modal" data-target="#trialModal">Marca uma Aula Experimental</button> <button type="button" class="site-btn sb-ghost" data-toggle="modal" data-target="#trialModal">Marca uma Aula Experimental</button>
@@ -92,8 +112,8 @@ if ($offer):
<?php <?php
// ── Galeria (Instalações) ─────────────────────────────────────────────────────── // ── Galeria (Instalações) ───────────────────────────────────────────────────────
$gallery_path = 'uploads/gallery/1/'; $gallery_path = !empty($gym['gallery_id']) ? 'uploads/gallery/' . (int) $gym['gallery_id'] . '/' : '';
$gallery_files = glob($gallery_path . '*.jpg'); $gallery_files = ($on('galeria') && $gallery_path !== '') ? glob($gallery_path . '*.jpg') : [];
if ($gallery_files && count($gallery_files) > 0): if ($gallery_files && count($gallery_files) > 0):
$gallery_h2 = 'Conhece as nossas instalações'; $gallery_h2 = 'Conhece as nossas instalações';
?> ?>
@@ -102,6 +122,59 @@ $gallery_h2 = 'Conhece as nossas instalações';
</section> </section>
<?php endif; ?> <?php endif; ?>
<?php
// ── Modalidades ─────────────────────────────────────────────────────────────────
if ($modalidades):
?>
<section id="modalidades" class="service-section" aria-labelledby="modalidades-title">
<div class="container">
<div class="row justify-content-md-center mb-4">
<div class="col-lg-8">
<div class="section-title text-center deco">
<span class="section-eyebrow">Treinos</span>
<h2 id="modalidades-title">Modalidades</h2>
<p class="section-subtitle">Encontra a modalidade certa para ti.</p>
</div>
</div>
</div>
</div>
<?php /* Full-width strip of modalidade cards. It is a real horizontal
scroller (touch, trackpad/wheel, mouse drag, arrows) that also
glides on its own until someone interacts (main.js). A card with a
description opens .mod-detail — one shared panel outside the
scroller, so it isn't clipped — which grows out of the card. */ ?>
<div class="mod-strip">
<button type="button" class="mod-nav mod-nav-prev" aria-label="Modalidades anteriores"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>
<div class="mod-scroll" tabindex="0" aria-label="Modalidades — desliza para ver mais">
<?php foreach($modalidades as $d): $desc = trim(strip_tags((string) ($d['descricao'] ?? ''))) !== '' ? trim((string) $d['descricao']) : ''; ?>
<div class="icon-box-item mod-card<?= $desc !== '' ? ' has-info' : '' ?>" data-color="<?= htmlspecialchars((string) $d['cor']) ?>"<?= $desc !== '' ? ' role="button" tabindex="0" aria-haspopup="dialog" aria-label="Ver descrição de ' . htmlspecialchars((string) $d['nome']) . '"' : '' ?>>
<div class="ib-icon">
<?= modalidade_icon_html($d['icon']) /* inline animated SVG for the FitMe set, else <img> */ ?>
</div>
<h3><?= htmlspecialchars((string) $d['nome']) ?></h3>
<?php if ($desc !== ''): ?>
<?php /* descricao is admin-authored rich text — sanitised allowlist HTML */ ?>
<template class="mod-desc"><?= clean_richtext($desc) ?></template>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="mod-nav mod-nav-next" aria-label="Mais modalidades"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
</div>
<div class="mod-detail" role="dialog" aria-modal="false" aria-hidden="true">
<button type="button" class="md-close" aria-label="Fechar">&times;</button>
<div class="md-card">
<div class="ib-icon"></div>
<h3></h3>
</div>
<div class="md-body">
<strong class="md-name"></strong>
<div class="md-text"></div>
</div>
</div>
</section>
<?php endif; ?>
<?php <?php
// ── Equipa ────────────────────────────────────────────────────────────────────── // ── Equipa ──────────────────────────────────────────────────────────────────────
if ($team): if ($team):
@@ -166,59 +239,6 @@ if ($team):
</section> </section>
<?php endif; ?> <?php endif; ?>
<?php
// ── Modalidades ─────────────────────────────────────────────────────────────────
if ($modalidades):
?>
<section id="modalidades" class="service-section" aria-labelledby="modalidades-title">
<div class="container">
<div class="row justify-content-md-center mb-4">
<div class="col-lg-8">
<div class="section-title text-center deco">
<span class="section-eyebrow">Treinos</span>
<h2 id="modalidades-title">Modalidades</h2>
<p class="section-subtitle">Encontra a modalidade certa para ti.</p>
</div>
</div>
</div>
</div>
<?php /* Full-width strip of modalidade cards. It is a real horizontal
scroller (touch, trackpad/wheel, mouse drag, arrows) that also
glides on its own until someone interacts (main.js). A card with a
description opens .mod-detail — one shared panel outside the
scroller, so it isn't clipped — which grows out of the card. */ ?>
<div class="mod-strip">
<button type="button" class="mod-nav mod-nav-prev" aria-label="Modalidades anteriores"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>
<div class="mod-scroll" tabindex="0" aria-label="Modalidades — desliza para ver mais">
<?php foreach($modalidades as $d): $desc = trim(strip_tags((string) ($d['descricao'] ?? ''))) !== '' ? trim((string) $d['descricao']) : ''; ?>
<div class="icon-box-item mod-card<?= $desc !== '' ? ' has-info' : '' ?>" data-color="<?= htmlspecialchars((string) $d['cor']) ?>"<?= $desc !== '' ? ' role="button" tabindex="0" aria-haspopup="dialog" aria-label="Ver descrição de ' . htmlspecialchars((string) $d['nome']) . '"' : '' ?>>
<div class="ib-icon">
<?= modalidade_icon_html($d['icon']) /* inline animated SVG for the FitMe set, else <img> */ ?>
</div>
<h3><?= htmlspecialchars((string) $d['nome']) ?></h3>
<?php if ($desc !== ''): ?>
<?php /* descricao is admin-authored rich text — sanitised allowlist HTML */ ?>
<template class="mod-desc"><?= clean_richtext($desc) ?></template>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="mod-nav mod-nav-next" aria-label="Mais modalidades"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
</div>
<div class="mod-detail" role="dialog" aria-modal="false" aria-hidden="true">
<button type="button" class="md-close" aria-label="Fechar">&times;</button>
<div class="md-card">
<div class="ib-icon"></div>
<h3></h3>
</div>
<div class="md-body">
<strong class="md-name"></strong>
<div class="md-text"></div>
</div>
</div>
</section>
<?php endif; ?>
<?php <?php
// ── Horário ───────────────────────────────────────────────────────────────────── // ── Horário ─────────────────────────────────────────────────────────────────────
if ($horario): if ($horario):
@@ -556,6 +576,7 @@ if (isset($_GET['assunto'])) {
} }
$session = session(); $session = session();
?> ?>
<?php if ($on('contactos')): ?>
<?php /* contacts-bg.jpg never existed — outside2.jpg is the current photo in img/contactos/ */ ?> <?php /* contacts-bg.jpg never existed — outside2.jpg is the current photo in img/contactos/ */ ?>
<section id="contactos" class="contact-section set-bg" data-setbg="<?= base_url('site/assets/img/contactos/outside2.jpg') ?>" aria-labelledby="contactos-title"> <section id="contactos" class="contact-section set-bg" data-setbg="<?= base_url('site/assets/img/contactos/outside2.jpg') ?>" aria-labelledby="contactos-title">
<div class="container"> <div class="container">
@@ -663,6 +684,8 @@ $session = session();
</div> </div>
</section> </section>
<?php endif; /* contactos */ ?>
<?php <?php
// ── Horário de Funcionamento ────────────────────────────────────────────────── // ── Horário de Funcionamento ──────────────────────────────────────────────────
if ($whours): if ($whours):
@@ -694,6 +717,7 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
<?php <?php
// ── Personal Trainer CTA ──────────────────────────────────────────────────────── // ── Personal Trainer CTA ────────────────────────────────────────────────────────
if ($on('personal_trainer')):
?> ?>
<section id="personal-trainer" class="classes-section" aria-labelledby="pt-title"> <section id="personal-trainer" class="classes-section" aria-labelledby="pt-title">
<div class="container"> <div class="container">
@@ -713,6 +737,8 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
</div> </div>
</section> </section>
<?php endif; /* personal trainer */ ?>
<?php <?php
// ── Aula experimental modal ───────────────────────────────────────────────── // ── Aula experimental modal ─────────────────────────────────────────────────
// Queues a booking request to id.andmore.pt (centralized mail) via site/agendar.php. // Queues a booking request to id.andmore.pt (centralized mail) via site/agendar.php.
@@ -726,7 +752,7 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
<p>Treina connosco sem compromisso. Deixa os teus dados e entramos em contacto para confirmar o dia e a hora.</p> <p>Treina connosco sem compromisso. Deixa os teus dados e entramos em contacto para confirmar o dia e a hora.</p>
<button type="button" class="trial-close" data-dismiss="modal" aria-label="Fechar">&times;</button> <button type="button" class="trial-close" data-dismiss="modal" aria-label="Fechar">&times;</button>
</div> </div>
<form class="trial-form" method="post" action="<?= base_url('home/agendar') ?>"> <form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'agendar')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="form-row"> <div class="form-row">
<div class="col-12 col-md-6 mb-3"> <div class="col-12 col-md-6 mb-3">
@@ -785,7 +811,7 @@ if ($memberships):
<p>Deixa os teus contactos e entramos em contacto para concluir a inscrição.</p> <p>Deixa os teus contactos e entramos em contacto para concluir a inscrição.</p>
<button type="button" class="trial-close" data-dismiss="modal" aria-label="Fechar">&times;</button> <button type="button" class="trial-close" data-dismiss="modal" aria-label="Fechar">&times;</button>
</div> </div>
<form class="trial-form" method="post" action="<?= base_url('home/inscricao') ?>"> <form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'inscricao')) ?>">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="text" name="website" class="event-hp" tabindex="-1" autocomplete="off" aria-hidden="true"> <input type="text" name="website" class="event-hp" tabindex="-1" autocomplete="off" aria-hidden="true">
<div class="form-row"> <div class="form-row">
+11 -4
View File
@@ -10,15 +10,20 @@
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php'; require_once __DIR__ . '/../config/functions.php';
// The gym comes from the URL folder (/<slug>/inscricao); the old /home/inscricao
// route has no ?gym and falls back to the default gym.
$gym = site_gym($pdo) ?? db_gyms_default($pdo) ?? gym_fallback();
$GLOBALS['_gym'] = $gym;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
header('Location: ' . base_url('home#mensalidades')); header('Location: ' . gym_url($gym, '#mensalidades'));
exit; exit;
} }
csrf_check(); // 403s and exits on a bad/missing token csrf_check(); // 403s and exits on a bad/missing token
function inscricao_redirect(string $status): void { function inscricao_redirect(string $status): void {
header('Location: ' . base_url('home?inscricao=' . $status . '#mensalidades')); header('Location: ' . gym_url(current_gym(), '?inscricao=' . $status . '#mensalidades'));
exit; exit;
} }
@@ -27,9 +32,11 @@ if (trim((string) ($_POST['website'] ?? '')) !== '') inscricao_redirect('ok');
$consent_text = 'Autorizo o contacto e o tratamento dos meus dados para efeitos desta inscrição, nos termos da Política de Privacidade.'; $consent_text = 'Autorizo o contacto e o tratamento dos meus dados para efeitos desta inscrição, nos termos da Política de Privacidade.';
$membership = db_memberships_get($pdo, (int) ($_POST['membership_id'] ?? 0)); $membership = db_memberships_get($pdo, (int) ($_POST['membership_id'] ?? 0));
if ($membership && ($membership['deleted_at'] !== null || !$membership['is_active'])) $membership = null; if ($membership && ($membership['deleted_at'] !== null || !$membership['is_active']
|| (int) $membership['gym_id'] !== (int) $gym['id'])) $membership = null;
$lead = [ $lead = [
'gym_id' => (int) $gym['id'],
'membership_id' => $membership ? (int) $membership['id'] : null, 'membership_id' => $membership ? (int) $membership['id'] : null,
'membership_name' => $membership ? $membership['name'] : '', 'membership_name' => $membership ? $membership['name'] : '',
'name' => trim((string) ($_POST['nome'] ?? '')), 'name' => trim((string) ($_POST['nome'] ?? '')),
@@ -58,7 +65,7 @@ try {
// Notify the gym. Best effort: the request is already stored. // Notify the gym. Best effort: the request is already stored.
try { try {
$info = site_info($pdo); $info = site_info($pdo, (int) $gym['id']);
$to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM; $to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM;
$e = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); $e = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$rows = [ $rows = [
+9
View File
@@ -37,6 +37,15 @@ include __DIR__ . '/../../includes/header.php';
<div class="card shadow-sm mb-4"><div class="card-body"> <div class="card shadow-sm mb-4"><div class="card-body">
<h2 class="h5">Gestão de Conteúdos</h2> <h2 class="h5">Gestão de Conteúdos</h2>
<div class="row g-3 mt-1"> <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"> <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') ?>"> <a class="card text-decoration-none border-primary shadow-sm h-100" href="<?= base_url('views/admin/team/index.php') ?>">
<div class="card-body"> <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(); $user = require_admin();
$errors = []; $errors = [];
$data = site_info($pdo); $data = site_info($pdo, admin_gym_id()); // the gym being edited
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check(); csrf_check();
+2 -1
View File
@@ -5,7 +5,8 @@ $user = require_admin();
$id = (int) ($_GET['id'] ?? 0); $id = (int) ($_GET['id'] ?? 0);
$item = $id ? db_memberships_get($pdo, $id) : null; $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 = []; $errors = [];
$data = $item; $data = $item;
+6 -2
View File
@@ -2,6 +2,9 @@
require __DIR__ . '/../../../config/db.php'; require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php'; require __DIR__ . '/../../../config/functions.php';
$user = require_admin(); $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; $search = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -18,7 +21,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} elseif ($key !== '') { } elseif ($key !== '') {
db_update_setting($pdo, 'google_places_api_key', secret_encrypt($key), $uid); 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_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_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); 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', '') !== ''; $env_key = (string) env('GOOGLE_PLACES_API_KEY', '') !== '';
$cache = reviews_cache_read(); $cache = reviews_cache_read();
$preview = site_reviews(); $preview = site_reviews();
$info = site_info($pdo); $info = site_info($pdo, (int) $rgym['id']);
$page_title = 'Reviews Google'; $page_title = 'Reviews Google';
include __DIR__ . '/../../../includes/header.php'; include __DIR__ . '/../../../includes/header.php';
?> ?>