, // 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)', 'comunidade' => 'Comunidade (Instagram)', '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, 'brand_color' => '', 'logo' => '', '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); } // Brand colour of a gym as :root overrides for the public site. Derives the // hover shade, the text-on-light ink and the text colour that stays readable on // the colour (dark on light brands, white on dark ones). '' = stylesheet default. function gym_brand_vars(array $gym): string { $hex = strtoupper((string) ($gym['brand_color'] ?? '')); if (!preg_match('/^#[0-9A-F]{6}$/', $hex)) return ''; [$r, $g, $b] = [hexdec(substr($hex, 1, 2)), hexdec(substr($hex, 3, 2)), hexdec(substr($hex, 5, 2))]; $shade = fn(float $k) => sprintf('#%02X%02X%02X', (int) round($r * $k), (int) round($g * $k), (int) round($b * $k)); $lum = (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255; return '--fitme-accent:' . $hex . ';--fitme-accent-rgb:' . "$r, $g, $b" . ';--fitme-accent-dark:' . $shade(.86) . ';--fitme-accent-ink:' . $shade($lum > .5 ? .5 : .85) . ';--fitme-on-accent:' . ($lum > .5 ? '#0B0B0D' : '#FFFFFF'); } // The gym's logo (relative path). The main gym falls back to the site's own // FitMe 3070 logo, so its navbar button has one before anything is uploaded. function gym_logo(array $gym): string { $logo = (string) ($gym['logo'] ?? ''); if ($logo === '' && !empty($gym['is_default'])) $logo = 'site/assets/img/logos/logo_black.svg'; return $logo; } function gym_brand_css(array $gym): string { $v = gym_brand_vars($gym); return $v === '' ? '' : ':root{' . $v . '}'; } 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, 'logo' => mb_substr(trim((string) ($post['logo'] ?? '')), 0, 255), 'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))), 'item_order' => (int) ($post['item_order'] ?? 0), ]; // Header image + logo come from file inputs (a fresh upload wins, "remover" // clears it, otherwise the current value posted in the hidden field stays). foreach (['hero' => 'hero_image', 'logo' => 'logo'] as $kind => $field) { if (!empty($post[$kind . '_remove'])) $data[$field] = ''; $file = $_FILES[$kind . '_file'] ?? null; if ($file && ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { $err = null; $saved = gyms_save_image($file, $kind, $err); if ($saved !== null) $data[$field] = $saved; else $errors[] = ($kind === 'hero' ? 'Imagem do header: ' : 'Logótipo: ') . ($err ?? 'ficheiro inválido.'); } } $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['logo'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(svg|png|webp|jpe?g|jxl)$~i', $data['logo'])) { $errors[] = 'Logótipo: imagem inválida.'; } if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) { $errors[] = 'Cor da marca: formato #RRGGBB (ex.: #C2D500).'; } 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|jxl)$~i', $data['hero_image'])) { $errors[] = 'Imagem do header: imagem inválida.'; } return $data; } // Columns written by create/update, in order. brand_color and logo were added by // migration_20261013_gym_brand_color.sql: an install that hasn't run it yet keeps // working (they are just not saved) instead of failing on an unknown column. function _gyms_cols(PDO $pdo, array $d): array { $cols = [ 'slug' => $d['slug'], 'name' => $d['name'], 'locality' => $d['locality'], 'postal_code' => $d['postal_code'], 'is_published' => $d['is_published'], 'is_default' => $d['is_default'], 'sections' => $d['sections'], 'hero_title' => $d['hero_title'], 'hero_subtitle' => $d['hero_subtitle'], 'hero_image' => $d['hero_image'], 'seo_title' => $d['seo_title'], 'seo_description' => $d['seo_description'], 'gallery_id' => $d['gallery_id'], 'google_place_id' => $d['google_place_id'], 'has_crossfit' => $d['has_crossfit'], ]; foreach (['brand_color', 'logo'] as $opt) { if (_gyms_has_col($pdo, 'gyms', $opt)) $cols[$opt] = $d[$opt] ?? ''; } $cols['item_order'] = $d['item_order']; return $cols; } function db_gyms_create(PDO $pdo, array $d): int { $cols = _gyms_cols($pdo, $d); $pdo->beginTransaction(); $pdo->prepare('INSERT INTO gyms (' . implode(', ', array_keys($cols)) . ', created_at, updated_at) VALUES (' . implode(',', array_fill(0, count($cols), '?')) . ', NOW(), NOW())')->execute(array_values($cols)); $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); $cols = _gyms_cols($pdo, $d); $pdo->beginTransaction(); $st = $pdo->prepare('UPDATE gyms SET ' . implode(' = ?, ', array_keys($cols)) . ' = ?, updated_at = NOW() WHERE id = ?'); $st->execute(array_merge(array_values($cols), [$id])); $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; } // ─── Header image / logo uploads ────────────────────────────────────────────── // Files live in uploads/gyms/ (uploads/.htaccess blocks script execution) and the // DB keeps the site-relative path, which is what the public pages already read. function gyms_upload_dir(): string { return dirname(__DIR__) . '/uploads/gyms/'; } // Validates by content (upload_image_ext, SVGs through svg_is_safe), stores the // file under a random name and returns "uploads/gyms/", or null with $error. function gyms_save_image(array $file, string $kind, ?string &$error = null): ?string { if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { $error = ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) ? 'o ficheiro é demasiado grande.' : 'o envio falhou.'; return null; } if (($file['size'] ?? 0) > 6 * 1024 * 1024) { $error = 'máximo 6 MB.'; return null; } $allowed = $kind === 'hero' ? ['jpg', 'jpeg', 'png', 'webp', 'jxl'] : ['svg', 'png', 'webp', 'jpg', 'jpeg', 'jxl']; $ext = upload_image_ext($file, $allowed); if ($ext === null) { $error = $kind === 'hero' ? 'usa JPG, PNG, WebP ou JPEG XL.' : 'usa SVG, PNG, WebP, JPG ou JPEG XL (SVG sem scripts).'; return null; } $dir = gyms_upload_dir(); if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { $error = 'não foi possível criar a pasta de envio.'; return null; } $name = $kind . '_' . bin2hex(random_bytes(6)) . '.' . $ext; if (!move_uploaded_file($file['tmp_name'], $dir . $name)) { $error = 'não foi possível guardar o ficheiro.'; return null; } if ($ext !== 'svg' && function_exists('img_optimize')) { img_optimize($dir . $name, $kind === 'hero' ? IMG_MAX_GALLERY : 600); } return 'uploads/gyms/' . $name; } // Deletes an uploaded image that is no longer referenced (never a bundled one). function gyms_remove_image(?string $rel): void { $rel = (string) $rel; if (!preg_match('~^uploads/gyms/[A-Za-z0-9_.-]+$~', $rel)) return; $path = gyms_upload_dir() . basename($rel); if (is_file($path)) @unlink($path); img_fallback_follow($path); } // 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]; }