feat: vários ginásios (/3070/ e /<código postal>/), CRUD de ginásios, modalidades após a galeria
Estrutura multi-ginásio: as mesmas secções do site, conteúdo próprio por ginásio, e cada um numa pasta da URL com o código postal da zona. - migration_20260926_gyms: tabela gyms (pasta, nome, localidade, publicado, principal, secções visíveis, título/imagem do header, SEO, galeria, Place ID, página CrossFit) com o FitMe 3070 como ginásio 1 em /3070/; gym_id em team, modalidades (o horário segue a modalidade), work_hours, memberships, membership_leads e website_offer (tudo o existente → ginásio 1); events com gym_id NULL = todos; fitme3070_info passa a uma linha por ginásio (id = gym). - Site: /<pasta>/, /<pasta>/crossfit, /<pasta>/agendar|inscricao. Ginásio por publicar → 404 para o público, pré-visualização (com faixa) para a equipa. / e /home → 301 para o ginásio principal (a manter a query); com mais de um ginásio publicado, a raiz passa a uma página para escolher. Secções desligadas não aparecem nem no menu; menu, canonical, JSON-LD, sitemap, formulários, e-mails e reviews (Place ID e cache) são do ginásio. - Backoffice: Gestão → Ginásios (criar/editar/apagar, secções, e «começar com o conteúdo de» outro ginásio — cópias independentes) e o seletor «A editar» no topo; equipa, modalidades, horários, horário de funcionamento, mensalidades, pedidos, contactos e reviews trabalham sobre o ginásio escolhido, e um registo de outro ginásio não abre. - Modalidades passam para logo a seguir à galeria das instalações. - Galeria sem cantos arredondados (raio da marca). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f8f68c61a
commit
5e300fc341
33 files changed
+1146
-197
No files matched your search
@@ -61,6 +61,14 @@ RewriteRule ^home/inscricao/?$ site/inscricao.php [L,QSA]
|
||||
RewriteRule ^home/crossfit/?$ site/crossfit.php [L,QSA]
|
||||
RewriteRule ^home/privacidade/?$ site/privacidade.php [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 ^sitemap\.xml$ site/seo_files.php?f=sitemap [L]
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ require_once __DIR__ . '/functions_queue.php'; // message_queue + enque
|
||||
require_once __DIR__ . '/functions_csrf.php';
|
||||
require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed)
|
||||
require_once __DIR__ . '/ci_compat.php'; // CI4 view-helper shim (script_tag/link_tag/view/esc/session)
|
||||
require_once __DIR__ . '/functions_gyms.php'; // multi-gym: gyms, URL folders, admin gym switcher
|
||||
require_once __DIR__ . '/functions_site.php'; // public-site data (site_info/offer/team/modalidades/horario)
|
||||
require_once __DIR__ . '/functions_images.php'; // upload resize/compress (gallery/team/modalidades caps)
|
||||
require_once __DIR__ . '/functions_team.php'; // team CRUD
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -10,7 +10,7 @@ function db_horario_list(PDO $pdo): array {
|
||||
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim_fmt
|
||||
FROM horario h
|
||||
JOIN modalidades m ON m.m_id = h.id_modalidade
|
||||
WHERE h.deleted_at IS NULL
|
||||
WHERE h.deleted_at IS NULL AND m.gym_id = " . admin_gym_id() . "
|
||||
ORDER BY FIELD(h.dia_semana,'segunda','terça','quarta','quinta','sexta','sábado','domingo'),
|
||||
h.hora_inicio ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -24,9 +24,9 @@ function db_horario_get(PDO $pdo, int $id): ?array {
|
||||
m.nome AS modalidade_nome
|
||||
FROM horario h
|
||||
JOIN modalidades m ON m.m_id = h.id_modalidade
|
||||
WHERE h.id = ? AND h.deleted_at IS NULL LIMIT 1
|
||||
WHERE h.id = ? AND h.deleted_at IS NULL AND m.gym_id = ? LIMIT 1
|
||||
");
|
||||
$stmt->execute([$id]);
|
||||
$stmt->execute([$id, admin_gym_id()]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,6 @@ function db_horario_move(PDO $pdo, int $id, string $dia_semana, string $hora_ini
|
||||
// Modalidades dropdown (id => nome + cor) for horario create/edit forms.
|
||||
function db_modalidades_options(PDO $pdo): array {
|
||||
return $pdo->query("
|
||||
SELECT m_id, nome, cor FROM modalidades WHERE deleted_at IS NULL ORDER BY nome
|
||||
SELECT m_id, nome, cor FROM modalidades WHERE deleted_at IS NULL AND gym_id = " . admin_gym_id() . " ORDER BY nome
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
@@ -43,15 +43,17 @@ function info_from_post(array $post, array &$errors): array {
|
||||
|
||||
function db_info_save(PDO $pdo, array $data): bool {
|
||||
$cols = array_keys(info_fields());
|
||||
$exists = (bool) $pdo->query("SELECT COUNT(*) FROM fitme3070_info WHERE id = 1")->fetchColumn();
|
||||
// One row per gym (id = gyms.id): the gym being edited in the backoffice.
|
||||
$gid = admin_gym_id();
|
||||
$exists = (bool) $pdo->query("SELECT COUNT(*) FROM fitme3070_info WHERE id = " . $gid)->fetchColumn();
|
||||
$vals = array_map(fn($c) => $data[$c] ?? '', $cols);
|
||||
if ($exists) {
|
||||
$sets = implode(', ', array_map(fn($c) => "`$c` = ?", $cols));
|
||||
$stmt = $pdo->prepare("UPDATE fitme3070_info SET $sets, updated_at = NOW() WHERE id = 1");
|
||||
$stmt = $pdo->prepare("UPDATE fitme3070_info SET $sets, updated_at = NOW() WHERE id = " . $gid);
|
||||
} else {
|
||||
$names = implode(', ', array_map(fn($c) => "`$c`", $cols));
|
||||
$marks = implode(', ', array_fill(0, count($cols), '?'));
|
||||
$stmt = $pdo->prepare("INSERT INTO fitme3070_info (id, $names, updated_at) VALUES (1, $marks, NOW())");
|
||||
$stmt = $pdo->prepare("INSERT INTO fitme3070_info (id, $names, updated_at) VALUES (" . $gid . ", $marks, NOW())");
|
||||
}
|
||||
$stmt->execute($vals);
|
||||
return $stmt->rowCount() > 0;
|
||||
|
||||
@@ -14,23 +14,27 @@ function memberships_defaults(): array {
|
||||
}
|
||||
|
||||
function db_memberships_list(PDO $pdo, bool $include_deleted = false): array {
|
||||
$sql = "SELECT * FROM memberships";
|
||||
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL";
|
||||
// Scoped to the gym being edited in the backoffice (functions_gyms.php).
|
||||
$sql = "SELECT * FROM memberships WHERE gym_id = " . admin_gym_id();
|
||||
if (!$include_deleted) $sql .= " AND deleted_at IS NULL";
|
||||
$sql .= " ORDER BY deleted_at IS NOT NULL, item_order, name";
|
||||
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// Public list: active, not deleted. Defensive — the table may not exist yet on
|
||||
// an install that hasn't run the migration.
|
||||
function site_memberships(PDO $pdo): array {
|
||||
function site_memberships(PDO $pdo, ?int $gym_id = null): array {
|
||||
try {
|
||||
return $pdo->query(
|
||||
"SELECT * FROM memberships WHERE deleted_at IS NULL AND is_active = 1 ORDER BY item_order, name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
$st = $pdo->prepare(
|
||||
"SELECT * FROM memberships WHERE deleted_at IS NULL AND is_active = 1 AND gym_id = ? ORDER BY item_order, name"
|
||||
);
|
||||
$st->execute([$gym_id ?? (int) current_gym()['id']]);
|
||||
return $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable $e) { return []; }
|
||||
}
|
||||
|
||||
function db_memberships_get(PDO $pdo, int $id): ?array {
|
||||
// Any gym (the public sign-up handler checks the gym itself).
|
||||
$stmt = $pdo->prepare("SELECT * FROM memberships WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
@@ -70,7 +74,11 @@ function _memberships_params(array $d): array {
|
||||
}
|
||||
|
||||
function _memberships_clear_featured(PDO $pdo, int $except): void {
|
||||
$pdo->prepare("UPDATE memberships SET is_featured = 0 WHERE id <> ? AND is_featured = 1")->execute([$except]);
|
||||
// One featured card per gym.
|
||||
$pdo->prepare("UPDATE memberships SET is_featured = 0
|
||||
WHERE id <> ? AND is_featured = 1
|
||||
AND gym_id = (SELECT g FROM (SELECT gym_id AS g FROM memberships WHERE id = ?) x)")
|
||||
->execute([$except, $except]);
|
||||
}
|
||||
|
||||
function db_memberships_create(PDO $pdo, array $d): int {
|
||||
@@ -78,10 +86,10 @@ function db_memberships_create(PDO $pdo, array $d): int {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO memberships (name, subtitle, price, is_addon, price_suffix, features,
|
||||
is_featured, featured_label, cta_label, item_order, is_active,
|
||||
created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
|
||||
gym_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
|
||||
");
|
||||
$stmt->execute(_memberships_params($d));
|
||||
$stmt->execute(array_merge(_memberships_params($d), [admin_gym_id()]));
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
if ($d['is_featured']) _memberships_clear_featured($pdo, $id);
|
||||
$pdo->commit();
|
||||
@@ -142,8 +150,8 @@ function membership_lead_statuses(): array {
|
||||
}
|
||||
|
||||
function db_membership_leads_list(PDO $pdo, string $status = ''): array {
|
||||
$sql = "SELECT * FROM membership_leads WHERE deleted_at IS NULL";
|
||||
$args = [];
|
||||
$sql = "SELECT * FROM membership_leads WHERE deleted_at IS NULL AND gym_id = ?";
|
||||
$args = [admin_gym_id()];
|
||||
if ($status !== '' && isset(membership_lead_statuses()[$status])) { $sql .= " AND status = ?"; $args[] = $status; }
|
||||
$sql .= " ORDER BY created_at DESC, id DESC";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
@@ -152,17 +160,18 @@ function db_membership_leads_list(PDO $pdo, string $status = ''): array {
|
||||
}
|
||||
|
||||
function db_membership_leads_count_new(PDO $pdo): int {
|
||||
try { return (int) $pdo->query("SELECT COUNT(*) FROM membership_leads WHERE status = 'novo' AND deleted_at IS NULL")->fetchColumn(); }
|
||||
try { return (int) $pdo->query("SELECT COUNT(*) FROM membership_leads WHERE status = 'novo' AND deleted_at IS NULL AND gym_id = " . admin_gym_id())->fetchColumn(); }
|
||||
catch (Throwable $e) { return 0; }
|
||||
}
|
||||
|
||||
function db_membership_lead_create(PDO $pdo, array $d): int {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO membership_leads (membership_id, membership_name, name, email, phone, message,
|
||||
INSERT INTO membership_leads (gym_id, membership_id, membership_name, name, email, phone, message,
|
||||
consent, consent_text, ip, user_agent, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
(int) ($d['gym_id'] ?? current_gym()['id']),
|
||||
$d['membership_id'] ?: null,
|
||||
mb_substr((string) ($d['membership_name'] ?? ''), 0, 100),
|
||||
mb_substr(trim((string) ($d['name'] ?? '')), 0, 150),
|
||||
|
||||
@@ -4,24 +4,26 @@
|
||||
// description, and item_order for sorting.
|
||||
|
||||
function db_modalidades_list(PDO $pdo, bool $include_deleted = false): array {
|
||||
$sql = "SELECT * FROM modalidades";
|
||||
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL";
|
||||
// Scoped to the gym being edited in the backoffice (functions_gyms.php).
|
||||
$sql = "SELECT * FROM modalidades WHERE gym_id = " . admin_gym_id();
|
||||
if (!$include_deleted) $sql .= " AND deleted_at IS NULL";
|
||||
$sql .= " ORDER BY item_order DESC, nome";
|
||||
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function db_modalidades_get(PDO $pdo, int $id): ?array {
|
||||
$stmt = $pdo->prepare("SELECT * FROM modalidades WHERE m_id = ? LIMIT 1");
|
||||
$stmt->execute([$id]);
|
||||
$stmt = $pdo->prepare("SELECT * FROM modalidades WHERE m_id = ? AND gym_id = ? LIMIT 1");
|
||||
$stmt->execute([$id, admin_gym_id()]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
function db_modalidades_create(PDO $pdo, array $data): int {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO modalidades (nome, cor, icon, descricao, item_order)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO modalidades (gym_id, nome, cor, icon, descricao, item_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
admin_gym_id(),
|
||||
$data['nome'],
|
||||
$data['cor'] ?? '#808080',
|
||||
$data['icon'] ?? '',
|
||||
|
||||
@@ -27,10 +27,26 @@
|
||||
|
||||
const REVIEWS_FIELD_MASK = 'displayName,rating,userRatingCount,googleMapsUri,reviews';
|
||||
|
||||
// Multi-gym: the API key and display options are shared; the Place ID (and so
|
||||
// the cache) belongs to a gym (gyms.google_place_id). Which gym is taken from
|
||||
// reviews_gym(): the gym the public page renders, or — on the backoffice
|
||||
// Reviews page — the gym being edited (set with reviews_use_gym()).
|
||||
function reviews_use_gym(array $gym): void {
|
||||
$GLOBALS['_reviews_gym'] = $gym;
|
||||
}
|
||||
|
||||
function reviews_gym(): array {
|
||||
return $GLOBALS['_reviews_gym'] ?? current_gym();
|
||||
}
|
||||
|
||||
function reviews_config(): array {
|
||||
$gym = reviews_gym();
|
||||
$place = trim((string) ($gym['google_place_id'] ?? ''));
|
||||
// Pre-multi-gym installs kept the Place ID in settings (gym 1 only).
|
||||
if ($place === '' && (int) ($gym['id'] ?? 1) === 1) $place = trim((string) setting('google_place_id', ''));
|
||||
return [
|
||||
'api_key' => setting_secret('google_places_api_key', 'GOOGLE_PLACES_API_KEY'),
|
||||
'place_id' => trim((string) setting('google_place_id', '')),
|
||||
'place_id' => $place,
|
||||
'min_rating' => max(1, min(5, (int) setting('google_reviews_min_rating', 4))),
|
||||
'max' => max(1, min(5, (int) setting('google_reviews_max', 3))),
|
||||
'cache_hours' => max(1, (int) setting('google_reviews_cache_hours', 24)),
|
||||
@@ -50,7 +66,7 @@ function reviews_configured(): bool {
|
||||
}
|
||||
|
||||
function _reviews_cache_path(): string {
|
||||
return __DIR__ . '/cache/google_reviews.cache.php';
|
||||
return __DIR__ . '/cache/google_reviews_' . (int) (reviews_gym()['id'] ?? 1) . '.cache.php';
|
||||
}
|
||||
|
||||
function reviews_cache_read(): ?array {
|
||||
@@ -170,7 +186,8 @@ function reviews_get(): array {
|
||||
|
||||
// What the homepage renders: summary + the best qualifying reviews, or null
|
||||
// when there is nothing worth showing (not configured, no qualifying review).
|
||||
function site_reviews(): ?array {
|
||||
function site_reviews(?array $gym = null): ?array {
|
||||
if ($gym !== null) reviews_use_gym($gym);
|
||||
try { $data = reviews_get(); } catch (Throwable $e) { error_log('[reviews] ' . $e->getMessage()); return null; }
|
||||
if (!$data || empty($data['reviews'])) return null;
|
||||
$c = reviews_config();
|
||||
|
||||
+70
-52
@@ -3,15 +3,23 @@
|
||||
// The ported website/* partials read the same shapes the CI4 models returned.
|
||||
// Every query is defensive — the gym-content tables can be empty on a fresh DB.
|
||||
|
||||
// fitme3070_info row (id=1). Always returns every key the views read, so empty
|
||||
// installs render without warnings.
|
||||
function site_info(PDO $pdo): array {
|
||||
// Every site_*() reader takes the gym (functions_gyms.php); by default the one
|
||||
// the current page renders (current_gym()), so single-gym code paths — the
|
||||
// privacy page, events, mail handlers — keep working unchanged.
|
||||
function _site_gym_id(?int $gym_id): int {
|
||||
return $gym_id ?? (int) current_gym()['id'];
|
||||
}
|
||||
|
||||
// fitme3070_info row of the gym (id = gyms.id). Always returns every key the
|
||||
// views read, so empty installs render without warnings.
|
||||
function site_info(PDO $pdo, ?int $gym_id = null): array {
|
||||
$cols = ['id','name','adress','phone','telephone','whatsapp','instagram',
|
||||
'facebook','messenger','email','website','google_maps','google_maps_link'];
|
||||
$row = [];
|
||||
try {
|
||||
$row = $pdo->query("SELECT * FROM fitme3070_info WHERE id = 1 LIMIT 1")
|
||||
->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$st = $pdo->prepare("SELECT * FROM fitme3070_info WHERE id = ? LIMIT 1");
|
||||
$st->execute([_site_gym_id($gym_id)]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
} catch (Throwable $e) { $row = []; }
|
||||
foreach ($cols as $c) {
|
||||
if (!array_key_exists($c, $row) || $row[$c] === null) $row[$c] = '';
|
||||
@@ -21,55 +29,63 @@ function site_info(PDO $pdo): array {
|
||||
return $row;
|
||||
}
|
||||
|
||||
// Top navigation map (label => CI-style path). Kept identical to the original
|
||||
// so navbar/footer markup is unchanged; .htaccess rewrites these to site/*.php.
|
||||
function site_navbar(): array {
|
||||
// One-page layout: Home, Equipa, Modalidades, Horário, Contactos are all
|
||||
// anchor links on the single homepage. CrossFit is a separate page styled
|
||||
// differently (see navbar.php for the 'crossfit_btn' flag).
|
||||
return [
|
||||
'Home' => '#home',
|
||||
'Equipa' => '#equipa',
|
||||
'Modalidades' => '#modalidades',
|
||||
'Horário' => '#horario',
|
||||
'Mensalidades' => '#mensalidades',
|
||||
'Contactos' => '#contactos',
|
||||
'CrossFit' => 'home/crossfit', // separate page, navbar styles it specially
|
||||
];
|
||||
// Top navigation (label => absolute URL): anchors on the gym's own page, only
|
||||
// for the sections that gym shows, plus its CrossFit page when it has one.
|
||||
function site_navbar(?array $gym = null): array {
|
||||
$gym = $gym ?? current_gym();
|
||||
$items = ['Home' => ['#home', null]];
|
||||
foreach (['Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
|
||||
'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) {
|
||||
if (gym_section_on($gym, $sec)) $items[$label] = ['#' . $sec, $sec];
|
||||
}
|
||||
$nav = [];
|
||||
foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash);
|
||||
if (!empty($gym['has_crossfit'])) $nav['CrossFit'] = gym_url($gym, 'crossfit');
|
||||
return $nav;
|
||||
}
|
||||
|
||||
function site_offer(PDO $pdo): array {
|
||||
function site_offer(PDO $pdo, ?int $gym_id = null): array {
|
||||
try {
|
||||
return $pdo->query(
|
||||
$st = $pdo->prepare(
|
||||
"SELECT * FROM website_offer
|
||||
WHERE o_deleted_at IS NULL OR o_deleted_at = '0000-00-00 00:00:00' OR o_deleted_at = ''"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
WHERE gym_id = ? AND (o_deleted_at IS NULL OR o_deleted_at = '0000-00-00 00:00:00' OR o_deleted_at = '')"
|
||||
);
|
||||
$st->execute([_site_gym_id($gym_id)]);
|
||||
return $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable $e) { return []; }
|
||||
}
|
||||
|
||||
function site_work_hours(PDO $pdo): array {
|
||||
try { return $pdo->query("SELECT * FROM work_hours WHERE deleted_at IS NULL")->fetchAll(PDO::FETCH_ASSOC); }
|
||||
catch (Throwable $e) { return []; }
|
||||
}
|
||||
|
||||
function site_team(PDO $pdo): array {
|
||||
try { return $pdo->query("SELECT * FROM team WHERE deleted_at IS NULL")->fetchAll(PDO::FETCH_ASSOC); }
|
||||
catch (Throwable $e) { return []; }
|
||||
}
|
||||
|
||||
function site_modalidades(PDO $pdo): array {
|
||||
function site_work_hours(PDO $pdo, ?int $gym_id = null): array {
|
||||
try {
|
||||
return $pdo->query(
|
||||
"SELECT * FROM modalidades WHERE deleted_at IS NULL ORDER BY item_order DESC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
$st = $pdo->prepare("SELECT * FROM work_hours WHERE deleted_at IS NULL AND gym_id = ?");
|
||||
$st->execute([_site_gym_id($gym_id)]);
|
||||
return $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable $e) { return []; }
|
||||
}
|
||||
|
||||
// Currently-visible time-limited events for the homepage banner. Thin wrapper
|
||||
// over db_events_active() (functions_events.php) so the site code reads in the
|
||||
// same site_*() style as everything else here.
|
||||
function site_active_events(PDO $pdo): array {
|
||||
return function_exists('db_events_active') ? db_events_active($pdo) : [];
|
||||
function site_team(PDO $pdo, ?int $gym_id = null): array {
|
||||
try {
|
||||
$st = $pdo->prepare("SELECT * FROM team WHERE deleted_at IS NULL AND gym_id = ?");
|
||||
$st->execute([_site_gym_id($gym_id)]);
|
||||
return $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable $e) { return []; }
|
||||
}
|
||||
|
||||
function site_modalidades(PDO $pdo, ?int $gym_id = null): array {
|
||||
try {
|
||||
$st = $pdo->prepare("SELECT * FROM modalidades WHERE deleted_at IS NULL AND gym_id = ? ORDER BY item_order DESC");
|
||||
$st->execute([_site_gym_id($gym_id)]);
|
||||
return $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable $e) { return []; }
|
||||
}
|
||||
|
||||
// Currently-visible time-limited events for the homepage banner: the gym's own
|
||||
// plus those for every gym (gym_id NULL).
|
||||
function site_active_events(PDO $pdo, ?int $gym_id = null): array {
|
||||
if (!function_exists('db_events_active')) return [];
|
||||
$gid = _site_gym_id($gym_id);
|
||||
return array_values(array_filter(db_events_active($pdo), fn($e) =>
|
||||
!isset($e['gym_id']) || $e['gym_id'] === null || (int) $e['gym_id'] === $gid));
|
||||
}
|
||||
|
||||
// ─── Image-path resolver ──────────────────────────────────────────────────────
|
||||
@@ -90,15 +106,15 @@ function site_img_url($value, $base_dir): string {
|
||||
|
||||
// Schedule joined to modalidades (name/colour/icon), optionally filtered by
|
||||
// local (e.g. 'crossfit'). Mirrors Horario_model::getAll().
|
||||
function site_horario(PDO $pdo, string $local = ''): array {
|
||||
function site_horario(PDO $pdo, string $local = '', ?int $gym_id = null): array {
|
||||
$sql = "SELECT h.*,
|
||||
TIME_FORMAT(h.hora_inicio, '%H:%i') AS hora_inicio,
|
||||
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim,
|
||||
m.nome, m.cor, m.icon
|
||||
FROM horario h
|
||||
JOIN modalidades m ON m.m_id = h.id_modalidade AND m.deleted_at IS NULL
|
||||
WHERE h.deleted_at IS NULL";
|
||||
$args = [];
|
||||
WHERE h.deleted_at IS NULL AND m.gym_id = ?";
|
||||
$args = [_site_gym_id($gym_id)];
|
||||
if ($local !== '') { $sql .= " AND h.local = ?"; $args[] = $local; }
|
||||
$sql .= " ORDER BY h.hora_inicio ASC, m.nome ASC";
|
||||
try { $st = $pdo->prepare($sql); $st->execute($args); return $st->fetchAll(PDO::FETCH_ASSOC); }
|
||||
@@ -117,12 +133,14 @@ function seo_noindex(): bool {
|
||||
// Public, indexable URLs for sitemap.xml: the fixed pages plus any live event
|
||||
// landing page.
|
||||
function site_sitemap_urls(PDO $pdo): array {
|
||||
$urls = [
|
||||
['loc' => base_url(), 'priority' => '1.0'],
|
||||
['loc' => base_url('home/crossfit'), 'priority' => '0.8'],
|
||||
['loc' => base_url('home/privacidade'), 'priority' => '0.2'],
|
||||
];
|
||||
foreach (site_active_events($pdo) as $ev) {
|
||||
// One entry per PUBLISHED gym (unpublished ones must not be discoverable).
|
||||
$urls = [];
|
||||
foreach (db_gyms_published($pdo) ?: [gym_fallback()] as $g) {
|
||||
$urls[] = ['loc' => gym_url($g), 'priority' => '1.0'];
|
||||
if (!empty($g['has_crossfit'])) $urls[] = ['loc' => gym_url($g, 'crossfit'), 'priority' => '0.8'];
|
||||
}
|
||||
$urls[] = ['loc' => base_url('home/privacidade'), 'priority' => '0.2'];
|
||||
foreach (function_exists('db_events_active') ? db_events_active($pdo) : [] as $ev) {
|
||||
$urls[] = ['loc' => base_url('evento/' . rawurlencode($ev['slug'])), 'priority' => '0.6'];
|
||||
}
|
||||
return $urls;
|
||||
|
||||
@@ -4,24 +4,26 @@
|
||||
// Used by views/admin/team/*.php
|
||||
|
||||
function db_team_list(PDO $pdo, bool $include_deleted = false): array {
|
||||
$sql = "SELECT * FROM team";
|
||||
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL";
|
||||
// Scoped to the gym being edited in the backoffice (functions_gyms.php).
|
||||
$sql = "SELECT * FROM team WHERE gym_id = " . admin_gym_id();
|
||||
if (!$include_deleted) $sql .= " AND deleted_at IS NULL";
|
||||
$sql .= " ORDER BY FIELD(status,'ativo','inativo'), name";
|
||||
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function db_team_get(PDO $pdo, int $id): ?array {
|
||||
$stmt = $pdo->prepare("SELECT * FROM team WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$id]);
|
||||
$stmt = $pdo->prepare("SELECT * FROM team WHERE id = ? AND gym_id = ? LIMIT 1");
|
||||
$stmt->execute([$id, admin_gym_id()]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
function db_team_create(PDO $pdo, array $data): int {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO team (name, title, birthday, `at`, status, description, skills, certifications, photo, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
INSERT INTO team (gym_id, name, title, birthday, `at`, status, description, skills, certifications, photo, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
admin_gym_id(),
|
||||
$data['name'],
|
||||
$data['title'] ?? '',
|
||||
$data['birthday'] ?: null,
|
||||
|
||||
@@ -5,20 +5,20 @@
|
||||
function db_whours_list(PDO $pdo): array {
|
||||
return $pdo->query("
|
||||
SELECT * FROM work_hours
|
||||
WHERE deleted_at IS NULL
|
||||
WHERE deleted_at IS NULL AND gym_id = " . admin_gym_id() . "
|
||||
ORDER BY FIELD(week_day,'segunda','terça','quarta','quinta','sexta','sabado','domingo','feriados')
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function db_whours_get(PDO $pdo, int $id): ?array {
|
||||
$stmt = $pdo->prepare("SELECT * FROM work_hours WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$id]);
|
||||
$stmt = $pdo->prepare("SELECT * FROM work_hours WHERE id = ? AND gym_id = ? LIMIT 1");
|
||||
$stmt->execute([$id, admin_gym_id()]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
function db_whours_create(PDO $pdo, array $data): int {
|
||||
$stmt = $pdo->prepare("INSERT INTO work_hours (week_day, open, close) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$data['week_day'], $data['open'], $data['close']]);
|
||||
$stmt = $pdo->prepare("INSERT INTO work_hours (gym_id, week_day, open, close) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([admin_gym_id(), $data['week_day'], $data['open'], $data['close']]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
@@ -106,6 +106,10 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
||||
<?php if (is_admin()): ?>
|
||||
<div class="sidebar-section">Gestão</div>
|
||||
<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">
|
||||
<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>
|
||||
@@ -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">
|
||||
<i class="bi bi-list"></i>
|
||||
</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">
|
||||
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>">
|
||||
<?= htmlspecialchars(role_label($user['role'])) ?>
|
||||
|
||||
+16
-3
@@ -5,14 +5,27 @@
|
||||
require_once __DIR__ . '/../config/db.php';
|
||||
require_once __DIR__ . '/../config/functions.php';
|
||||
|
||||
$info = site_info($pdo);
|
||||
$navbar = site_navbar();
|
||||
// The gym this page belongs to: ?gym=<slug> from the URL folder (/3070/…), or
|
||||
// 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 ?? '';
|
||||
$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('navbar');
|
||||
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
|
||||
// every public page gets one <main> (and the skip link a real target).
|
||||
echo '<main id="conteudo">' . "\n";
|
||||
+11
-6
@@ -8,16 +8,21 @@
|
||||
require_once __DIR__ . '/../config/db.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') {
|
||||
header('Location: ' . base_url('home'));
|
||||
header('Location: ' . gym_url($gym));
|
||||
exit;
|
||||
}
|
||||
|
||||
csrf_check(); // 403s and exits on a bad/missing token
|
||||
|
||||
function agendar_redirect(string $status): void {
|
||||
header('Location: ' . base_url('home?agendar=' . $status . '#contactos'));
|
||||
header('Location: ' . gym_url(current_gym(), '?agendar=' . $status . '#contactos'));
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -39,7 +44,7 @@ if ($data !== '') {
|
||||
$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;
|
||||
|
||||
$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>';
|
||||
}
|
||||
$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>';
|
||||
|
||||
$alt = "Novo pedido de aula experimental\n\n";
|
||||
@@ -69,7 +74,7 @@ foreach ($rows as $label => $value) {
|
||||
|
||||
$id = enqueue_email([
|
||||
'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,
|
||||
'body' => $body,
|
||||
'alt_body' => $alt,
|
||||
|
||||
@@ -4149,7 +4149,7 @@ h3.contact-title {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--fitme-radius); /* brand: square-ish corners */
|
||||
position: relative;
|
||||
background: var(--fitme-surface-2);
|
||||
cursor: zoom-in;
|
||||
@@ -4229,7 +4229,7 @@ h3.contact-title {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 110px);
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--fitme-radius);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, .5);
|
||||
animation: gal-in .35s ease;
|
||||
}
|
||||
|
||||
+14
-2
@@ -1,7 +1,19 @@
|
||||
<?php
|
||||
// Was Home::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';
|
||||
// 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 = [
|
||||
'title' => 'CrossFit',
|
||||
@@ -13,13 +25,13 @@ $data = [
|
||||
'header_video_video' => 'site/assets/img/crossfit.mp4',
|
||||
'header_video_img' => 'site/assets/img/crossfit.jpg',
|
||||
'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_h2' => 'Precisas de um <span>Personal Trainer</span> especializado em CrossFit?',
|
||||
'ad_footer_bg' => '',
|
||||
'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('crossfit', $data);
|
||||
|
||||
@@ -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>
|
||||
@@ -21,7 +21,7 @@
|
||||
<?php endif; ?>
|
||||
<ul class="footer-menu">
|
||||
<?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; ?>
|
||||
</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>
|
||||
|
||||
@@ -10,10 +10,25 @@ $page_meta = [
|
||||
'privacidade' => ['Política de Privacidade | FitMe 3070',
|
||||
'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
|
||||
// and canonical via these globals before requiring _open.php.
|
||||
[$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>
|
||||
<html lang="pt">
|
||||
<head>
|
||||
@@ -34,7 +49,7 @@ $canonical = $GLOBALS['_canonical_override'] ?? base_url($page !== '' ? 'home/'
|
||||
<link rel="dns-prefetch" href="https://forms.zohopublic.eu">
|
||||
<?php if (($current_uri_2 ?? '') === ''): ?>
|
||||
<!-- 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; ?>
|
||||
|
||||
<!-- Fonts -->
|
||||
|
||||
@@ -9,18 +9,20 @@ else
|
||||
?>
|
||||
<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; ">
|
||||
<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;">
|
||||
</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>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav mx-auto ">
|
||||
<?php foreach($navbar as $key => $n):
|
||||
// $n is an absolute URL on the gym's own page (site_navbar()).
|
||||
$is_crossfit = ($key === 'CrossFit');
|
||||
$is_anchor = str_contains($n, '#');
|
||||
$is_active = false;
|
||||
if ($is_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
|
||||
$is_active = ($current_uri_2 === '');
|
||||
}
|
||||
@@ -28,16 +30,15 @@ else
|
||||
?>
|
||||
<li class="nav-item <?= $is_active ? 'active' : '' ?> main-menu-li" >
|
||||
<?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 ?>
|
||||
</a>
|
||||
<?php elseif (isset($n[0]) && $n[0] === '#'): ?>
|
||||
<?php $href = ($current_uri_2 === 'crossfit') ? base_url('home' . $n) : base_url($n); ?>
|
||||
<a class="nav-link smooth-scroll" href="<?= $href ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
|
||||
<?php elseif ($is_anchor): ?>
|
||||
<a class="nav-link smooth-scroll" href="<?= htmlspecialchars($n) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
|
||||
<?= $key ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<a class="nav-link" href="<?= base_url($n) ?>">
|
||||
<a class="nav-link" href="<?= htmlspecialchars($n) ?>">
|
||||
<?= $key ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<?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.
|
||||
$ld_gym = current_gym();
|
||||
$ld = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'ExerciseGym',
|
||||
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070',
|
||||
'url' => base_url(),
|
||||
'image' => base_url('site/assets/img/home.jpg'),
|
||||
'name' => $ld_gym['name'] !== '' ? $ld_gym['name'] : (($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070'),
|
||||
'url' => gym_url($ld_gym),
|
||||
'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'),
|
||||
];
|
||||
if (!empty($info['phone'])) $ld['telephone'] = site_tel($info['phone']);
|
||||
@@ -15,7 +16,7 @@ if (!empty($info['adress'])) {
|
||||
$ld['address'] = [
|
||||
'@type' => 'PostalAddress',
|
||||
'streetAddress' => $info['adress'],
|
||||
'addressLocality' => 'Mira',
|
||||
'addressLocality' => ($ld_gym['locality'] ?? '') !== '' ? $ld_gym['locality'] : 'Mira',
|
||||
'addressCountry' => 'PT',
|
||||
];
|
||||
}
|
||||
|
||||
+94
-68
@@ -2,17 +2,37 @@
|
||||
// One-page site: Home + Equipa + Modalidades + Horário + Contactos.
|
||||
// CrossFit is a separate page (site/crossfit.php).
|
||||
$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 ───────────────────────────────────────────────────────────────
|
||||
$offer = site_offer($pdo);
|
||||
$team = site_team($pdo);
|
||||
$modalidades = site_modalidades($pdo);
|
||||
$horario = site_horario($pdo);
|
||||
$whours = site_work_hours($pdo);
|
||||
$events = site_active_events($pdo); // time-limited events, banner below the hero
|
||||
$memberships = site_memberships($pdo);
|
||||
$reviews = site_reviews(); // null when not configured / nothing qualifies
|
||||
// Everything is this gym's content; a section the gym switched off
|
||||
// (Gestão → Ginásios) loads as empty, so its block below simply isn't printed.
|
||||
$gid = (int) $gym['id'];
|
||||
$on = fn(string $key) => gym_section_on($gym, $key);
|
||||
$offer = $on('oferta') ? site_offer($pdo, $gid) : [];
|
||||
$team = $on('equipa') ? site_team($pdo, $gid) : [];
|
||||
$modalidades = $on('modalidades') ? site_modalidades($pdo, $gid) : [];
|
||||
$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 ────────────────────────────────────────────────────────────────
|
||||
?>
|
||||
@@ -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;
|
||||
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="hero-text">
|
||||
<?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; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<h1>Ginásio, Box, Aulas<br><span>& 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">
|
||||
<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>
|
||||
@@ -92,8 +112,8 @@ if ($offer):
|
||||
|
||||
<?php
|
||||
// ── Galeria (Instalações) ───────────────────────────────────────────────────────
|
||||
$gallery_path = 'uploads/gallery/1/';
|
||||
$gallery_files = glob($gallery_path . '*.jpg');
|
||||
$gallery_path = !empty($gym['gallery_id']) ? 'uploads/gallery/' . (int) $gym['gallery_id'] . '/' : '';
|
||||
$gallery_files = ($on('galeria') && $gallery_path !== '') ? glob($gallery_path . '*.jpg') : [];
|
||||
if ($gallery_files && count($gallery_files) > 0):
|
||||
$gallery_h2 = 'Conhece as nossas instalações';
|
||||
?>
|
||||
@@ -102,6 +122,59 @@ $gallery_h2 = 'Conhece as nossas instalações';
|
||||
</section>
|
||||
<?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">×</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
|
||||
// ── Equipa ──────────────────────────────────────────────────────────────────────
|
||||
if ($team):
|
||||
@@ -166,59 +239,6 @@ if ($team):
|
||||
</section>
|
||||
<?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">×</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
|
||||
// ── Horário ─────────────────────────────────────────────────────────────────────
|
||||
if ($horario):
|
||||
@@ -556,6 +576,7 @@ if (isset($_GET['assunto'])) {
|
||||
}
|
||||
$session = session();
|
||||
?>
|
||||
<?php if ($on('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">
|
||||
<div class="container">
|
||||
@@ -663,6 +684,8 @@ $session = session();
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php endif; /* contactos */ ?>
|
||||
|
||||
<?php
|
||||
// ── Horário de Funcionamento ──────────────────────────────────────────────────
|
||||
if ($whours):
|
||||
@@ -694,6 +717,7 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
|
||||
|
||||
<?php
|
||||
// ── Personal Trainer CTA ────────────────────────────────────────────────────────
|
||||
if ($on('personal_trainer')):
|
||||
?>
|
||||
<section id="personal-trainer" class="classes-section" aria-labelledby="pt-title">
|
||||
<div class="container">
|
||||
@@ -713,6 +737,8 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php endif; /* personal trainer */ ?>
|
||||
|
||||
<?php
|
||||
// ── Aula experimental modal ─────────────────────────────────────────────────
|
||||
// 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>
|
||||
<button type="button" class="trial-close" data-dismiss="modal" aria-label="Fechar">×</button>
|
||||
</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() ?>
|
||||
<div class="form-row">
|
||||
<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>
|
||||
<button type="button" class="trial-close" data-dismiss="modal" aria-label="Fechar">×</button>
|
||||
</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() ?>
|
||||
<input type="text" name="website" class="event-hp" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||
<div class="form-row">
|
||||
|
||||
+11
-4
@@ -10,15 +10,20 @@
|
||||
require_once __DIR__ . '/../config/db.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') {
|
||||
header('Location: ' . base_url('home#mensalidades'));
|
||||
header('Location: ' . gym_url($gym, '#mensalidades'));
|
||||
exit;
|
||||
}
|
||||
|
||||
csrf_check(); // 403s and exits on a bad/missing token
|
||||
|
||||
function inscricao_redirect(string $status): void {
|
||||
header('Location: ' . base_url('home?inscricao=' . $status . '#mensalidades'));
|
||||
header('Location: ' . gym_url(current_gym(), '?inscricao=' . $status . '#mensalidades'));
|
||||
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.';
|
||||
$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 = [
|
||||
'gym_id' => (int) $gym['id'],
|
||||
'membership_id' => $membership ? (int) $membership['id'] : null,
|
||||
'membership_name' => $membership ? $membership['name'] : '',
|
||||
'name' => trim((string) ($_POST['nome'] ?? '')),
|
||||
@@ -58,7 +65,7 @@ try {
|
||||
|
||||
// Notify the gym. Best effort: the request is already stored.
|
||||
try {
|
||||
$info = site_info($pdo);
|
||||
$info = site_info($pdo, (int) $gym['id']);
|
||||
$to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM;
|
||||
$e = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
$rows = [
|
||||
|
||||
@@ -37,6 +37,15 @@ include __DIR__ . '/../../includes/header.php';
|
||||
<div class="card shadow-sm mb-4"><div class="card-body">
|
||||
<h2 class="h5">Gestão de Conteúdos</h2>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<a class="card text-decoration-none border-primary shadow-sm h-100" href="<?= base_url('views/admin/gyms/index.php') ?>">
|
||||
<div class="card-body">
|
||||
<i class="bi bi-buildings-fill fs-3 text-primary mb-2 d-block"></i>
|
||||
<strong>Ginásios</strong>
|
||||
<p class="text-muted small mb-0">A editar: <?= htmlspecialchars(admin_gym($pdo)['name']) ?> — criar e gerir ginásios</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<a class="card text-decoration-none border-primary shadow-sm h-100" href="<?= base_url('views/admin/team/index.php') ?>">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
// Shared gym form fields (create.php / edit.php). Expects $data (gym row or
|
||||
// gym_defaults()) and $galleries (id => name).
|
||||
$sections_on = [];
|
||||
foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $sections_on[] = $k;
|
||||
?>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Nome <span class="text-danger">*</span></label>
|
||||
<input class="form-control" name="name" required maxlength="100" placeholder="ex.: FitMe 3070" value="<?= htmlspecialchars($data['name'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Pasta da URL <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">/</span>
|
||||
<input class="form-control font-monospace" name="slug" required maxlength="20" pattern="[a-z0-9][a-z0-9-]{1,19}" placeholder="3070" value="<?= htmlspecialchars($data['slug'] ?? '') ?>">
|
||||
<span class="input-group-text">/</span>
|
||||
</div>
|
||||
<div class="form-text">O código postal da zona.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Ordem</label>
|
||||
<input type="number" class="form-control" name="item_order" value="<?= (int) ($data['item_order'] ?? 0) ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Localidade</label>
|
||||
<input class="form-control" name="locality" maxlength="100" placeholder="ex.: Mira" value="<?= htmlspecialchars($data['locality'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Código postal</label>
|
||||
<input class="form-control" name="postal_code" maxlength="20" placeholder="ex.: 3070-xxx" value="<?= htmlspecialchars($data['postal_code'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12"><hr class="my-1"><strong>Página</strong></div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Título do header</label>
|
||||
<input class="form-control" name="hero_title" maxlength="150" placeholder="Ginásio, Box, Aulas" value="<?= htmlspecialchars($data['hero_title'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Segunda linha do título</label>
|
||||
<input class="form-control" name="hero_subtitle" maxlength="150" placeholder="& Acompanhamento" value="<?= htmlspecialchars($data['hero_subtitle'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Imagem do header</label>
|
||||
<input class="form-control font-monospace" name="hero_image" maxlength="255" placeholder="site/assets/img/home.jpg" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
|
||||
<div class="form-text">Caminho de uma imagem do site. Vazio = a do ginásio principal.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Galeria das instalações</label>
|
||||
<select class="form-select" name="gallery_id">
|
||||
<option value="">— nenhuma —</option>
|
||||
<?php foreach ($galleries as $gid => $gname): ?>
|
||||
<option value="<?= (int) $gid ?>" <?= (int) ($data['gallery_id'] ?? 0) === (int) $gid ? 'selected' : '' ?>><?= htmlspecialchars($gname) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text">As galerias gerem-se em Gestão → Galerias.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Título para o Google (SEO)</label>
|
||||
<input class="form-control" name="seo_title" maxlength="160" placeholder="FitMe 3070 — Ginásio & CrossFit em Mira" value="<?= htmlspecialchars($data['seo_title'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Place ID Google (reviews)</label>
|
||||
<input class="form-control font-monospace" name="google_place_id" maxlength="120" placeholder="ChIJ…" value="<?= htmlspecialchars($data['google_place_id'] ?? '') ?>">
|
||||
<div class="form-text">A chave da API é comum (Gestão → Reviews Google).</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Descrição para o Google (SEO)</label>
|
||||
<textarea class="form-control" name="seo_description" rows="2" maxlength="300"><?= htmlspecialchars($data['seo_description'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12"><hr class="my-1"><strong>Secções visíveis</strong>
|
||||
<div class="form-text mt-0">O layout é o mesmo em todos os ginásios; aqui escolhes quais aparecem.</div></div>
|
||||
<?php foreach (gym_sections() as $key => $label): ?>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="sections[]" value="<?= $key ?>" id="sec_<?= $key ?>" <?= in_array($key, $sections_on, true) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="sec_<?= $key ?>"><?= htmlspecialchars($label) ?></label>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="has_crossfit" value="1" id="has_crossfit" <?= !empty($data['has_crossfit']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="has_crossfit">Página CrossFit</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12"><hr class="my-1"><strong>Estado</strong></div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="is_published" value="1" id="is_published" <?= !empty($data['is_published']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="is_published">Publicado</label>
|
||||
</div>
|
||||
<div class="form-text">Por publicar, só quem tem sessão iniciada no backoffice vê a página (pré-visualização).</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="is_default" value="1" id="is_default" <?= !empty($data['is_default']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="is_default">Ginásio principal</label>
|
||||
</div>
|
||||
<div class="form-text">É para onde vão os endereços antigos (/home). Só pode haver um.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../../config/db.php';
|
||||
require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
$errors = [];
|
||||
$data = gym_defaults();
|
||||
$galleries = $pdo->query("SELECT id, name FROM galleries" . (_gyms_has_col($pdo, 'galleries', 'deleted_at') ? " WHERE deleted_at IS NULL" : "") . " ORDER BY id")
|
||||
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$sources = db_gyms_list($pdo);
|
||||
$copy_from = (int) ($_POST['copy_from'] ?? 0);
|
||||
$copy_what = (array) ($_POST['copy'] ?? array_keys(gyms_copy_options()));
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$data = gyms_from_post($pdo, $_POST, $errors);
|
||||
if (!$errors) {
|
||||
$id = db_gyms_create($pdo, $data);
|
||||
$msg = 'Ginásio criado.';
|
||||
if ($copy_from && db_gyms_get($pdo, $copy_from)) {
|
||||
$what = array_intersect(array_keys(gyms_copy_options()), $copy_what);
|
||||
try {
|
||||
$done = gyms_copy_content($pdo, $copy_from, $id, $what);
|
||||
if ($done) $msg .= ' Copiado: ' . implode(', ', $done) . '.';
|
||||
} catch (Throwable $e) {
|
||||
error_log('[gyms] copy: ' . $e->getMessage());
|
||||
$msg .= ' A cópia do conteúdo falhou — o ginásio foi criado vazio.';
|
||||
}
|
||||
}
|
||||
$_SESSION['admin_gym_id'] = $id; // start editing the new gym straight away
|
||||
session()->setFlashdata('success', $msg . ' Estás agora a editar este ginásio.');
|
||||
header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$page_title = 'Novo ginásio';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:900px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">Novo ginásio</h1>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
|
||||
</div>
|
||||
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
|
||||
<form method="POST" class="card shadow-sm"><div class="card-body">
|
||||
<?= csrf_field() ?>
|
||||
<?php include __DIR__ . '/_form.php'; ?>
|
||||
|
||||
<?php if ($sources): ?>
|
||||
<hr>
|
||||
<strong>Começar com conteúdo de outro ginásio</strong>
|
||||
<div class="form-text mb-2">Cria cópias independentes — depois é só ajustar. Editar um ginásio nunca altera o outro.</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-5">
|
||||
<select class="form-select" name="copy_from">
|
||||
<option value="0">— não copiar, começar vazio —</option>
|
||||
<?php foreach ($sources as $s): ?>
|
||||
<option value="<?= (int) $s['id'] ?>" <?= $copy_from === (int) $s['id'] ? 'selected' : '' ?>><?= htmlspecialchars($s['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<?php foreach (gyms_copy_options() as $k => $label): ?>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="copy[]" value="<?= $k ?>" id="copy_<?= $k ?>" <?= in_array($k, $copy_what, true) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="copy_<?= $k ?>"><?= htmlspecialchars($label) ?></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Criar</button>
|
||||
<a class="btn btn-outline-secondary" href="index.php">Cancelar</a>
|
||||
</div>
|
||||
</div></form>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../../config/db.php';
|
||||
require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
$item = $id ? db_gyms_get($pdo, $id) : null;
|
||||
if (!$item || $item['deleted_at'] !== null) { header('Location: index.php'); exit; }
|
||||
|
||||
$errors = [];
|
||||
$data = $item;
|
||||
$galleries = $pdo->query("SELECT id, name FROM galleries" . (_gyms_has_col($pdo, 'galleries', 'deleted_at') ? " WHERE deleted_at IS NULL" : "") . " ORDER BY id")
|
||||
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$data = gyms_from_post($pdo, $_POST, $errors, $id);
|
||||
// Unsetting "principal" here would leave no default gym — keep it.
|
||||
if ($item['is_default'] && !$data['is_default']) {
|
||||
$data['is_default'] = 1;
|
||||
if (!$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado. Para despublicá-lo, torna outro ginásio principal primeiro.';
|
||||
}
|
||||
if (!$errors) {
|
||||
$changed = db_gyms_update($pdo, $id, $data);
|
||||
session()->setFlashdata($changed ? 'success' : 'warning', $changed ? 'Ginásio atualizado.' : 'Nenhuma alteração.');
|
||||
header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$page_title = 'Editar ginásio';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container" style="max-width:900px;">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-4">
|
||||
<h1 class="h3 mb-0"><?= htmlspecialchars($item['name']) ?></h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars(gym_url($item)) ?>" target="_blank" rel="noopener">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i><?= $item['is_published'] ? 'Ver página' : 'Pré-visualizar' ?></a>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
|
||||
<?php if (admin_gym_id() !== $id): ?>
|
||||
<form method="POST" action="<?= base_url('views/admin/gyms/switch.php') ?>" class="alert alert-info d-flex justify-content-between align-items-center gap-2">
|
||||
<?= csrf_field() ?><input type="hidden" name="gym_id" value="<?= $id ?>"><input type="hidden" name="back" value="views/admin/dashboard.php">
|
||||
<span>Para editar a equipa, modalidades, horários, mensalidades e contactos deste ginásio, muda o ginásio em edição.</span>
|
||||
<button class="btn btn-sm btn-primary text-nowrap">Editar conteúdo deste</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<form method="POST" class="card shadow-sm"><div class="card-body">
|
||||
<?= csrf_field() ?>
|
||||
<?php include __DIR__ . '/_form.php'; ?>
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Atualizar</button>
|
||||
<a class="btn btn-outline-secondary" href="index.php">Cancelar</a>
|
||||
</div>
|
||||
</div></form>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php'; ?>
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../../config/db.php';
|
||||
require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
if (isset($_POST['delete'])) {
|
||||
$ok = db_gyms_delete($pdo, (int) $_POST['delete']);
|
||||
session()->setFlashdata($ok ? 'success' : 'warning', $ok ? 'Ginásio apagado.' : 'O ginásio principal não pode ser apagado.');
|
||||
} elseif (isset($_POST['restore'])) {
|
||||
db_gyms_restore($pdo, (int) $_POST['restore']);
|
||||
session()->setFlashdata('success', 'Ginásio restaurado (por publicar).');
|
||||
}
|
||||
header('Location: ' . base_url('views/admin/gyms/index.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$gyms = db_gyms_list($pdo, true);
|
||||
$editing = admin_gym_id();
|
||||
$page_title = 'Ginásios';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||||
<h1 class="h3 mb-0">Ginásios</h1>
|
||||
<a class="btn btn-primary" href="<?= base_url('views/admin/gyms/create.php') ?>"><i class="bi bi-plus-lg me-1"></i>Adicionar ginásio</a>
|
||||
</div>
|
||||
<p class="text-muted small">Cada ginásio usa as mesmas secções do site com o seu próprio conteúdo (equipa, modalidades e horário, mensalidades, contactos…). O conteúdo que editas nas outras páginas é o do ginásio escolhido no topo («A editar»).</p>
|
||||
|
||||
<div class="card shadow-sm"><div class="card-body p-0">
|
||||
<form method="POST" class="mb-0"><?= csrf_field() ?>
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light"><tr>
|
||||
<th>Ginásio</th><th>Endereço</th><th>Estado</th><th>Secções</th><th style="width:150px">Ações</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php if (!$gyms): ?>
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">Sem ginásios — falta aplicar a migração (Sistema → Atualizar plataforma).</td></tr>
|
||||
<?php else: foreach ($gyms as $g): $deleted = $g['deleted_at'] !== null;
|
||||
$n_on = count(array_filter(array_keys(gym_sections()), fn($k) => gym_section_on($g, $k))); ?>
|
||||
<tr class="<?= $deleted ? 'text-muted' : '' ?>">
|
||||
<td><strong><?= htmlspecialchars($g['name']) ?></strong>
|
||||
<?php if ($g['is_default']): ?><span class="badge bg-primary ms-1">Principal</span><?php endif; ?>
|
||||
<?php if ((int) $g['id'] === $editing && !$deleted): ?><span class="badge bg-info text-dark ms-1">A editar</span><?php endif; ?>
|
||||
<?php if ($g['locality'] !== ''): ?><div class="small text-muted"><?= htmlspecialchars($g['locality']) ?></div><?php endif; ?></td>
|
||||
<td><a href="<?= htmlspecialchars(gym_url($g)) ?>" target="_blank" rel="noopener" class="font-monospace">/<?= htmlspecialchars($g['slug']) ?>/</a></td>
|
||||
<td><?php if ($deleted): ?><span class="badge bg-secondary">Apagado</span>
|
||||
<?php elseif ($g['is_published']): ?><span class="badge bg-success">Publicado</span>
|
||||
<?php else: ?><span class="badge bg-warning text-dark" title="Só a equipa vê a página">Por publicar</span><?php endif; ?></td>
|
||||
<td class="small"><?= $n_on ?> / <?= count(gym_sections()) ?></td>
|
||||
<td class="text-nowrap">
|
||||
<?php if ($deleted): ?>
|
||||
<button class="btn btn-sm btn-outline-info" name="restore" value="<?= (int) $g['id'] ?>">Restaurar</button>
|
||||
<?php else: ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="edit.php?id=<?= (int) $g['id'] ?>" title="Editar"><i class="bi bi-pencil"></i></a>
|
||||
<?php if (!$g['is_default']): ?>
|
||||
<button class="btn btn-sm btn-outline-danger" name="delete" value="<?= (int) $g['id'] ?>" title="Apagar" onclick="return confirm('Apagar este ginásio? O conteúdo dele fica guardado.')"><i class="bi bi-trash"></i></button>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// Backoffice gym switcher: sets which gym the content pages edit (session),
|
||||
// then goes back to where the admin was. POST + CSRF only.
|
||||
require __DIR__ . '/../../../config/db.php';
|
||||
require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
$gym = db_gyms_get($pdo, (int) ($_POST['gym_id'] ?? 0));
|
||||
if ($gym && $gym['deleted_at'] === null) {
|
||||
$_SESSION['admin_gym_id'] = (int) $gym['id'];
|
||||
session()->setFlashdata('success', 'A editar: ' . $gym['name'] . '.');
|
||||
}
|
||||
}
|
||||
// Only same-site backoffice paths as the return target.
|
||||
$back = (string) ($_POST['back'] ?? '');
|
||||
if (!preg_match('~^views/[A-Za-z0-9_/.-]+\.php(\?[A-Za-z0-9_=&%.-]*)?$~', $back) || str_contains($back, '..')) {
|
||||
$back = 'views/admin/dashboard.php';
|
||||
}
|
||||
header('Location: ' . base_url($back));
|
||||
exit;
|
||||
@@ -4,7 +4,7 @@ require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
|
||||
$errors = [];
|
||||
$data = site_info($pdo);
|
||||
$data = site_info($pdo, admin_gym_id()); // the gym being edited
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
csrf_check();
|
||||
|
||||
@@ -5,7 +5,8 @@ $user = require_admin();
|
||||
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
$item = $id ? db_memberships_get($pdo, $id) : null;
|
||||
if (!$item) { header('Location: index.php'); exit; }
|
||||
// Only the gym being edited (a stale link after switching gyms goes back to the list).
|
||||
if (!$item || (int) $item['gym_id'] !== admin_gym_id()) { header('Location: index.php'); exit; }
|
||||
|
||||
$errors = [];
|
||||
$data = $item;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
require __DIR__ . '/../../../config/db.php';
|
||||
require __DIR__ . '/../../../config/functions.php';
|
||||
$user = require_admin();
|
||||
// The Place ID and cache are per gym: this page works on the gym being edited.
|
||||
$rgym = admin_gym($pdo);
|
||||
reviews_use_gym($rgym);
|
||||
|
||||
$search = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
@@ -18,7 +21,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
} elseif ($key !== '') {
|
||||
db_update_setting($pdo, 'google_places_api_key', secret_encrypt($key), $uid);
|
||||
}
|
||||
db_update_setting($pdo, 'google_place_id', trim((string) ($_POST['google_place_id'] ?? '')), $uid);
|
||||
$pdo->prepare("UPDATE gyms SET google_place_id = ?, updated_at = NOW() WHERE id = ?")
|
||||
->execute([mb_substr(trim((string) ($_POST['google_place_id'] ?? '')), 0, 120), (int) $rgym['id']]);
|
||||
db_update_setting($pdo, 'google_reviews_min_rating', (string) max(1, min(5, (int) ($_POST['min_rating'] ?? 4))), $uid);
|
||||
db_update_setting($pdo, 'google_reviews_max', (string) max(1, min(5, (int) ($_POST['max'] ?? 3))), $uid);
|
||||
db_update_setting($pdo, 'google_reviews_cache_hours', (string) max(1, (int) ($_POST['cache_hours'] ?? 24)), $uid);
|
||||
@@ -54,7 +58,7 @@ $cfg = reviews_config();
|
||||
$env_key = (string) env('GOOGLE_PLACES_API_KEY', '') !== '';
|
||||
$cache = reviews_cache_read();
|
||||
$preview = site_reviews();
|
||||
$info = site_info($pdo);
|
||||
$info = site_info($pdo, (int) $rgym['id']);
|
||||
$page_title = 'Reviews Google';
|
||||
include __DIR__ . '/../../../includes/header.php';
|
||||
?>
|
||||
|
||||
Reference in new issue
Block a user