release: promote beta to main (gyms, brand colours, JPEG XL, SEO per gym) #3

Merged
andre merged 74 commits from beta into main 2026-10-26 19:47:48 +00:00
6 changed files with 42 additions and 10 deletions
Showing only changes of commit 58d5199883 - Show all commits

No files matched your search

+1 -1
View File
@@ -79,7 +79,7 @@ Since 2026-09-25, one install serves several physical gyms: the `gyms` table, on
folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the
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 has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. Each gym can have a `logo` (path, same migration); the navbar button for another gym is drawn in that gym's brand colour (`gym_brand_vars()` inline) with the logo as a CSS mask in the text colour.
An unpublished gym 404s publicly but previews for staff.
## platform-tools
+17 -8
View File
@@ -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, 'brand_color' => '', 'item_order' => 0,
'google_place_id' => '', 'has_crossfit' => 0, 'brand_color' => '', 'logo' => '', 'item_order' => 0,
];
}
@@ -106,14 +106,19 @@ function gym_section_on(array $gym, string $key): bool {
// 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 {
function gym_brand_vars(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') . '}';
return '--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_brand_css(array $gym): string {
$v = gym_brand_vars($gym);
return $v === '' ? '' : ':root{' . $v . '}';
}
function gym_url(array $gym, string $path = ''): string {
@@ -172,6 +177,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,
'logo' => mb_substr(trim((string) ($post['logo'] ?? '')), 0, 255),
'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
'item_order' => (int) ($post['item_order'] ?? 0),
];
@@ -187,6 +193,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['logo'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(svg|png|webp|jpe?g)$~i', $data['logo'])) {
$errors[] = 'Logótipo: caminho de uma imagem do site (ex.: site/assets/img/logos/gym2.svg).';
}
if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) {
$errors[] = 'Cor da marca: formato #RRGGBB (ex.: #C2D500).';
}
@@ -200,15 +209,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['brand_color'], $d['item_order']];
$d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['brand_color'], $d['logo'], $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, brand_color, item_order, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
google_place_id, has_crossfit, brand_color, logo, item_order, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
$id = (int) $pdo->lastInsertId();
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.
@@ -228,7 +237,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 = ?, brand_color = ?, item_order = ?, updated_at = NOW()
has_crossfit = ?, brand_color = ?, logo = ?, item_order = ?, updated_at = NOW()
WHERE id = ?");
$st->execute($params);
$changed = $st->rowCount() > 0;
@@ -3,3 +3,6 @@
-- 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` = '';
-- Logótipo do ginásio (caminho relativo, ex.: site/assets/img/logos/ginasio.svg).
ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `logo` VARCHAR(255) NOT NULL DEFAULT '' AFTER `brand_color`;
+12
View File
@@ -472,6 +472,18 @@ section[id] {
transition: background-color var(--fitme-transition), color var(--fitme-transition);
}
.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). */
.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;
}
.gym-switch-btn:hover,
.gym-switch-btn:focus-visible {
background: var(--fitme-dark);
+4 -1
View File
@@ -59,7 +59,10 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
if ($other_gyms): ?>
<div class="gym-switch d-flex flex-wrap gap-2 ms-lg-auto py-2 py-lg-0 pe-lg-3">
<?php foreach ($other_gyms as $g): ?>
<a class="gym-switch-btn" href="<?= htmlspecialchars(gym_url($g)) ?>"><?= htmlspecialchars($g['name']) ?></a>
<a class="gym-switch-btn" href="<?= htmlspecialchars(gym_url($g)) ?>" style="<?= htmlspecialchars(gym_brand_vars($g)) ?>">
<?php if (($g['logo'] ?? '') !== ''): ?><span class="gym-switch-logo" aria-hidden="true" style="--logo:url('<?= htmlspecialchars(base_url($g['logo']), ENT_QUOTES) ?>')"></span><?php endif; ?>
<span><?= htmlspecialchars($g['name']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
+5
View File
@@ -86,6 +86,11 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
</div>
</div>
<div class="col-md-6">
<label class="form-label">Logótipo (botão no menu dos outros ginásios)</label>
<input class="form-control font-monospace" name="logo" maxlength="255" placeholder="site/assets/img/logos/ginasio.svg" value="<?= htmlspecialchars($data['logo'] ?? '') ?>">
<div class="form-text">Opcional. Aparece a uma só cor (a do texto do botão); funciona melhor com SVG de uma cor.</div>
</div>
<div class="col-sm-6 col-lg-4">
<label class="form-label" for="brand_color">Cor da marca</label>
<input type="color" class="form-control form-control-color" id="brand_color" name="brand_color" value="<?= htmlspecialchars(($data['brand_color'] ?? '') !== '' ? $data['brand_color'] : '#C2D500') ?>">