Files
fitme3070.pt/config/functions_site.php
T
andreandClaude Sonnet 5 595a37fd8b fix(site): width/height nas imagens da home (auditoria 4.7)
125 imagens na home, 0 com srcset e 122 sem width/height — o browser
só sabia o tamanho depois de descarregar, o que causa layout shift.

Uploads são redimensionados a um limite de lado maior (functions_images.php),
não a uma dimensão fixa (fotos de equipa vão de 389x512 a 512x512, ícones
de oferta de 128x128 a 256x256), por isso as dimensões não dão para
hardcode: site_img_dims_attr() em functions_site.php lê o ficheiro real
com getimagesize() por pedido, tal como o gallery.php já fazia, e
devolve o par width="" height="" ou "" se o ficheiro não existir. SVG
não passa por getimagesize() (sem decoder embutido) — svg_img_dims_attr()
lê os atributos width/height (ícones fitme-*.svg, 48x48 fixo) ou cai
para o viewBox (logos do navbar, só têm viewBox).

Aplicado a: ícones da oferta, fotos de equipa (card + painel de detalhe),
ícone de modalidade em modalidade_icon_html() e no horário (index.php e
includes/horario.php), logo do navbar.

srcset ficou de fora: as imagens já vêm coartadas para pouco mais do que
o maior tamanho a que aparecem (fotos de equipa a 512px cabidas numa caixa
de no máx. 200px CSS com object-fit:cover; ícones a 128-256px mostrados a
44px) — é a folga normal para ecrãs retina, não desperdício a resolver
com múltiplas resoluções. E o layout é sempre CSS-driven (width/height:
100% + object-fit), por isso um srcset não mudaria o que é descarregado
por variação de viewport, só pouparia bytes nalguns casos — gerar 2-3
tamanhos por upload via GD seria trabalho a mais para o ganho real neste
site. Fica só o fix de dimensões, que é o que resolve o CLS que a
auditoria mediu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:44:29 +01:00

246 lines
12 KiB
PHP

<?php
// ─── Public-site data (plain-PDO replacements for the old CI4 models) ─────────
// 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.
// 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 {
$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] = '';
}
// 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)) $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 <img> 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 <svg> carries data-anim, a per-family movement (see style.css). Anything
// else (legacy PNGs, uploaded SVGs) stays a plain <img>.
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), '<svg')) {
// Belt and braces: no scripts / event handlers, whatever the file says.
$svg = preg_replace('~<script\b.*?</script>~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*<svg\b~', '<svg class="mi" data-anim="' . modalidade_icon_anim($m[1]) . '" aria-hidden="true" focusable="false"', $svg, 1);
return $svg;
}
}
$dims = site_img_dims_attr($icon, 'uploads/modalidades/icons');
return '<img loading="lazy" decoding="async" draggable="false" ' . $dims
. ' src="' . htmlspecialchars(site_img_url($icon, 'uploads/modalidades/icons')) . '" alt="">';
}