Files
fitme3070.pt/CLAUDE.md
T
andre e804c52b4e feat(site): Comunidade section with Instagram posts the gym was tagged in
Per-gym Graph API /tags fetch with file cache and local thumbnails, section after the gallery, navbar link and admin page (Gestão > Site).
2026-10-26 14:19:43 +00:00

187 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# fitme3070 — architecture
Backoffice + public site for a gym (ginásio) chain, plain PHP 8.4, no framework. This
file exists because the pattern below was inherited from a CodeIgniter 4 port and had
never been written down as a decision (audit 2026-09-24, item 6.1) — it describes what
the codebase actually does, kept deliberately, not a design proposal for a rewrite.
## Request lifecycle
**Every page is its own controller-and-view combined.** A page in `views/admin/*.php`
or `site/*.php` does, in order: bootstrap (`config/db.php` + `config/functions.php`),
an auth guard, read `$_POST`/`$_GET` and call model functions, then fall through into
its own HTML. There is no separate template layer and no front controller — 71+ PHP
files are directly reachable by URL (docroot is the repo root; `.htaccess` denies
partials, `shared/platform-tools/*.php`, and `*.md`, but doesn't allowlist).
**This is a known, accepted gap, not an oversight** — see "What's not done" below.
Introducing a real front controller/router for the app's own pages would touch most of
`views/`, `.htaccess`'s friendly-URL block, and every hardcoded `base_url('views/...')`
call; it's a genuine rewrite, tracked separately, not bundled into this doc.
## Data layer: `config/functions_*.php` as models
SQL lives in `config/functions_*.php`, one file per domain (`functions_team.php`,
`functions_galerias.php`, `functions_gyms.php`, `functions_memberships.php`, …) plus
`functions_db.php` for cross-cutting `users` queries. Functions are named
`db_<noun>_<verb>()` (`db_gyms_create`, `db_team_update`, `db_gallery_list`). **Views
never write SQL directly** — if you're adding a query, it goes in the matching
`functions_*.php`, not inline in the page. (Audit 6.3, closed 2026-09-25 — `grep -rl
'\$pdo->\(query\|prepare\|exec\)' views/` should always return nothing.)
`config/functions.php` is the loader: every page requires `config/db.php` (opens
`$pdo`, starts the session, connects to platform-tools) then
`config/functions.php` (requires every `functions_*.php` in order). Read that file's
own header comment before adding a new one — order matters for a few (`auth.php`
before anything that calls `require_login()`, etc.).
## Auth
Login/session/roles run on `shared/platform-tools/functions_auth.php` (`'auth' =>
'shared'` in `config/db.php`, migrated 2026-09-25 — audit 1.3). `config/auth.php` keeps
the app's own guard names (`require_login()`, `require_admin()`, `require_staff()`,
`require_sysadmin()`, `is_admin()`, etc.) as a thin layer over the shared primitives, so
none of the ~30 call sites in `views/` had to change. Role ladder:
`sysadmin`(100) > `admin`(80) > `staff`(40) > `client`(10, portal-only). "Manter sessão
iniciada" is the shared `auth_remember_*` module.
The login-attempt throttle (`config/functions_throttle.php`) stays the app's own,
deliberately not the shared one — see the comment in `config/db.php` for why (avoids
double-counting the same form with two independent throttles). A throttled login feeds
`platform_ip_record_offence()`, so repeat offenders get fleet-wide IP-banned.
## Soft delete / trash
Deletes are soft (`deleted_at`/`deleted_by`) via `trash_delete()` in
`config/functions_trash.php`. Gestão → Lixeira lists everything deleted across entity
types and only allows permanent purge after 30 days (enforced in the `DELETE`'s
`WHERE`, not just hidden in the UI). Adding a new deletable entity means adding it to
`trash_types()` in that file.
## CSRF
`csrf_check()` (`config/functions_csrf.php`) refuses any non-POST request outright —
state changes must be POST forms carrying `csrf_field()`'s token. There is no PRG
(Post-Redirect-Get) convention beyond that; several admin pages still re-render the
form directly on POST rather than redirecting (audit flags this as inconsistent, not
yet unified — tracked separately, low priority).
## Audit log
Models call `audit_row()` before a write and `audit_change()` after (diffs
before/after automatically); ad-hoc events elsewhere use `audit('entity.verb', [...])`.
Both come from `shared/platform-tools` via `config/functions_audit.php`. Read via
Sistema → Auditoria (`platform.php?tool=audit`).
## Multi-gym
Since 2026-09-25, one install serves several physical gyms: the `gyms` table, one URL
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. 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). On the home page and the CrossFit page (`$nav_center_logo` set in `site/index.php` / `site/crossfit.php`, shared by `_open.php`; the CrossFit video header no longer prints its own logo, only a `visually-hidden` h1) the header starts taller (desktop >= 992px) with a second, big copy of the logo centred (fixed 52px height, so tall logos like the CrossFit one clear the links) (`.navbar-brand-hero`, `aria-hidden`); once `scrollY >= 40` (script in `navbar.php` toggles `.is-top`) the bar shrinks, the big logo fades out and the 150px left logo fades in (opacity crossfade, no movement). The hero gets `padding-top: 120px` on desktop so its event card clears the tall bar. On desktop the brand is absolute at the left and the links are centred on the full bar width; nav padding and logo width live in CSS, not inline. The other-gym buttons (`.gym-switch`) sit in the **match head**, a glimpse into the other site: painted in the first other gym's brand (`navbar.php` puts `gym_brand_vars()` of `$other_gyms[0]` on `.gym-switch`), a 5px strip in its accent on the slash, the face a shade darker (`--fitme-accent-dark` to 82% of it mixed with black) with a shadow inside the cut so the strip reads, and its logo in that gym's on-accent ink (`brightness(0)`, plus `invert(1)` with `.gym-switch--light-ink` when the on-accent is white). Paint is shared; geometry per breakpoint: desktop = absolute block at the right end of the bar (full height, width follows its buttons), left side cut on the brand slash (22px); below 992px = a full-width band across the bottom of the open menu, top edge slashed (18px). 992-1199px: links shrink (`.8125rem`, `--bs-navbar-nav-link-padding-x: .35rem`) so the 3070 menu clears the logo and head. Menu links are `.875rem` bold. Below 992px the open menu fills the screen under the bar (absolute, `100dvh - var(--nav-h)`, `--nav-h` = the header's real height, set by the `navbar.php` script, links/buttons centred, opacity fade; not `position:fixed` because the header's backdrop-filter is its containing block). The bar is liquid glass in Chromium: `backdrop-filter: url(#nav-glass) blur(6px) saturate(180%)` (`#nav-glass` in `include.php`: blurred low-frequency `feTurbulence` -> `feDisplacementMap`, bends the page behind it), gated by `@supports (-webkit-app-region: drag)` (Chromium-only property); Safari/Firefox keep the plain blur. **Brand slash template:** the cut corners of the "in" box are one rule in `style.css` ("Brand slash (template)"): `class="fx-slash"` (both ends) or `fx-slash-start` (left only), size via `--slash` (default 4px); pseudo-elements are added to that rule's selector list. Used by the nav and section-title underlines and the CrossFit pill (`--slash: 8px`, no radius); never paste the polygon again. The hero text has no glow, only a tight `0 1px 2px` shadow (h1, `.hero-brand`, `.hero-tagline`) and the hero frosted-band blur (`.hero-section .container::before`) is at opacity .8. The hero subtitle (`.hero-text h1 span`, gym `hero_subtitle`) is a block strapline at `.42em` of the h1, weight 600, tracked out. The hero event card's border is solid `--ev-accent`, the same colour as its button (button text stays `--fitme-on-accent`). Logos (`.navbar-brand img`, `.gym-switch-logo`) get the same `#pt-noise` grain filter as h1/h2, and `.hero-event-go`, `.cta-btn`, `.btn-primary` joined the `--noise` button list. Section-title underlines (`.section-title.deco h2:after`) use the same slashed 4px bar as the nav underline. The hero event button text uses `--fitme-on-accent` (white on red). The nav underline (`.nav-link:after`) is a 4px bar 3px under the text with slashed ends (`clip-path`, like the "in" box of the FitMe in logo). The hero event card (`.hero-event`) is square (no radius on card, badge or button), taller (88px min) and has a defocus gradient: `::after` is a `backdrop-filter: blur()` layer masked left->right, sharp on the left and most blurred on the right; the card itself must not get filter/backdrop-filter/opacity/mask or the blur stops reaching the hero photo. 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 thin dark `drop-shadow` outline on the image for contrast (a white halo washed out the lime FitMe 3070 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'`; 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.
- **SEO per gym (2026-10-26):** `site/includes/include.php` builds title/description per page from the gym (`$gym_name`, `$loc_in` = «na Zona …»/«em Mira»); gym 1 keeps hand-written texts, other gyms get generated ones (never the 3070 texts). The gym's own `seo_title` / `seo_description` (Gestão → Ginásios) override the homepage each on its own. Keywords, author, og:site_name, favicon (gym logo if SVG) and the JSON-LD logo (`seo.php`) follow the gym. FitMe in's SEO texts were set in the dev DB only; production needs them entered in Gestão → Ginásios.
- `site/crossfit.php` decides the 404 for gyms without `has_crossfit` before `_open.php`, so it is a real 404 status.
- **Events per gym:** Eventos form «Mostrar em» writes `events.gym_id` (NULL = all gyms; `site_active_events()` filters on it). `site/evento.php` renders the event in its gym's brand/name/info. The hero event button text uses `--ev-on` (white/black computed from the event colour), not the page gym's on-accent.
- Navbar: the desktop bottom sweep line stops before the other-gym head via `--switch-w` (set by `setNavH` in `navbar.php`). The hero blur (`.hero-section .container::before`) has a second diagonal mask, so it is weaker at the top left. The «Marca uma aula experimental» link was removed; `#trialModal` stays (only reopened by `?agendar=erro`).
- The other-gym logo button is a light plate (92% white) with the logo in its own colours (2026-10-26; before: logo inked to the head's on-accent).
- YourFit modalidades use their brand glyphs: `fitme-yourfit-*.svg` were traced (2026-10-26) from the left tile of the wordmarks `uploads/modalidades/icons/yourfit-*.png` (150×33, the only source found) into one even-odd `currentColor` path, so they keep the CSS colour/hover and the transform animations (no stroke draw).
## 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). The gallery edit page (`views/admin/galerias/edit.php`) takes several files at once (`images[]`, one flash with the count plus each refused file and why, from `db_gallery_image_upload(..., &$error)`; limits shown from `gallery_upload_max_bytes()` / `max_file_uploads` / `post_max_size`, and a POST over `post_max_size` gets its own message). A `.jxl` without fallback shows as a «JPEG XL» placeholder tile with its filename (Chrome can't draw it); with a fallback it uses `img_tag()`.
- **Optimizer** (`config/functions_images.php`, built 2026-10-26): `img_optimize()` hands `.jxl` to `img_jxl_optimize()`, which decodes with `djxl` (or Imagick if it lists JXL), resizes with GD, re-encodes a smaller `.jxl` with `cjxl -d 1.0` when resized, and always writes the fallback **`foto.jxl.webp`** (`.jxl.jpg` if GD has no WebP) beside it. Idempotent (skips when the fallback is newer and within the cap). With no decoder it returns `skipped` («JPEG XL sem descodificador no servidor»), uploads never fail.
- **No decoder exists yet** (checked 2026-10-26): the VPS `fitme` image (php:8.4-apache, Debian trixie) has GD only; the dev `webdevops/php-apache:8.2` has Imagick without JXL. Both need `libjxl-tools` (cjxl/djxl) installed. 2026-10-26: added to the VPS `/srv/stacks/fitme.andmore.pt/Dockerfile` apt line (backup `Dockerfile.bak-20261026`), active only after `docker compose build && docker compose up -d` there (the user runs it); on dev it is installed by hand in the running container (`apt-get install libjxl-tools` as root) and is lost when the TrueNAS app recreates it. Until then `.jxl` uploads have no fallback and show only in browsers with JXL (Safari). After installing, press «Otimizar» on each gallery to backfill.
- **Serving:** `img_tag($rel, $attrs)` (`<picture><source type="image/jxl"><img src=fallback>`, plain `<img>` otherwise), `img_css_bg($rel)` (hero: `url(fallback)` then `image-set()` with the JXL), `img_compat_rel()` (single-URL places: lightbox link, JSON-LD image, admin preview), `img_list_dir()` (gallery listings, hides `*.jxl.*`). Used in `site/includes/gallery.php` (lightbox shows the tile's `currentSrc`), `site/index.php` hero, `include.php` preload (`type="image/jxl"` for a JXL hero), `seo.php`, navbar logos. `picture { display: contents }` in `style.css` keeps the old img sizing.
- **Moves/deletes keep the pair together:** `img_fallback_follow($from, $to='')` in trash (`_trash_image_file`), `gyms_remove_image()`, `events_image_remove()`.
- Still open: events/modalidades/team uploads don't accept `.jxl` yet. 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, Comunidade = cached posts). The gallery section background is a 7% brand-colour tint, not white.
## Comunidade: Instagram posts the gym was tagged in (2026-10-26)
Section `comunidade` (after the gallery in `site/index.php`, navbar link «Comunidade», key in `gym_sections()`) shows the latest public Instagram posts where the gym's account was tagged, as a square grid linking to each post; hidden when there are none. `config/functions_instagram.php` mirrors the reviews module: Graph API `GET /{ig-user-id}/tags` (`INSTAGRAM_GRAPH`, v21.0), cache `config/cache/instagram_<gid>.cache.php`, lazy refresh under a non-blocking lock, old copy kept on error (retry after 1 h). Thumbnails (`thumbnail_url` for videos, `media_url` otherwise; only JPEGs from `cdninstagram.com`/`fbcdn.net`) are downloaded at refresh time to `uploads/instagram/<gid>/<media id>.jpg` (git-ignored) because Instagram CDN URLs expire; stale ones are deleted.
- **Per gym, no migration:** `system_settings` keys `instagram_user_id_<gid>` and `instagram_token_<gid>` (encrypted with `secret_encrypt`; `.env` `INSTAGRAM_TOKEN_<gid>` wins); `instagram_max` (3–24, default 8) and `instagram_cache_hours` (default 6) are shared. Admin: Gestão → Site → «Comunidade (Instagram)» (`views/admin/instagram/index.php`, edits the gym in the switcher): token, account id, «Encontrar» (lists `me/accounts` Pages with their linked IG account), «Atualizar agora», status + preview.
- **Needs from Meta:** an Instagram professional (business/creator) account linked to a Facebook Page, a Meta app, and a token with `instagram_basic`, `instagram_manage_comments`, `pages_read_engagement` (a Business Manager system-user token does not expire; a long-lived user token lasts 60 days). Private posts and some reels without `media_url` are skipped.
- Gyms whose `sections` column is an explicit list (not NULL) don't get the new section until it is ticked in Ginásios → Secções. Not configured anywhere yet (2026-10-26): tested on dev with fake cached posts only.
## platform-tools
`shared/platform-tools` is a **git submodule** (pinned `branch = main` in
`.gitmodules`), not vendored — see `PLATFORM_TOOLS.md` for why it went vendored→submodule→
back, and don't repeat that mistake without reading it first. `config/db.php` wires it
via `platform_connect()`: shared auth (above), fleet IP-ban guard (runs on every
request), the sysadmin router (`platform.php` / `api/platform.php` — Backups,
Actualizações, Auditoria, IP bans, Geo, Origem das visitas, all one front controller,
just for *those* pages, not the app's own).
## Deploy
Push to `master` → Gitea webhook → `api/platform.php?endpoint=auto_update` → the VPS
pulls, syncs the submodule, applies pending `database/migration_*.sql` files, done in
seconds. No staging branch; the VPS checkout **is** what's live. `deploy.sh` (on the
VPS, not in this repo) is the manual fallback. See `PLATFORM_TOOLS.md`.
## Dependencies
`composer.json`/`composer.lock` are real (`composer install` works, `composer
audit`/`outdated` are meaningful) — added 2026-09-25, matching the fotografia
platform's setup. `vendor/PHPMailer/` is the one hand-vendored exception, gitignore-
allowlisted: `shared/platform-tools/functions_mail.php` hard-codes that exact path
fleet-wide, so Composer's own `vendor/phpmailer/phpmailer/` exists in parallel but
isn't what actually gets `require_once`'d. See `ASSETS.md` for frontend asset versions.
Both the public site and the backoffice load Bootstrap 5.3.3 (unified 2026-09-25 — the
public site ran 4.3.1 before that); jQuery is one shared copy, not duplicated.
## What's not done (tracked, not silently missing)
The full audit and its live status live in a separate report, not in this repo
(`CLAUDE_GITEA/audits/fitme3070-auditoria-2026-09-24.html` on the maintainer's
machine). As of 2026-09-25: no real front controller (6.2, above), no central listing
registry, no DataTables on backoffice listings, no `srcset` (deliberate — uploads are
already close to their CSS display size, see the audit item), `views/admin/galerias/`
still has separate create/edit (deliberately — they're not actually a duplicated pair,
see the audit item). None of these are urgent; none should be "fixed" as a side effect
of an unrelated change — each is its own piece of work.
## Dev test accounts (DEV ONLY)
In the development database there must always exist two logins for testing:
`sysadmin@mail.pt` (role sysadmin) and `admin@mail.pt` (role admin), password `123`.
Ensure/repair them with `php scripts/dev_accounts.php` (CLI only, idempotent upsert with
`password_hash`, active flags set). The script fails closed: it refuses unless `.env` has
`APP_ENV=dev` AND `DB_HOST` is `192.168.1.3:3181` or localhost. It must never be run, deployed
or adapted for production, and these accounts must never exist in a production database.
## 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`).
## 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 `<head>` 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á `<style>` inline no `header.php`. Prefixo `bo-` nas peças novas; `.sidebar`, `.topbar`, `.nav-link` mantêm-se.
- Cabeçalho fixo `[☰][marca][↗ site público]` (↗ abre `base_url('/')` em nova aba, único sítio), utilizador logo abaixo (avatar com iniciais, nome, crachá de papel, menu Perfil / Terminar sessão). A topbar fica só com o seletor «A editar», o tema e, em telemóvel, o botão que abre a gaveta. Um só manipulador (`data-sidebar-toggle`, em `includes/footer.php`): desktop alterna mini (80px, `localStorage bo-sidebar-mini`), telemóvel abre o off-canvas.
- Menu em `includes/header.php` com os auxiliares de `includes/nav_helpers.php`: `bo_nav_link()` (ativo por sufixo do `SCRIPT_NAME`, `aria-current`, title + aria-label), `bo_nav_group_start/end()` (grupo recolhível, `data-short` = 4 letras), `bo_dashboard_path()`. Ordem: Painel (admin: `views/admin/dashboard.php`; staff: `views/staff/dashboard.php`; cliente: `views/user/dashboard.php`), Gestão (subgrupos Site, Horários, Comercial, Legal), Utilizadores, Sistema por último. Cada ligação mantém o seu `is_admin()`/`is_sysadmin()`.
- Estado sem flash: script no `<head>` põe `html.bo-mini` e `data-bo-groups`; estado dos grupos em `localStorage bo-nav-groups`; scroll da lista em `sessionStorage bo-nav-scroll`. Grupo novo = chave nova na lista `:is(...)` de grupos fechados em `backoffice.css`.
- Mini: categorias em texto (`data-short`), nunca ícones. Ícones: só Bootstrap Icons de contorno.
- `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.
## Pacotes de ícones partilhados (2026-10-13)
- `config/db.php` regista `'icons' => ['packs' => ['bootstrap-icons','fontawesome'], 'default' => 'bootstrap-icons']` só se `shared/platform-tools/functions_icons.php` existe. Flaticon NÃO está registado: o site usa PNGs (`uploads/modalidades/icons`), não a fonte.
- `fit_icon_assets($packs, $fallbackLocal)` (`config/functions_icons.php`) imprime `pt_icon_assets()` ou, sem submódulo novo, os `<link>` locais. Backoffice (`includes/header.php`, `includes/auth_header.php`): `bootstrap-icons`; site público (`site/includes/include.php`): `fontawesome` (FA 6.7.2; as classes `fas/far/fab` e os nomes FA5 funcionam por aliases).
- Sidebar > Sistema: «Pacotes de ícones» (sysadmin) -> `platform.php?tool=icons`.
- Sem uso quando o submódulo é novo (não apagados): `assets/vendor/bootstrap-icons/`, `site/assets/fonts/fontawesome/`, `site/assets/icon-fonts/`, `site/assets/css/flaticon.css`. `config/error_handler.php` ainda usa bootstrap-icons por CDN (página de erro autónoma).