From 4b2823d1e582126a05e71defd2fc555a0f979bc8 Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 13 Oct 2026 17:08:30 +0100 Subject: [PATCH] feat(gyms): per-gym brand colour; CrossFit pill follows it Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 1 + config/functions_gyms.php | 27 +++++++++++++++---- .../migration_20261013_gym_brand_color.sql | 5 ++++ site/assets/css/style.css | 19 +++++++------ site/includes/include.php | 3 ++- views/admin/gyms/_form.php | 6 +++++ 6 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 database/migration_20261013_gym_brand_color.sql diff --git a/CLAUDE.md b/CLAUDE.md index 749ae69..889963b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,6 +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. An unpublished gym 404s publicly but previews for staff. ## platform-tools diff --git a/config/functions_gyms.php b/config/functions_gyms.php index 82ffd5a..a863576 100644 --- a/config/functions_gyms.php +++ b/config/functions_gyms.php @@ -41,7 +41,7 @@ function gym_defaults(): array { '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, 'item_order' => 0, + 'google_place_id' => '', 'has_crossfit' => 0, 'brand_color' => '', 'item_order' => 0, ]; } @@ -103,6 +103,19 @@ function gym_section_on(array $gym, string $key): bool { 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_css(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 ':root{--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') . '}'; +} + function gym_url(array $gym, string $path = ''): string { return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/')); } @@ -159,6 +172,7 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null): '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, + 'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))), 'item_order' => (int) ($post['item_order'] ?? 0), ]; $on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? []))); @@ -173,6 +187,9 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null): $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['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).'; @@ -183,15 +200,15 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null): 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['item_order']]; + $d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['brand_color'], $d['item_order']]; } function db_gyms_create(PDO $pdo, array $d): int { $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, item_order, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d)); + google_place_id, has_crossfit, brand_color, item_order, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d)); $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. @@ -211,7 +228,7 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool { $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 = ?, item_order = ?, updated_at = NOW() + has_crossfit = ?, brand_color = ?, item_order = ?, updated_at = NOW() WHERE id = ?"); $st->execute($params); $changed = $st->rowCount() > 0; diff --git a/database/migration_20261013_gym_brand_color.sql b/database/migration_20261013_gym_brand_color.sql new file mode 100644 index 0000000..1af031a --- /dev/null +++ b/database/migration_20261013_gym_brand_color.sql @@ -0,0 +1,5 @@ +-- Cor de marca por ginásio (hex #RRGGBB). Vazio = verde FitMe por defeito. +-- Ginásio 1 = verde; o ginásio 2 usa vermelho (definir em Gestão → Ginásios). +-- Safe to re-run. +ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `brand_color` VARCHAR(7) NOT NULL DEFAULT '' AFTER `has_crossfit`; +UPDATE `gyms` SET `brand_color` = '#C2D500' WHERE `id` = 1 AND `brand_color` = ''; diff --git a/site/assets/css/style.css b/site/assets/css/style.css index fdc1aa9..3514f80 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -28,6 +28,8 @@ (WCAG AA needs 4.5:1), so text uses this darker olive. Bright accent stays for dark sections and button backgrounds. */ --fitme-accent-ink: #667000; + /* readable text colour on top of --fitme-accent (a gym's brand colour may be dark) */ + --fitme-on-accent: #0B0B0D; /* deeper moss for hover / active */ /* The Anchor — Void Black, and a cool ramp lifting toward Charcoal Slate. */ @@ -314,7 +316,7 @@ section[id] { min-width: 200px; padding: 16px 36px; background: var(--fitme-accent); - color: var(--fitme-dark); + color: var(--fitme-on-accent); text-align: center; cursor: pointer; border-radius: var(--fitme-radius); @@ -325,7 +327,7 @@ section[id] { .site-btn:hover { background: var(--fitme-accent-dark); - color: var(--fitme-dark); + color: var(--fitme-on-accent); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(var(--fitme-accent-rgb), .35); } @@ -384,13 +386,13 @@ section[id] { background: transparent !important; } -/* ── CrossFit nav pill ─────────────────────────────────────────────────────── */ +/* ── CrossFit nav pill (follows the gym's brand colour) ─────────────────────────────────────────────────────── */ .crossfit-nav-link { - color: #b71c1c !important; + color: var(--fitme-accent-ink) !important; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; - border: 2px solid #b71c1c; + border: 2px solid var(--fitme-accent-ink); border-radius: var(--fitme-radius); padding: 6px 14px !important; transition: background-color var(--fitme-transition), color var(--fitme-transition); @@ -404,15 +406,16 @@ section[id] { generic .nav-item.active .nav-link transparent-background rule above) */ .crossfit-nav-link:hover, .nav-item.active .crossfit-nav-link { - background: #b71c1c !important; - color: #fff !important; + background: var(--fitme-accent) !important; + border-color: var(--fitme-accent); + color: var(--fitme-on-accent) !important; } /* ── Other-gym button (right end of the navbar) ────────────────────────────── */ .gym-switch-btn { display: inline-block; background: var(--fitme-accent); - color: var(--fitme-dark); + color: var(--fitme-on-accent); font-weight: 700; text-transform: uppercase; letter-spacing: .5px; diff --git a/site/includes/include.php b/site/includes/include.php index 363d660..b16eacc 100644 --- a/site/includes/include.php +++ b/site/includes/include.php @@ -39,7 +39,7 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) { - + <?= htmlspecialchars($page_title) ?> @@ -85,6 +85,7 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) { + diff --git a/views/admin/gyms/_form.php b/views/admin/gyms/_form.php index 48835ec..14e2ca4 100644 --- a/views/admin/gyms/_form.php +++ b/views/admin/gyms/_form.php @@ -86,6 +86,12 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti +
+ + +
Botões, destaques e links do site deste ginásio.
+
+

Estado