release: promote beta to main (gyms, brand colours, JPEG XL, SEO per gym) #3
No files matched your search
@@ -79,6 +79,7 @@ Since 2026-09-25, one install serves several physical gyms: the `gyms` table, on
|
|||||||
folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the
|
folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the
|
||||||
backoffice has an "a editar" gym switcher (`admin_gym_id()` /
|
backoffice has an "a editar" gym switcher (`admin_gym_id()` /
|
||||||
`admin_gym()` in `config/functions_gyms.php`). `/` and `/home` 301 to the default gym (`is_default`); the other published gyms appear as highlighted buttons (`.gym-switch-btn`) at the right end of the public navbar (`site/includes/navbar.php`). `site/gym_chooser.php` is no longer used.
|
`admin_gym()` in `config/functions_gyms.php`). `/` and `/home` 301 to the default gym (`is_default`); the other published gyms appear as highlighted buttons (`.gym-switch-btn`) at the right end of the public navbar (`site/includes/navbar.php`). `site/gym_chooser.php` is no longer used.
|
||||||
|
Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too.
|
||||||
An unpublished gym 404s publicly but previews for staff.
|
An unpublished gym 404s publicly but previews for staff.
|
||||||
|
|
||||||
## platform-tools
|
## platform-tools
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function gym_defaults(): array {
|
|||||||
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
|
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
|
||||||
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
|
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
|
||||||
'hero_image' => '', 'seo_title' => '', 'seo_description' => '', 'gallery_id' => null,
|
'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);
|
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 {
|
function gym_url(array $gym, string $path = ''): string {
|
||||||
return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/'));
|
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,
|
'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null,
|
||||||
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
|
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
|
||||||
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0,
|
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0,
|
||||||
|
'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
|
||||||
'item_order' => (int) ($post['item_order'] ?? 0),
|
'item_order' => (int) ($post['item_order'] ?? 0),
|
||||||
];
|
];
|
||||||
$on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
|
$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);
|
$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 ($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['is_default'] && !$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado.';
|
||||||
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp)$~i', $data['hero_image'])) {
|
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp)$~i', $data['hero_image'])) {
|
||||||
$errors[] = 'Imagem do header: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).';
|
$errors[] = 'Imagem do header: 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 {
|
function _gyms_params(array $d): array {
|
||||||
return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'],
|
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['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 {
|
function db_gyms_create(PDO $pdo, array $d): int {
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
$pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections,
|
$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,
|
hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id,
|
||||||
google_place_id, has_crossfit, item_order, created_at, updated_at)
|
google_place_id, has_crossfit, brand_color, item_order, created_at, updated_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
|
||||||
$id = (int) $pdo->lastInsertId();
|
$id = (int) $pdo->lastInsertId();
|
||||||
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
||||||
// Its own contacts row (id = gym id), starting from the gym's name.
|
// Its own contacts row (id = gym id), starting from the gym's name.
|
||||||
@@ -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 = ?,
|
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
|
||||||
is_default = ?, sections = ?, hero_title = ?, hero_subtitle = ?, hero_image = ?,
|
is_default = ?, sections = ?, hero_title = ?, hero_subtitle = ?, hero_image = ?,
|
||||||
seo_title = ?, seo_description = ?, gallery_id = ?, google_place_id = ?,
|
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 = ?");
|
WHERE id = ?");
|
||||||
$st->execute($params);
|
$st->execute($params);
|
||||||
$changed = $st->rowCount() > 0;
|
$changed = $st->rowCount() > 0;
|
||||||
|
|||||||
@@ -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` = '';
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
(WCAG AA needs 4.5:1), so text uses this darker olive. Bright accent stays
|
(WCAG AA needs 4.5:1), so text uses this darker olive. Bright accent stays
|
||||||
for dark sections and button backgrounds. */
|
for dark sections and button backgrounds. */
|
||||||
--fitme-accent-ink: #667000;
|
--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 */
|
/* deeper moss for hover / active */
|
||||||
|
|
||||||
/* The Anchor — Void Black, and a cool ramp lifting toward Charcoal Slate. */
|
/* The Anchor — Void Black, and a cool ramp lifting toward Charcoal Slate. */
|
||||||
@@ -314,7 +316,7 @@ section[id] {
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
padding: 16px 36px;
|
padding: 16px 36px;
|
||||||
background: var(--fitme-accent);
|
background: var(--fitme-accent);
|
||||||
color: var(--fitme-dark);
|
color: var(--fitme-on-accent);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--fitme-radius);
|
border-radius: var(--fitme-radius);
|
||||||
@@ -325,7 +327,7 @@ section[id] {
|
|||||||
|
|
||||||
.site-btn:hover {
|
.site-btn:hover {
|
||||||
background: var(--fitme-accent-dark);
|
background: var(--fitme-accent-dark);
|
||||||
color: var(--fitme-dark);
|
color: var(--fitme-on-accent);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 16px rgba(var(--fitme-accent-rgb), .35);
|
box-shadow: 0 4px 16px rgba(var(--fitme-accent-rgb), .35);
|
||||||
}
|
}
|
||||||
@@ -384,13 +386,13 @@ section[id] {
|
|||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── CrossFit nav pill ─────────────────────────────────────────────────────── */
|
/* ── CrossFit nav pill (follows the gym's brand colour) ─────────────────────────────────────────────────────── */
|
||||||
.crossfit-nav-link {
|
.crossfit-nav-link {
|
||||||
color: #b71c1c !important;
|
color: var(--fitme-accent-ink) !important;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .5px;
|
letter-spacing: .5px;
|
||||||
border: 2px solid #b71c1c;
|
border: 2px solid var(--fitme-accent-ink);
|
||||||
border-radius: var(--fitme-radius);
|
border-radius: var(--fitme-radius);
|
||||||
padding: 6px 14px !important;
|
padding: 6px 14px !important;
|
||||||
transition: background-color var(--fitme-transition), color var(--fitme-transition);
|
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) */
|
generic .nav-item.active .nav-link transparent-background rule above) */
|
||||||
.crossfit-nav-link:hover,
|
.crossfit-nav-link:hover,
|
||||||
.nav-item.active .crossfit-nav-link {
|
.nav-item.active .crossfit-nav-link {
|
||||||
background: #b71c1c !important;
|
background: var(--fitme-accent) !important;
|
||||||
color: #fff !important;
|
border-color: var(--fitme-accent);
|
||||||
|
color: var(--fitme-on-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Other-gym button (right end of the navbar) ────────────────────────────── */
|
/* ── Other-gym button (right end of the navbar) ────────────────────────────── */
|
||||||
.gym-switch-btn {
|
.gym-switch-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--fitme-accent);
|
background: var(--fitme-accent);
|
||||||
color: var(--fitme-dark);
|
color: var(--fitme-on-accent);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .5px;
|
letter-spacing: .5px;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
|
|||||||
<meta name="description" content="<?= htmlspecialchars($page_desc) ?>">
|
<meta name="description" content="<?= htmlspecialchars($page_desc) ?>">
|
||||||
<meta name="keywords" content="fitness, ginásio, exercício, personal trainer, crossfit, box, aveiro, mira">
|
<meta name="keywords" content="fitness, ginásio, exercício, personal trainer, crossfit, box, aveiro, mira">
|
||||||
<meta name="author" content="FitMe 3070">
|
<meta name="author" content="FitMe 3070">
|
||||||
<meta name="theme-color" content="#C2D500">
|
<meta name="theme-color" content="<?= htmlspecialchars(($gym_meta['brand_color'] ?? '') !== '' ? $gym_meta['brand_color'] : '#C2D500') ?>">
|
||||||
<title><?= htmlspecialchars($page_title) ?></title>
|
<title><?= htmlspecialchars($page_title) ?></title>
|
||||||
<link rel="canonical" href="<?= htmlspecialchars($canonical) ?>">
|
<link rel="canonical" href="<?= htmlspecialchars($canonical) ?>">
|
||||||
<?php if (seo_noindex()): /* staging copies (e.g. fitme.andmore.pt) must not compete with the real domain */ ?>
|
<?php if (seo_noindex()): /* staging copies (e.g. fitme.andmore.pt) must not compete with the real domain */ ?>
|
||||||
@@ -85,6 +85,7 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
|
|||||||
|
|
||||||
<!-- Structured data -->
|
<!-- Structured data -->
|
||||||
<?= view('seo') ?>
|
<?= view('seo') ?>
|
||||||
|
<?php if (($brand_css = gym_brand_css($gym_meta)) !== ''): ?><style><?= $brand_css ?></style><?php endif; ?>
|
||||||
<?php include dirname(__DIR__, 2) . '/includes/env_ribbon.php'; // moldura de aviso fora de produção ?>
|
<?php include dirname(__DIR__, 2) . '/includes/env_ribbon.php'; // moldura de aviso fora de produção ?>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
|
|||||||
</div>
|
</div>
|
||||||
</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') ?>">
|
||||||
|
<div class="form-text">Botões, destaques e links do site deste ginásio.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-12"><hr class="my-1"><strong>Estado</strong></div>
|
<div class="col-12"><hr class="my-1"><strong>Estado</strong></div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
|
|||||||
Reference in new issue
Block a user