From a85bec68515076dff657e6f752a9f4f7385e2456 Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 13 Oct 2026 19:12:25 +0100 Subject: [PATCH] fix(gyms): saving no longer fails before the brand-colour migration; upload header image and logo instead of typing paths Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- config/functions_gyms.php | 93 ++++++++++++++++++++++++++++++------- views/admin/gyms/_form.php | 29 +++++++++--- views/admin/gyms/create.php | 2 +- views/admin/gyms/edit.php | 4 +- 5 files changed, 104 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec75562..fd4f47f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Since 2026-09-25, one install serves several physical gyms: the `gyms` table, on folder per gym (`/3070/`, `//`), content rows carry `gym_id`, and the backoffice has an "a editar" gym switcher (`admin_gym_id()` / `admin_gym()` in `config/functions_gyms.php`). `/` and `/home` 301 to the default gym (`is_default`); the other published gyms appear as highlighted buttons (`.gym-switch-btn`) at the right end of the public navbar (`site/includes/navbar.php`). `site/gym_chooser.php` is no longer used. -Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. Each gym can have a `logo` (path, same migration); the navbar button for another gym is drawn in that gym's brand colour (`gym_brand_vars()` inline) with the logo as a CSS mask in the text colour. +Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. Each gym can have a `logo` (same migration); the header image and the logo are uploaded in Gestão → Ginásios (`gyms_save_image()` → `uploads/gyms/`, validated by content, SVG through `svg_is_safe()`), not typed as paths; `db_gyms_create/update` skip `brand_color`/`logo` while the migration isn't applied (`_gyms_cols()`); the navbar button for another gym is drawn in that gym's brand colour (`gym_brand_vars()` inline) with the logo as a CSS mask in the text colour. An unpublished gym 404s publicly but previews for staff. ## platform-tools diff --git a/config/functions_gyms.php b/config/functions_gyms.php index 546f818..0ed159f 100644 --- a/config/functions_gyms.php +++ b/config/functions_gyms.php @@ -181,6 +181,18 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null): '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); @@ -194,30 +206,41 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null): 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)$~i', $data['logo'])) { - $errors[] = 'Logótipo: caminho de uma imagem do site (ex.: site/assets/img/logos/gym2.svg).'; + $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)$~i', $data['hero_image'])) { - $errors[] = 'Imagem do header: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).'; + $errors[] = 'Imagem do header: imagem inválida.'; } 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['brand_color'], $d['logo'], $d['item_order']]; +// 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 (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, brand_color, logo, item_order, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d)); + $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. @@ -232,14 +255,10 @@ function db_gyms_create(PDO $pdo, array $d): int { 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(); - $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 = ?, brand_color = ?, logo = ?, item_order = ?, updated_at = NOW() - WHERE id = ?"); - $st->execute($params); + $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(); @@ -248,6 +267,46 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool { 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'] : ['svg', 'png', 'webp', 'jpg', 'jpeg']; + $ext = upload_image_ext($file, $allowed); + if ($ext === null) { + $error = $kind === 'hero' ? 'usa JPG, PNG ou WebP.' : 'usa SVG, PNG, WebP ou JPG (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); +} + // 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. diff --git a/views/admin/gyms/_form.php b/views/admin/gyms/_form.php index 4111f97..9d22f4a 100644 --- a/views/admin/gyms/_form.php +++ b/views/admin/gyms/_form.php @@ -41,9 +41,16 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
- - -
Caminho de uma imagem do site. Vazio = a do ginásio principal.
+ + +
+ + + +
JPG, PNG ou WebP (até 6 MB). Vazio = a do ginásio principal.
+ +
+
@@ -87,9 +94,19 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
- - -
Opcional. Aparece a uma só cor (a do texto do botão); funciona melhor com SVG de uma cor.
+ + +
+ + + +
SVG, PNG, WebP ou JPG (até 6 MB). Opcional. Aparece a uma só cor (a do texto do botão); funciona melhor com SVG de uma cor.
+ +
Falta aplicar a migração migration_20261013_gym_brand_color.sql (Sistema → Atualizar plataforma) para guardar o logótipo e a cor da marca.
+ + +
+
diff --git a/views/admin/gyms/create.php b/views/admin/gyms/create.php index a3bc45b..22e88c8 100644 --- a/views/admin/gyms/create.php +++ b/views/admin/gyms/create.php @@ -42,7 +42,7 @@ include __DIR__ . '/../../../includes/header.php'; Voltar
-
+
diff --git a/views/admin/gyms/edit.php b/views/admin/gyms/edit.php index c1b6ae5..cb8effd 100644 --- a/views/admin/gyms/edit.php +++ b/views/admin/gyms/edit.php @@ -21,6 +21,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } if (!$errors) { $changed = db_gyms_update($pdo, $id, $data); + // Uploaded images that were replaced or removed are no longer referenced. + foreach (['hero_image', 'logo'] as $f) if (($item[$f] ?? '') !== ($data[$f] ?? '')) gyms_remove_image($item[$f] ?? ''); session()->setFlashdata($changed ? 'success' : 'warning', $changed ? 'Ginásio atualizado.' : 'Nenhuma alteração.'); header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id)); exit; @@ -47,7 +49,7 @@ include __DIR__ . '/../../../includes/header.php'; -
+