, // 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(); $__id = $id; audit_change($pdo, 'gym', 'gyms', 'id', (int) $__id, 'create', null, 'name'); return $__id; } function db_gyms_update(PDO $pdo, int $id, array $d): bool { $__before = audit_row($pdo, 'gyms', 'id', (int) $id); $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(); $__r = $changed; audit_change($pdo, 'gym', 'gyms', 'id', (int) $id, 'update', $__before, 'name'); return $__r; } // Single-field setter for views/admin/reviews/index.php — that page only // touches google_place_id, not the rest of the gym record db_gyms_update() // would validate/audit. function db_gym_set_place_id(PDO $pdo, int $id, string $placeId): void { $pdo->prepare("UPDATE gyms SET google_place_id = ?, updated_at = NOW() WHERE id = ?") ->execute([$placeId, $id]); } // 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]; }