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 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Sonnet 5.5 committed 2026-10-13 19:12:25 +01:00
1 parent 965a123834
commit a85bec6851
5 files changed
+104 -26

No files matched your search

+1 -1
View File
@@ -79,7 +79,7 @@ Since 2026-09-25, one install serves several physical gyms: the `gyms` table, on
folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the
backoffice has an "a editar" gym switcher (`admin_gym_id()` / 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. `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. An unpublished gym 404s publicly but previews for staff.
## platform-tools ## platform-tools
+76 -17
View File
@@ -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'] ?? ''))), 'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
'item_order' => (int) ($post['item_order'] ?? 0), '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'] ?? []))); $on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on); $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 ($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'])) { 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'])) { if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) {
$errors[] = 'Cor da marca: formato #RRGGBB (ex.: #C2D500).'; $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['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'])) { 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; return $data;
} }
function _gyms_params(array $d): array { // Columns written by create/update, in order. brand_color and logo were added by
return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'], // migration_20261013_gym_brand_color.sql: an install that hasn't run it yet keeps
$d['sections'], $d['hero_title'], $d['hero_subtitle'], $d['hero_image'], $d['seo_title'], // working (they are just not saved) instead of failing on an unknown column.
$d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['brand_color'], $d['logo'], $d['item_order']]; 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 { function db_gyms_create(PDO $pdo, array $d): int {
$cols = _gyms_cols($pdo, $d);
$pdo->beginTransaction(); $pdo->beginTransaction();
$pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections, $pdo->prepare('INSERT INTO gyms (' . implode(', ', array_keys($cols)) . ', created_at, updated_at) VALUES ('
hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id, . implode(',', array_fill(0, count($cols), '?')) . ', NOW(), NOW())')->execute(array_values($cols));
google_place_id, has_crossfit, brand_color, logo, item_order, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
$id = (int) $pdo->lastInsertId(); $id = (int) $pdo->lastInsertId();
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]); 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. // 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 { function db_gyms_update(PDO $pdo, int $id, array $d): bool {
$__before = audit_row($pdo, 'gyms', 'id', (int) $id); $__before = audit_row($pdo, 'gyms', 'id', (int) $id);
$cols = _gyms_cols($pdo, $d);
$pdo->beginTransaction(); $pdo->beginTransaction();
$params = _gyms_params($d); $params[] = $id; $st = $pdo->prepare('UPDATE gyms SET ' . implode(' = ?, ', array_keys($cols)) . ' = ?, updated_at = NOW() WHERE id = ?');
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?, $st->execute(array_merge(array_values($cols), [$id]));
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);
$changed = $st->rowCount() > 0; $changed = $st->rowCount() > 0;
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]); if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
$pdo->commit(); $pdo->commit();
@@ -248,6 +267,46 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool {
return $__r; 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/<name>", 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 // 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() // touches google_place_id, not the rest of the gym record db_gyms_update()
// would validate/audit. // would validate/audit.
+23 -6
View File
@@ -41,9 +41,16 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
<input class="form-control" name="hero_subtitle" maxlength="150" placeholder="& Acompanhamento" value="<?= htmlspecialchars($data['hero_subtitle'] ?? '') ?>"> <input class="form-control" name="hero_subtitle" maxlength="150" placeholder="& Acompanhamento" value="<?= htmlspecialchars($data['hero_subtitle'] ?? '') ?>">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Imagem do header</label> <label class="form-label" for="hero_file">Imagem do header</label>
<input class="form-control font-monospace" name="hero_image" maxlength="255" placeholder="site/assets/img/home.jpg" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>"> <?php if (($data['hero_image'] ?? '') !== ''): ?>
<div class="form-text">Caminho de uma imagem do site. Vazio = a do ginásio principal.</div> <div class="mb-2"><img src="<?= htmlspecialchars(base_url($data['hero_image'])) ?>" alt="" class="rounded border" style="max-width:100%;max-height:110px;object-fit:cover"></div>
<?php endif; ?>
<input type="hidden" name="hero_image" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
<input type="file" class="form-control" id="hero_file" name="hero_file" accept="image/jpeg,image/png,image/webp">
<div class="form-text">JPG, PNG ou WebP (até 6 MB). Vazio = a do ginásio principal.</div>
<?php if (($data['hero_image'] ?? '') !== ''): ?>
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="hero_remove" value="1" id="hero_remove"><label class="form-check-label small" for="hero_remove">Remover a imagem atual</label></div>
<?php endif; ?>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Galeria das instalações</label> <label class="form-label">Galeria das instalações</label>
@@ -87,9 +94,19 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Logótipo (botão no menu dos outros ginásios)</label> <label class="form-label" for="logo_file">Logótipo (botão no menu dos outros ginásios)</label>
<input class="form-control font-monospace" name="logo" maxlength="255" placeholder="site/assets/img/logos/ginasio.svg" value="<?= htmlspecialchars($data['logo'] ?? '') ?>"> <?php if (($data['logo'] ?? '') !== ''): ?>
<div class="form-text">Opcional. Aparece a uma só cor (a do texto do botão); funciona melhor com SVG de uma cor.</div> <div class="mb-2"><img src="<?= htmlspecialchars(base_url($data['logo'])) ?>" alt="" class="rounded border p-2" style="max-width:100%;max-height:70px;background:#e9ecef"></div>
<?php endif; ?>
<input type="hidden" name="logo" value="<?= htmlspecialchars($data['logo'] ?? '') ?>">
<input type="file" class="form-control" id="logo_file" name="logo_file" accept="image/svg+xml,image/png,image/webp,image/jpeg">
<div class="form-text">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.</div>
<?php if (!_gyms_has_col($pdo, 'gyms', 'logo')): ?>
<div class="form-text text-warning">Falta aplicar a migração <code>migration_20261013_gym_brand_color.sql</code> (Sistema → Atualizar plataforma) para guardar o logótipo e a cor da marca.</div>
<?php endif; ?>
<?php if (($data['logo'] ?? '') !== ''): ?>
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="logo_remove" value="1" id="logo_remove"><label class="form-check-label small" for="logo_remove">Remover o logótipo atual</label></div>
<?php endif; ?>
</div> </div>
<div class="col-sm-6 col-lg-4"> <div class="col-sm-6 col-lg-4">
<label class="form-label" for="brand_color">Cor da marca</label> <label class="form-label" for="brand_color">Cor da marca</label>
+1 -1
View File
@@ -42,7 +42,7 @@ include __DIR__ . '/../../../includes/header.php';
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a> <a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
</div> </div>
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?> <?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
<form method="POST" class="card shadow-sm"><div class="card-body"> <form method="POST" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
<?= csrf_field() ?> <?= csrf_field() ?>
<?php include __DIR__ . '/_form.php'; ?> <?php include __DIR__ . '/_form.php'; ?>
+3 -1
View File
@@ -21,6 +21,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
if (!$errors) { if (!$errors) {
$changed = db_gyms_update($pdo, $id, $data); $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.'); session()->setFlashdata($changed ? 'success' : 'warning', $changed ? 'Ginásio atualizado.' : 'Nenhuma alteração.');
header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id)); header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id));
exit; exit;
@@ -47,7 +49,7 @@ include __DIR__ . '/../../../includes/header.php';
<button class="btn btn-sm btn-primary text-nowrap">Editar conteúdo deste</button> <button class="btn btn-sm btn-primary text-nowrap">Editar conteúdo deste</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<form method="POST" class="card shadow-sm"><div class="card-body"> <form method="POST" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
<?= csrf_field() ?> <?= csrf_field() ?>
<?php include __DIR__ . '/_form.php'; ?> <?php include __DIR__ . '/_form.php'; ?>
<div class="mt-4 d-flex gap-2"> <div class="mt-4 d-flex gap-2">