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] = ''; } // wa.me only accepts digits — a stray "+" or space would break the link. $row['whatsapp'] = preg_replace('/\D+/', '', (string) $row['whatsapp']); return $row; } // 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)) continue; // No active plans → the section isn't rendered, so no link to it either. if ($sec === 'mensalidades' && !empty($GLOBALS['pdo']) && !site_memberships($GLOBALS['pdo'], (int) $gym['id'])) continue; $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, ?int $gym_id = null): array { try { $st = $pdo->prepare( "SELECT * 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 = '')" ); $st->execute([_site_gym_id($gym_id)]); return $st->fetchAll(PDO::FETCH_ASSOC); } catch (Throwable $e) { return []; } } function site_work_hours(PDO $pdo, ?int $gym_id = null): array { try { $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 []; } } 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 ────────────────────────────────────────────────────── // DB stores either a bare filename ("1.jpg") or a full relative path // ("uploads/team/foto/1.jpg"). This normalises both to a working URL. // $base_dir is the upload subdirectory (e.g. "uploads/team/foto"). function site_img_url($value, $base_dir): string { // Coerce null/empty to empty string $value = (string) $value; if ($value === '') return ''; // Already a full URL if (substr($value, 0, 4) === 'http') return $value; // Already has the uploads/ prefix — use as-is if (substr($value, 0, 8) === 'uploads/') return base_url($value); // Bare filename — prepend the base directory return base_url(rtrim((string) $base_dir, '/') . '/' . ltrim($value, '/')); } // Same value/$base_dir resolution as site_img_url(), but to a filesystem path // (for getimagesize()) instead of a URL. Empty string for a remote http(s) // image or anything that doesn't resolve to a local file. function site_img_path($value, $base_dir): string { $value = (string) $value; if ($value === '' || substr($value, 0, 4) === 'http') return ''; $root = dirname(__DIR__); $rel = (substr($value, 0, 8) === 'uploads/') ? $value : rtrim((string) $base_dir, '/') . '/' . ltrim($value, '/'); return $root . '/' . $rel; } // width="" height="" attribute string for an uploaded image, read from the // file itself (uploads aren't resized to one fixed size — see // config/functions_images.php — so this can't be hardcoded). Empty string // when the file is missing/unreadable, so the just renders without the // attributes rather than with bogus 0x0 ones. function site_img_dims_attr($value, $base_dir): string { $path = site_img_path($value, $base_dir); if ($path === '') return ''; return svg_img_dims_attr($path); } // width="" height="" for any local raster or SVG file. getimagesize() covers // raster; for SVG (which it doesn't decode) this reads the width/height // attributes and falls back to the viewBox aspect ratio when only that is // set (e.g. the site logos). Empty string when the file is missing or has no // size info to go on. function svg_img_dims_attr(string $path): string { if (!is_file($path)) return ''; if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'svg') { $info = @getimagesize($path); return $info ? 'width="' . (int) $info[0] . '" height="' . (int) $info[1] . '"' : ''; } $svg = (string) @file_get_contents($path, false, null, 0, 4096); if (preg_match('/\bwidth="([\d.]+)"/', $svg, $mw) && preg_match('/\bheight="([\d.]+)"/', $svg, $mh)) { return 'width="' . (int) round((float) $mw[1]) . '" height="' . (int) round((float) $mh[1]) . '"'; } if (preg_match('/\bviewBox="[\d.\-]+\s+[\d.\-]+\s+([\d.]+)\s+([\d.]+)"/', $svg, $mv)) { return 'width="' . (int) round((float) $mv[1]) . '" height="' . (int) round((float) $mv[2]) . '"'; } return ''; } // 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 = '', ?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 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); } catch (Throwable $e) { return []; } } // ─── Indexing ───────────────────────────────────────────────────────────────── // SEO_NOINDEX=true in .env keeps an install out of search results (meta robots // + robots.txt "Disallow: /"). For staging copies such as fitme.andmore.pt, // which serve the same content as the real domain and would otherwise compete // with it as duplicate content. function seo_noindex(): bool { return filter_var(env('SEO_NOINDEX', 'false'), FILTER_VALIDATE_BOOLEAN); } // Public, indexable URLs for sitemap.xml: the fixed pages plus any live event // landing page. function site_sitemap_urls(PDO $pdo): array { // 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']; $urls[] = ['loc' => base_url('home/termos'), 'priority' => '0.1']; 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; } // E.164 form for tel: links and structured data. Numbers are stored however // the admin typed them ("964 201 137", "+351 231 451 346"); a bare 9-digit // Portuguese number gets +351 so the link also works from a foreign phone. function site_tel(?string $phone): string { $p = preg_replace('/[^\d+]/', '', (string) $phone); if ($p === '') return ''; if ($p[0] !== '+' && strlen($p) === 9) return '+351' . $p; if ($p[0] !== '+' && str_starts_with($p, '00')) return '+' . substr($p, 2); return $p; } // ─── Modalidade icon markup ─────────────────────────────────────────────────── // The FitMe icon set (uploads/modalidades/icons/fitme-*.svg, shipped in the repo // — backoffice uploads are always named upl_*) is inlined so CSS can animate // the strokes: every drawable element gets pathLength="1" for the draw-in, and // the carries data-anim, a per-family movement (see style.css). Anything // else (legacy PNGs, uploaded SVGs) stays a plain . function modalidade_icon_anim(string $slug): string { $map = [ 'lift' => ['yourfit-pump', 'crossfit', 'crossfit-teen-s', 'fbb', 'yourfit-core', 'total-workout'], 'punch' => ['yourfit-fight', 'bootcamp'], 'breathe' => ['yourfit-mind', 'yoga', 'pilates'], 'bounce' => ['zumba', 'jump', 'yourfit-pulse', 'hybrid'], 'flicker' => ['fitburn'], 'ride' => ['cycling'], 'run' => ['run3070', 'on-ramp', 'localizada'], ]; foreach ($map as $anim => $slugs) if (in_array($slug, $slugs, true)) return $anim; return 'lift'; } function modalidade_icon_html(?string $icon): string { $icon = trim((string) $icon); if ($icon === '') return ''; if (preg_match('/^fitme-([a-z0-9-]+)\.svg$/', $icon, $m)) { $file = dirname(__DIR__) . '/uploads/modalidades/icons/' . $icon; $svg = is_file($file) ? (string) file_get_contents($file) : ''; if (str_starts_with(ltrim($svg), '~is', '', $svg); $svg = preg_replace('~\son\w+\s*=\s*("[^"]*"|\'[^\']*\')~i', '', $svg); $svg = preg_replace('~\scolor="[^"]*"~', '', $svg, 1); // colour comes from CSS $svg = preg_replace('~<(path|circle|rect|line|polyline|polygon|ellipse)\b~', '<$1 pathLength="1"', $svg); $svg = preg_replace('~^\s*