From 5f64ae57db30baf490a3d42b4f591a65a83d7a66 Mon Sep 17 00:00:00 2001 From: andre Date: Fri, 9 Oct 2026 22:10:58 +0100 Subject: [PATCH 01/73] feat(dev): adopt shared platform-tools dev toolbar and one-click dev login Co-Authored-By: Claude Sonnet 5.5 --- .htaccess | 3 ++ CLAUDE.md | 5 +++ config/dev_guard.php | 19 +++++++++ dev_login.php | 30 +++++++++++++++ includes/dev_toolbar.php | 11 ++++++ includes/footer.php | 1 + scripts/dev_accounts.php | 83 ++++------------------------------------ 7 files changed, 76 insertions(+), 76 deletions(-) create mode 100644 config/dev_guard.php create mode 100644 dev_login.php create mode 100644 includes/dev_toolbar.php diff --git a/.htaccess b/.htaccess index 316b4b8..ddf23fe 100644 --- a/.htaccess +++ b/.htaccess @@ -58,6 +58,9 @@ RewriteRule ^site/assets/ - [L] RewriteRule ^assets/ - [L] # ── Friendly URLs ───────────────────────────────────────────────────────────── +# DEV ONLY: dev_login.php 404s sozinho se o platform-tools não tiver functions_dev.php. +RewriteCond %{REQUEST_FILENAME}.php -f +RewriteRule ^dev_login/?$ dev_login.php [L] RewriteRule ^home/?$ site/index.php [L] RewriteRule ^home/equipa/?$ site/equipa.php [L,QSA] RewriteRule ^home/modalidades/?$ site/modalidades.php [L,QSA] diff --git a/CLAUDE.md b/CLAUDE.md index f385e30..ca79486 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,3 +132,8 @@ or adapted for production, and these accounts must never exist in a production d ## Brand feed (2026-10-09) - `api/brand.php` is a one-line stub to `shared/platform-tools/api/brand.php` (public JSON: name, logos, colours; README "Brand feed"). Declared in `platform_connect(['brand' => ...])` in `config/db.php`; colours are the `:root` tokens (`--fitme-accent` #c2d500, `--fitme-dark` #0b0b0d, `--fitme-accent-dark` #a6b600). `logo_light` = light-coloured logo (`logo_crossfit_white.svg`), `logo_dark` = `logo_crossfit.svg`. - `site_logo_rel()` (config/functions.php) resolves logos for both the feed and `load_email_template()`: setting file in `uploads/public/logo/` (convention), then legacy `assets/img/logo/`, then the static logo. `views/sysadmin/system_settings.php` now uploads logos to `uploads/public/logo/` (dir created on demand, 0755; `uploads/.htaccess` blocks script execution); old files in `assets/img/logo/` still resolve as fallback and in the settings preview. Brand block in `config/db.php` already prefers the setting file via `site_logo_rel()`, else the static SVGs. + +## Dev toolbar partilhada (2026-10-09) +- DEV ONLY, do platform-tools (`functions_dev.php`, README "Dev tooling"; produção segue `main`, que não tem o ficheiro: tudo degrada para "não é dev"). +- Ficheiros: `config/dev_guard.php` (shim), `includes/dev_toolbar.php` (incluído no fim de `includes/footer.php`), `dev_login.php` (na RAIZ: o site não tem `public/`; `.htaccess` tem a regra `^dev_login/?$` com `-f`), `scripts/dev_accounts.php` (`platform_dev_accounts_run`). +- Contas `admin@mail.pt` / `sysadmin@mail.pt` (pw `123`). O login usa `auth_login()` + `fit_stamp_login_ua()` como o login real; CSRF do site = campo `_csrf` / `$_SESSION['csrf_token']` (verificação sem `exit`). diff --git a/config/dev_guard.php b/config/dev_guard.php new file mode 100644 index 0000000..f53cfd2 --- /dev/null +++ b/config/dev_guard.php @@ -0,0 +1,19 @@ + ['admin' => 'admin@mail.pt', 'sysadmin' => 'sysadmin@mail.pt'], + 'bootstrap' => function () { global $pdo; require __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/functions.php'; }, + 'csrf_ok' => fn(): bool => is_string($_POST['_csrf'] ?? null) && is_string($_SESSION['csrf_token'] ?? null) + && $_SESSION['csrf_token'] !== '' && hash_equals($_SESSION['csrf_token'], $_POST['_csrf']), + 'login' => function (string $role, string $email): ?string { + global $pdo; + $st = $pdo->prepare('SELECT * FROM users WHERE email = ? AND role = ? AND is_active = 1 LIMIT 1'); + $st->execute([$email, $role]); + $user = $st->fetch(PDO::FETCH_ASSOC); + if (!$user) return null; // conta em falta: corra php scripts/dev_accounts.php + auth_login($pdo, $user); + fit_stamp_login_ua($pdo, (int) $user['id']); + return base_url(post_login_destination($role)); + }, +]); diff --git a/includes/dev_toolbar.php b/includes/dev_toolbar.php new file mode 100644 index 0000000..fdc4889 --- /dev/null +++ b/includes/dev_toolbar.php @@ -0,0 +1,11 @@ + base_url('dev_login'), + 'logout_url' => base_url('views/auth/logout.php'), + 'roles' => ['admin' => 'Admin', 'sysadmin' => 'Sysadmin'], + 'csrf_field' => 'csrf_field', +]); diff --git a/includes/footer.php b/includes/footer.php index b57fdee..bee17b4 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -105,5 +105,6 @@ main:after { margin-top: 2rem; } + diff --git a/scripts/dev_accounts.php b/scripts/dev_accounts.php index 936eb23..a94881e 100644 --- a/scripts/dev_accounts.php +++ b/scripts/dev_accounts.php @@ -1,81 +1,12 @@ 'users', 'id' => 'id', 'email' => 'email', 'name' => 'name', - 'password' => 'password_hash', 'role' => 'role', - 'flags' => ['is_active' => 1], // column => value that lets login work (insert AND update) - 'extra' => [], // column => value for new rows only; '{email}' is substituted - 'roles' => ['sysadmin@mail.pt' => 'sysadmin', 'admin@mail.pt' => 'admin'], -]; -// --------------------------------------------------------------------------- - -const DEV_PASSWORD = '123'; -function dev_fail(string $m): never { fwrite(STDERR, "REFUSED: $m\n"); exit(1); } - -$root = dirname(__DIR__); -$envFile = $root . '/.env'; -if (!is_file($envFile)) dev_fail('.env not found'); -$env = []; -foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $l) { - $l = trim($l); - if ($l === '' || $l[0] === '#' || !str_contains($l, '=')) continue; - [$k, $v] = explode('=', $l, 2); - $v = trim($v); - if ($v !== '' && $v[0] !== '"' && $v[0] !== "'" && ($h = strpos($v, ' #')) !== false) $v = rtrim(substr($v, 0, $h)); - $env[trim($k)] = trim($v, " \"'"); -} -foreach (array_keys($env) as $k) { if (($r = getenv($k)) !== false) $env[$k] = $r; } // real env wins, like the app - -$appEnv = strtolower($env['APP_ENV'] ?? ''); -if (!in_array($appEnv, ['dev', 'development', 'local'], true)) dev_fail('APP_ENV is not dev'); -$host = strtolower($env['DB_HOST'] ?? ''); $port = (int) ($env['DB_PORT'] ?? 3306); -$ok = in_array($host, ['localhost', '127.0.0.1', '::1'], true) || ($host === '192.168.1.3' && $port === 3181); -if (!$ok) dev_fail('DB host is not the dev server'); - -try { - $pdo = new PDO("mysql:host=$host;port=$port;dbname={$env['DB_NAME']};charset=utf8mb4", $env['DB_USER'] ?? '', $env['DB_PASS'] ?? '', - [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); -} catch (Throwable $e) { dev_fail('DB connect failed'); } - -$q = fn(string $c) => '`' . str_replace('`', '', $c) . '`'; -$cols = $pdo->prepare('SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'); -$cols->execute([$T['table']]); -$have = $cols->fetchAll(PDO::FETCH_COLUMN); -$need = array_merge([$T['email'], $T['password'], $T['role']], array_keys($T['flags']), array_keys($T['extra'])); -if ($T['name']) $need[] = $T['name']; -$missing = array_diff($need, $have); -if (!$have) dev_fail("table {$T['table']} missing (schema behind)"); -if ($missing) dev_fail('schema behind, missing columns: ' . implode(', ', $missing)); - -$hash = password_hash(DEV_PASSWORD, PASSWORD_DEFAULT); -foreach ($T['roles'] as $email => $role) { - $set = [$T['password'] => $hash, $T['role'] => $role] + $T['flags']; - $s = $pdo->prepare("SELECT {$q($T['id'])} FROM {$q($T['table'])} WHERE {$q($T['email'])} = ? LIMIT 1"); - $s->execute([$email]); - $id = $s->fetchColumn(); - if ($id !== false) { - $sql = "UPDATE {$q($T['table'])} SET " . implode(', ', array_map(fn($c) => $q($c) . ' = ?', array_keys($set))) - . " WHERE {$q($T['id'])} = ?"; - $pdo->prepare($sql)->execute([...array_values($set), $id]); - echo "updated $email ($role)\n"; - } else { - $set[$T['email']] = $email; - if ($T['name']) $set[$T['name']] = ucfirst($role) . ' (dev)'; - foreach ($T['extra'] as $c => $v) $set[$c] = is_string($v) ? str_replace('{email}', $email, $v) : $v; - $sql = "INSERT INTO {$q($T['table'])} (" . implode(', ', array_map($q, array_keys($set))) . ') VALUES (' - . implode(', ', array_fill(0, count($set), '?')) . ')'; - $pdo->prepare($sql)->execute(array_values($set)); - echo "created $email ($role)\n"; - } -} +$_dev_shared = dirname(__DIR__) . '/shared/platform-tools/functions_dev.php'; +if (!is_file($_dev_shared)) { fwrite(STDERR, "REFUSED: platform-tools sem functions_dev.php\n"); exit(1); } +require_once $_dev_shared; +exit(platform_dev_accounts_run(['flags' => ['is_active' => 1]])); -- 2.54.0 From b73c23bf5518b6a405f87d8004c9dda1a91d7ebd Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 13 Oct 2026 16:56:40 +0100 Subject: [PATCH 02/73] feat(dev): environment ribbon and one-click dev login on the public site and auth pages Co-Authored-By: Claude Sonnet 5.5 --- dev_login.php | 3 +- includes/auth_footer.php | 1 + includes/auth_header.php | 1 + includes/dev_toolbar.php | 4 +-- includes/env_ribbon.php | 62 +++++++++++++++++++++++++++++++++++++++ scripts/dev_accounts.php | 6 +++- site/includes/footer.php | 1 + site/includes/include.php | 1 + 8 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 includes/env_ribbon.php diff --git a/dev_login.php b/dev_login.php index bb046a1..25486a4 100644 --- a/dev_login.php +++ b/dev_login.php @@ -13,7 +13,8 @@ if (!is_file($_dev_shared)) { http_response_code(404); exit; } // submódulo s require_once $_dev_shared; platform_dev_login_handle([ - 'roles' => ['admin' => 'admin@mail.pt', 'sysadmin' => 'sysadmin@mail.pt'], + 'roles' => ['client' => 'client@mail.pt', 'staff' => 'staff@mail.pt', + 'admin' => 'admin@mail.pt', 'sysadmin' => 'sysadmin@mail.pt'], 'bootstrap' => function () { global $pdo; require __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/functions.php'; }, 'csrf_ok' => fn(): bool => is_string($_POST['_csrf'] ?? null) && is_string($_SESSION['csrf_token'] ?? null) && $_SESSION['csrf_token'] !== '' && hash_equals($_SESSION['csrf_token'], $_POST['_csrf']), diff --git a/includes/auth_footer.php b/includes/auth_footer.php index d70c087..af57b6d 100644 --- a/includes/auth_footer.php +++ b/includes/auth_footer.php @@ -4,5 +4,6 @@ + diff --git a/includes/auth_header.php b/includes/auth_header.php index 69deafe..8c7ac88 100644 --- a/includes/auth_header.php +++ b/includes/auth_header.php @@ -65,6 +65,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; /* 16px inputs stop iOS from auto-zooming on focus */ @media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } } +
diff --git a/includes/dev_toolbar.php b/includes/dev_toolbar.php index fdc4889..4a86f4b 100644 --- a/includes/dev_toolbar.php +++ b/includes/dev_toolbar.php @@ -1,11 +1,11 @@ base_url('dev_login'), 'logout_url' => base_url('views/auth/logout.php'), - 'roles' => ['admin' => 'Admin', 'sysadmin' => 'Sysadmin'], + 'roles' => ['client' => 'Cliente', 'staff' => 'Staff', 'admin' => 'Admin', 'sysadmin' => 'Sysadmin'], 'csrf_field' => 'csrf_field', ]); diff --git a/includes/env_ribbon.php b/includes/env_ribbon.php new file mode 100644 index 0000000..21f349d --- /dev/null +++ b/includes/env_ribbon.php @@ -0,0 +1,62 @@ + in both includes/header.php (backoffice) and + * site/includes/head.php (public site). It hangs off html::before / html::after + * rather than a wrapper element, so no page markup has to change and nothing + * can shift the layout: the frame is fixed, on top, and click-through. + * + * Reads env() directly rather than the APP_ENV constant, because the public + * site does not always load config/functions.php. + */ + +$_env = strtolower((string) (function_exists('env') ? env('APP_ENV', 'prod') : 'prod')); +if ($_env === 'prod' || $_env === 'production') return; +?> + diff --git a/scripts/dev_accounts.php b/scripts/dev_accounts.php index a94881e..a3edef8 100644 --- a/scripts/dev_accounts.php +++ b/scripts/dev_accounts.php @@ -9,4 +9,8 @@ declare(strict_types=1); $_dev_shared = dirname(__DIR__) . '/shared/platform-tools/functions_dev.php'; if (!is_file($_dev_shared)) { fwrite(STDERR, "REFUSED: platform-tools sem functions_dev.php\n"); exit(1); } require_once $_dev_shared; -exit(platform_dev_accounts_run(['flags' => ['is_active' => 1]])); +exit(platform_dev_accounts_run([ + 'flags' => ['is_active' => 1], + 'accounts' => ['sysadmin@mail.pt' => 'sysadmin', 'admin@mail.pt' => 'admin', + 'client@mail.pt' => 'client', 'staff@mail.pt' => 'staff'], +])); diff --git a/site/includes/footer.php b/site/includes/footer.php index f982464..af1a51c 100644 --- a/site/includes/footer.php +++ b/site/includes/footer.php @@ -39,5 +39,6 @@ + diff --git a/site/includes/include.php b/site/includes/include.php index 71754e7..363d660 100644 --- a/site/includes/include.php +++ b/site/includes/include.php @@ -85,6 +85,7 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) { + -- 2.54.0 From aa55d444063c105cecac83726e015e6222a7dd61 Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 13 Oct 2026 16:56:46 +0100 Subject: [PATCH 03/73] feat(sidebar): fleet sidebar standard (header row, user in sidebar, collapsible groups, mini mode, no-flash transitions) Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 15 ++ assets/css/backoffice.css | 351 +++++++++++++++++++++++++++++ includes/footer.php | 96 ++++++-- includes/header.php | 282 ++++++++--------------- includes/nav_helpers.php | 96 ++++++++ includes/navbar.php | 18 -- views/staff/dashboard.php | 20 ++ views/sysadmin/system_settings.php | 1 - 8 files changed, 654 insertions(+), 225 deletions(-) create mode 100644 assets/css/backoffice.css create mode 100644 includes/nav_helpers.php delete mode 100644 includes/navbar.php create mode 100644 views/staff/dashboard.php diff --git a/CLAUDE.md b/CLAUDE.md index ca79486..752cbf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,3 +137,18 @@ or adapted for production, and these accounts must never exist in a production d - DEV ONLY, do platform-tools (`functions_dev.php`, README "Dev tooling"; produção segue `main`, que não tem o ficheiro: tudo degrada para "não é dev"). - Ficheiros: `config/dev_guard.php` (shim), `includes/dev_toolbar.php` (incluído no fim de `includes/footer.php`), `dev_login.php` (na RAIZ: o site não tem `public/`; `.htaccess` tem a regra `^dev_login/?$` com `-f`), `scripts/dev_accounts.php` (`platform_dev_accounts_run`). - Contas `admin@mail.pt` / `sysadmin@mail.pt` (pw `123`). O login usa `auth_login()` + `fit_stamp_login_ua()` como o login real; CSRF do site = campo `_csrf` / `$_SESSION['csrf_token']` (verificação sem `exit`). + +## Ambiente de dev: moldura e login de um clique (2026-10-13) +- `includes/env_ribbon.php` (moldura às riscas + etiqueta DEV, só fora de `APP_ENV=prod`) incluído no `` de `includes/header.php`, `includes/auth_header.php` e `site/includes/include.php`. +- A barra de login de dev (`includes/dev_toolbar.php`) vai também em `includes/auth_footer.php` e `site/includes/footer.php`. Botões Cliente / Staff / Admin / Sysadmin (`client@mail.pt` e `staff@mail.pt` criados por `scripts/dev_accounts.php`; o painel do staff é `views/staff/dashboard.php`, criado em 2026-10-13). +- Auditoria da sidebar: `docs/audits/2026-10-13-fitme3070-sidebar.md` (em Y:/GitTea; aplicada, ver «Barra lateral» abaixo). + +## Barra lateral (2026-10-13) +- Padrão da frota (`.claude/skills/sysadmin-navbar-audit`). CSS todo em `assets/css/backoffice.css` (tokens `--bo-*`, casca, mini, view transitions; carregado com `?v=filemtime` por `_asset_versioned`); já não há ` + + - + + + 'sub']) ?> + 'sub']) ?> + 'sub']) ?> + + 'sub', + 'active' => (($_GET['tool'] ?? 'index') === 'index' && str_contains($_SERVER['REQUEST_URI'] ?? '', 'platform.php'))]) ?> + + + +
- -
+
- - - - - -
0). + */ +function bo_nav_link(string $href, string $icon, string $label, array $o = []): string +{ + $active = array_key_exists('active', $o) ? (bool) $o['active'] : bo_nav_active($o['match'] ?? strtok($href, '?')); + $mod = isset($o['mod']) ? ' bo-nav-item--' . $o['mod'] : ''; + $l = htmlspecialchars($label, ENT_QUOTES, 'UTF-8'); + $badge = (int) ($o['badge'] ?? 0); + return '' + . '' + . '' . $l . '' + . ($badge > 0 ? '' . $badge . '' : '') + . '' . "\n"; +} + +/** + * Abre um grupo recolhível. O conteúdo fica em buffer até bo_nav_group_end(), + * que precisa de saber se algum item é o ativo (o grupo da página atual abre + * sempre). $sub = subcategoria dentro de outro grupo (recuada). + */ +function bo_nav_group_start(string $key, string $label, string $icon, bool $sub = false): void +{ + $GLOBALS['bo_nav_group_stack'][] = [$key, $label, $icon, $sub]; + ob_start(); +} + +/** Fecha o grupo aberto por bo_nav_group_start() e escreve o markup final. */ +function bo_nav_group_end(): void +{ + $items = (string) ob_get_clean(); + [$key, $label, $icon, $sub] = array_pop($GLOBALS['bo_nav_group_stack']); + $hasActive = str_contains($items, 'aria-current="page"'); + $id = 'bo-grp-' . $key; + $k = htmlspecialchars($key, ENT_QUOTES, 'UTF-8'); + $l = htmlspecialchars($label, ENT_QUOTES, 'UTF-8'); + $i = htmlspecialchars($icon, ENT_QUOTES, 'UTF-8'); + $short = htmlspecialchars(mb_strtoupper(mb_substr($label, 0, 4)), ENT_QUOTES, 'UTF-8'); + echo '
' + . '' + . '
' . $items . '
' + . '
' . "\n"; +} + +/** Iniciais para o avatar (até duas letras: primeiro e último nome). */ +function bo_initials(string $name): string +{ + // Só palavras que começam por letra: «Staff (dev)» dá «SD»... não «S(». + preg_match_all('/\p{L}[\p{L}\p{M}-]*/u', $name, $m); + $parts = $m[0]; + if (!$parts) return '?'; + $first = mb_substr($parts[0], 0, 1); + $last = count($parts) > 1 ? mb_substr($parts[count($parts) - 1], 0, 1) : ''; + return mb_strtoupper($first . $last); +} diff --git a/includes/navbar.php b/includes/navbar.php deleted file mode 100644 index 582cb2f..0000000 --- a/includes/navbar.php +++ /dev/null @@ -1,18 +0,0 @@ - - - - diff --git a/views/staff/dashboard.php b/views/staff/dashboard.php new file mode 100644 index 0000000..c0f7dcc --- /dev/null +++ b/views/staff/dashboard.php @@ -0,0 +1,20 @@ + +
+

Olá,

+ +
+

Bem-vindo à área da equipa FitMe 3070.

+ O meu perfil +

As ferramentas da equipa serão disponibilizadas num passo seguinte.

+
+
+ diff --git a/views/sysadmin/system_settings.php b/views/sysadmin/system_settings.php index 7bfb019..f45717f 100644 --- a/views/sysadmin/system_settings.php +++ b/views/sysadmin/system_settings.php @@ -128,7 +128,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } include __DIR__ . '/../../includes/header.php'; -include __DIR__ . '/../../includes/navbar.php'; ?>
-- 2.54.0 From 716eec9f6d1d4ecbecdb78494d5baea0f693cbc3 Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 13 Oct 2026 17:06:59 +0100 Subject: [PATCH 04/73] feat(site): root opens the main gym; other gyms as navbar buttons Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- site/assets/css/style.css | 23 +++++++++++++++++++++++ site/includes/navbar.php | 16 ++++++++++++++++ site/index.php | 8 +++++--- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 752cbf7..749ae69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Sistema → Auditoria (`platform.php?tool=audit`). Since 2026-09-25, one install serves several physical gyms: the `gyms` table, one URL 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. +`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. An unpublished gym 404s publicly but previews for staff. ## platform-tools diff --git a/site/assets/css/style.css b/site/assets/css/style.css index 2d06d44..fdc1aa9 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -408,6 +408,29 @@ section[id] { color: #fff !important; } +/* ── Other-gym button (right end of the navbar) ────────────────────────────── */ +.gym-switch-btn { + display: inline-block; + background: var(--fitme-accent); + color: var(--fitme-dark); + font-weight: 700; + text-transform: uppercase; + letter-spacing: .5px; + text-decoration: none; + white-space: nowrap; + border: 2px solid var(--fitme-accent); + border-radius: var(--fitme-radius); + padding: 6px 16px; + transition: background-color var(--fitme-transition), color var(--fitme-transition); +} + +.gym-switch-btn:hover, +.gym-switch-btn:focus-visible { + background: var(--fitme-dark); + border-color: var(--fitme-dark); + color: var(--fitme-accent); +} + /* ── Sticky Navbar ─────────────────────────────────────────────────────────── */ .navbar-toggler { min-width: 44px; diff --git a/site/includes/navbar.php b/site/includes/navbar.php index 8b2ef63..0d72446 100644 --- a/site/includes/navbar.php +++ b/site/includes/navbar.php @@ -47,6 +47,22 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel); + +
+ + + +
+
diff --git a/site/index.php b/site/index.php index 0373665..0ef7a03 100644 --- a/site/index.php +++ b/site/index.php @@ -5,13 +5,15 @@ $current_uri_2 = ''; // No gym in the URL (/, /home, old links): send people to a gym page. With a // single published gym that is a 301 to it (query string kept, e.g. -// ?agendar=ok); with more, the gym chooser. +// ?agendar=ok); with more, the main (default) gym — the others are reached +// from the navbar's «Ginásios» menu. if (($_GET['gym'] ?? '') === '') { require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/functions.php'; $published = db_gyms_published($pdo); - if (count($published) > 1) { require __DIR__ . '/gym_chooser.php'; exit; } - $to = $published[0] ?? db_gyms_default($pdo) ?? gym_fallback(); + $main = null; + foreach ($published as $g) { if ((int) ($g['is_default'] ?? 0) === 1) { $main = $g; break; } } + $to = $main ?? $published[0] ?? db_gyms_default($pdo) ?? gym_fallback(); $qs = $_SERVER['QUERY_STRING'] ?? ''; header('Location: ' . gym_url($to) . ($qs !== '' ? '?' . $qs : ''), true, 301); exit; -- 2.54.0 From 4b2823d1e582126a05e71defd2fc555a0f979bc8 Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 13 Oct 2026 17:08:30 +0100 Subject: [PATCH 05/73] 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
-- 2.54.0 From 32f8d7a61358ee4e9b07631c5c8cd9ee00dabe12 Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 13 Oct 2026 17:16:50 +0100 Subject: [PATCH 06/73] feat(branding): re-theme Bootstrap to the brand colour; 3D neon button hover; hide Mensalidades link without plans Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 3 + assets/css/bs-brand.css | 253 ++++++++++++++++++++++++++++++++++++++ config/functions_site.php | 5 +- includes/auth_header.php | 1 + includes/header.php | 1 + site/assets/css/style.css | 45 +++++++ 6 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 assets/css/bs-brand.css diff --git a/CLAUDE.md b/CLAUDE.md index 889963b..78fd058 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,3 +153,6 @@ or adapted for production, and these accounts must never exist in a production d - `includes/navbar.php` (barra antiga) foi apagado; `system_settings.php` já não o inclui. - `views/staff/dashboard.php`: saudação mínima (o staff ainda não alcança nada em `views/admin/`). - Gotcha de dev: a tabela `trial_leads` não existe na BD de dev, por isso `views/admin/trials/index.php` dá 500 (migração por aplicar; anterior a esta mudança). + +## Bootstrap re-tematizado (2026-10-13) +- `assets/css/bs-brand.css` (carregado depois do Bootstrap em `includes/header.php` e `includes/auth_header.php`) sobrescreve as cores do Bootstrap só com variáveis `--bs-*` e as variáveis de componente (`.btn-*`, `.form-check-input`, `.page-link`…), claro e escuro. A cor de marca é `--fitme-accent` (fallback `#C2D500`); todos os tons derivam dela com `color-mix()`; `--brand-on` é o texto sobre a cor. Secondary/success/danger/warning/info têm valores próprios. Verificação: `platform.php?tool=branding` (Montra da marca) não deve mostrar nenhum azul por omissão. O site público ainda não carrega este ficheiro. diff --git a/assets/css/bs-brand.css b/assets/css/bs-brand.css new file mode 100644 index 0000000..126c6d9 --- /dev/null +++ b/assets/css/bs-brand.css @@ -0,0 +1,253 @@ +/* ── Bootstrap re-themed to the FitMe brand ────────────────────────────────── + Loaded after bootstrap.min.css (and before backoffice.css). Bootstrap's source + is never edited: everything here is --bs-* variables plus the component-scoped + variables Bootstrap 5.3 exposes (.btn-*, .form-check-input, .page-link …). + The brand colour is a single token, --fitme-accent (a gym's own colour on the + public site, see gym_brand_css()); every shade below is derived from it with + color-mix(), so changing the brand colour re-themes all of Bootstrap at once. + The other semantic colours (success/danger/warning/info) are tuned to sit next + to the brand without being confused with it. */ +:root, +[data-bs-theme="light"], +[data-bs-theme="dark"] { + --brand: var(--fitme-accent, #C2D500); + --brand-ink: color-mix(in srgb, var(--brand) 55%, #000); /* text on light surfaces */ + --brand-on: var(--fitme-on-accent, #0B0B0D); /* text on the brand colour */ + + --bs-primary: var(--brand); + --bs-primary-rgb: var(--fitme-accent-rgb, 194, 213, 0); + --bs-secondary: #3D403E; + --bs-secondary-rgb: 61, 64, 62; + --bs-success: #2E9E5B; + --bs-success-rgb: 46, 158, 91; + --bs-danger: #D6322E; + --bs-danger-rgb: 214, 50, 46; + --bs-warning: #F2A900; + --bs-warning-rgb: 242, 169, 0; + --bs-info: #2C9FD0; + --bs-info-rgb: 44, 159, 208; + --bs-light: #F7F8FA; + --bs-light-rgb: 247, 248, 250; + --bs-dark: #0B0B0D; + --bs-dark-rgb: 11, 11, 13; + + --bs-focus-ring-color: color-mix(in srgb, var(--brand) 40%, transparent); +} + +/* Subtle / emphasis / border tints: light theme (tint 80 / 60, shade 60) … */ +:root, +[data-bs-theme="light"] { + --bs-primary-text-emphasis: color-mix(in srgb, var(--brand) 40%, #000); + --bs-primary-bg-subtle: color-mix(in srgb, var(--brand) 20%, #fff); + --bs-primary-border-subtle: color-mix(in srgb, var(--brand) 40%, #fff); + --bs-secondary-text-emphasis: #191a19; + --bs-secondary-bg-subtle: #e5e6e5; + --bs-secondary-border-subtle: #c5c6c5; + --bs-success-text-emphasis: #124f2c; + --bs-success-bg-subtle: #d5ecde; + --bs-success-border-subtle: #acdabd; + --bs-danger-text-emphasis: #561411; + --bs-danger-bg-subtle: #f7d6d5; + --bs-danger-border-subtle: #efadac; + --bs-warning-text-emphasis: #613f00; + --bs-warning-bg-subtle: #fceecc; + --bs-warning-border-subtle: #fadf99; + --bs-info-text-emphasis: #11404e; + --bs-info-bg-subtle: #d5ecf6; + --bs-info-border-subtle: #abd9ec; + --bs-link-color: var(--brand-ink); + --bs-link-color-rgb: 0, 0, 0; + --bs-link-hover-color: color-mix(in srgb, var(--brand) 40%, #000); +} + +/* … dark theme (tint 40, shade 80 / 60) */ +[data-bs-theme="dark"] { + --bs-primary-text-emphasis: color-mix(in srgb, var(--brand) 60%, #fff); + --bs-primary-bg-subtle: color-mix(in srgb, var(--brand) 20%, #000); + --bs-primary-border-subtle: color-mix(in srgb, var(--brand) 40%, #000); + --bs-secondary-text-emphasis: #b1b2b1; + --bs-secondary-bg-subtle: #131413; + --bs-secondary-border-subtle: #252726; + --bs-success-text-emphasis: #82c59d; + --bs-success-bg-subtle: #09200f; + --bs-success-border-subtle: #1c5f36; + --bs-danger-text-emphasis: #e88380; + --bs-danger-bg-subtle: #2b0a09; + --bs-danger-border-subtle: #801e1c; + --bs-warning-text-emphasis: #f7ca66; + --bs-warning-bg-subtle: #302200; + --bs-warning-border-subtle: #916800; + --bs-info-text-emphasis: #80c5e3; + --bs-info-bg-subtle: #0e2833; + --bs-info-border-subtle: #1a5f7d; + --bs-link-color: var(--brand); + --bs-link-color-rgb: var(--fitme-accent-rgb, 194, 213, 0); + --bs-link-hover-color: color-mix(in srgb, var(--brand) 75%, #fff); +} + +/* ── Components: Bootstrap hard-codes white text on solid primary/semantic + fills; the brand colour is light, so the text must be dark. ─────────────── */ +.btn-primary { + --bs-btn-color: var(--brand-on); + --bs-btn-bg: var(--brand); + --bs-btn-border-color: var(--brand); + --bs-btn-hover-color: var(--brand-on); + --bs-btn-hover-bg: color-mix(in srgb, var(--brand) 86%, #000); + --bs-btn-hover-border-color: color-mix(in srgb, var(--brand) 80%, #000); + --bs-btn-focus-shadow-rgb: var(--fitme-accent-rgb, 194, 213, 0); + --bs-btn-active-color: var(--brand-on); + --bs-btn-active-bg: color-mix(in srgb, var(--brand) 78%, #000); + --bs-btn-active-border-color: color-mix(in srgb, var(--brand) 70%, #000); + --bs-btn-disabled-color: var(--brand-on); + --bs-btn-disabled-bg: var(--brand); + --bs-btn-disabled-border-color: var(--brand); +} + +.btn-outline-primary { + --bs-btn-color: var(--brand-ink); + --bs-btn-border-color: var(--brand); + --bs-btn-hover-color: var(--brand-on); + --bs-btn-hover-bg: var(--brand); + --bs-btn-hover-border-color: var(--brand); + --bs-btn-focus-shadow-rgb: var(--fitme-accent-rgb, 194, 213, 0); + --bs-btn-active-color: var(--brand-on); + --bs-btn-active-bg: var(--brand); + --bs-btn-active-border-color: var(--brand); + --bs-btn-disabled-color: var(--brand-ink); + --bs-btn-disabled-border-color: var(--brand); +} +[data-bs-theme="dark"] .btn-outline-primary { --bs-btn-color: var(--brand); --bs-btn-disabled-color: var(--brand); } + +.btn-secondary { + --bs-btn-bg: var(--bs-secondary); + --bs-btn-border-color: var(--bs-secondary); + --bs-btn-hover-bg: #2f3230; + --bs-btn-hover-border-color: #2b2d2c; + --bs-btn-active-bg: #2b2d2c; + --bs-btn-active-border-color: #262826; + --bs-btn-disabled-bg: var(--bs-secondary); + --bs-btn-disabled-border-color: var(--bs-secondary); +} +.btn-outline-secondary { + --bs-btn-color: var(--bs-secondary); + --bs-btn-border-color: var(--bs-secondary); + --bs-btn-hover-bg: var(--bs-secondary); + --bs-btn-hover-border-color: var(--bs-secondary); + --bs-btn-active-bg: var(--bs-secondary); + --bs-btn-active-border-color: var(--bs-secondary); + --bs-btn-disabled-color: var(--bs-secondary); + --bs-btn-disabled-border-color: var(--bs-secondary); +} +[data-bs-theme="dark"] .btn-outline-secondary { --bs-btn-color: #9a9d9b; --bs-btn-border-color: #5b5f5d; } + +.btn-success { --bs-btn-bg: var(--bs-success); --bs-btn-border-color: var(--bs-success); --bs-btn-hover-bg: #27864d; --bs-btn-hover-border-color: #257d49; --bs-btn-active-bg: #257d49; --bs-btn-active-border-color: #227545; --bs-btn-disabled-bg: var(--bs-success); --bs-btn-disabled-border-color: var(--bs-success); } +.btn-danger { --bs-btn-bg: var(--bs-danger); --bs-btn-border-color: var(--bs-danger); --bs-btn-hover-bg: #b62a27; --bs-btn-hover-border-color: #ab2825; --bs-btn-active-bg: #ab2825; --bs-btn-active-border-color: #a02622; --bs-btn-disabled-bg: var(--bs-danger); --bs-btn-disabled-border-color: var(--bs-danger); } +.btn-warning { --bs-btn-bg: var(--bs-warning); --bs-btn-border-color: var(--bs-warning); --bs-btn-hover-bg: #f5b61f; --bs-btn-hover-border-color: #f4b212; --bs-btn-active-bg: #f6bb2b; --bs-btn-active-border-color: #f4b212; --bs-btn-disabled-bg: var(--bs-warning); --bs-btn-disabled-border-color: var(--bs-warning); } +.btn-info { --bs-btn-bg: var(--bs-info); --bs-btn-border-color: var(--bs-info); --bs-btn-hover-bg: #52b0d9; --bs-btn-hover-border-color: #42a9d6; --bs-btn-active-bg: #5bb5dc; --bs-btn-active-border-color: #42a9d6; --bs-btn-disabled-bg: var(--bs-info); --bs-btn-disabled-border-color: var(--bs-info); } +.btn-outline-success { --bs-btn-color: var(--bs-success); --bs-btn-border-color: var(--bs-success); --bs-btn-hover-bg: var(--bs-success); --bs-btn-hover-border-color: var(--bs-success); --bs-btn-active-bg: var(--bs-success); --bs-btn-active-border-color: var(--bs-success); --bs-btn-disabled-color: var(--bs-success); --bs-btn-disabled-border-color: var(--bs-success); } +.btn-outline-danger { --bs-btn-color: var(--bs-danger); --bs-btn-border-color: var(--bs-danger); --bs-btn-hover-bg: var(--bs-danger); --bs-btn-hover-border-color: var(--bs-danger); --bs-btn-active-bg: var(--bs-danger); --bs-btn-active-border-color: var(--bs-danger); --bs-btn-disabled-color: var(--bs-danger); --bs-btn-disabled-border-color: var(--bs-danger); } +.btn-outline-warning { --bs-btn-color: #b07a00; --bs-btn-border-color: var(--bs-warning); --bs-btn-hover-bg: var(--bs-warning); --bs-btn-hover-border-color: var(--bs-warning); --bs-btn-active-bg: var(--bs-warning); --bs-btn-active-border-color: var(--bs-warning); --bs-btn-disabled-color: #b07a00; --bs-btn-disabled-border-color: var(--bs-warning); } +.btn-outline-info { --bs-btn-color: #1f7ea6; --bs-btn-border-color: var(--bs-info); --bs-btn-hover-bg: var(--bs-info); --bs-btn-hover-border-color: var(--bs-info); --bs-btn-active-bg: var(--bs-info); --bs-btn-active-border-color: var(--bs-info); --bs-btn-disabled-color: #1f7ea6; --bs-btn-disabled-border-color: var(--bs-info); } +[data-bs-theme="dark"] .btn-outline-warning { --bs-btn-color: var(--bs-warning); --bs-btn-disabled-color: var(--bs-warning); } +[data-bs-theme="dark"] .btn-outline-info { --bs-btn-color: var(--bs-info); --bs-btn-disabled-color: var(--bs-info); } +.btn-success, .btn-danger, .btn-info, .btn-outline-success, .btn-outline-danger, .btn-outline-info { --bs-btn-hover-color: #fff; --bs-btn-active-color: #fff; } +.btn-success, .btn-danger, .btn-info { --bs-btn-color: #fff; --bs-btn-disabled-color: #fff; } +.btn-warning, .btn-outline-warning { --bs-btn-hover-color: #0B0B0D; --bs-btn-active-color: #0B0B0D; } +.btn-warning { --bs-btn-color: #0B0B0D; --bs-btn-disabled-color: #0B0B0D; } + +/* ── Solid fills that use the colour as background ───────────────────────── */ +.text-bg-primary { color: var(--brand-on) !important; background-color: var(--brand) !important; } +.text-bg-secondary { background-color: var(--bs-secondary) !important; } +.text-bg-success { background-color: var(--bs-success) !important; } +.text-bg-danger { background-color: var(--bs-danger) !important; } +.text-bg-warning { color: #0B0B0D !important; background-color: var(--bs-warning) !important; } +.text-bg-info { color: #fff !important; background-color: var(--bs-info) !important; } +.link-primary { color: var(--brand-ink) !important; } +[data-bs-theme="dark"] .link-primary { color: var(--brand) !important; } + +/* ── Forms ───────────────────────────────────────────────────────────────── */ +.form-control:focus, .form-select:focus { + border-color: color-mix(in srgb, var(--brand) 70%, #000); + box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); +} +.form-check-input:checked, +.form-check-input[type="checkbox"]:indeterminate { + background-color: var(--brand); + border-color: var(--brand); +} +/* the tick / dot / knob are inline SVGs with a hard-coded white fill */ +.form-check-input:checked[type="checkbox"] { + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%230B0B0D' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e"); +} +.form-check-input:checked[type="radio"] { + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%230B0B0D'/%3e%3c/svg%3e"); +} +.form-check-input:focus { border-color: var(--brand); box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); } +.form-switch .form-check-input:focus { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23B9C65A'/%3e%3c/svg%3e"); +} +.form-switch .form-check-input:checked { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%230B0B0D'/%3e%3c/svg%3e"); +} +.form-range::-webkit-slider-thumb { background-color: var(--brand); } +.form-range::-webkit-slider-thumb:active { background-color: color-mix(in srgb, var(--brand) 60%, #fff); } +.form-range::-moz-range-thumb { background-color: var(--brand); } +.form-range::-moz-range-thumb:active { background-color: color-mix(in srgb, var(--brand) 60%, #fff); } +.form-range:focus::-webkit-slider-thumb { box-shadow: 0 0 0 1px #fff, 0 0 0 .25rem var(--bs-focus-ring-color); } +.form-range:focus::-moz-range-thumb { box-shadow: 0 0 0 1px #fff, 0 0 0 .25rem var(--bs-focus-ring-color); } +.was-validated .form-control:valid, .form-control.is-valid, +.was-validated .form-select:valid, .form-select.is-valid { border-color: var(--bs-success); } +.was-validated .form-control:invalid, .form-control.is-invalid, +.was-validated .form-select:invalid, .form-select.is-invalid { border-color: var(--bs-danger); } + +/* ── Navigation & lists ──────────────────────────────────────────────────── */ +.nav { + --bs-nav-link-color: var(--bs-link-color); + --bs-nav-link-hover-color: var(--bs-link-hover-color); +} +.nav-pills { + --bs-nav-pills-link-active-color: var(--brand-on); + --bs-nav-pills-link-active-bg: var(--brand); +} +.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link { border-top: 2px solid var(--brand); } +.pagination { + --bs-pagination-color: var(--bs-link-color); + --bs-pagination-hover-color: var(--bs-link-hover-color); + --bs-pagination-focus-color: var(--bs-link-hover-color); + --bs-pagination-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); + --bs-pagination-active-color: var(--brand-on); + --bs-pagination-active-bg: var(--brand); + --bs-pagination-active-border-color: var(--brand); +} +.list-group { --bs-list-group-active-color: var(--brand-on); --bs-list-group-active-bg: var(--brand); --bs-list-group-active-border-color: var(--brand); } +.dropdown-menu { --bs-dropdown-link-active-color: var(--brand-on); --bs-dropdown-link-active-bg: var(--brand); } +.accordion { + --bs-accordion-active-color: var(--bs-primary-text-emphasis); + --bs-accordion-active-bg: var(--bs-primary-bg-subtle); + --bs-accordion-btn-focus-border-color: var(--brand); + --bs-accordion-btn-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); +} + +/* ── Feedback ────────────────────────────────────────────────────────────── */ +.progress { --bs-progress-bar-bg: var(--brand); --bs-progress-bar-color: var(--brand-on); } +.alert-primary { + --bs-alert-color: var(--bs-primary-text-emphasis); + --bs-alert-bg: var(--bs-primary-bg-subtle); + --bs-alert-border-color: var(--bs-primary-border-subtle); + --bs-alert-link-color: var(--bs-primary-text-emphasis); +} +.table-primary { + --bs-table-color: var(--bs-primary-text-emphasis); + --bs-table-bg: var(--bs-primary-bg-subtle); + --bs-table-border-color: var(--bs-primary-border-subtle); + --bs-table-striped-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 94%, #000); + --bs-table-hover-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 92%, #000); + --bs-table-active-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 90%, #000); +} +.border-primary { border-color: var(--brand) !important; } +.bg-primary { background-color: var(--brand) !important; color: var(--brand-on); } +.text-primary { color: var(--brand-ink) !important; } +[data-bs-theme="dark"] .text-primary { color: var(--brand) !important; } +.bg-primary-subtle { background-color: var(--bs-primary-bg-subtle) !important; } +.text-primary-emphasis { color: var(--bs-primary-text-emphasis) !important; } +.border-primary-subtle { border-color: var(--bs-primary-border-subtle) !important; } diff --git a/config/functions_site.php b/config/functions_site.php index f9d96c9..e9aeb3d 100644 --- a/config/functions_site.php +++ b/config/functions_site.php @@ -36,7 +36,10 @@ function site_navbar(?array $gym = null): array { $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]; + 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); diff --git a/includes/auth_header.php b/includes/auth_header.php index 8c7ac88..2b6743e 100644 --- a/includes/auth_header.php +++ b/includes/auth_header.php @@ -29,6 +29,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; })(); + + + + + + + + + +
+
+

Ginásio, Box, Aulas & Acompanhamento

+
+ Inscreve-te já! + Ghost + Dark + Line + +
+

Um link no texto sobre fundo escuro.

+
+
+ +
+
+
+ Secção +

Título de secção (h2)

+

Subtítulo de exemplo para ver o grão no h2 sobre fundo claro.

+
+ +
Botões e links
+ +

Texto corrido com um link normal e outro link mais comprido para testar.

+ +
Galeria (fotos)
+
+ $src): ?> +
+ +
+ +
Equipa (moldura)
+
+
+

Nome do membro

+
+ +
Modalidades (ícone)
+
+

Musculação

+

Cycling

+
+ +
Mensalidades (cartão)
+
+

Plano exemplo

Passa o rato por cima.

Inscrever
+
+
+
+ + + + -- 2.54.0 From 72cf3dad986489b8f3993cde8d0f5ce0b80f4491 Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 13 Oct 2026 18:57:21 +0100 Subject: [PATCH 39/73] feat(dev): lab - full grain controls (type, frequency, octaves, seed, contrast, softness, tile, blend, mono) + generated CSS Co-Authored-By: Claude Sonnet 5.5 --- lab.php | 103 +++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/lab.php b/lab.php index c3019dd..20f1a84 100644 --- a/lab.php +++ b/lab.php @@ -51,15 +51,16 @@ $img_gal = [base_url('site/assets/img/home.jpg'), base_url('site/assets/img/cro @@ -88,6 +89,40 @@ $img_gal = [base_url('site/assets/img/home.jpg'), base_url('site/assets/img/cro +

Textura do grão

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Passa o rato pelos botões, links, fotos, moldura e cartão para ver os efeitos. Os valores não são guardados.

@@ -153,11 +188,20 @@ $img_gal = [base_url('site/assets/img/home.jpg'), base_url('site/assets/img/cro (function () { var root = document.documentElement, ov = document.getElementById('lab-overrides'); var $ = function (id) { return document.getElementById(id); }; - var DEF = { color: '#c2d500', btn: 16, head: 25, sat: 90, glow: 60, spin: 32, ring: 2 }; + var DEF = { color: '#c2d500', btn: 16, head: 25, sat: 90, glow: 60, spin: 32, ring: 2, + type: 'fractalNoise', freq: 90, oct: 2, seed: 4, contrast: 100, soft: 0, tile: 140, bbm: 'normal', hbm: 'multiply', mono: true }; var st = Object.assign({}, DEF); - function noiseUrl(op) { - return "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='" + op + "'/%3E%3C/svg%3E\")"; + function noiseUrl() { + var f = (st.freq / 100).toFixed(2), op = (st.btn / 100).toFixed(3), c = st.contrast / 100; + var svg = "" + + "" + + "" + + (st.mono ? "" : "") + + (st.soft > 0 ? "" : "") + + (c !== 1 ? "" : "") + + ""; + return 'url("data:image/svg+xml,' + svg.replace(//g, '%3E').replace(/#/g, '%23').replace(/%(?![0-9A-F]{2})/g, '%25') + '")'; } function brandVars(hex) { var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16); @@ -169,16 +213,30 @@ $img_gal = [base_url('site/assets/img/home.jpg'), base_url('site/assets/img/cro function apply() { var v = brandVars(st.color); Object.keys(v).forEach(function (k) { root.style.setProperty(k, v[k]); }); - root.style.setProperty('--noise', noiseUrl((st.btn / 100).toFixed(3))); - var s = st.head / 100, slope = (s * .6).toFixed(3), icpt = (1 - s * .6).toFixed(3); + var url = noiseUrl(); + root.style.setProperty('--noise', url); + + // headings: the SVG filter + var s = st.head / 100, c = st.contrast / 100; + var slope = (s * .6 * c).toFixed(3), icpt = (1 - s * .6 * c / 2 - s * .6 * c / 2).toFixed(3); ['lab-fr', 'lab-fg', 'lab-fb'].forEach(function (id) { $(id).setAttribute('slope', slope); $(id).setAttribute('intercept', icpt); }); + $('lab-turb').setAttribute('type', st.type); + $('lab-turb').setAttribute('baseFrequency', (st.freq / 100).toFixed(2)); + $('lab-turb').setAttribute('numOctaves', st.oct); + $('lab-turb').setAttribute('seed', st.seed); + $('lab-sat').setAttribute('values', st.mono ? '0' : '1'); + $('lab-blur').setAttribute('stdDeviation', (st.soft / 10).toFixed(1)); + $('lab-blend').setAttribute('mode', st.hbm); + var glow = st.glow / 100, spin = (st.spin / 10).toFixed(1) + 's'; ov.textContent = + '.site-btn,.gym-switch-btn,.crossfit-nav-link { background-size: ' + st.tile + 'px ' + st.tile + 'px !important; background-blend-mode: ' + st.bbm + ' !important; }' + '.gal-link img, .trainer-item .trainer-pic img { filter: saturate(' + (st.sat / 100) + '); }' + '.gal-link:hover img, .trainer-item .trainer-pic:hover img { filter: saturate(1) brightness(1.05); }' + 'a:where(:not(.site-btn,.gym-switch-btn,.gal-link,.nav-link)):is(:hover,:focus-visible) { text-shadow: 0 0 10px rgba(var(--fitme-accent-rgb),' + (.6 * glow * 1.67).toFixed(2) + '), 0 0 22px rgba(var(--fitme-accent-rgb),' + (.3 * glow * 1.67).toFixed(2) + ') !important; }' + '.site-btn::before,.site-btn::after,.gym-switch-btn::before,.gym-switch-btn::after,.price-card::before,.price-card::after,.gal-link::after,.icon-box-item .ib-icon::after,.trainer-item .trainer-pic:hover { animation-duration: ' + spin + ' !important; }' + '.site-btn,.gym-switch-btn { --ring: ' + st.ring + 'px; }'; + $('o-color').textContent = st.color.toUpperCase(); $('o-btn').textContent = st.btn + '%'; $('o-head').textContent = st.head + '%'; @@ -186,14 +244,35 @@ $img_gal = [base_url('site/assets/img/home.jpg'), base_url('site/assets/img/cro $('o-glow').textContent = st.glow + '%'; $('o-spin').textContent = spin; $('o-ring').textContent = st.ring; + $('o-freq').textContent = (st.freq / 100).toFixed(2); + $('o-oct').textContent = st.oct; + $('o-seed').textContent = st.seed; + $('o-contrast').textContent = st.contrast + '%'; + $('o-soft').textContent = (st.soft / 10).toFixed(1); + $('o-tile').textContent = st.tile; + + $('c-css').value = + ':root {\n --noise: ' + url + ';\n}\n' + + '.site-btn, .gym-switch-btn, .crossfit-nav-link {\n background-image: var(--noise) !important;\n background-size: ' + st.tile + 'px ' + st.tile + 'px !important;' + + (st.bbm !== 'normal' ? '\n background-blend-mode: ' + st.bbm + ' !important;' : '') + '\n}\n' + + '/* títulos: */\n' + + 'feTurbulence type="' + st.type + '" baseFrequency="' + (st.freq / 100).toFixed(2) + '" numOctaves="' + st.oct + '" seed="' + st.seed + '"\n' + + 'feColorMatrix saturate ' + (st.mono ? 0 : 1) + ' · feGaussianBlur ' + (st.soft / 10).toFixed(1) + '\n' + + 'feFunc* slope="' + slope + '" intercept="' + icpt + '" · feBlend mode="' + st.hbm + '"'; } - var map = { 'c-color': 'color', 'c-btn': 'btn', 'c-head': 'head', 'c-sat': 'sat', 'c-glow': 'glow', 'c-spin': 'spin', 'c-ring': 'ring' }; + var map = { 'c-color': 'color', 'c-btn': 'btn', 'c-head': 'head', 'c-sat': 'sat', 'c-glow': 'glow', 'c-spin': 'spin', 'c-ring': 'ring', + 'c-type': 'type', 'c-freq': 'freq', 'c-oct': 'oct', 'c-seed': 'seed', 'c-contrast': 'contrast', 'c-soft': 'soft', 'c-tile': 'tile', + 'c-bbm': 'bbm', 'c-hbm': 'hbm', 'c-mono': 'mono' }; + var textual = { 'c-color': 1, 'c-type': 1, 'c-bbm': 1, 'c-hbm': 1 }; Object.keys(map).forEach(function (id) { - $(id).addEventListener('input', function () { st[map[id]] = id === 'c-color' ? this.value : +this.value; apply(); }); + $(id).addEventListener('input', function () { + st[map[id]] = id === 'c-mono' ? this.checked : (textual[id] ? this.value : +this.value); + apply(); + }); }); $('c-reset').addEventListener('click', function () { st = Object.assign({}, DEF); - Object.keys(map).forEach(function (id) { $(id).value = DEF[map[id]]; }); + Object.keys(map).forEach(function (id) { if (id === 'c-mono') $(id).checked = DEF.mono; else $(id).value = DEF[map[id]]; }); apply(); }); apply(); -- 2.54.0 From 9e3eee227b2a86fcf0c0b5e1be9004c4188efc13 Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 13 Oct 2026 18:59:49 +0100 Subject: [PATCH 40/73] chore(dev): lab - label the grain sliders as opacity Co-Authored-By: Claude Sonnet 5.5 --- lab.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lab.php b/lab.php index 20f1a84..49b39f1 100644 --- a/lab.php +++ b/lab.php @@ -71,10 +71,10 @@ $img_gal = [base_url('site/assets/img/home.jpg'), base_url('site/assets/img/cro - + - + -- 2.54.0 From a2219ebd637f68c3b3985db3f40a497f2516abd0 Mon Sep 17 00:00:00 2001 From: andre Date: Tue, 13 Oct 2026 19:01:18 +0100 Subject: [PATCH 41/73] style(site): apply tuned grain from the lab (freq 1.98, 1 octave, overlay on buttons, 155px tile) Co-Authored-By: Claude Sonnet 5.5 --- site/assets/css/style.css | 8 +++++--- site/includes/include.php | 10 +++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/site/assets/css/style.css b/site/assets/css/style.css index 547d031..b67c4d6 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -480,11 +480,12 @@ section[id] { z-index: -1; border: var(--ring) solid transparent; background: - var(--noise) 0 0 / 140px padding-box, + var(--noise) 0 0 / 155px padding-box, linear-gradient(var(--btn-face), var(--btn-face)) padding-box, conic-gradient(from var(--pc-a), rgba(var(--fitme-accent-rgb), 0) 0deg, rgba(var(--fitme-accent-rgb), 0) 150deg, var(--fitme-accent) 250deg, #f4ff9c 300deg, var(--fitme-accent) 330deg, rgba(var(--fitme-accent-rgb), 0) 360deg) border-box; + background-blend-mode: overlay, normal, normal; } .site-btn::after, .gym-switch-btn::after { inset: calc(-1 * (var(--ring) + var(--elb)) - 2px); @@ -4623,12 +4624,13 @@ a:where(:not(.site-btn, .gym-switch-btn, .gal-link, .skip-link, .splide__arrow, ::before carries the same layer). Headings: the #pt-noise SVG filter (defined in site/includes/include.php) multiplies grain into the lettering only. */ :root { - --noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); + --noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='155' height='155'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.98' numOctaves='1' seed='7' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='1.87' intercept='-0.435'/%3E%3CfeFuncG type='linear' slope='1.87' intercept='-0.435'/%3E%3CfeFuncB type='linear' slope='1.87' intercept='-0.435'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.160'/%3E%3C/svg%3E"); } .site-btn, .gym-switch-btn, .crossfit-nav-link { background-image: var(--noise) !important; - background-size: 140px 140px !important; + background-size: 155px 155px !important; + background-blend-mode: overlay !important; } h1, h2 { filter: url(#pt-noise); } diff --git a/site/includes/include.php b/site/includes/include.php index ea1c1fb..891e65a 100644 --- a/site/includes/include.php +++ b/site/includes/include.php @@ -91,14 +91,14 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
- - -
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'; -
+
-- 2.54.0 From d445ba64b99dea43452b2e968c71411e19336559 Mon Sep 17 00:00:00 2001 From: andre Date: Mon, 26 Oct 2026 09:35:46 +0000 Subject: [PATCH 48/73] feat(gyms): show gym logos in navbar buttons and brand; accept JPEG XL uploads --- .htaccess | 5 +++++ CLAUDE.md | 2 +- config/functions_gyms.php | 16 ++++++++++++---- config/functions_images.php | 9 +++++++++ site/assets/css/style.css | 20 +++++++++++++------- site/includes/navbar.php | 13 +++++++++---- views/admin/gyms/_form.php | 4 ++-- 7 files changed, 51 insertions(+), 18 deletions(-) diff --git a/.htaccess b/.htaccess index ddf23fe..08eaced 100644 --- a/.htaccess +++ b/.htaccess @@ -1,6 +1,11 @@ # Disable directory listing Options -Indexes +# JPEG XL uploads (gym header/logo): older mime.types don't know the extension. + + AddType image/jxl .jxl + + # Block direct HTTP access to sensitive files Require all denied diff --git a/CLAUDE.md b/CLAUDE.md index fd4f47f..9a5ee27 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` (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. +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 a logo (`gym_logo()`: the gym's own, or for the default gym the bundled `logo_black.svg`) the button shows the logo in full colour on a white face (`.gym-switch-btn--logo`, name as alt), without one it shows the name. The navbar brand on each gym's page uses that gym's logo too. Uploads (header + logo) also accept JPEG XL (`.jxl`, detected by signature in `upload_image_ext()` when the caller allows `'jxl'`; only the gyms form does; `AddType image/jxl` in `.htaccess`; GD can't resize it, so it is stored as uploaded; Chrome/Firefox may not display JXL, Safari does). An unpublished gym 404s publicly but previews for staff. ## platform-tools diff --git a/config/functions_gyms.php b/config/functions_gyms.php index 0ed159f..872d6aa 100644 --- a/config/functions_gyms.php +++ b/config/functions_gyms.php @@ -116,6 +116,14 @@ function gym_brand_vars(array $gym): string { . ';--fitme-accent-ink:' . $shade($lum > .5 ? .5 : .85) . ';--fitme-on-accent:' . ($lum > .5 ? '#0B0B0D' : '#FFFFFF'); } +// The gym's logo (relative path). The main gym falls back to the site's own +// FitMe 3070 logo, so its navbar button has one before anything is uploaded. +function gym_logo(array $gym): string { + $logo = (string) ($gym['logo'] ?? ''); + if ($logo === '' && !empty($gym['is_default'])) $logo = 'site/assets/img/logos/logo_black.svg'; + return $logo; +} + function gym_brand_css(array $gym): string { $v = gym_brand_vars($gym); return $v === '' ? '' : ':root{' . $v . '}'; @@ -205,14 +213,14 @@ 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['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|jxl)$~i', $data['logo'])) { $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'])) { + if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp|jxl)$~i', $data['hero_image'])) { $errors[] = 'Imagem do header: imagem inválida.'; } return $data; @@ -283,10 +291,10 @@ function gyms_save_image(array $file, string $kind, ?string &$error = null): ?st 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']; + $allowed = $kind === 'hero' ? ['jpg', 'jpeg', 'png', 'webp', 'jxl'] : ['svg', 'png', 'webp', 'jpg', 'jpeg', 'jxl']; $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).'; + $error = $kind === 'hero' ? 'usa JPG, PNG, WebP ou JPEG XL.' : 'usa SVG, PNG, WebP, JPG ou JPEG XL (SVG sem scripts).'; return null; } $dir = gyms_upload_dir(); diff --git a/config/functions_images.php b/config/functions_images.php index d3625c7..be90b46 100644 --- a/config/functions_images.php +++ b/config/functions_images.php @@ -39,6 +39,15 @@ function upload_image_ext(array $file, array $allowed): ?string { } $raster = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp']; + // JPEG XL: o getimagesize()/GD não o conhecem e o finfo só nas versões + // recentes, por isso vê-se pela assinatura (codestream FF 0A ou contentor + // ISOBMFF "JXL "). Só entra onde quem chama pôs 'jxl' em $allowed; o + // img_optimize() salta-o (não há GD para JXL). + if (in_array('jxl', $allowed, true)) { + $head = (string) @file_get_contents($tmp, false, null, 0, 12); + if (str_starts_with($head, "\xFF\x0A") || $head === "\x00\x00\x00\x0CJXL \x0D\x0A\x87\x0A") return 'jxl'; + } + if (isset($raster[$mime]) || $mime === '') { $info = @getimagesize($tmp); $ext = $info ? ($raster[$info['mime']] ?? null) : null; diff --git a/site/assets/css/style.css b/site/assets/css/style.css index 67f327b..130ef31 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -437,14 +437,20 @@ section[id] { .gym-switch-btn { display: inline-flex; align-items: center; gap: .5rem; } -/* The logo is a mask filled with the text colour, so any single-colour logo - follows the button (brand colour, hover state, light or dark brand). */ +/* With a logo the button shows the gym's wordmark in its own colours on a + light face, framed by the brand colour; the name is the image's alt. The + hover face stays light too, so dark parts of the logo never vanish. */ +.gym-switch-btn--logo { + --btn-face: #fff; + background: #fff; + padding: 4px 12px; +} .gym-switch-logo { - height: 1.4em; - aspect-ratio: 1; - background-color: currentColor; - -webkit-mask: var(--logo) center / contain no-repeat; - mask: var(--logo) center / contain no-repeat; + display: block; + height: 30px; + width: auto; + max-width: 130px; + object-fit: contain; } /* ── Button hover: the same spinning edge as the Mensalidades cards ─────────── diff --git a/site/includes/navbar.php b/site/includes/navbar.php index cb695b2..23b25c4 100644 --- a/site/includes/navbar.php +++ b/site/includes/navbar.php @@ -4,7 +4,8 @@ if($current_uri_2 == 'crossfit'){ } else { - $logo_rel = 'site/assets/img/logos/logo_black.svg'; + // Each gym shows its own logo (FitMe In on /mira/, FitMe 3070 on /3070/). + $logo_rel = gym_logo(current_gym()) ?: 'site/assets/img/logos/logo_black.svg'; } $logo = base_url($logo_rel); $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel); @@ -12,7 +13,7 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
-- 2.54.0 From 1eb2a6103189be504963d136a90a1c353cff4dd4 Mon Sep 17 00:00:00 2001 From: andre Date: Mon, 26 Oct 2026 10:13:29 +0000 Subject: [PATCH 55/73] fix(admin): empty DataTables listings no longer error; their message becomes emptyTable docs: JPEG XL optimizer plan and image-input rule --- CLAUDE.md | 5 +++++ assets/js/datatables-init.js | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index f25773e..4bc9530 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,11 @@ backoffice has an "a editar" gym switcher (`admin_gym_id()` / 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. The public navbar's bottom edge is an always-on, slow (14s) sweep in the brand colour (`.header-section::before/::after`, `nav-sweep`, static with reduced motion). Each gym can have a `logo` (same migration, plus `migration_20261026_gym_logo.sql`: the column was added to the 2026-10-13 file after it had already run on dev, so dev had no `logo` column until 2026-10-26 — never edit a migration that has run, add a new one); 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 a logo (`gym_logo()`: the gym's own, or for the default gym the bundled `logo_black.svg`) the button shows the logo in full colour straight on the frosted navbar, with no face and no darkening on hover (`.gym-switch-btn--logo`: thin brand outline, hover = spinning ring only, no glow, a light `drop-shadow` halo on the image for contrast; name as alt), without one it shows the name. The navbar brand on each gym's page uses that gym's logo too. Uploads (header + logo) also accept JPEG XL (`.jxl`, detected by signature in `upload_image_ext()` when the caller allows `'jxl'`; the gyms form and gallery photos (`db_gallery_image_upload()`) do; `AddType image/jxl` in `.htaccess`; GD can't resize it, so it is stored as uploaded; Chrome/Firefox may not display JXL, Safari does). The public gallery lists its folder by extension (`site/includes/gallery.php`, `site/index.php`: jpg/jpeg/png/gif/webp/jxl; before 2026-10-26 it only listed `*.jpg`, so PNG/WebP uploads never showed). Gallery photos are not in the scroll "hover" (`.is-centered`, main.js) any more; only a real hover lights them. Mensalidades (`.pricing-section::before`) has a slow (26s) drifting brand-colour glow over the metal texture (`pricing-drift`, static with reduced motion). An unpublished gym 404s publicly but previews for staff. +## Imagens / JPEG XL (2026-10-26) +Fleet transition to JPEG XL (root `Y:\GitTea\CLAUDE.md`, "Images"); fitme3070 is the reference site. Uploads already accept `.jxl` (gyms header/logo, gallery photos). **To do:** `img_optimize()` (`config/functions_images.php`) still skips JXL because it is GD-only; it must resize/re-encode JXL (Imagick with JXL or `cjxl`/`djxl`, check what the dev `webdevops/php-apache:8.2` container and the VPS `fitme` image have) and write a `.webp`/`.jpg` fallback beside every `.jxl`; the gallery, hero and logos then serve `` with the JXL source and the fallback ``, and the gallery listing groups files by base name. Every image input in the backoffice shows the current image next to it with a remove control (gyms form does; check events, modalidades, team, galerias, system settings). + +The navbar only links sections the gym shows **and** has content for (`site_section_has_content()` in `config/functions_site.php`: Galeria = photos in its folder, Modalidades, Equipa = active members (`site_team_active()`, also used by the page), Horário, Mensalidades). The gallery section background is a 7% brand-colour tint, not white. + ## platform-tools `shared/platform-tools` is a **git submodule** (pinned `branch = main` in diff --git a/assets/js/datatables-init.js b/assets/js/datatables-init.js index 2d69791..c19c28e 100644 --- a/assets/js/datatables-init.js +++ b/assets/js/datatables-init.js @@ -46,8 +46,18 @@ function initDataTable(selector, opts) { if (!window.jQuery || !jQuery.fn || !jQuery.fn.DataTable) return null; var $table = jQuery(selector); if (!$table.length) return null; + // Listagem vazia: as páginas desenham uma única linha com a + // mensagem, que a DataTables rejeita (nº de colunas errado → erro). Tira-se + // a linha e a mensagem dela (com links) passa a ser o emptyTable. + var language = DATATABLES_PT_PT; + var $rows = $table.find('tbody > tr'); + var $only = $rows.length === 1 ? $rows.children('td[colspan]') : jQuery(); + if ($only.length === 1 && $rows.children().length === 1) { + language = jQuery.extend({}, DATATABLES_PT_PT, { emptyTable: $only.html() }); + $rows.remove(); + } return $table.DataTable(jQuery.extend(true, { - language: DATATABLES_PT_PT, + language: language, pageLength: 25, lengthMenu: [[10, 25, 50, -1], [10, 25, 50, 'Todos']], responsive: true, -- 2.54.0 From 49adbd0ae167cd15bcf5f4abb673af9f4eefc747 Mon Sep 17 00:00:00 2001 From: andre Date: Mon, 26 Oct 2026 10:14:04 +0000 Subject: [PATCH 56/73] feat(site): navbar links only sections with content, Galeria link, brand-tinted gallery background --- config/functions_site.php | 29 +++++++++++++++++++++++++---- site/index.php | 4 ++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/config/functions_site.php b/config/functions_site.php index e9aeb3d..09dd189 100644 --- a/config/functions_site.php +++ b/config/functions_site.php @@ -29,16 +29,37 @@ function site_info(PDO $pdo, ?int $gym_id = null): array { return $row; } +/** Whether the home page renders section $sec for $gym (same data checks as site/index.php). */ +function site_section_has_content(array $gym, string $sec): bool { + $pdo = $GLOBALS['pdo'] ?? null; + $gid = (int) $gym['id']; + if (!$pdo && $sec !== 'galeria') return true; + return match ($sec) { + 'modalidades' => (bool) site_modalidades($pdo, $gid), + 'equipa' => (bool) site_team_active($pdo, $gid), + 'horario' => (bool) site_horario($pdo, '', $gid), + 'mensalidades' => (bool) site_memberships($pdo, $gid), + 'galeria' => !empty($gym['gallery_id']) && preg_grep('/\.(jpe?g|png|gif|webp|jxl)$/i', + glob(dirname(__DIR__) . '/uploads/gallery/' . (int) $gym['gallery_id'] . '/*') ?: []), + default => true, + }; +} + +/** Team members shown on the site (status «ativo»). */ +function site_team_active(PDO $pdo, ?int $gym_id = null): array { + return array_values(array_filter(site_team($pdo, $gym_id), fn($d) => ($d['status'] ?? '') === 'ativo')); +} + // 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. +// for the sections that gym shows and has content for, 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', + foreach (['Galeria' => 'galeria', '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; + // A section with nothing to show isn't rendered, so no link to it either. + if (!site_section_has_content($gym, $sec)) continue; $items[$label] = ['#' . $sec, $sec]; } $nav = []; diff --git a/site/index.php b/site/index.php index 16c30fc..915546d 100644 --- a/site/index.php +++ b/site/index.php @@ -27,7 +27,7 @@ require __DIR__ . '/_open.php'; // resolves $gym (404 if unknown / not visible $gid = (int) $gym['id']; $on = fn(string $key) => gym_section_on($gym, $key); $offer = $on('oferta') ? site_offer($pdo, $gid) : []; -$team = $on('equipa') ? site_team($pdo, $gid) : []; +$team = $on('equipa') ? site_team_active($pdo, $gid) : []; $modalidades = $on('modalidades') ? site_modalidades($pdo, $gid) : []; $horario = $on('horario') ? site_horario($pdo, '', $gid) : []; $whours = $on('workhours') ? site_work_hours($pdo, $gid) : []; @@ -121,7 +121,7 @@ $gallery_files = ($on('galeria') && $gallery_path !== '') ? preg_grep('/\.(jpe?g if ($gallery_files && count($gallery_files) > 0): $gallery_h2 = 'Conhece as nossas instalações'; ?> -