From 5e300fc34171281f488b7b4fc9ac9b03f8bc00d0 Mon Sep 17 00:00:00 2001 From: andre Date: Fri, 25 Sep 2026 07:26:40 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20v=C3=A1rios=20gin=C3=A1sios=20(/3070/?= =?UTF-8?q?=20e=20//),=20CRUD=20de=20gin=C3=A1sios,?= =?UTF-8?q?=20modalidades=20ap=C3=B3s=20a=20galeria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: //, //crossfit, //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 --- .htaccess | 8 + config/functions.php | 1 + config/functions_gyms.php | 330 +++++++++++++++++++++++++++ config/functions_horario.php | 8 +- config/functions_info.php | 8 +- config/functions_memberships.php | 39 ++-- config/functions_modalidades.php | 14 +- config/functions_reviews.php | 23 +- config/functions_site.php | 122 +++++----- config/functions_team.php | 14 +- config/functions_work_hours.php | 10 +- database/migration_20260926_gyms.sql | 54 +++++ includes/header.php | 25 ++ site/_open.php | 19 +- site/agendar.php | 17 +- site/assets/css/style.css | 4 +- site/crossfit.php | 16 +- site/gym_chooser.php | 52 +++++ site/includes/footer.php | 2 +- site/includes/include.php | 19 +- site/includes/navbar.php | 15 +- site/includes/seo.php | 9 +- site/index.php | 162 +++++++------ site/inscricao.php | 15 +- views/admin/dashboard.php | 9 + views/admin/gyms/_form.php | 104 +++++++++ views/admin/gyms/create.php | 80 +++++++ views/admin/gyms/edit.php | 60 +++++ views/admin/gyms/index.php | 69 ++++++ views/admin/gyms/switch.php | 22 ++ views/admin/info/edit.php | 2 +- views/admin/memberships/edit.php | 3 +- views/admin/reviews/index.php | 8 +- 33 files changed, 1146 insertions(+), 197 deletions(-) create mode 100644 config/functions_gyms.php create mode 100644 database/migration_20260926_gyms.sql create mode 100644 site/gym_chooser.php create mode 100644 views/admin/gyms/_form.php create mode 100644 views/admin/gyms/create.php create mode 100644 views/admin/gyms/edit.php create mode 100644 views/admin/gyms/index.php create mode 100644 views/admin/gyms/switch.php diff --git a/.htaccess b/.htaccess index a9369dd..2bc1f02 100644 --- a/.htaccess +++ b/.htaccess @@ -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: // (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] diff --git a/config/functions.php b/config/functions.php index a988bc0..c6b7843 100644 --- a/config/functions.php +++ b/config/functions.php @@ -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 diff --git a/config/functions_gyms.php b/config/functions_gyms.php new file mode 100644 index 0000000..9fa8232 --- /dev/null +++ b/config/functions_gyms.php @@ -0,0 +1,330 @@ +, +// 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]; +} diff --git a/config/functions_horario.php b/config/functions_horario.php index 32c1e45..da5396a 100644 --- a/config/functions_horario.php +++ b/config/functions_horario.php @@ -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); } diff --git a/config/functions_info.php b/config/functions_info.php index e5eaade..2c6a28a 100644 --- a/config/functions_info.php +++ b/config/functions_info.php @@ -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; diff --git a/config/functions_memberships.php b/config/functions_memberships.php index edd1190..b6aa02a 100644 --- a/config/functions_memberships.php +++ b/config/functions_memberships.php @@ -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), diff --git a/config/functions_modalidades.php b/config/functions_modalidades.php index d976381..3e44ddb 100644 --- a/config/functions_modalidades.php +++ b/config/functions_modalidades.php @@ -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'] ?? '', diff --git a/config/functions_reviews.php b/config/functions_reviews.php index 9736f2e..6074452 100644 --- a/config/functions_reviews.php +++ b/config/functions_reviews.php @@ -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(); diff --git a/config/functions_site.php b/config/functions_site.php index 85343d3..450af61 100644 --- a/config/functions_site.php +++ b/config/functions_site.php @@ -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; diff --git a/config/functions_team.php b/config/functions_team.php index a80712e..5cf595e 100644 --- a/config/functions_team.php +++ b/config/functions_team.php @@ -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, diff --git a/config/functions_work_hours.php b/config/functions_work_hours.php index 67c593d..c0cc1a8 100644 --- a/config/functions_work_hours.php +++ b/config/functions_work_hours.php @@ -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(); } diff --git a/database/migration_20260926_gyms.sql b/database/migration_20260926_gyms.sql new file mode 100644 index 0000000..b05bb44 --- /dev/null +++ b/database/migration_20260926_gyms.sql @@ -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// + `google_place_id` VARCHAR(120) NOT NULL DEFAULT '', + `has_crossfit` TINYINT(1) NOT NULL DEFAULT 0, -- mostra a página //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`); diff --git a/includes/header.php b/includes/header.php index 4af14bf..d0bf3f1 100644 --- a/includes/header.php +++ b/includes/header.php @@ -106,6 +106,10 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';