diff --git a/.gitignore b/.gitignore index f9d3e4f..0ae5d99 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ uploads/**/.trash/ # (config/functions_privacy.php) para o browser não as pedir à Google. uploads/reviews/ +# Miniaturas das publicações do Instagram da secção «Comunidade» +# (config/functions_instagram.php), descarregadas a cada atualização. +uploads/instagram/ + # Nota: as fotos da equipa, galerias e ícones em uploads/ ainda estão # versionados (vieram assim do site antigo). Em produção uploads/ é um volume # (/srv/data/fitme.andmore.pt/uploads) e é esse a fonte; o que está no git é @@ -57,6 +61,7 @@ Thumbs.db .claude/worktrees/ .claude/ +CLAUDE.md # ─── Dependências ─────────────────────────────────────────────────────────── # vendor/ é output do composer install, exceto vendor/PHPMailer: o 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/ASSETS.md b/ASSETS.md index d451fee..2d10a6d 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -9,9 +9,9 @@ mas ainda sem versão no nome das pastas dos ficheiros hand-vendored. | Biblioteca | Versão | Caminho | Usado em | |------------------------|--------|----------------------------------------|----------------------| | Bootstrap | 5.3.3 | `assets/vendor/bootstrap5/` | Site público + backoffice (unificado 25/09) | -| Bootstrap Icons | — | `assets/vendor/bootstrap-icons/` | Backoffice | +| Bootstrap Icons | — | `assets/vendor/bootstrap-icons/` | Backoffice (agora carregado do pacote partilhado `bootstrap-icons` 1.13.1; esta cópia só como recurso) | | jQuery | 3.7.1 | `assets/vendor/jquery/jquery-3.7.1.min.js` | Site público + backoffice (deduplicado 25/09) | -| Font Awesome Free | 5.13.1 | `site/assets/fonts/fontawesome/` | Site público (~18 MB, todo o pacote) | +| Font Awesome Free | 5.13.1 | `site/assets/fonts/fontawesome/` | Site público: agora FA 6.7.2 do pacote partilhado `fontawesome`; esta cópia (~18 MB) só como recurso, sem uso se o submódulo tiver `functions_icons.php` | | PHPMailer | 7.1.1 | `vendor/PHPMailer/` | Envio de email (`config/functions_mail.php`, partilhado) | `assets/vendor/bootstrap/` (a cópia 4.3.1) ficou sem nenhuma referência depois da migração — diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ca79486..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,139 +0,0 @@ -# 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__()` (`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/`, `//`), 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. -An unpublished gym 404s publicly but previews for staff. - -## 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`). diff --git a/assets/css/backoffice.css b/assets/css/backoffice.css new file mode 100644 index 0000000..bc5b408 --- /dev/null +++ b/assets/css/backoffice.css @@ -0,0 +1,351 @@ +/* Backoffice FitMe 3070: tokens da marca, casca (barra lateral, topbar, gaveta + em telemóvel) e acertos dos componentes. Carregado por includes/header.php + depois do Bootstrap. O Bootstrap nunca se edita: o que muda faz-se com + variáveis e com classes próprias (prefixo bo-). */ + +:root { + --bo-accent: #C2D500; + --bo-accent-rgb: 194,213,0; + --bo-dark: #0a0a0a; + --bo-dark-2: #151515; + --bo-surface: #eef0f4; + --bo-sidebar-w: 264px; + --bo-sidebar-w-mini: 80px; + --bo-topbar-h: 56px; + --bo-ease: .2s ease; + /* Texto da barra lateral (escura nos dois temas) */ + --bo-nav-text: rgba(255,255,255,.62); + --bo-nav-text-muted: rgba(255,255,255,.5); + --bo-nav-line: rgba(255,255,255,.1); + --bo-nav-strong: #fff; +} +[data-bs-theme="dark"] { --bo-surface: #121416; } + +body { background: var(--bo-surface); font-size: .925rem; } +a { color: var(--bs-emphasis-color); } +main { margin-left: var(--bo-sidebar-w); padding-top: calc(var(--bo-topbar-h) + 12px); } + +/* ── Barra lateral ───────────────────────────────────────────────────────── */ +/* Sem overflow aqui: o menu do utilizador sai da barra no modo mini. + Quem rola é só .bo-sidebar__nav; cabeçalho e utilizador ficam fixos. */ +.sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 100; + width: var(--bo-sidebar-w); + display: flex; + flex-direction: column; + background: var(--bo-dark); +} + +/* Cabeçalho numa linha: [☰] [marca] [↗ site público] */ +.bo-sidebar__head { + flex: none; + display: flex; + align-items: center; + gap: .5rem; + height: var(--bo-topbar-h); + padding: 0 .75rem; + border-bottom: 1px solid var(--bo-nav-line); +} +.bo-sidebar__toggle, +.bo-sidebar__site { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + min-height: 40px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--bo-nav-text); + font-size: 1.25rem; + line-height: 1; + text-decoration: none; + transition: background var(--bo-ease), color var(--bo-ease); +} +.bo-sidebar__site { font-size: 1rem; } +.bo-sidebar__toggle:hover, +.bo-sidebar__site:hover { background: rgba(255,255,255,.08); color: var(--bo-nav-strong); } +.bo-sidebar__brand { + flex: 1; + min-width: 0; + color: var(--bo-nav-strong); + font-weight: 700; + font-size: 1.05rem; + letter-spacing: .5px; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bo-sidebar__brand i { color: var(--bo-accent); } + +/* Utilizador logo abaixo do cabeçalho */ +.bo-sidebar__user { + flex: none; + padding: .6rem .75rem; + border-bottom: 1px solid var(--bo-nav-line); +} +.bo-user-menu { + width: 100%; + display: flex; + align-items: center; + gap: .6rem; + padding: .35rem .45rem; + border: 1px solid var(--bo-nav-line); + border-radius: 8px; + background: transparent; + color: var(--bo-nav-strong); + text-align: left; +} +.bo-user-menu:hover, +.bo-user-menu.show { background: rgba(255,255,255,.08); color: var(--bo-nav-strong); } +.bo-user-menu::after { margin-left: auto; flex: none; } +.bo-user-menu__avatar { + flex: none; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--bo-accent); + color: var(--bo-dark); + font-size: .8rem; + font-weight: 700; +} +.bo-user-menu__text { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; line-height: 1.25; } +.bo-user-menu__name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; font-weight: 600; } +.bo-user-menu__text .badge { font-size: .62rem; font-weight: 600; } +.bo-sidebar__user .dropdown-menu { min-width: 14rem; z-index: 1110; } + +/* Lista de navegação: a única parte que rola */ +.bo-sidebar__nav { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: .5rem 0 1rem; + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar .bo-nav-item { + position: relative; + display: flex; + align-items: center; + gap: .6rem; + min-height: 40px; + padding: .5rem 1.25rem; + border-left: 3px solid transparent; + color: var(--bo-nav-text); + font-size: .875rem; + transition: background var(--bo-ease), color var(--bo-ease), border-color var(--bo-ease); +} +.sidebar .bo-nav-item i { flex: none; width: 1.25rem; text-align: center; font-size: 1.05rem; } +.sidebar .bo-nav-item:hover { color: var(--bo-nav-strong); background: rgba(255,255,255,.06); border-left-color: rgba(255,255,255,.2); } +.sidebar .bo-nav-item.active { color: var(--bo-nav-strong); background: rgba(var(--bo-accent-rgb),.12); border-left-color: var(--bo-accent); } +.sidebar .bo-nav-item.active i { color: var(--bo-accent); } +.sidebar .bo-nav-item--primary i { font-size: 1.25rem; } +/* Indentação = cadeia de dados: filhos de um grupo, e de um subgrupo. */ +.sidebar .bo-nav-item--sub { padding-left: 2.3rem; font-size: .84rem; } +.sidebar .bo-nav-item--sub2 { padding-left: 3.2rem; font-size: .84rem; } +.bo-nav-item__label { min-width: 0; } + +.sidebar .bo-nav-item:focus-visible, +.bo-nav-group__label:focus-visible, +.bo-sidebar__toggle:focus-visible, +.bo-sidebar__site:focus-visible, +.bo-user-menu:focus-visible, +.bo-sidebar__brand:focus-visible { outline: 2px solid var(--bo-accent); outline-offset: -2px; } + +/* Grupos recolhíveis */ +.bo-nav-group { margin-top: .25rem; } +.bo-nav-group__label { + display: flex; + align-items: center; + gap: .6rem; + width: 100%; + padding: .55rem 1.25rem; + border: 0; + background: transparent; + color: var(--bo-nav-text-muted); + font-family: inherit; + font-size: .68rem; + font-weight: 700; + letter-spacing: .14em; + text-align: left; + text-transform: uppercase; + cursor: pointer; +} +.bo-nav-group__label:hover { color: var(--bo-nav-strong); background: rgba(255,255,255,.04); } +.bo-nav-group__icon { flex: none; width: 1.25rem; text-align: center; font-size: 1.1rem; letter-spacing: 0; } +.bo-nav-group__text { flex: 1; min-width: 0; } +.bo-nav-group__chevron { font-size: .75rem; letter-spacing: 0; transition: rotate var(--bo-ease); } +.bo-nav-group__label[aria-expanded="false"] .bo-nav-group__chevron { rotate: -90deg; } +.bo-nav-group--sub > .bo-nav-group__label { padding-left: 1.9rem; } + +/* Corpo recolhível: grelha 1fr -> 0fr anima a altura sem JavaScript a medir. */ +.bo-nav-group__body { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows var(--bo-ease), visibility var(--bo-ease); +} +.bo-nav-group__inner { + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 2px; +} +/* Fechado = classe (clique no grupo da página ativa) ou chave no atributo do + posto pelo script do antes da pintura. O grupo da página ativa + só fecha por clique (.is-closed), nunca pelo estado guardado. visibility + tira os links fechados da ordem de tabulação. Grupo novo = chave nova aqui. */ +:is(.bo-nav-group.is-closed, + html[data-bo-groups~="gestao"] .bo-nav-group[data-group="gestao"]:not(.has-active), + html[data-bo-groups~="site"] .bo-nav-group[data-group="site"]:not(.has-active), + html[data-bo-groups~="horarios"] .bo-nav-group[data-group="horarios"]:not(.has-active), + html[data-bo-groups~="comercial"] .bo-nav-group[data-group="comercial"]:not(.has-active), + html[data-bo-groups~="legal"] .bo-nav-group[data-group="legal"]:not(.has-active), + html[data-bo-groups~="utilizadores"] .bo-nav-group[data-group="utilizadores"]:not(.has-active), + html[data-bo-groups~="sistema"] .bo-nav-group[data-group="sistema"]:not(.has-active)) > .bo-nav-group__body { + grid-template-rows: 0fr; + visibility: hidden; +} + +/* Véu por trás da gaveta em telemóvel */ +.sidebar-backdrop { display: none; position: fixed; inset: 0; z-index: 1090; background: rgba(0,0,0,.45); } +.sidebar-backdrop.show { display: block; } + +/* ── Topbar ──────────────────────────────────────────────────────────────── */ +.topbar { + position: fixed; + top: 0; + left: var(--bo-sidebar-w); + right: 0; + z-index: 99; + height: var(--bo-topbar-h); + padding: 0 1.5rem; + display: flex; + align-items: center; + gap: .5rem; + background: var(--bs-body-bg); + border-bottom: 1px solid var(--bs-border-color); +} +.bo-topbar__tools { display: flex; align-items: center; gap: .5rem; margin-left: auto; } +.menu-toggle { + display: none; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid var(--bs-border-color); + border-radius: 8px; + background: var(--bs-body-bg); + color: var(--bs-body-color); + font-size: 1.35rem; + line-height: 1; + align-items: center; + justify-content: center; +} +.theme-toggle { min-width: 44px; min-height: 44px; } + +/* ── Marca nos componentes ───────────────────────────────────────────────── */ +.badge-role-sysadmin { background: var(--bo-accent); color: var(--bo-dark); } +.badge-role-admin { background: var(--bo-accent); color: var(--bo-dark); opacity: .85; } +.badge-role-staff { background: #6f42c1; } +.badge-role-client { background: #adb5bd; color: #212529; } +.btn-outline-primary { color: var(--bo-accent); border-color: var(--bo-accent); } +.btn-outline-primary:hover { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); } +.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); } +.btn-primary:hover { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); } +.card { border: none; box-shadow: 0 1px 3px rgba(0,0,0,.04); } +[data-bs-theme="dark"] .table-light { --bs-table-bg: var(--bs-tertiary-bg); --bs-table-color: var(--bs-body-color); } + +/* Summernote (WYSIWYG): alinhado com o aspeto do backoffice */ +.note-editor.note-frame { border-color: #ced4da; border-radius: .375rem; } +.note-editor .note-editing-area .note-editable { font-size: .95rem; } +.note-editor.note-frame.note-focused { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); } +.note-toolbar { background: var(--bs-tertiary-bg); } +.note-btn.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); } + +/* ── Telemóvel: barra lateral em gaveta ──────────────────────────────────── */ +@media (max-width: 991.98px) { + .sidebar { + transform: translateX(-100%); + transition: transform .25s ease; + z-index: 1100; + box-shadow: 2px 0 18px rgba(0,0,0,.3); + } + .sidebar.open { transform: translateX(0); } + main { margin-left: 0; } + .topbar { left: 0; padding: 0 .75rem; } + .menu-toggle { display: inline-flex; } + /* Tabelas largas rolam dentro do cartão em vez de alargar a página */ + main .card > .card-body { overflow-x: auto; -webkit-overflow-scrolling: touch; } +} +@media (max-width: 575.98px) { + /* Cabeçalhos de página (título + botões) empilham em vez de transbordar */ + main .d-flex.justify-content-between.align-items-center { flex-wrap: wrap; gap: .5rem; } + /* 16px nos campos: o iOS não faz zoom ao focar */ + .form-control, .form-select { font-size: 16px; } + /* Margens confortáveis em telemóvel */ + /* !important: .p-3 é uma utilitária do Bootstrap e tem !important de origem */ + .p-3 { padding: .85rem !important; } +} + +/* ── Modo mini (só ecrã grande; classe posta no pelo script do ) ── */ +@media (min-width: 992px) { + html.bo-mini { --bo-sidebar-w: var(--bo-sidebar-w-mini); } + html.bo-mini .bo-sidebar__head { + flex-direction: column; + justify-content: center; + height: auto; + padding: .5rem 0; + gap: .25rem; + } + html.bo-mini .bo-sidebar__brand { display: none; } + html.bo-mini .bo-sidebar__user { padding: .5rem; } + html.bo-mini .bo-user-menu { justify-content: center; padding: .25rem; } + html.bo-mini .bo-user-menu__text, + html.bo-mini .bo-user-menu::after { display: none; } + html.bo-mini .bo-sidebar__nav { padding: .5rem 0; } + html.bo-mini .sidebar .bo-nav-item, + html.bo-mini .sidebar .bo-nav-item--sub, + html.bo-mini .sidebar .bo-nav-item--sub2 { justify-content: center; padding: .5rem; } + html.bo-mini .bo-nav-item__label { display: none; } + html.bo-mini .bo-nav-item__badge { + position: absolute; + top: 2px; + right: 8px; + margin: 0; + padding: .15em .4em; + font-size: .6rem; + } + /* Categoria no mini: sigla de texto (data-short), sem ícone nem chevron. */ + html.bo-mini .bo-nav-group__label, + html.bo-mini .bo-nav-group--sub > .bo-nav-group__label { justify-content: center; padding: .55rem .25rem; font-size: 0; } + html.bo-mini .bo-nav-group__label::before { content: attr(data-short); font-size: .66rem; font-weight: 700; letter-spacing: .08em; } + html.bo-mini .bo-nav-group__text, + html.bo-mini .bo-nav-group__icon, + html.bo-mini .bo-nav-group__chevron { display: none; } +} + +/* ── Navegação sem piscar: só o conteúdo faz fade; barra e topbar ficam ── */ +@media (prefers-reduced-motion: no-preference) { + @view-transition { navigation: auto; } + .sidebar { view-transition-name: bo-sidebar; } + .topbar { view-transition-name: bo-topbar; } + ::view-transition-old(root), + ::view-transition-new(root) { animation-duration: .14s; } +} +@media (prefers-reduced-motion: reduce) { + .sidebar, + .sidebar .bo-nav-item, + .bo-nav-group__body, + .bo-nav-group__chevron { transition: none; } +} 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/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, diff --git a/config/db.php b/config/db.php index 2d0eb78..27dd54c 100644 --- a/config/db.php +++ b/config/db.php @@ -96,7 +96,17 @@ try { // para deixar a porta aberta a usar o throttle partilhado no futuro. Ver // views/auth/login.php para a nota completa. require_once dirname(__DIR__) . '/shared/platform-tools/functions_connector.php'; -platform_connect($pdo, [ +// Pacotes de ícones partilhados (platform?tool=icons, pt_icon_assets()). Só se o +// submódulo já os tem; sem ele os layouts caem nas cópias locais (fit_icon_assets()). +// Flaticon NÃO está registado: o site usa PNGs (uploads/modalidades/icons), não a fonte. +$iconsFile = dirname(__DIR__) . '/shared/platform-tools/functions_icons.php'; +if (is_file($iconsFile)) { + require_once $iconsFile; +} +$iconsOpt = is_file($iconsFile) + ? ['icons' => ['packs' => ['bootstrap-icons', 'fontawesome'], 'default' => 'bootstrap-icons']] + : []; +platform_connect($pdo, $iconsOpt + [ 'auth' => 'shared', 'roles' => [ // Preserva a hierarquia que config/auth.php já tinha (sysadmin > diff --git a/config/functions.php b/config/functions.php index 16d6b53..8c6e7bb 100644 --- a/config/functions.php +++ b/config/functions.php @@ -27,6 +27,7 @@ require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrat require_once __DIR__ . '/functions_queue.php'; // message_queue + enqueue_*/flush_message require_once __DIR__ . '/functions_csrf.php'; require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed) +require_once __DIR__ . '/functions_icons.php'; // fit_icon_assets(): pacotes de ícones partilhados + recurso local require_once __DIR__ . '/ci_compat.php'; // CI4 view-helper shim (script_tag/link_tag/view/esc/session) require_once __DIR__ . '/functions_gyms.php'; // multi-gym: gyms, URL folders, admin gym switcher require_once __DIR__ . '/functions_site.php'; // public-site data (site_info/offer/team/modalidades/horario) @@ -40,6 +41,7 @@ require_once __DIR__ . '/functions_events.php'; // time-limited events + require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_info) editor require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache +require_once __DIR__ . '/functions_instagram.php'; // «Comunidade»: posts do Instagram onde o ginásio é identificado require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental require_once __DIR__ . '/functions_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos diff --git a/config/functions_events.php b/config/functions_events.php index 948e0b8..77185d8 100644 --- a/config/functions_events.php +++ b/config/functions_events.php @@ -15,6 +15,7 @@ function events_defaults(): array { 'location' => '', 'event_starts_at' => null, 'event_ends_at' => null, 'publish_from' => '', 'publish_until' => '', 'is_active' => 1, 'cta_label' => '', 'cta_url' => '', 'show_lead_form' => 1, + 'gym_id' => null, // NULL = shown on every gym's homepage ]; } @@ -117,8 +118,8 @@ function db_events_create(PDO $pdo, array $data): int { INSERT INTO events (slug, title, subtitle, summary, description, hero_image, cor, location, event_starts_at, event_ends_at, publish_from, publish_until, - is_active, cta_label, cta_url, show_lead_form) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + is_active, cta_label, cta_url, show_lead_form, gym_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "); $stmt->execute([ $d['slug'], $d['title'], $d['subtitle'], $d['summary'], $d['description'], @@ -127,6 +128,7 @@ function db_events_create(PDO $pdo, array $data): int { str_replace('T', ' ', (string) $d['publish_from']), str_replace('T', ' ', (string) $d['publish_until']), (int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'], + $d['gym_id'] ? (int) $d['gym_id'] : null, ]); $__id = (int) $pdo->lastInsertId(); audit_change($pdo, 'event', 'events', 'e_id', (int) $__id, 'create', null, 'title'); @@ -141,7 +143,7 @@ function db_events_update(PDO $pdo, int $id, array $data): bool { slug = ?, title = ?, subtitle = ?, summary = ?, description = ?, hero_image = ?, cor = ?, location = ?, event_starts_at = ?, event_ends_at = ?, publish_from = ?, publish_until = ?, - is_active = ?, cta_label = ?, cta_url = ?, show_lead_form = ? + is_active = ?, cta_label = ?, cta_url = ?, show_lead_form = ?, gym_id = ? WHERE e_id = ? "); $stmt->execute([ @@ -151,6 +153,7 @@ function db_events_update(PDO $pdo, int $id, array $data): bool { str_replace('T', ' ', (string) $d['publish_from']), str_replace('T', ' ', (string) $d['publish_until']), (int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'], + $d['gym_id'] ? (int) $d['gym_id'] : null, $id, ]); $__r = $stmt->rowCount() > 0; @@ -225,6 +228,7 @@ function events_image_remove(?string $filename): void { if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return; $path = events_image_dir() . $filename; if (is_file($path)) @unlink($path); + img_fallback_follow($path); } // ─── Leads ───────────────────────────────────────────────────────────────────── diff --git a/config/functions_galerias.php b/config/functions_galerias.php index 4570373..e6b4bda 100644 --- a/config/functions_galerias.php +++ b/config/functions_galerias.php @@ -50,23 +50,34 @@ function db_gallery_images(PDO $pdo, int $gallery_id): array { return $stmt->fetchAll(PDO::FETCH_ASSOC); } -function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?string { - if ($file['error'] !== UPLOAD_ERR_OK) return null; +// Limite por ficheiro: o menor entre 16 MB e o upload_max_filesize do PHP. +function gallery_upload_max_bytes(): int { + $ini = trim((string) ini_get('upload_max_filesize')); + $n = (int) $ini; + $n *= match (strtolower(substr($ini, -1))) { 'g' => 1 << 30, 'm' => 1 << 20, 'k' => 1 << 10, default => 1 }; + return $n > 0 ? min($n, 16 * 1024 * 1024) : 16 * 1024 * 1024; +} - if (($file['size'] ?? 0) > 16 * 1024 * 1024) return null; +// $error recebe o motivo (para a mensagem ao utilizador) quando devolve null. +function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file, ?string &$error = null): ?string { + $max_mb = round(gallery_upload_max_bytes() / 1048576); + if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) { $error = "maior que {$max_mb} MB"; return null; } + if ($file['error'] !== UPLOAD_ERR_OK) { $error = 'o envio falhou'; return null; } + + if (($file['size'] ?? 0) > gallery_upload_max_bytes()) { $error = "maior que {$max_mb} MB"; return null; } // Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php). - $ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp']); - if ($ext === null) return null; + $ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl']); + if ($ext === null) { $error = 'formato não suportado'; return null; } $upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/'; if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true); $filename = bin2hex(random_bytes(16)) . '.' . $ext; - if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) return null; + if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) { $error = 'não foi possível gravar'; return null; } img_optimize($upload_dir . $filename, IMG_MAX_GALLERY); $stmt = $pdo->prepare("INSERT INTO gallery (i_file_name, i_file_type, i_project_id) VALUES (?, ?, ?)"); - $stmt->execute([$filename, $file['type'] ?? 'image/jpeg', $gallery_id]); + $stmt->execute([$filename, $ext === 'jxl' ? 'image/jxl' : ($file['type'] ?? 'image/jpeg'), $gallery_id]); return $filename; } diff --git a/config/functions_gyms.php b/config/functions_gyms.php index 82ffd5a..e71c5ec 100644 --- a/config/functions_gyms.php +++ b/config/functions_gyms.php @@ -19,6 +19,7 @@ function gym_sections(): array { return [ 'oferta' => 'A Nossa Oferta', 'galeria' => 'Galeria (instalações)', + 'comunidade' => 'Comunidade (Instagram)', 'modalidades' => 'Modalidades', 'equipa' => 'Equipa', 'horario' => 'Horário de aulas', @@ -41,7 +42,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' => '', 'logo' => '', 'item_order' => 0, ]; } @@ -103,6 +104,32 @@ 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_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 '--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'); +} + +// 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 . '}'; +} + function gym_url(array $gym, string $path = ''): string { return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/')); } @@ -159,8 +186,22 @@ 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), ]; + // Header image + logo come from file inputs (a fresh upload wins, "remover" + // clears it, otherwise the current value posted in the hidden field stays). + foreach (['hero' => 'hero_image', 'logo' => 'logo'] as $kind => $field) { + if (!empty($post[$kind . '_remove'])) $data[$field] = ''; + $file = $_FILES[$kind . '_file'] ?? null; + if ($file && ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + $err = null; + $saved = gyms_save_image($file, $kind, $err); + if ($saved !== null) $data[$field] = $saved; + else $errors[] = ($kind === 'hero' ? 'Imagem do header: ' : 'Logótipo: ') . ($err ?? 'ficheiro inválido.'); + } + } $on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? []))); $data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on); @@ -173,25 +214,42 @@ 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|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'])) { - $errors[] = 'Imagem do header: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).'; + 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; } -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']]; +// Columns written by create/update, in order. brand_color and logo were added by +// migration_20261013_gym_brand_color.sql: an install that hasn't run it yet keeps +// working (they are just not saved) instead of failing on an unknown column. +function _gyms_cols(PDO $pdo, array $d): array { + $cols = [ + 'slug' => $d['slug'], 'name' => $d['name'], 'locality' => $d['locality'], 'postal_code' => $d['postal_code'], + 'is_published' => $d['is_published'], 'is_default' => $d['is_default'], 'sections' => $d['sections'], + 'hero_title' => $d['hero_title'], 'hero_subtitle' => $d['hero_subtitle'], 'hero_image' => $d['hero_image'], + 'seo_title' => $d['seo_title'], 'seo_description' => $d['seo_description'], 'gallery_id' => $d['gallery_id'], + 'google_place_id' => $d['google_place_id'], 'has_crossfit' => $d['has_crossfit'], + ]; + foreach (['brand_color', 'logo'] as $opt) { + if (_gyms_has_col($pdo, 'gyms', $opt)) $cols[$opt] = $d[$opt] ?? ''; + } + $cols['item_order'] = $d['item_order']; + return $cols; } function db_gyms_create(PDO $pdo, array $d): int { + $cols = _gyms_cols($pdo, $d); $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)); + $pdo->prepare('INSERT INTO gyms (' . implode(', ', array_keys($cols)) . ', created_at, updated_at) VALUES (' + . implode(',', array_fill(0, count($cols), '?')) . ', NOW(), NOW())')->execute(array_values($cols)); $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. @@ -206,14 +264,10 @@ function db_gyms_create(PDO $pdo, array $d): int { function db_gyms_update(PDO $pdo, int $id, array $d): bool { $__before = audit_row($pdo, 'gyms', 'id', (int) $id); + $cols = _gyms_cols($pdo, $d); $pdo->beginTransaction(); - $params = _gyms_params($d); $params[] = $id; - $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() - WHERE id = ?"); - $st->execute($params); + $st = $pdo->prepare('UPDATE gyms SET ' . implode(' = ?, ', array_keys($cols)) . ' = ?, updated_at = NOW() WHERE id = ?'); + $st->execute(array_merge(array_values($cols), [$id])); $changed = $st->rowCount() > 0; if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]); $pdo->commit(); @@ -222,6 +276,47 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool { return $__r; } +// ─── Header image / logo uploads ────────────────────────────────────────────── +// Files live in uploads/gyms/ (uploads/.htaccess blocks script execution) and the +// DB keeps the site-relative path, which is what the public pages already read. +function gyms_upload_dir(): string { + return dirname(__DIR__) . '/uploads/gyms/'; +} + +// Validates by content (upload_image_ext, SVGs through svg_is_safe), stores the +// file under a random name and returns "uploads/gyms/", or null with $error. +function gyms_save_image(array $file, string $kind, ?string &$error = null): ?string { + if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { + $error = ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) + ? 'o ficheiro é demasiado grande.' : 'o envio falhou.'; + return null; + } + if (($file['size'] ?? 0) > 6 * 1024 * 1024) { $error = 'máximo 6 MB.'; return null; } + $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, WebP ou JPEG XL.' : 'usa SVG, PNG, WebP, JPG ou JPEG XL (SVG sem scripts).'; + return null; + } + $dir = gyms_upload_dir(); + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { $error = 'não foi possível criar a pasta de envio.'; return null; } + $name = $kind . '_' . bin2hex(random_bytes(6)) . '.' . $ext; + if (!move_uploaded_file($file['tmp_name'], $dir . $name)) { $error = 'não foi possível guardar o ficheiro.'; return null; } + if ($ext !== 'svg' && function_exists('img_optimize')) { + img_optimize($dir . $name, $kind === 'hero' ? IMG_MAX_GALLERY : 600); + } + return 'uploads/gyms/' . $name; +} + +// Deletes an uploaded image that is no longer referenced (never a bundled one). +function gyms_remove_image(?string $rel): void { + $rel = (string) $rel; + if (!preg_match('~^uploads/gyms/[A-Za-z0-9_.-]+$~', $rel)) return; + $path = gyms_upload_dir() . basename($rel); + if (is_file($path)) @unlink($path); + img_fallback_follow($path); +} + // Single-field setter for views/admin/reviews/index.php — that page only // touches google_place_id, not the rest of the gym record db_gyms_update() // would validate/audit. diff --git a/config/functions_icons.php b/config/functions_icons.php new file mode 100644 index 0000000..688eb33 --- /dev/null +++ b/config/functions_icons.php @@ -0,0 +1,18 @@ + locais de recurso, caminhos para link_tag()/base_url() +function fit_icon_assets(array $packs, array $localHrefs): string { + if (function_exists('pt_icon_assets')) { + $html = pt_icon_assets($packs); + if ($html !== '') return $html; + } + $out = ''; + foreach ($localHrefs as $href) { + $out .= '' . "\n"; + } + return $out; +} diff --git a/config/functions_images.php b/config/functions_images.php index d3625c7..48f23eb 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; @@ -80,6 +89,7 @@ function img_optimize(string $path, int $max_dim): array { if (!is_file($path)) return $fail('ficheiro não encontrado'); if (!extension_loaded('gd')) return $fail('extensão GD indisponível'); + if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'jxl') return img_jxl_optimize($path, $max_dim); $info = @getimagesize($path); if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster @@ -177,9 +187,9 @@ function img_optimize_dir(string $dir, int $max_dim): array { if (!is_dir($dir)) return $stats; foreach (scandir($dir) ?: [] as $file) { $path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file; - if (!is_file($path)) continue; + if (!is_file($path) || img_is_jxl_fallback($file)) continue; $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); - if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) continue; + if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl'], true)) continue; $r = img_optimize($path, $max_dim); $stats[$r['status']]++; $stats['saved'] += $r['saved']; @@ -205,3 +215,226 @@ function img_human_bytes(int $bytes): string { if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB'; return $bytes . ' B'; } + +// ─── JPEG XL ───────────────────────────────────────────────────────────────── +// O GD não lê JXL. Descodifica-se com os binários do libjxl (djxl/cjxl, pacote +// Debian libjxl-tools) ou com o Imagick se tiver o formato JXL; sem nenhum, o +// .jxl fica como foi enviado (status "skipped") e o upload não falha. +// Ao lado de cada foto.jxl grava-se foto.jxl.webp (ou .jpg sem WebP no GD): +// é a versão para os browsers sem JXL, servida por img_tag()/img_css_bg() e +// escondida das listagens (img_list_dir). Redimensiona-se a partir da versão +// descodificada e, havendo encoder, o .jxl é regravado ao novo tamanho. + +const IMG_JXL_DISTANCE = '1.0'; // cjxl -d: 1.0 = visualmente sem perdas + +// Backends on this server: ['imagick' => bool, 'djxl' => path|'', 'cjxl' => path|'']. +function img_jxl_tools(): array { + static $tools = null; + if ($tools !== null) return $tools; + $imagick = class_exists('Imagick') && in_array('JXL', \Imagick::queryFormats('JXL'), true); + $find = static function (string $bin): string { + if (!function_exists('proc_open')) return ''; + $dirs = array_merge(explode(PATH_SEPARATOR, (string) getenv('PATH')), ['/usr/bin', '/usr/local/bin']); + foreach (array_unique(array_filter($dirs)) as $d) { + $p = rtrim($d, '/\\') . DIRECTORY_SEPARATOR . $bin; + if (@is_file($p) && @is_executable($p)) return $p; + } + return ''; + }; + return $tools = ['imagick' => $imagick, 'djxl' => $find('djxl'), 'cjxl' => $find('cjxl')]; +} + +function img_jxl_can_decode(): bool { + $t = img_jxl_tools(); + return $t['imagick'] || $t['djxl'] !== ''; +} + +// Runs a binary with an argument list (no shell). True when it exits 0 and wrote $out. +function _img_run(string $bin, array $args, string $out): bool { + $proc = @proc_open(array_merge([$bin], $args), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($proc)) return false; + stream_get_contents($pipes[1]); stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + clearstatcache(true, $out); + return proc_close($proc) === 0 && is_file($out) && filesize($out) > 0; +} + +function img_jxl_decode(string $jxl, string $png): bool { + $t = img_jxl_tools(); + if ($t['djxl'] !== '' && _img_run($t['djxl'], [$jxl, $png], $png)) return true; + if ($t['imagick']) { + try { + $im = new \Imagick($jxl); + $im->setImageFormat('PNG'); + return $im->writeImage($png) && is_file($png); + } catch (\Throwable $e) { return false; } + } + return false; +} + +function img_jxl_encode(string $png, string $jxl): bool { + $t = img_jxl_tools(); + if ($t['cjxl'] !== '' && _img_run($t['cjxl'], [$png, $jxl, '-d', IMG_JXL_DISTANCE, '-e', '7'], $jxl)) return true; + if ($t['imagick']) { + try { + $im = new \Imagick($png); + $im->setImageFormat('JXL'); + $im->setImageCompressionQuality(90); + return $im->writeImage($jxl) && is_file($jxl); + } catch (\Throwable $e) { return false; } + } + return false; +} + +// "foto.jxl.webp" / "foto.jxl.jpg": the fallback copies, never listed as photos. +function img_is_jxl_fallback(string $name): bool { + return (bool) preg_match('/\.jxl\.(webp|jpe?g|png)$/i', $name); +} + +// Absolute path of the fallback beside $jxl_path, or '' when there is none. +function img_jxl_fallback(string $jxl_path): string { + foreach (['webp', 'jpg', 'png'] as $e) { + if (is_file($jxl_path . '.' . $e)) return $jxl_path . '.' . $e; + } + return ''; +} + +// Moves (or, with $to = '', deletes) the fallback that goes with an image file. +// Callers that move/delete an upload call this too, so the pair stays together. +function img_fallback_follow(string $from, string $to = ''): void { + if (!preg_match('/\.jxl$/i', $from)) return; + foreach (['webp', 'jpg', 'png'] as $e) { + if (!is_file($from . '.' . $e)) continue; + if ($to === '') @unlink($from . '.' . $e); + else @rename($from . '.' . $e, $to . '.' . $e); + } +} + +function img_jxl_optimize(string $path, int $max_dim): array { + $skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why]; + $fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why]; + + // Fallback newer than the .jxl and within the cap: nothing to do (idempotent). + $old = img_jxl_fallback($path); + if ($old !== '' && filemtime($old) >= filemtime($path)) { + $d = @getimagesize($old); + if ($d && $d[0] <= $max_dim && $d[1] <= $max_dim) return $skip('já dentro do limite'); + } + if (!img_jxl_can_decode()) return $skip('JPEG XL sem descodificador no servidor (libjxl-tools)'); + + $dec = $path . '.dec.png'; + if (!img_jxl_decode($path, $dec)) { @unlink($dec); return $fail('JPEG XL ilegível'); } + $info = @getimagesize($dec); + if (!$info) { @unlink($dec); return $fail('JPEG XL ilegível'); } + [$w, $h] = $info; + if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M'); + $src = @imagecreatefrompng($dec); + if (!$src) { @unlink($dec); return $fail('JPEG XL ilegível'); } + + $scale = min(1, $max_dim / max($w, $h, 1)); + $nw = max(1, (int) round($w * $scale)); + $nh = max(1, (int) round($h * $scale)); + if ($scale < 1) { + $dst = imagecreatetruecolor($nw, $nh); + imagealphablending($dst, false); + imagesavealpha($dst, true); + imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127)); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h); + imagedestroy($src); + $src = $dst; + } + + // Fallback: WebP keeps transparency; without WebP in GD, JPEG. + img_fallback_follow($path); + if (function_exists('imagewebp')) { + imagesavealpha($src, true); + $ok = imagewebp($src, $path . '.webp', IMG_WEBP_QUALITY); + } else { + imageinterlace($src, true); + $ok = imagejpeg($src, $path . '.jpg', IMG_JPEG_QUALITY); + } + + // Smaller JXL: re-encode from the resized pixels when there is an encoder. + $saved = 0; + if ($ok && $scale < 1 && imagepng($src, $dec)) { + $tmp = $path . '.opt.tmp'; + $before = (int) filesize($path); + if (img_jxl_encode($dec, $tmp) && filesize($tmp) < $before) { + if (!@rename($tmp, $path)) { @unlink($path); @rename($tmp, $path); } + clearstatcache(true, $path); + $saved = max(0, $before - (int) filesize($path)); + } + @unlink($tmp); + } + imagedestroy($src); + @unlink($dec); + if (!$ok) return $fail('falha ao gravar a versão alternativa'); + // The fallback must not look older than the .jxl (idempotence check above). + @touch(img_jxl_fallback($path)); + return ['status' => 'resized', 'saved' => $saved, + 'reason' => ($scale < 1 ? "{$w}×{$h} → {$nw}×{$nh}, " : '') . 'versão alternativa criada']; +} + +// ─── Servir imagens (JPEG XL com alternativa) ──────────────────────────────── +// $rel é o caminho relativo à raiz do projeto (o que a BD guarda / o glob dá). + +function _img_abs(string $rel): string { + return dirname(__DIR__) . '/' . ltrim($rel, '/'); +} + +// Fallback of a .jxl as a project-relative path, or '' (not JXL / no fallback). +function img_fallback_rel(string $rel): string { + if (!preg_match('/\.jxl$/i', $rel)) return ''; + $fb = img_jxl_fallback(_img_abs($rel)); + return $fb === '' ? '' : $rel . substr($fb, -strlen(strrchr($fb, '.'))); +} + +// Where only one image fits (lightbox, og:image, preload, e-mail): the +// fallback of a .jxl when there is one, else the file itself. +function img_compat_rel(string $rel): string { + return img_fallback_rel($rel) ?: $rel; +} + +// for $rel; a .jxl with a fallback becomes with the JXL source +// first. $attrs: extra attributes (trusted names, values escaped here); +// width/height come from the file (or its fallback) unless given. +function img_tag(string $rel, array $attrs = []): string { + $fb = img_fallback_rel($rel); + $src = $fb ?: $rel; + if (!isset($attrs['width'])) { + $d = @getimagesize(_img_abs($src)); + if ($d) { $attrs['width'] = $d[0]; $attrs['height'] = $d[1]; } + } + $a = ''; + foreach ($attrs as $k => $v) { + if ($v === null || $v === false) continue; + $a .= ' ' . $k . ($v === true ? '' : '="' . htmlspecialchars((string) $v) . '"'); + } + $img = ''; + if ($fb === '') return $img; + return '' . $img . ''; +} + +// CSS for a style="" attribute: url(fallback) first, then image-set() with the +// JXL, so browsers without image-set()/JXL keep the fallback. +function img_css_bg(string $rel): string { + $q = static fn(string $r) => "url('" . str_replace(["'", '\\'], ['%27', '%5C'], base_url($r)) . "')"; + $fb = img_fallback_rel($rel); + if ($fb === '') return 'background-image:' . $q($rel); + $type = str_ends_with(strtolower($fb), '.webp') ? 'image/webp' : 'image/jpeg'; + return 'background-image:' . $q($fb) . ';background-image:image-set(' . $q($rel) . ' type("image/jxl"), ' . $q($fb) . ' type("' . $type . '"))'; +} + +// Photos directly inside a project-relative folder ("uploads/gallery/3/"), +// sorted, without the .jxl fallbacks. +function img_list_dir(string $dir_rel): array { + $dir_rel = rtrim($dir_rel, '/') . '/'; + $out = []; + foreach (glob(_img_abs($dir_rel) . '*') ?: [] as $p) { + $name = basename($p); + if (!is_file($p) || img_is_jxl_fallback($name) || !preg_match('/\.(jpe?g|png|gif|webp|jxl)$/i', $name)) continue; + $out[] = $dir_rel . $name; + } + sort($out); + return $out; +} diff --git a/config/functions_instagram.php b/config/functions_instagram.php new file mode 100644 index 0000000..e216813 --- /dev/null +++ b/config/functions_instagram.php @@ -0,0 +1,188 @@ + / +// instagram_token_ (token encrypted, or INSTAGRAM_TOKEN_ in .env). +// Same cache pattern as functions_reviews.php: config/cache/instagram_.cache.php, +// lazy refresh under a non-blocking lock, old copy kept on error. Thumbnails +// are downloaded at refresh time to uploads/instagram// (Instagram CDN +// URLs expire), so visitors never wait on Instagram. + +const INSTAGRAM_GRAPH = 'https://graph.facebook.com/v21.0/'; + +function instagram_use_gym(array $gym): void { + $GLOBALS['_instagram_gym'] = $gym; +} + +function instagram_gym(): array { + return $GLOBALS['_instagram_gym'] ?? current_gym(); +} + +function _instagram_gid(): int { + return (int) (instagram_gym()['id'] ?? 1); +} + +function instagram_config(): array { + $gid = _instagram_gid(); + return [ + 'user_id' => preg_replace('/\D+/', '', (string) setting('instagram_user_id_' . $gid, '')), + 'token' => setting_secret('instagram_token_' . $gid, 'INSTAGRAM_TOKEN_' . $gid), + 'max' => max(3, min(24, (int) setting('instagram_max', 8))), + 'cache_hours' => max(1, (int) setting('instagram_cache_hours', 6)), + ]; +} + +function instagram_configured(): bool { + $c = instagram_config(); + return $c['user_id'] !== '' && $c['token'] !== ''; +} + +function _instagram_cache_path(): string { + return __DIR__ . '/cache/instagram_' . _instagram_gid() . '.cache.php'; +} + +function _instagram_media_dir(): string { + return dirname(__DIR__) . '/uploads/instagram/' . _instagram_gid() . '/'; +} + +function instagram_cache_read(): ?array { + $path = _instagram_cache_path(); + if (!is_file($path)) return null; + $data = @include $path; + return is_array($data) ? $data : null; +} + +function _instagram_cache_write(array $data): void { + $path = _instagram_cache_path(); + if (!is_dir(dirname($path))) @mkdir(dirname($path), 0775, true); + $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; + if (@file_put_contents($tmp, " true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 6, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $raw = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $cerr = curl_error($ch); + curl_close($ch); + if ($raw === false) return [null, 'Sem ligação ao Instagram: ' . $cerr]; + $json = json_decode((string) $raw, true); + if ($status !== 200) { + $msg = is_array($json) ? (string) ($json['error']['message'] ?? '') : ''; + return [$json, 'Instagram respondeu ' . $status . ($msg !== '' ? ': ' . $msg : '')]; + } + return [is_array($json) ? $json : null, is_array($json) ? '' : 'Resposta inválida do Instagram.']; +} + +// Saves one thumbnail locally; returns the uploads-relative path or ''. +function _instagram_save_thumb(string $src, string $id): string { + if (!preg_match('#^https://[a-z0-9.-]+\.(cdninstagram\.com|fbcdn\.net)/#i', $src)) return ''; + $dir = _instagram_media_dir(); + $rel = 'uploads/instagram/' . _instagram_gid() . '/' . $id . '.jpg'; + if (is_file($dir . $id . '.jpg')) return $rel; + $ch = curl_init($src); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 2, + CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + ]); + $bin = curl_exec($ch); + curl_close($ch); + $info = (is_string($bin) && $bin !== '' && strlen($bin) < 4 * 1024 * 1024) ? @getimagesizefromstring($bin) : false; + if (!$info || $info['mime'] !== 'image/jpeg') return ''; + if (!is_dir($dir)) @mkdir($dir, 0775, true); + return @file_put_contents($dir . $id . '.jpg', $bin) ? $rel : ''; +} + +// Fetch the tagged media now and rewrite the cache. Returns the cache array. +function instagram_refresh(): array { + $c = instagram_config(); + $old = instagram_cache_read() ?? []; + if ($c['user_id'] === '' || $c['token'] === '') { + return $old + ['error' => 'Falta o ID da conta ou o token.']; + } + // Ask for a few extra: private posts are never returned, and some come without a picture. + $url = INSTAGRAM_GRAPH . $c['user_id'] . '/tags?' . http_build_query([ + 'fields' => 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,username', + 'limit' => min(50, $c['max'] + 6), + 'access_token' => $c['token'], + ]); + [$json, $err] = _instagram_request($url); + + if ($err !== '') { + // Keep serving the previous copy; retry after an hour, not a full window. + $data = $old; + $data['error'] = $err; + $data['error_at'] = time(); + $data['fetched_at'] = time() - max(0, $c['cache_hours'] * 3600 - 3600); + _instagram_cache_write($data); + error_log('[instagram] ' . $err); + return $data; + } + + $posts = []; + foreach ((array) ($json['data'] ?? []) as $m) { + if (count($posts) >= $c['max']) break; + $id = preg_replace('/\D+/', '', (string) ($m['id'] ?? '')); + if ($id === '' || empty($m['permalink'])) continue; + // Videos/reels: the cover (thumbnail_url); photos and albums: media_url. + $src = (string) (($m['media_type'] ?? '') === 'VIDEO' ? ($m['thumbnail_url'] ?? '') : ($m['media_url'] ?? '')); + $img = $src !== '' ? _instagram_save_thumb($src, $id) : ''; + if ($img === '') continue; + $posts[] = [ + 'id' => $id, + 'image' => $img, + 'type' => (string) ($m['media_type'] ?? ''), + 'permalink' => (string) $m['permalink'], + 'username' => (string) ($m['username'] ?? ''), + 'caption' => (string) ($m['caption'] ?? ''), + 'timestamp' => (string) ($m['timestamp'] ?? ''), + ]; + } + // Drop thumbnails of posts that are no longer shown. + $keep = array_flip(array_column($posts, 'id')); + foreach (glob(_instagram_media_dir() . '*.jpg') ?: [] as $f) { + if (!isset($keep[basename($f, '.jpg')])) @unlink($f); + } + $data = ['fetched_at' => time(), 'posts' => $posts, 'error' => '']; + _instagram_cache_write($data); + return $data; +} + +// Cached data, refreshed when older than the cache window (one refresh at a time). +function instagram_get(): array { + $c = instagram_config(); + $cache = instagram_cache_read(); + $fresh = $cache && (time() - (int) ($cache['fetched_at'] ?? 0)) < $c['cache_hours'] * 3600; + if ($fresh || !instagram_configured()) return $cache ?? []; + + $lock = @fopen(_instagram_cache_path() . '.lock', 'c'); + if ($lock && flock($lock, LOCK_EX | LOCK_NB)) { + $cache = instagram_refresh(); + flock($lock, LOCK_UN); + } + if ($lock) fclose($lock); + return $cache ?? []; +} + +// Posts the homepage shows (only those whose thumbnail is on disk), or [] to hide the section. +function site_instagram(?array $gym = null): array { + if ($gym !== null) instagram_use_gym($gym); + try { $data = instagram_get(); } catch (Throwable $e) { error_log('[instagram] ' . $e->getMessage()); return []; } + $root = dirname(__DIR__) . '/'; + return array_values(array_filter((array) ($data['posts'] ?? []), fn($p) => is_file($root . $p['image']))); +} diff --git a/config/functions_site.php b/config/functions_site.php index f9d96c9..3ccb1f4 100644 --- a/config/functions_site.php +++ b/config/functions_site.php @@ -29,14 +29,38 @@ 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' && $sec !== 'comunidade') 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']) && img_list_dir('uploads/gallery/' . (int) $gym['gallery_id']), + 'comunidade' => (bool) site_instagram($gym), + 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', 'Comunidade' => 'comunidade', '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; + // 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 = []; foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash); diff --git a/config/functions_trash.php b/config/functions_trash.php index e8ed8a4..abd3f56 100644 --- a/config/functions_trash.php +++ b/config/functions_trash.php @@ -269,9 +269,12 @@ function _trash_image_file(PDO $pdo, int $imageId, string $action): void { if ($action === 'delete' && is_file($live)) { if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true); @rename($live, $trash); + img_fallback_follow($live, $trash); // foto.jxl.webp goes with foto.jxl } elseif ($action === 'restore' && is_file($trash)) { @rename($trash, $live); + img_fallback_follow($trash, $live); } elseif ($action === 'purge') { if (is_file($trash)) @unlink($trash); + img_fallback_follow($trash); } } diff --git a/database/migration_20261013_gym_brand_color.sql b/database/migration_20261013_gym_brand_color.sql new file mode 100644 index 0000000..71b4f5b --- /dev/null +++ b/database/migration_20261013_gym_brand_color.sql @@ -0,0 +1,8 @@ +-- 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` = ''; + +-- 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`; diff --git a/database/migration_20261026_gym_logo.sql b/database/migration_20261026_gym_logo.sql new file mode 100644 index 0000000..2fd0afe --- /dev/null +++ b/database/migration_20261026_gym_logo.sql @@ -0,0 +1,4 @@ +-- Logótipo do ginásio. A coluna foi acrescentada a migration_20261013_gym_brand_color.sql +-- depois de essa migração já ter corrido nalgumas bases (dev incluída), que por isso +-- ficaram sem ela. Safe to re-run. +ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `logo` VARCHAR(255) NOT NULL DEFAULT '' AFTER `brand_color`; 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..2d65a91 100644 --- a/includes/auth_header.php +++ b/includes/auth_header.php @@ -29,7 +29,8 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; })(); - + + +
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/includes/footer.php b/includes/footer.php index bee17b4..33eb6b2 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -78,23 +78,93 @@ document.addEventListener('DOMContentLoaded', function () { mq.addEventListener('change', function () { if (pref === 'auto') apply('auto'); }); })(); -// Mobile off-canvas sidebar: hamburger toggles .sidebar.open + the backdrop; -// tapping the backdrop, a nav link, Escape, or resizing to desktop closes it. +// Barra lateral. Em ecrã grande o hamburger alterna entre completa e mini (so +// o texto das categorias, lembrado em localStorage); em telemovel abre/fecha a +// gaveta por cima do conteudo. Um so manipulador para os dois botoes. O CSS +// decide o que cada estado mostra; o estado inicial ja veio do . (function () { var sb = document.getElementById('sidebar'); - var tg = document.getElementById('menuToggle'); var bd = document.getElementById('sidebarBackdrop'); - if (!sb || !tg) return; - var mq = window.matchMedia('(max-width: 991.98px)'); - function open() { sb.classList.add('open'); if (bd) bd.classList.add('show'); document.body.style.overflow = 'hidden'; tg.setAttribute('aria-expanded', 'true'); } - function close() { sb.classList.remove('open'); if (bd) bd.classList.remove('show'); document.body.style.overflow = ''; tg.setAttribute('aria-expanded', 'false'); } - tg.addEventListener('click', function () { sb.classList.contains('open') ? close() : open(); }); - if (bd) bd.addEventListener('click', close); - sb.querySelectorAll('.nav-link').forEach(function (a) { - a.addEventListener('click', function () { if (mq.matches) close(); }); + var toggles = document.querySelectorAll('[data-sidebar-toggle]'); + var root = document.documentElement; + var nav = sb && sb.querySelector('.bo-sidebar__nav'); + if (!sb || !toggles.length) return; + var mq = window.matchMedia('(min-width: 992px)'); + function store(k, v) { try { localStorage.setItem(k, v); } catch (e) {} } + + function sync() { + var desktop = mq.matches; + var open = desktop ? !root.classList.contains('bo-mini') : sb.classList.contains('open'); + toggles.forEach(function (t) { + t.setAttribute('aria-expanded', open ? 'true' : 'false'); + t.setAttribute('aria-label', open ? (desktop ? 'Reduzir menu' : 'Fechar menu') : (desktop ? 'Expandir menu' : 'Abrir menu')); + }); + if (bd) bd.classList.toggle('show', !desktop && open); + document.body.style.overflow = (!desktop && open) ? 'hidden' : ''; + } + function setDrawer(open) { sb.classList.toggle('open', open); sync(); } + + toggles.forEach(function (t) { + t.addEventListener('click', function () { + if (mq.matches) { + store('bo-sidebar-mini', root.classList.toggle('bo-mini') ? '1' : '0'); + sync(); + } else { + setDrawer(!sb.classList.contains('open')); + } + }); }); - document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); }); - window.addEventListener('resize', function () { if (!mq.matches) close(); }); + if (bd) bd.addEventListener('click', function () { setDrawer(false); }); + sb.querySelectorAll('.bo-nav-item').forEach(function (a) { + a.addEventListener('click', function () { if (!mq.matches) setDrawer(false); }); + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && sb.classList.contains('open')) { + setDrawer(false); + var o = document.querySelector('.menu-toggle'); + if (o && o.offsetParent) o.focus(); + } + }); + if (mq.addEventListener) mq.addEventListener('change', function () { setDrawer(false); }); + sync(); + + // Grupos recolhiveis. O estado inicial veio do (atributo no ); + // aqui so se liga o clique e se mantem atributo, armazenamento e aria alinhados. + var saved = {}; + try { saved = JSON.parse(localStorage.getItem('bo-nav-groups') || '{}'); } catch (e) {} + function groupOpen(g) { + if (g.classList.contains('has-active')) return !g.classList.contains('is-closed'); + return !saved[g.getAttribute('data-group')]; + } + function paint(g) { + g.querySelector('[data-group-toggle]').setAttribute('aria-expanded', groupOpen(g) ? 'true' : 'false'); + } + var groups = sb.querySelectorAll('.bo-nav-group[data-group]'); + groups.forEach(function (g) { + g.querySelector('[data-group-toggle]').addEventListener('click', function () { + var key = g.getAttribute('data-group'); + var willClose = groupOpen(g); + if (g.classList.contains('has-active')) g.classList.toggle('is-closed', willClose); + if (willClose) saved[key] = true; else delete saved[key]; + store('bo-nav-groups', JSON.stringify(saved)); + root.setAttribute('data-bo-groups', Object.keys(saved).join(' ')); + groups.forEach(paint); + }); + }); + groups.forEach(paint); + + // A lista rola sozinha: lembrar a posicao entre paginas e mostrar o item ativo. + if (nav) { + try { nav.scrollTop = +sessionStorage.getItem('bo-nav-scroll') || 0; } catch (e) {} + var cur = nav.querySelector('[aria-current="page"]'); + if (cur) { + var r = cur.getBoundingClientRect(), n = nav.getBoundingClientRect(); + if (r.top < n.top || r.bottom > n.bottom) cur.scrollIntoView({ block: 'center' }); + } + addEventListener('pagehide', function () { + try { sessionStorage.setItem('bo-nav-scroll', nav.scrollTop); } catch (e) {} + }); + } })(); + + - + + + 'sub']) ?> + 'sub']) ?> + 'sub']) ?> + + 'sub', + 'active' => (($_GET['tool'] ?? 'index') === 'index' && str_contains($_SERVER['REQUEST_URI'] ?? '', 'platform.php'))]) ?> + + 'sub', + 'active' => (($_GET['tool'] ?? '') === 'icons')]) ?> + + + + +
- -
+
- - - - - -
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/lab.php b/lab.php new file mode 100644 index 0000000..49b39f1 --- /dev/null +++ b/lab.php @@ -0,0 +1,282 @@ + + + + + + +Laboratório de estilo — FitMe + + + + + + + + + + + + +
+
+

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
+
+
+
+ + + + 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/_open.php b/site/_open.php index 166b668..7861c64 100644 --- a/site/_open.php +++ b/site/_open.php @@ -16,7 +16,8 @@ $GLOBALS['_gym'] = $gym; $info = site_info($pdo, (int) $gym['id']); $navbar = site_navbar($gym); $current_uri_2 = $current_uri_2 ?? ''; -$GLOBALS['_view_shared'] = compact('info', 'navbar', 'current_uri_2', 'gym'); +$nav_center_logo = $nav_center_logo ?? false; // index.php: big centred logo (navbar.php) +$GLOBALS['_view_shared'] = compact('info', 'navbar', 'current_uri_2', 'gym', 'nav_center_logo'); echo view('include'); // renders view('seo') inside echo view('navbar'); diff --git a/site/assets/css/style.css b/site/assets/css/style.css index 2d06d44..91a24b6 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. */ @@ -307,6 +309,9 @@ section[id] { /* ── Buttons ───────────────────────────────────────────────────────────────── */ .site-btn { + position: relative; + z-index: 0; + text-decoration: none; /* Bootstrap underlines every ; buttons never */ display: inline-block; border: none; font-size: 1rem; @@ -314,7 +319,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 +330,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); } @@ -354,19 +359,37 @@ section[id] { /* ── Bootstrap Nav Overrides ───────────────────────────────────────────────── */ .nav-link { font-weight: 700; + font-size: .875rem; + letter-spacing: .02em; color: var(--fitme-text); margin: 0 .6rem !important; transition: color var(--fitme-transition); position: relative; } +/* ── Brand slash (template) ────────────────────────────────────────────────── + The cut of the "in" box in the FitMe in logo: top-left and bottom-right + corners sliced off. Put class="fx-slash" on any element (or add a pseudo + element to this list) instead of writing the polygon again; --slash sets the + cut size. .fx-slash-start cuts the left side only (open to the right edge). */ +:root { --slash: 4px; } +.fx-slash, +.nav-link::after, +.section-title.deco h2::after { + clip-path: polygon(var(--slash) 0, 100% 0, calc(100% - var(--slash)) 100%, 0 100%); +} +.fx-slash-start { + clip-path: polygon(var(--slash) 0, 100% 0, 100% 100%, 0 100%); +} + +/* Underline: a short bar with the brand slash, tucked right under the text. */ .nav-link:after { content: ''; position: absolute; - bottom: -2px; - left: 0; - width: 100%; - height: 2px; + bottom: 3px; + left: var(--bs-navbar-nav-link-padding-x, .5rem); + right: var(--bs-navbar-nav-link-padding-x, .5rem); + height: 4px; background: var(--fitme-accent); transform: scaleX(0); transform-origin: left; @@ -384,30 +407,159 @@ section[id] { background: transparent !important; } -/* ── CrossFit nav pill ─────────────────────────────────────────────────────── */ +/* ── CrossFit nav pill (follows the gym's brand colour) ─────────────────────────────────────────────────────── */ .crossfit-nav-link { - color: #b71c1c !important; + /* the gym's own brand colour (not the darker "ink" shade), filled so the text + stays readable on the light navbar: dark/white text via --fitme-on-accent */ + background: var(--fitme-accent) !important; + color: var(--fitme-on-accent) !important; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; - border: 2px solid #b71c1c; - border-radius: var(--fitme-radius); - padding: 6px 14px !important; - transition: background-color var(--fitme-transition), color var(--fitme-transition); + border: 2px solid var(--fitme-accent); + --slash: 8px; /* shape: .fx-slash on the link (navbar.php) */ + padding: 6px 16px !important; + transition: background-color .4s ease, color .4s ease, border-color .4s ease; } .crossfit-nav-link:after { display: none; } -/* Hover and current-page state: filled pill, white text (must outrank the +/* Hover and current-page state: near-black face with white text (outranks the generic .nav-item.active .nav-link transparent-background rule above) */ .crossfit-nav-link:hover, .nav-item.active .crossfit-nav-link { - background: #b71c1c !important; + background: color-mix(in srgb, var(--fitme-accent) 10%, #000) !important; + border-color: var(--fitme-accent); color: #fff !important; } +/* ── Other-gym button (right end of the navbar) ────────────────────────────── */ +.gym-switch-btn { + position: relative; + z-index: 0; + display: inline-block; + background: var(--fitme-accent); + color: var(--fitme-on-accent); + 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 { display: inline-flex; align-items: center; gap: .5rem; } + + +.gym-switch-logo { + display: block; + height: 30px; + width: auto; + max-width: 130px; + object-fit: contain; +} + +/* ── Button hover: the same spinning edge as the Mensalidades cards ─────────── + Reuses their registered --pc-a angle + pc-spin keyframes. On hover the face + turns near-black (10% brand colour) with WHITE text, lifts a few px, and a + conic brand-colour edge sweeps round it with a soft blurred glow behind. + ::before = the face + 2px edge, ::after = the glow. --elb is the element's own + border (sb-line / sb-ghost), so the ring always sits outside it. */ +:is(.site-btn, .gym-switch-btn) { + --btn-face: color-mix(in srgb, var(--fitme-accent) 10%, #000); + --elb: 0px; + --ring: 2px; +} +.site-btn.sb-line, .site-btn.sb-ghost { --elb: 2px; } + +/* Slower, softer lift (the face colour fades with it). */ +.site-btn.site-btn, +.gym-switch-btn { + transition: transform .5s cubic-bezier(.22, .8, .3, 1), background-color .4s ease, color .4s ease, border-color .4s ease; +} + +.site-btn::before, .site-btn::after, +.gym-switch-btn::before, .gym-switch-btn::after { + content: ''; + position: absolute; + pointer-events: none; + border-radius: calc(var(--fitme-radius) + var(--ring) + var(--elb)); + opacity: 0; + transition: opacity .45s ease; +} +.site-btn::before, .gym-switch-btn::before { + inset: calc(-1 * (var(--ring) + var(--elb))); + z-index: -1; + border: var(--ring) solid transparent; + background: + 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); + z-index: -2; + background: 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); + filter: blur(10px); +} + +.site-btn.site-btn:is(:hover, :focus-visible), +.gym-switch-btn:is(:hover, :focus-visible) { + background: var(--btn-face); + border-color: transparent; + color: #fff; + transform: translateY(-3px); + box-shadow: none; +} +.site-btn:is(:hover, :focus-visible)::before, +.gym-switch-btn:is(:hover, :focus-visible)::before { opacity: 1; animation: pc-spin 3.2s linear infinite; } +.site-btn:is(:hover, :focus-visible)::after, +.gym-switch-btn:is(:hover, :focus-visible)::after { opacity: .75; animation: pc-spin 3.2s linear infinite; } + +@media (prefers-reduced-motion: reduce) { + .site-btn:is(:hover, :focus-visible)::before, .site-btn:is(:hover, :focus-visible)::after, + .gym-switch-btn:is(:hover, :focus-visible)::before, .gym-switch-btn:is(:hover, :focus-visible)::after { animation: none; } +} + +/* With a logo the button shows the gym's wordmark in its own colours straight + on the navbar (no face, like the main logo), framed by a thin brand-colour + outline; the name is the image's alt. On hover nothing fills in behind the + logo: ::before is masked down to just its ring, so only the spinning edge + shows. */ +.gym-switch-btn--logo, +.gym-switch-btn--logo:is(:hover, :focus-visible) { + background: transparent; +} +.gym-switch-btn--logo { + padding: 4px 12px; + border-width: 1px; +} +.gym-switch-btn--logo::before { + background: 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; + -webkit-mask: linear-gradient(#000 0 0) padding-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) padding-box exclude, linear-gradient(#000 0 0); +} +/* No glow either: blurred brand colour behind a dark-red wordmark muddies it. + A thin light halo keeps the logo legible over whatever shows through the + frosted navbar. */ +.gym-switch-btn--logo::after, +.gym-switch-btn--logo:is(:hover, :focus-visible)::after { display: none; } +/* Thin dark outline, not a white halo: a white glow washed out the lime FitMe 3070 logo. */ +.gym-switch-btn--logo .gym-switch-logo { filter: url(#pt-noise) drop-shadow(0 0 .6px rgba(0, 0, 0, .55)) drop-shadow(0 1px 1px rgba(0, 0, 0, .18)); } + /* ── Sticky Navbar ─────────────────────────────────────────────────────────── */ .navbar-toggler { min-width: 44px; @@ -419,10 +571,212 @@ section[id] { top: 0; width: 100%; z-index: 1000; - -webkit-backdrop-filter: blur(14px) saturate(140%); - backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(180%) brightness(1.06); + backdrop-filter: blur(14px) saturate(180%) brightness(1.06); background: var(--fitme-nav-bg); border-bottom: 1px solid rgba(0, 0, 0, .04); + /* glass edge: a bright specular line on top, a faint inner rim below */ + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .7), inset 0 -1px 0 rgba(255, 255, 255, .25); +} +/* Liquid glass (Chromium only: the only engine that runs an SVG filter in + backdrop-filter). #nav-glass (include.php) bends the page behind the bar + with smooth low-frequency displacement before the blur, so it reads as + rippled glass, not just frost; the tint is lighter so the bending shows. + -webkit-app-region is a Chromium-only property, used as the engine test; + Safari/Firefox keep the plain frosted blur above. */ +@supports (-webkit-app-region: drag) { + .header-section { + backdrop-filter: url(#nav-glass) blur(6px) saturate(180%) brightness(1.06); + background: rgba(247, 248, 250, .5); + } +} + +/* Bottom edge: the Mensalidades sweep in the current gym's colour (:root + --fitme-accent from gym_brand_css()), always on and much slower. A wide + gradient slides along a 2px line (::after); ::before is the same strip + blurred, the soft glow under it. Same peak/fade shape as the cards. */ +.header-section::before, +.header-section::after { + content: ''; + position: absolute; + left: 0; + right: var(--switch-w, 0px); /* ends where the other-gym head starts (navbar.php JS) */ + bottom: -1px; + height: 2px; + pointer-events: none; + background: linear-gradient(90deg, + rgba(var(--fitme-accent-rgb), .15) 0%, rgba(var(--fitme-accent-rgb), .15) 30%, + var(--fitme-accent) 42%, color-mix(in srgb, var(--fitme-accent) 45%, #fff) 50%, var(--fitme-accent) 58%, + rgba(var(--fitme-accent-rgb), .15) 70%, rgba(var(--fitme-accent-rgb), .15) 100%) + 0 0 / 300% 100% repeat-x; + animation: nav-sweep 14s linear infinite; +} +.header-section::before { + height: 6px; + bottom: -4px; + filter: blur(6px); + opacity: .6; +} + +@keyframes nav-sweep { + to { background-position: 150% 0; } +} + +/* Bar layout. Desktop: the links are centred on the full bar width, the logo + sits out of flow at the left and the other-gym buttons out of flow at the + right, vertically centred on the whole header. On the home page + (.has-center-logo) the header starts taller with a big centred copy of the + logo (.navbar-brand-hero, .is-top); on scroll the bar shrinks, the big logo + fades out and the left one fades in (navbar.php toggles .is-top). */ +.header-section .navbar { padding: 0 0 0 1rem; } +.navbar-brand img { + width: 150px; + height: auto; +} +.navbar-brand-hero { display: none; } +/* Match head paint, shared by the desktop bar end and the mobile menu band + (geometry per breakpoint below): everything in the OTHER gym's brand (its + vars are inline on .gym-switch): a strip in its accent on the slashed edge, + a face a shade darker so the strip reads, its logo in that gym's on-accent + ink. */ +.header-section .gym-switch::before, +.header-section .gym-switch::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; +} +.header-section .gym-switch::before { background: var(--fitme-accent); } +/* the button is a light plate on the head, so the logo keeps its own colours */ +.header-section .gym-switch .gym-switch-btn--logo, +.header-section .gym-switch .gym-switch-btn--logo:is(:hover, :focus-visible) { + background: color-mix(in srgb, var(--fitme-white) 92%, transparent); + border-color: rgba(255, 255, 255, .6); +} +.header-section .gym-switch .gym-switch-btn--logo .gym-switch-logo { filter: url(#pt-noise); } +@media (min-width: 992px) { + .header-section .navbar { + padding: 0; + transition: padding-top .5s cubic-bezier(.4, 0, .2, 1); + } + .header-section .navbar-brand { + position: absolute; + z-index: 1; + left: 1rem; + top: 50%; + margin: 0; + transform: translate(0, -50%); + } + .header-section .navbar-brand, + .navbar-brand-hero { + transition: opacity .45s ease, visibility .45s; + } + .has-center-logo .navbar-brand-hero { + display: block; + position: absolute; + z-index: 1; + left: 50%; + top: 12px; + transform: translateX(-50%); + opacity: 0; + visibility: hidden; + } + /* capped height too: the CrossFit logo is taller and would cover the links */ + .navbar-brand-hero img { width: auto; height: 52px; max-width: none; } + .header-section .navbar-collapse { + min-height: 58px; + align-items: center; + } + .header-section .gym-switch { position: absolute; } /* the match head, below */ + .header-section.is-top .navbar { padding-top: 62px; } /* room for the 180px logo */ + /* Match head: the right end of the bar is a block behind the other-gym + button, its left side cut on the same slash as the underlines (the bar is + the matchstick, this is the head). It is painted in the OTHER gym's brand + (navbar.php puts its vars on .gym-switch), a glimpse into that site: its + accent strip on the cut, a face a shade darker and a shadow just inside it. + ::before = strip, ::after = head. */ + .header-section .gym-switch { + --slash: 22px; + top: 0; + bottom: 0; + right: 0; + transform: none; + z-index: 1; + align-items: center; + padding: 0 .85rem 0 calc(var(--slash) + .85rem) !important; + } + .header-section .gym-switch::before, + .header-section .gym-switch::after { + clip-path: polygon(var(--slash) 0, 100% 0, 100% 100%, 0 100%); /* .fx-slash-start */ + } + .header-section .gym-switch::before { left: -5px; } + .header-section .gym-switch::after { + background: + radial-gradient(70% 90% at 10% 50%, rgba(0, 0, 0, .28), transparent 70%), + linear-gradient(180deg, var(--fitme-accent-dark), color-mix(in srgb, var(--fitme-accent-dark) 82%, #000)); + } + .header-section.is-top .navbar-brand { opacity: 0; visibility: hidden; } + .header-section.is-top .navbar-brand-hero { opacity: 1; visibility: visible; } + /* keep the centred hero content (event card) clear of the taller top bar */ + .hero-section { padding-top: 120px; } +} +@media (prefers-reduced-motion: reduce) { + .header-section .navbar, .header-section .navbar-brand, .navbar-brand-hero { transition: none; } +} + +/* Mobile/tablet menu: the open collapse fills the screen below the bar, links + and gym buttons centred both ways, fading in and out. It is position:absolute + (top:100% of the .navbar), not fixed: the header's backdrop-filter makes the + header the containing block for fixed children anyway. Links shrink to their + text so the underline is only as wide as the word. */ +@media (max-width: 991.98px) { + .header-section .navbar-collapse { + position: absolute; + top: 100%; + left: 0; + right: 0; + height: calc(100dvh - var(--nav-h, 60px)) !important; + overflow-y: auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1.5rem; + transition: opacity .3s ease; + } + .header-section .navbar-collapse:not(.show):not(.collapsing) { display: none; } + .header-section .navbar-collapse.collapsing { opacity: 0; transition: opacity .3s ease; } + .header-section .navbar-nav { align-items: center; gap: .25rem; } + .header-section .navbar-nav .nav-link { font-size: 1.25rem; margin: 0 !important; } + .header-section .navbar-nav .nav-link::after { left: 0; right: 0; } /* no side padding here */ + /* the match head as a band across the bottom of the menu, its top edge on the slash */ + .header-section .navbar-collapse { padding-bottom: 96px; } + .header-section .gym-switch { + --slash: 18px; + justify-content: center; + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 1; + padding: calc(var(--slash) + .9rem) 1rem 1.1rem !important; + } + .header-section .gym-switch::before, + .header-section .gym-switch::after { clip-path: polygon(0 var(--slash), 100% 0, 100% 100%, 0 100%); } + .header-section .gym-switch::before { top: -5px; } + .header-section .gym-switch::after { + background: + radial-gradient(90% 80% at 50% 0, rgba(0, 0, 0, .28), transparent 70%), + linear-gradient(180deg, var(--fitme-accent-dark), color-mix(in srgb, var(--fitme-accent-dark) 82%, #000)); + } +} +@media (max-width: 991.98px) and (prefers-reduced-motion: reduce) { + .header-section .navbar-collapse, .header-section .navbar-collapse.collapsing { transition: none; } +} + +@media (prefers-reduced-motion: reduce) { + .header-section::before, .header-section::after { animation: none; } } .site-logo { @@ -522,6 +876,41 @@ section[id] { z-index: 2; } +/* Frosted band: the hero photo is blurred at full strength along a horizontal + line through the text and fades to sharp (0) outward, up and down — a backdrop + blur masked by a vertical gradient. ::before is the blur, ::after the frost: + a faint brand-colour (green) veil plus fine grain, same mask. Both sit behind the text + (z-index -1 in the container's stacking context) and above the photo. */ +.hero-section .container::before, +.hero-section .container::after { + content: ''; + position: absolute; /* the container is static, so these fill the hero */ + inset: 0; + z-index: -1; + pointer-events: none; + -webkit-mask-image: linear-gradient(to bottom, transparent 18%, rgba(0, 0, 0, .55) 36%, #000 50%, rgba(0, 0, 0, .55) 64%, transparent 82%); + mask-image: linear-gradient(to bottom, transparent 18%, rgba(0, 0, 0, .55) 36%, #000 50%, rgba(0, 0, 0, .55) 64%, transparent 82%); +} +/* The blur fades in diagonally: lightest at the top left, full at the bottom + right (second mask layer, intersected with the band above). */ +.hero-section .container::before { + -webkit-mask-image: linear-gradient(to bottom, transparent 18%, rgba(0, 0, 0, .55) 36%, #000 50%, rgba(0, 0, 0, .55) 64%, transparent 82%), + linear-gradient(135deg, rgba(0, 0, 0, .15) 15%, #000 75%); + mask-image: linear-gradient(to bottom, transparent 18%, rgba(0, 0, 0, .55) 36%, #000 50%, rgba(0, 0, 0, .55) 64%, transparent 82%), + linear-gradient(135deg, rgba(0, 0, 0, .15) 15%, #000 75%); + -webkit-mask-composite: source-in; + mask-composite: intersect; + -webkit-backdrop-filter: blur(22px) saturate(1.1); + backdrop-filter: blur(22px) saturate(1.1); + opacity: .8; +} +.hero-section .container::after { + background: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='f' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.15' numOctaves='3' seed='12' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='1.6' intercept='-.2'/%3E%3CfeFuncG type='linear' slope='1.6' intercept='-.2'/%3E%3CfeFuncB type='linear' slope='1.6' intercept='-.2'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)' opacity='.34'/%3E%3C/svg%3E") 0 0 / 180px 180px, + linear-gradient(rgba(var(--fitme-accent-rgb), .16), rgba(var(--fitme-accent-rgb), .16)); /* brand-colour frost tint */ + background-blend-mode: overlay, normal; +} + .hero-text { padding-top: 60px; } @@ -533,12 +922,21 @@ section[id] { font-size: clamp(1.75rem, calc(.7444rem + 4.0741vw), 3.8rem); font-weight: 900; color: #fff; - text-shadow: 0 2px 40px rgba(0, 0, 0, .5); + /* tight + soft shadow so the lettering separates from the frosted band */ + text-shadow: 0 1px 2px rgba(0, 0, 0, .25); line-height: 1.05; letter-spacing: -.5px; } +/* Subtitle (gym hero_subtitle): same h1 but a much smaller, tracked-out line + so the title leads and this reads as its strapline. */ .hero-text h1 span { + display: block; + margin-top: .55em; + font-size: .42em; + font-weight: 600; + line-height: 1.3; + letter-spacing: .12em; color: var(--fitme-accent); } @@ -551,7 +949,7 @@ section[id] { color: var(--fitme-accent); margin-bottom: 1rem; padding-left: .35em; - text-shadow: 0 2px 16px rgba(0, 0, 0, .5); + text-shadow: 0 1px 2px rgba(0, 0, 0, .3); } .hero-text .site-btn { @@ -564,14 +962,14 @@ section[id] { font-weight: 500; max-width: 600px; margin: 1.25rem auto 0; - text-shadow: 0 2px 24px rgba(0, 0, 0, .6); + text-shadow: 0 1px 2px rgba(0, 0, 0, .3); } .hero-cta-row { display: flex; + flex-direction: column; + align-items: center; gap: 14px; - justify-content: center; - flex-wrap: wrap; margin-top: 2.5rem; } @@ -586,11 +984,7 @@ section[id] { padding: 14px 36px; } -.site-btn.sb-ghost:hover { - background: #fff; - color: var(--fitme-dark); - box-shadow: none; -} +/* .sb-ghost hover: handled by the shared button hover above (dark face, white text, spinning edge) */ /* ── CSS performance: skip rendering off-screen sections ──────────────────── */ .trainers-section, @@ -661,16 +1055,18 @@ section[id] { /* Horário de Funcionamento, nested inside Informações e contactos (not its own section) — plain text lines, centered, matching the "less graphics" direction the rest of that column took once the icons came out. */ -.wh-text p { - margin: 0 0 8px; - color: rgba(255, 255, 255, .7); -} +/* One block per day, centred: the day on its own line, then one line per + opening period (Sábado: morning and afternoon). */ +.wh-day + .wh-day { margin-top: 14px; } -.wh-text p:last-child { - margin-bottom: 0; +.wh-text p { + margin: 0; + color: rgba(255, 255, 255, .7); + line-height: 1.6; } .wh-text strong { + display: block; color: var(--fitme-accent); font-weight: 700; } @@ -787,6 +1183,52 @@ section[id] { /* darken icon on accent bg */ } +/* Icon boxes (Modalidades, offer): same hover as the buttons — dark face, white + icon, spinning brand-colour edge (a 2px ring cut out of ::after with a mask) + and a soft glow. The official YOURFIT PNGs keep their own colours on a white + face instead of being turned into silhouettes. */ +.icon-box-item .ib-icon { + position: relative; + z-index: 0; + transition: background-color .4s ease, color .4s ease, transform .5s cubic-bezier(.22, .8, .3, 1), box-shadow .4s ease; +} +.icon-box-item .ib-icon::after { + content: ''; + position: absolute; + inset: 0; + padding: 2px; + border-radius: inherit; + background: conic-gradient(from var(--pc-a), rgba(var(--fitme-accent-rgb), .35) 0deg, rgba(var(--fitme-accent-rgb), .35) 150deg, + var(--fitme-accent) 250deg, #f4ff9c 300deg, var(--fitme-accent) 330deg, + rgba(var(--fitme-accent-rgb), .35) 360deg); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); + opacity: 0; + transition: opacity .45s ease; + pointer-events: none; +} +.icon-box-item:hover .ib-icon, +.icon-box-item:focus-visible .ib-icon { + background: color-mix(in srgb, var(--fitme-accent) 10%, #000); + color: #fff; + transform: scale(1.06); + box-shadow: 0 0 16px 1px rgba(var(--fitme-accent-rgb), .4); +} +.icon-box-item:hover .ib-icon::after, +.icon-box-item:focus-visible .ib-icon::after { opacity: 1; animation: pc-spin 3.2s linear infinite; } +.icon-box-item:hover .ib-icon svg.mi, +.icon-box-item:focus-visible .ib-icon svg.mi { color: #fff; } +.icon-box-item:hover .ib-icon img, +.icon-box-item:focus-visible .ib-icon img { filter: brightness(0) invert(1); mix-blend-mode: normal; } +.icon-box-item:hover .ib-icon:has(img[src*="yourfit"]), +.icon-box-item:focus-visible .ib-icon:has(img[src*="yourfit"]) { background: #fff; } +.icon-box-item:hover .ib-icon img[src*="yourfit"], +.icon-box-item:focus-visible .ib-icon img[src*="yourfit"] { filter: none; } +@media (prefers-reduced-motion: reduce) { + .icon-box-item:hover .ib-icon::after { animation: none; } +} + /* ── Trainers / Team ───────────────────────────────────────────────────────── */ .trainers-section { padding: 100px 0 60px; @@ -810,16 +1252,16 @@ section[id] { .trainer-item .trainer-pic { width: 200px; height: 200px; - border-radius: var(--fitme-radius); + border-radius: 0; margin: 0 auto 1.5rem; overflow: hidden; position: relative; border: 4px solid var(--fitme-accent); box-shadow: var(--fitme-shadow-lg); transform: rotate(-3deg); - /* Springy curve so the square "snaps" upright + lifts on hover */ - transition: transform .4s cubic-bezier(.34, 1.56, .64, 1), - box-shadow var(--fitme-transition); + /* Soft curve so the square straightens + lifts on hover (same as the buttons) */ + transition: transform .5s cubic-bezier(.22, .8, .3, 1), + box-shadow .4s ease; } /* CSS overlay replaces the old small_overlay_white.svg — one less HTTP request */ @@ -827,11 +1269,8 @@ section[id] { content: ''; position: absolute; inset: 0; - border-radius: var(--fitme-radius); - background: - radial-gradient(ellipse at 30% 20%, rgba(255, 255, 255, 0.25) 0%, transparent 50%), - radial-gradient(ellipse at 70% 80%, rgba(255, 255, 255, 0.08) 0%, transparent 45%), - linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, transparent 40%, rgba(0, 0, 0, 0.1) 100%); + border-radius: 0; + background: none; /* no gloss gradient over the member photos */ pointer-events: none; z-index: 1; } @@ -851,11 +1290,26 @@ section[id] { transform: rotate(0deg) translateY(-8px) scale(1.05); } -.trainer-item .trainer-pic:hover:after { - background: - radial-gradient(ellipse at 30% 20%, rgba(255, 255, 255, 0.32) 0%, transparent 50%), - radial-gradient(ellipse at 70% 80%, rgba(255, 255, 255, 0.10) 0%, transparent 45%), - linear-gradient(135deg, rgba(255, 255, 255, 0.18) 0%, transparent 40%, rgba(0, 0, 0, 0.08) 100%); +/* Same hover as the buttons / Mensalidades cards: the frame's 4px edge turns + into a conic brand-colour sweep (--pc-a / pc-spin, shared) over a dimmed + base of the same colour, with a soft glow round it. The photo covers the + padding box, so only the border area shows the gradient. */ +@media (hover: hover) { + .trainer-item .trainer-pic:hover, + #powerRotation>div:nth-child(even) .trainer-pic:hover { + border-color: transparent; + background: + linear-gradient(var(--fitme-dark), var(--fitme-dark)) padding-box, + conic-gradient(from var(--pc-a), rgba(var(--fitme-accent-rgb), .35) 0deg, rgba(var(--fitme-accent-rgb), .35) 150deg, + var(--fitme-accent) 250deg, #f4ff9c 300deg, var(--fitme-accent) 330deg, + rgba(var(--fitme-accent-rgb), .35) 360deg) border-box; + box-shadow: 0 0 18px 2px rgba(var(--fitme-accent-rgb), .4), var(--fitme-shadow-lg); + animation: pc-spin 3.2s linear infinite; + } +} + +@media (prefers-reduced-motion: reduce) { + .trainer-item .trainer-pic:hover { animation: none; } } .trainer-item .trainer-pic img { @@ -1812,7 +2266,7 @@ h3.contact-title { .consent-check a { color: #fff; - text-decoration: underline; + text-decoration: none; } .consent-check a:hover { @@ -1866,6 +2320,9 @@ h3.contact-title { /* ── 992px–1199px ──────────────────────────────────────────────────────────── */ @media only screen and (min-width: 992px) and (max-width: 1199px) { + /* tighter link spacing so a long menu (3070) clears the logo and match head */ + .header-section .navbar-nav { --bs-navbar-nav-link-padding-x: .35rem; } + .header-section .nav-link { margin: 0 .2rem !important; font-size: .8125rem; } .header-right .hr-box { display: none !important; } @@ -2230,6 +2687,7 @@ h3.contact-title { 4.5:1), so those use the ink green; dark sections keep the bright accent. */ .feature-section .section-eyebrow, .gallery-section .section-eyebrow, +.community-section .section-eyebrow, .service-section .section-eyebrow, .schedule-section .section-eyebrow, .workhours-section .section-eyebrow { @@ -2249,9 +2707,8 @@ h3.contact-title { bottom: 0; transform: translateX(-50%); width: 64px; - height: 3px; - border-radius: 2px; - background: var(--fitme-accent); + height: 4px; + background: var(--fitme-accent); /* slashed ends: "Brand slash" template */ } .section-subtitle { @@ -2585,7 +3042,7 @@ h3.contact-title { padding: 12px 4px; margin: -12px -4px; min-height: 44px; - text-decoration: underline; + text-decoration: none; transition: color var(--fitme-transition); } @@ -2976,19 +3433,33 @@ h3.contact-title { align-items: center; gap: 16px; width: min(640px, 92vw); - padding: 12px 14px 12px 18px; - background: rgba(11, 11, 13, .55); - border: 1.5px solid rgba(var(--fitme-accent-rgb), .55); - border-radius: 999px; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); + min-height: 88px; + padding: 18px 18px 18px 22px; + background: linear-gradient(90deg, rgba(11, 11, 13, .62), rgba(11, 11, 13, .42)); + border: 1.5px solid var(--ev-accent); /* same colour as its button */ text-decoration: none; text-align: left; animation: hero-event-glow 2.8s ease-in-out infinite; transition: transform var(--fitme-transition), box-shadow var(--fitme-transition), border-color var(--fitme-transition); } -/* The event's own image as a frosted (blurred + darkened) backdrop inside the - pill — only when --ev-img is set; otherwise the translucent dark bg shows. */ +/* Defocus gradient: ::after blurs whatever is behind it (the event image in + ::before, or the hero photo through the translucent bg), masked so the left is + sharp and the right most out of focus. .hero-event itself must not get + filter/backdrop-filter/opacity/mask, or it becomes the backdrop root and the + blur stops reaching the hero photo. */ +.hero-event::after { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); + -webkit-mask-image: linear-gradient(90deg, transparent 10%, #000 90%); + mask-image: linear-gradient(90deg, transparent 10%, #000 90%); +} +/* The event's own image as a darkened backdrop inside the card, only when + --ev-img is set; otherwise the translucent dark bg shows. */ .hero-event.has-img::before { content: ''; position: absolute; @@ -2997,11 +3468,11 @@ h3.contact-title { background-image: var(--ev-img); background-size: cover; background-position: center; - filter: blur(6px) saturate(1.15) brightness(.5); - transform: scale(1.18); /* hide the blur's transparent edges */ + filter: saturate(1.15) brightness(.5); + transform: scale(1.04); transition: transform var(--fitme-transition); } -.hero-event.has-img:hover::before { transform: scale(1.24); } +.hero-event.has-img:hover::before { transform: scale(1.1); } /* Keep the content above the image layer. */ .hero-event > * { position: relative; z-index: 1; } @keyframes hero-event-glow { @@ -3025,7 +3496,6 @@ h3.contact-title { text-transform: uppercase; color: var(--fitme-accent); background: rgba(var(--fitme-accent-rgb), .14); - border-radius: 999px; } .hero-event-text { flex: 1 1 auto; @@ -3058,9 +3528,8 @@ h3.contact-title { font-size: .92rem; font-weight: 700; white-space: nowrap; - color: var(--fitme-dark); + color: var(--ev-on, var(--fitme-on-accent)); /* set from the event colour (index.php) */ background: var(--ev-accent); - border-radius: 999px; } .hero-event-go i { transition: transform var(--fitme-transition); } .hero-event:hover .hero-event-go i { transform: translateX(4px); } @@ -3070,9 +3539,8 @@ h3.contact-title { flex-wrap: wrap; justify-content: center; text-align: center; - border-radius: 18px; gap: 10px; - padding: 14px; + padding: 18px 14px; } .hero-event-text { flex-basis: 100%; align-items: center; } .hero-event-title, .hero-event-sub { white-space: normal; } @@ -3215,7 +3683,7 @@ h3.contact-title { cursor: pointer; } .event-consent input { margin-top: 3px; flex: 0 0 auto; } -.event-consent a { color: var(--fitme-accent-ink); text-decoration: underline; } +.event-consent a { color: var(--fitme-accent-ink); text-decoration: none; } @media (max-width: 575px) { .event-form-grid { grid-template-columns: 1fr; } } @@ -3379,6 +3847,43 @@ h3.contact-title { .trainer-item.is-open { z-index: 31; } +/* ── Open detail cards (team + Modalidades): the same spinning brand edge ────── + A 3px ring cut out of ::after with a mask, shown while the card is open, plus + a soft brand glow added to the existing lift shadow. ─────────────────────── */ +.mod-detail::after, +.trainer-detail::after { + content: ''; + position: absolute; + inset: 0; + z-index: 5; + padding: 3px; + border-radius: inherit; + background: conic-gradient(from var(--pc-a), rgba(var(--fitme-accent-rgb), .35) 0deg, rgba(var(--fitme-accent-rgb), .35) 150deg, + var(--fitme-accent) 250deg, #f4ff9c 300deg, var(--fitme-accent) 330deg, + rgba(var(--fitme-accent-rgb), .35) 360deg); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); + opacity: 0; + transition: opacity .45s ease; + pointer-events: none; +} +.mod-detail.is-open::after, +.trainer-item.is-open .trainer-detail::after { opacity: 1; animation: pc-spin 3.2s linear infinite; } +.mod-detail.is-open { + box-shadow: 0 12px 30px rgba(var(--fitme-dark-rgb), .16), 0 2px 6px rgba(var(--fitme-dark-rgb), .10), + 0 0 22px 2px rgba(var(--fitme-accent-rgb), .35); +} +.trainer-item.is-open .trainer-detail { + box-shadow: 0 12px 30px rgba(0, 0, 0, .32), 0 2px 6px rgba(0, 0, 0, .18), + 0 0 22px 2px rgba(var(--fitme-accent-rgb), .35); +} +@media (prefers-reduced-motion: reduce) { + .mod-detail.is-open::after, + .trainer-item.is-open .trainer-detail::after { animation: none; } +} + + .td-photo { flex: 0 0 var(--td-pw, 200px); box-sizing: border-box; @@ -3552,6 +4057,33 @@ h3.contact-title { background-image: var(--metal-layers); background-size: var(--metal-sizes); color: var(--fitme-ash); + position: relative; + isolation: isolate; +} + +/* Slow brand-colour light drifting over the metal: three soft blobs in the + current gym's colour (and a paler tint of it) on a 200% layer whose position + eases back and forth. Sits above the section background, below the content. */ +.pricing-section::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(38% 50% at 18% 28%, rgba(var(--fitme-accent-rgb), .4), transparent 70%), + radial-gradient(34% 46% at 82% 72%, color-mix(in srgb, color-mix(in srgb, var(--fitme-accent) 55%, #fff) 26%, transparent), transparent 70%), + radial-gradient(30% 42% at 55% 105%, rgba(var(--fitme-accent-rgb), .32), transparent 70%); + background-size: 200% 200%; + animation: pricing-drift 26s ease-in-out infinite alternate; +} +@keyframes pricing-drift { + 0% { background-position: 0% 0%; } + 50% { background-position: 100% 40%; } + 100% { background-position: 30% 100%; } +} +@media (prefers-reduced-motion: reduce) { + .pricing-section::before { animation: none; } } .pricing-section .section-title h2 { color: #fff; } @@ -3929,8 +4461,7 @@ h3.contact-title { .reviews-link { font-weight: 700; color: var(--fitme-text-soft); - text-decoration: underline; - text-underline-offset: 3px; + text-decoration: none; } .reviews-link:hover { @@ -4072,53 +4603,44 @@ h3.contact-title { .mi, .mi [pathLength] { animation: none !important; } } -/* ══════════════════════════════════════════════════════════════════════════ - Galeria — masonry + lightbox (site/includes/gallery.php) - Without JS: a plain CSS grid (photos cropped to equal tiles). With JS - (.is-masonry): main.js positions every tile absolutely — shortest column - first, true aspect ratio, some tiles two columns wide — and sets the list's - height. 4 columns on desktop, 3 on tablets, 2 on phones. - ══════════════════════════════════════════════════════════════════════════ */ -.gal-masonry { - list-style: none; +/* ── Galeria — endless horizontal strip + lightbox (site/includes/gallery.php) ── + Full-height photos alternate with 2x2 grids of four. The strip is four copies + long; main.js auto-scrolls it, supports click-and-drag / swipe, and jumps by + exactly one copy to loop. */ +.gal-wrap { + --gal-h: 420px; + --gal-gap: 12px; + overflow-x: auto; /* scrolled by main.js (auto-advance + drag) and by touch */ + overflow-y: hidden; + scrollbar-width: none; margin: 0 0 70px; - padding: 0; + cursor: grab; + user-select: none; + -webkit-user-select: none; + -webkit-mask-image: linear-gradient(to right, transparent 0, #000 5%, #000 95%, transparent 100%); + mask-image: linear-gradient(to right, transparent 0, #000 5%, #000 95%, transparent 100%); +} +.gal-wrap::-webkit-scrollbar { display: none; } +.gal-wrap.is-dragging { cursor: grabbing; } +.gal-wrap img { -webkit-user-drag: none; } +.gal-track { display: flex; width: max-content; } +.gal-set { display: flex; flex: 0 0 auto; } +.gal-unit { + flex: 0 0 auto; + height: var(--gal-h); + margin-right: var(--gal-gap); +} +.gal-unit--full { width: calc(var(--gal-h) * .82); } +.gal-unit--grid { + width: calc(var(--gal-h) * 1.05); display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - grid-auto-rows: 170px; - grid-auto-flow: dense; - gap: 12px; + grid-template: 1fr 1fr / 1fr 1fr; + gap: var(--gal-gap); } +.gal-tile { position: relative; margin: 0; min-width: 0; min-height: 0; height: 100%; } -.gal-tile { position: relative; margin: 0; } -.gal-w { grid-column: span 2; } - -/* JS masonry: absolute tiles, sized and placed by main.js. */ -.gal-masonry.is-masonry { - display: block; - position: relative; -} - -.gal-masonry.is-masonry .gal-tile { - position: absolute; - top: 0; - left: 0; - transition: transform .5s cubic-bezier(.2, .8, .2, 1), width .5s cubic-bezier(.2, .8, .2, 1), height .5s cubic-bezier(.2, .8, .2, 1), opacity .6s ease; -} - -/* Entrance: tiles rise in one after another when the gallery comes into view - (main.js adds .is-in with a per-tile delay). */ -.gal-masonry.is-masonry .gal-tile:not(.is-in) { - opacity: 0; -} - -.gal-masonry.is-masonry .gal-tile .gal-link { - transition: transform .6s cubic-bezier(.2, .8, .2, 1); -} - -.gal-masonry.is-masonry .gal-tile:not(.is-in) .gal-link { - transform: translateY(24px) scale(.97); -} +@media (max-width: 991px) { .gal-wrap { --gal-h: 340px; } } +@media (max-width: 575px) { .gal-wrap { --gal-h: 260px; --gal-gap: 8px; margin-bottom: 48px; } } .gal-link { display: block; @@ -4131,6 +4653,8 @@ h3.contact-title { cursor: zoom-in; } +/* .jxl images are (img_tag): let the size against its parent as before. */ +.gal-link picture, .navbar-brand picture, .gym-switch-btn picture { display: contents; } .gal-link img { width: 100%; height: 100%; @@ -4156,17 +4680,81 @@ h3.contact-title { filter: brightness(1.05); } +/* Hover: the frame becomes the spinning brand-colour edge (same as buttons and + Mensalidades cards): ::after = 3px ring cut out with a mask, ::before = soft + inner glow. */ +.gal-link::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 22px 2px rgba(var(--fitme-accent-rgb), .5); + opacity: 0; + transition: opacity .45s ease; + pointer-events: none; + z-index: 1; +} +.gal-link::after { + z-index: 2; + padding: 3px; + box-shadow: none; + background: conic-gradient(from var(--pc-a), rgba(var(--fitme-accent-rgb), .35) 0deg, rgba(var(--fitme-accent-rgb), .35) 150deg, + var(--fitme-accent) 250deg, #f4ff9c 300deg, var(--fitme-accent) 330deg, + rgba(var(--fitme-accent-rgb), .35) 360deg); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); + opacity: 0; + transition: opacity .45s ease; +} +.gal-link:hover::before, +.gal-link:focus-visible::before { opacity: 1; } .gal-link:hover::after, -.gal-link:focus-visible::after { - box-shadow: inset 0 0 0 3px var(--fitme-accent); +.gal-link:focus-visible::after { opacity: 1; animation: pc-spin 3.2s linear infinite; } +@media (prefers-reduced-motion: reduce) { + .gal-link:hover::after { animation: none; } } -@media (max-width: 991px) { - .gal-masonry { grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: 150px; } -} +/* ── Photos at rest: 10% desaturated + a discreet brand-colour tint (gallery: a + gradient fading to transparent; team: a light multiply layer), both lifted on + hover. The tint follows each gym's brand colour. ───────────────────────── */ +.gal-link img { filter: saturate(.9); } +.gal-link:hover img, +.gal-link:focus-visible img { filter: saturate(1) brightness(1.05); } -@media (max-width: 575px) { - .gal-masonry { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 120px; gap: 8px; margin-bottom: 48px; } + +.trainer-item .trainer-pic img { filter: saturate(.9); transition: filter .45s ease; } +.trainer-item .trainer-pic:hover img { filter: saturate(1); } + +/* Official YOURFIT lock-ups are wide (≈4.5:1) PNGs. In the square icon box they + were squeezed to 44px wide while keeping their height attribute, so they came + out stretched and unreadable. Give their box a wide landscape shape and keep + the natural aspect ratio. */ +.icon-box-item .ib-icon:has(img[src*="yourfit"]), +.md-card .ib-icon:has(img[src*="yourfit"]) { + width: 148px; + max-width: 100%; +} +.icon-box-item .ib-icon img[src*="yourfit"], +.md-card .ib-icon img[src*="yourfit"] { + width: 124px; + height: auto; + max-width: 100%; + max-height: none; + object-fit: contain; +} +/* At rest the YOURFIT lock-ups are black like every other icon; their own + (certified) colours come back on hover, on the white face. */ +.icon-box-item .ib-icon img[src*="yourfit"], +.md-card .ib-icon img[src*="yourfit"] { + filter: brightness(0); + transition: filter .45s ease; +} +@media (max-width: 767px) { + .icon-box-item .ib-icon:has(img[src*="yourfit"]), + .md-card .ib-icon:has(img[src*="yourfit"]) { width: 128px; } + .icon-box-item .ib-icon img[src*="yourfit"], + .md-card .ib-icon img[src*="yourfit"] { width: 106px; } } /* Lightbox */ @@ -4269,6 +4857,167 @@ h3.contact-title { @media (prefers-reduced-motion: reduce) { .gal-link img, .gal-lb-figure img { transition: none; animation: none; } - .gal-masonry.is-masonry .gal-tile, - .gal-masonry.is-masonry .gal-tile .gal-link { transition: none; } +} + +/* ── "In the middle of the screen" = hovered ───────────────────────────────────── + main.js adds .is-centered to the trainer photo / card / icon that sits on the vertical + middle of the viewport (and, inside the Modalidades strip, on the horizontal + middle too; the gallery photos only react to a real hover), so scrolling past shows the same state as a hover. These mirror + the :hover rules above. ─────────────────────────────────────────────────── */ +.trainer-item .trainer-pic.is-centered, +#powerRotation>div:nth-child(even) .trainer-pic.is-centered { + transform: rotate(0deg) translateY(-8px) scale(1.05); + border-color: transparent; + background: + linear-gradient(var(--fitme-dark), var(--fitme-dark)) padding-box, + conic-gradient(from var(--pc-a), rgba(var(--fitme-accent-rgb), .35) 0deg, rgba(var(--fitme-accent-rgb), .35) 150deg, + var(--fitme-accent) 250deg, #f4ff9c 300deg, var(--fitme-accent) 330deg, + rgba(var(--fitme-accent-rgb), .35) 360deg) border-box; + box-shadow: 0 0 18px 2px rgba(var(--fitme-accent-rgb), .4), var(--fitme-shadow-lg); + animation: pc-spin 3.2s linear infinite; +} +.trainer-item .trainer-pic.is-centered img { filter: saturate(1); } +.trainer-item .trainer-pic.is-centered::before { opacity: 0; } + +.icon-box-item.is-centered { transform: translateY(-3px); } +.icon-box-item.is-centered .ib-icon { + background: color-mix(in srgb, var(--fitme-accent) 10%, #000); + color: #fff; + transform: scale(1.06); + box-shadow: 0 0 16px 1px rgba(var(--fitme-accent-rgb), .4); +} +.icon-box-item.is-centered .ib-icon::after { opacity: 1; animation: pc-spin 3.2s linear infinite; } +.icon-box-item.is-centered .ib-icon svg.mi { color: #fff; } +.icon-box-item.is-centered .ib-icon img { filter: brightness(0) invert(1); mix-blend-mode: normal; } +.icon-box-item.is-centered .ib-icon:has(img[src*="yourfit"]) { background: #fff; } +.icon-box-item.is-centered .ib-icon img[src*="yourfit"] { filter: none; } + +.price-card.is-centered { --pc-lift: -4px; --pc-bw: 2px; box-shadow: var(--fitme-shadow-lg); } +.price-card.is-centered::before { + background: + linear-gradient(var(--pc-face), var(--pc-face)) padding-box, + conic-gradient(from var(--pc-a) at var(--pc-x, 50%) var(--pc-y, 50%), + 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; + animation: pc-spin 3.2s linear infinite; +} +.price-card.is-centered::after { opacity: .75; animation: pc-spin 3.2s linear infinite; } +@media (prefers-reduced-motion: reduce) { + .trainer-item .trainer-pic.is-centered, + .icon-box-item.is-centered .ib-icon::after, + .price-card.is-centered::before, .price-card.is-centered::after { animation: none; } +} + +/* ── Links: never underlined; hover = colour shift + glow ───────────────────────── + Bootstrap underlines every . Links with their own hover colour keep it (their + selectors are more specific than the :where() below) and still get the glow. + --link-hover is the readable brand shade for the background it sits on: the + dark olive on light sections, the bright brand colour on the dark ones. */ +:root { --link-hover: var(--fitme-accent-ink); } +.hero-section, .trainers-section, .pricing-section, .contact-section, .footer-section, +.map-section, .event-preview-bar { --link-hover: var(--fitme-accent); } + +a { text-decoration: none; } +a:where(:not(.site-btn, .gym-switch-btn, .gal-link, .skip-link, .splide__arrow, .nav-link, .navbar-brand, .td-close)) { + transition: color .3s ease, text-shadow .3s ease; +} +a:where(:not(.site-btn, .gym-switch-btn, .gal-link, .skip-link, .splide__arrow, .nav-link, .navbar-brand, .td-close)):is(:hover, :focus-visible) { + color: var(--link-hover); + text-decoration: none; + text-shadow: 0 0 10px rgba(var(--fitme-accent-rgb), .6), 0 0 22px rgba(var(--fitme-accent-rgb), .3); +} +@media (prefers-reduced-motion: reduce) { + a { transition: none !important; } +} + +/* ── Noise texture ───────────────────────────────────────────────────────────── + Buttons: a tiled grey fractal-noise image laid over the face colour (!important + so the hover rules, which reset `background`, don't drop it; the hover face in + ::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='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, +.hero-event-go, +.cta-btn, +.btn-primary { + background-image: var(--noise) !important; + background-size: 155px 155px !important; + background-blend-mode: overlay !important; +} +/* Logos get the same grain as the headings (the logo gym buttons add their + drop-shadow after it, see .gym-switch-btn--logo). */ +h1, h2, +.navbar-brand img, +.gym-switch-logo { filter: url(#pt-noise); } + +/* ── Comunidade (Instagram posts the gym was tagged in, site/index.php) ── */ +.community-section { + padding: 80px 0 70px; + background: var(--fitme-white); +} + +.community-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(220px, 45%), 1fr)); + gap: 12px; + margin: 0; +} + +.community-post { + position: relative; + display: block; + aspect-ratio: 1; + overflow: hidden; + border-radius: var(--fitme-radius); + background: var(--fitme-surface-2); +} + +.community-post img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform .4s ease; +} + +.community-type { + position: absolute; + top: 10px; + right: 10px; + color: #fff; + text-shadow: 0 1px 4px rgba(var(--fitme-dark-rgb), .6); +} + +.community-user { + position: absolute; + inset: auto 0 0; + padding: 28px 12px 10px; + color: #fff; + font-size: .85rem; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: linear-gradient(transparent, rgba(var(--fitme-dark-rgb), .75)); + opacity: 0; + transition: opacity var(--fitme-transition); +} + +.community-post:hover img, +.community-post:focus-visible img { transform: scale(1.05); } + +.community-post:hover .community-user, +.community-post:focus-visible .community-user { opacity: 1; } + +/* Touch screens have no hover: keep the @user visible. */ +@media (hover: none) { + .community-user { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .community-post img { transition: none; } } diff --git a/site/assets/js/main.js b/site/assets/js/main.js index 2fdec7c..bd872d8 100644 --- a/site/assets/js/main.js +++ b/site/assets/js/main.js @@ -710,10 +710,11 @@ document.addEventListener('DOMContentLoaded', function () { opens the instead: prev / next buttons, ← → keys, Esc, swipe on touch, and a click on the dark backdrop closes it. */ document.addEventListener('DOMContentLoaded', function () { - document.querySelectorAll('.gal-masonry').forEach(function (grid) { + document.querySelectorAll('.gal-marquee').forEach(function (grid) { var box = grid.parentElement.nextElementSibling; if (!box || !box.classList.contains('gal-lightbox') || typeof box.showModal !== 'function') return; - var links = Array.prototype.slice.call(grid.querySelectorAll('.gal-link')); + var all = Array.prototype.slice.call(grid.querySelectorAll('.gal-link')); + var links = all.filter(function (a) { return !a.hasAttribute('data-dup'); }); // the repeated copies open the same photo var img = box.querySelector('.gal-lb-figure img'); var count = box.querySelector('.gal-lb-count'); var current = 0; @@ -721,17 +722,18 @@ document.addEventListener('DOMContentLoaded', function () { function show(i) { current = (i + links.length) % links.length; var src = links[current].querySelector('img'); - img.src = links[current].getAttribute('href'); + // The tile's chosen source (JXL inside where supported), else the link (fallback). + img.src = (src && src.currentSrc) || links[current].getAttribute('href'); img.alt = src ? src.alt : ''; // Restart the fade-in for each photo. img.style.animation = 'none'; void img.offsetWidth; img.style.animation = ''; count.textContent = (current + 1) + ' / ' + links.length; } - links.forEach(function (a, i) { + all.forEach(function (a) { a.addEventListener('click', function (e) { e.preventDefault(); - show(i); + show(+a.getAttribute('data-index') || 0); box.showModal(); }); }); @@ -744,7 +746,7 @@ document.addEventListener('DOMContentLoaded', function () { if (e.key === 'ArrowLeft') show(current - 1); else if (e.key === 'ArrowRight') show(current + 1); }); - box.addEventListener('close', function () { links[current].focus(); }); + box.addEventListener('close', function () { if (links[current]) links[current].focus(); }); var x0 = null; box.addEventListener('touchstart', function (e) { x0 = e.touches[0].clientX; }, { passive: true }); @@ -757,91 +759,109 @@ document.addEventListener('DOMContentLoaded', function () { }); }); -/* ── Galeria: masonry layout ───────────────────────────────────────────────── - A small hand-rolled masonry (no library): the grid is split into columns - (4 / 3 / 2 by width), and each tile — at its photo's true aspect ratio, from - the width/height attributes the PHP writes — is dropped into the shortest - column; tiles marked .gal-w take two adjacent columns (the pair whose taller - one is lowest), which is what staggers the rows. Tiles are absolutely - positioned with transforms, so a relayout on resize glides into place. - The first time the gallery scrolls into view the tiles rise in one by one. */ +/* ── Scroll "hover": what sits in the middle of the screen looks hovered ─────── + Trainer photos, cards and icons get .is-centered while their box crosses the + middle of the viewport (±12% of its height); inside a sliding strip + (Modalidades) they must also cross the horizontal middle. Gallery photos are + left out: they only light up on a real hover. Throttled, paused + while the tab is hidden, skipped with "reduce motion". The CSS mirrors the + :hover rules (style.css, "In the middle of the screen"). */ document.addEventListener('DOMContentLoaded', function () { - document.querySelectorAll('.gal-masonry').forEach(function (grid) { - var tiles = Array.prototype.slice.call(grid.querySelectorAll('.gal-tile')); - if (!tiles.length) return; - grid.classList.add('is-masonry'); - - function ratio(tile) { - var img = tile.querySelector('img'); - var w = +img.getAttribute('width') || img.naturalWidth || 4; - var h = +img.getAttribute('height') || img.naturalHeight || 3; - return h / w; - } - function layout() { - var width = grid.clientWidth; - var cols = width >= 900 ? 4 : width >= 520 ? 3 : 2; - var gap = width >= 520 ? 12 : 8; - var colW = (width - gap * (cols - 1)) / cols; - var heights = []; - for (var c = 0; c < cols; c++) heights.push(0); - - tiles.forEach(function (tile) { - var span = (tile.classList.contains('gal-w') && cols > 1) ? 2 : 1; - // Best column (or pair of columns) = lowest top edge. - var pick = function (span) { - var best = 0, bestTop = Infinity, waste = 0; - for (var i = 0; i <= cols - span; i++) { - var cells = heights.slice(i, i + span); - var top = Math.max.apply(null, cells); - if (top < bestTop - 0.5) { bestTop = top; best = i; waste = top - Math.min.apply(null, cells); } - } - return [best, bestTop, waste]; - }; - var p = pick(span); - // A wide tile whose two columns end at different heights would - // leave a hole under the shorter one — use a single column then. - if (span === 2 && p[2] > 60) { span = 1; p = pick(1); } - var best = p[0], bestTop = p[1]; - var w = colW * span + gap * (span - 1); - var h = Math.round(w * ratio(tile)); - tile.style.width = w + 'px'; - tile.style.height = h + 'px'; - tile.style.transform = 'translate(' + (best * (colW + gap)) + 'px,' + bestTop + 'px)'; - for (var j = best; j < best + span; j++) heights[j] = bestTop + h + gap; - }); - grid.style.height = Math.max(0, Math.max.apply(null, heights) - gap) + 'px'; - } - - layout(); - // Re-lay out whenever the grid's own width changes (window resize, but - // also a gallery that was laid out while hidden / zero-width). - var t, lastW = grid.clientWidth; - var relayout = function () { - if (grid.clientWidth === lastW) return; - lastW = grid.clientWidth; - clearTimeout(t); t = setTimeout(layout, 80); - }; - if ('ResizeObserver' in window) new ResizeObserver(relayout).observe(grid); - window.addEventListener('resize', relayout, { passive: true }); // belt and braces; relayout() is idempotent - // Re-run once images report their size, in case an attribute was missing. - tiles.forEach(function (tile) { - var img = tile.querySelector('img'); - if (!img.getAttribute('width')) img.addEventListener('load', layout, { once: true }); + if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return; + var items = Array.prototype.slice.call(document.querySelectorAll( + '.trainer-item .trainer-pic, .icon-box-item, .price-card')).map(function (el) { + return { el: el, strip: !!el.closest('.mod-scroll'), on: false }; + }); + if (!items.length) return; + var last = 0; + function tick(now) { + requestAnimationFrame(tick); + if (document.hidden || now - last < 120) return; + last = now; + var vw = window.innerWidth, vh = window.innerHeight, cy = vh / 2, band = vh * 0.12; + items.forEach(function (it) { + var r = it.el.getBoundingClientRect(); + var on = r.bottom > 0 && r.top < vh && + r.top <= cy + band && r.bottom >= cy - band && + (!it.strip || (r.left <= vw / 2 && r.right >= vw / 2)); + if (on !== it.on) { it.on = on; it.el.classList.toggle('is-centered', on); } }); + } + requestAnimationFrame(tick); +}); - // Staggered entrance. - var enter = function () { - tiles.forEach(function (tile, i) { - setTimeout(function () { tile.classList.add('is-in'); }, Math.min(i, 12) * 70); - }); - }; - if ('IntersectionObserver' in window) { - var io = new IntersectionObserver(function (entries) { - if (entries.some(function (e) { return e.isIntersecting; })) { enter(); io.disconnect(); } - }, { rootMargin: '0px 0px -80px 0px' }); - io.observe(grid); - } else { - enter(); +/* ── Galeria: auto-scroll + click-and-drag ─────────────────────────────────── + The strip (.gal-wrap) is four identical copies long. It advances by itself, + follows a mouse drag (touch uses the native swipe), pauses while you hover or + hold it and resumes a moment after, and whenever the position leaves the two + middle copies it jumps by exactly one copy — invisible, since every copy is the + same. A drag longer than a few px swallows the click, so it doesn't open the + lightbox. */ +document.addEventListener('DOMContentLoaded', function () { + var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; + document.querySelectorAll('.gal-marquee').forEach(function (strip) { + var wrap = strip.parentElement; + var set = strip.querySelector('.gal-set'); + if (!wrap || !set) return; + var loop = 0, pos = null, hold = false, resumeAt = 0, dragging = false, moved = 0, startX = 0, startLeft = 0, last = 0; + var SPEED = 40; // px per second + + function measure() { loop = set.offsetWidth; } + function normalise() { + if (!loop) return; + if (wrap.scrollLeft >= loop * 2) wrap.scrollLeft -= loop; + else if (wrap.scrollLeft < loop) wrap.scrollLeft += loop; } + function start() { measure(); wrap.scrollLeft = loop; } + start(); + window.addEventListener('load', start); + window.addEventListener('resize', measure, { passive: true }); + wrap.addEventListener('scroll', normalise, { passive: true }); + + wrap.addEventListener('mouseenter', function () { hold = true; }); + wrap.addEventListener('mouseleave', function () { if (!dragging) { hold = false; resumeAt = performance.now() + 600; } }); + wrap.addEventListener('focusin', function () { hold = true; }); + wrap.addEventListener('focusout', function () { hold = false; }); + wrap.addEventListener('touchstart', function () { hold = true; }, { passive: true }); + wrap.addEventListener('touchend', function () { hold = false; resumeAt = performance.now() + 1800; }, { passive: true }); + + wrap.addEventListener('pointerdown', function (e) { + if (e.pointerType === 'touch' || e.button !== 0) return; + dragging = true; moved = 0; + startX = e.clientX; startLeft = wrap.scrollLeft; + wrap.classList.add('is-dragging'); + wrap.setPointerCapture(e.pointerId); + }); + wrap.addEventListener('pointermove', function (e) { + if (!dragging) return; + var dx = e.clientX - startX; + moved = Math.max(moved, Math.abs(dx)); + wrap.scrollLeft = startLeft - dx; + }); + function endDrag(e) { + if (!dragging) return; + dragging = false; + wrap.classList.remove('is-dragging'); + try { wrap.releasePointerCapture(e.pointerId); } catch (err) {} + resumeAt = performance.now() + 1200; + } + wrap.addEventListener('pointerup', endDrag); + wrap.addEventListener('pointercancel', endDrag); + // After a real drag, the click that follows must not open the lightbox. + wrap.addEventListener('click', function (e) { + if (moved > 5) { e.preventDefault(); e.stopPropagation(); moved = 0; } + }, true); + + function tick(now) { + requestAnimationFrame(tick); + var dt = last ? Math.min(now - last, 100) : 16; last = now; + if (reduce || document.hidden || dragging || hold || now < resumeAt) { pos = null; return; } + // scrollLeft only keeps whole pixels, so a ~0.6px/frame step would round + // away: carry the fraction in our own float and write it back. + if (pos === null || Math.abs(wrap.scrollLeft - pos) > 2) pos = wrap.scrollLeft; + pos += SPEED * dt / 1000; + wrap.scrollLeft = pos; + } + requestAnimationFrame(tick); }); }); diff --git a/site/crossfit.php b/site/crossfit.php index 44cd284..0edd2a7 100644 --- a/site/crossfit.php +++ b/site/crossfit.php @@ -1,6 +1,7 @@ 'CrossFit', diff --git a/site/evento.php b/site/evento.php index 692cbcd..39a4d54 100644 --- a/site/evento.php +++ b/site/evento.php @@ -105,8 +105,12 @@ $seo_desc = $event['summary'] !== '' : trim(mb_substr(strip_tags((string) ($event['subtitle'] ?: $event['description'])), 0, 200)); $current_uri_2 = 'evento'; +// An event tied to one gym (Eventos → «Mostrar em») renders in that gym's brand. +$ev_gym = !empty($event['gym_id']) ? db_gyms_get($pdo, (int) $event['gym_id']) : null; +if ($ev_gym && empty($_GET['gym'])) $_GET['gym'] = $ev_gym['slug']; +$ev_gym = $ev_gym ?? site_gym($pdo) ?? gym_fallback(); $GLOBALS['_page_meta_override'] = [ - ($event['title'] !== '' ? $event['title'] : 'Evento') . ' | FitMe 3070', + ($event['title'] !== '' ? $event['title'] : 'Evento') . ' | ' . $ev_gym['name'], $seo_desc, ]; $GLOBALS['_canonical_override'] = $event_url; @@ -114,7 +118,7 @@ $GLOBALS['_og_type_override'] = 'article'; if ($hero_url !== '') $GLOBALS['_og_image_override'] = $hero_url; // Event structured data (schema.org/Event) for rich link previews / search. -$info = site_info($pdo); +$info = site_info($pdo, (int) $ev_gym['id']); $ld_event = [ '@context' => 'https://schema.org', '@type' => 'Event', @@ -130,11 +134,11 @@ if (!empty($event['event_ends_at'])) $ld_event['endDate'] = str_replace(' ', $ld_event['location'] = $event['location'] !== '' ? ['@type' => 'Place', 'name' => $event['location']] : ['@type' => 'Place', - 'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070', + 'name' => ($info['name'] ?? '') !== '' ? $info['name'] : $ev_gym['name'], 'address' => $info['adress'] ?? '']; $ld_event['organizer'] = [ '@type' => 'Organization', - 'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070', + 'name' => ($info['name'] ?? '') !== '' ? $info['name'] : $ev_gym['name'], 'url' => base_url(), ]; $GLOBALS['_jsonld_extra'] = $ld_event; diff --git a/site/includes/contactos.php b/site/includes/contactos.php index 2b260ca..9f0053c 100644 --- a/site/includes/contactos.php +++ b/site/includes/contactos.php @@ -70,10 +70,11 @@ $session = session(); } } ?> -

: - - Encerrado -

+
+ +

+

Encerrado

+
diff --git a/site/includes/footer.php b/site/includes/footer.php index f982464..3c6285f 100644 --- a/site/includes/footer.php +++ b/site/includes/footer.php @@ -32,6 +32,7 @@

+ @@ -39,5 +40,6 @@ + diff --git a/site/includes/gallery.php b/site/includes/gallery.php index 4b9972c..d7ee110 100644 --- a/site/includes/gallery.php +++ b/site/includes/gallery.php @@ -1,13 +1,15 @@ -// lightbox with prev / next, arrow keys, Esc and swipe (main.js). -$files = glob(rtrim($gallery_path, '/') . '/*.jpg'); -if (!$files || !is_array($files)) $files = []; +// Gallery partial — an endless horizontal strip (home "instalações" and the +// CrossFit page): full-height photos alternate with 2x2 grids of four. main.js +// scrolls it by itself, lets you click-and-drag (mouse) or swipe (touch), pauses +// while you interact and loops seamlessly over the repeated copies. Clicking a +// tile (without dragging) opens it full size in a lightbox with prev / +// next, arrow keys, Esc and swipe; the repeated copies are hidden from assistive +// tech and open the same photo. +// Every format the admin upload accepts (functions_galerias.php), without the +// .jxl.webp/.jpg fallbacks; a .jxl tile is (img_tag), the link goes to +// the fallback and the lightbox shows whichever the tile picked (currentSrc). +$files = img_list_dir($gallery_path); // Column span per tile, cycled: w = two columns, s = one. $gal_shapes = ['w', 's', 's', 's', 's', 'w', 's', 's', 's', 's', 's']; $gal_label = $gallery_h2 ?? 'Instalações'; @@ -21,23 +23,44 @@ $gal_label = $gallery_h2 ?? 'Instalações'; - -
- += 4); + $take = $grid ? 4 : 1; + $units[] = ['grid' => $grid, 'idx' => range($i, $i + $take - 1)]; + $i += $take; $k++; + } + // The strip is rendered four times: main.js keeps the scroll position inside + // the middle copies and jumps by exactly one copy, so it loops without a seam. + $copies = 4; +?> + diff --git a/site/includes/header_video.php b/site/includes/header_video.php index fa5d8ee..a0bcdb9 100644 --- a/site/includes/header_video.php +++ b/site/includes/header_video.php @@ -7,7 +7,7 @@
-

CrossFit 3070 — box de CrossFit em Mira

+

CrossFit 3070 — box de CrossFit em Mira

diff --git a/site/includes/include.php b/site/includes/include.php index 71754e7..882533c 100644 --- a/site/includes/include.php +++ b/site/includes/include.php @@ -2,27 +2,37 @@ // Per-page metadata, keyed by $current_uri_2. The canonical URL comes from // base_url(), which matches the https + non-www redirect in .htaccess. $page = $current_uri_2 ?? ''; -$page_meta = [ - '' => ['FitMe 3070 — Ginásio & CrossFit em Mira', - 'Ginásio em Mira (Lagoa) com musculação, CrossFit, aulas de grupo, personal trainers, nutrição e fisioterapia. Inscreve-te já no FitMe 3070!'], - 'crossfit' => ['CrossFit 3070 — Box de CrossFit em Mira | FitMe 3070', - 'Box de CrossFit em Mira (Lagoa). Aulas para todos os níveis com treinadores certificados. Marca a tua aula experimental no CrossFit 3070!'], - 'privacidade' => ['Política de Privacidade | FitMe 3070', - 'Política de privacidade do FitMe 3070 — como recolhemos, usamos e protegemos os teus dados pessoais.'], - 'termos' => ['Termos e Condições | FitMe 3070', - 'Termos e condições de utilização do website do FitMe 3070.'], -]; -// The gym's homepage uses the gym's own SEO title/description when set -// (Gestão → Ginásios); a new gym without them gets a generic one from its name -// and locality rather than the 3070 texts above. +// Titles ~50-60 characters, descriptions ~140-160 (what Google shows). $gym_meta = current_gym(); -if ($page === '' && ($gym_meta['seo_title'] ?? '') !== '') { - $page_meta[''] = [$gym_meta['seo_title'], ($gym_meta['seo_description'] ?? '') !== '' ? $gym_meta['seo_description'] : $page_meta[''][1]]; -} elseif ($page === '' && (int) $gym_meta['id'] !== 1) { - $loc = ($gym_meta['locality'] ?? '') !== '' ? ' em ' . $gym_meta['locality'] : ''; - $page_meta[''] = [$gym_meta['name'] . ' — Ginásio' . $loc, - ($gym_meta['seo_description'] ?? '') !== '' ? $gym_meta['seo_description'] : 'Ginásio ' . $gym_meta['name'] . $loc . ': treino, aulas de grupo e acompanhamento.']; +$gym_name = ($gym_meta['name'] ?? '') !== '' ? $gym_meta['name'] : 'FitMe 3070'; +$is_main = (int) ($gym_meta['id'] ?? 1) === 1; +$loc = ($gym_meta['locality'] ?? '') !== '' ? $gym_meta['locality'] : 'Mira'; +$loc_in = (preg_match('/^(zona|rua|avenida|praia|vila)/iu', $loc) ? 'na ' : 'em ') . $loc; // «na Zona Industrial de Mira» +$page_meta = [ + '' => $is_main + ? ['FitMe 3070 — Ginásio e CrossFit em Mira', + 'Ginásio em Mira (Lagoa) com musculação, CrossFit, aulas de grupo, personal trainers, nutrição e fisioterapia. Vê horários e mensalidades e inscreve-te!'] + // Other gyms without their own texts get them from name and locality, never the 3070 ones. + : [$gym_name . ' — Ginásio ' . $loc_in, + $gym_name . ', ginásio ' . $loc_in . ': musculação, cardio e treino funcional com acompanhamento. Vê horários e mensalidades e inscreve-te já!'], + 'crossfit' => $is_main + ? ['CrossFit 3070 — Box de CrossFit em Mira | FitMe 3070', + 'Box de CrossFit em Mira (Lagoa) com treinadores certificados e aulas para todos os níveis, do primeiro treino à competição. Vê o horário e treina connosco!'] + : ['CrossFit — Box ' . $loc_in . ' | ' . $gym_name, + 'Box de CrossFit do ' . $gym_name . ' ' . $loc_in . ': aulas para todos os níveis com treinadores certificados. Vê o horário e treina connosco!'], + 'privacidade' => ['Política de Privacidade | ' . $gym_name, + 'Política de privacidade do ' . $gym_name . ': que dados pessoais recolhemos, para quê, durante quanto tempo e como exerces os teus direitos (RGPD).'], + 'termos' => ['Termos e Condições | ' . $gym_name, + 'Termos e condições de utilização do site do ' . $gym_name . ': pedidos de inscrição, preços, horários, eventos e limites de responsabilidade.'], +]; +// The gym's own SEO title/description (Gestão → Ginásios) win on its homepage, each on its own. +if ($page === '') { + if (($gym_meta['seo_title'] ?? '') !== '') $page_meta[''][0] = $gym_meta['seo_title']; + if (($gym_meta['seo_description'] ?? '') !== '') $page_meta[''][1] = $gym_meta['seo_description']; } +// Favicon: the gym's own logo when it is an SVG. +$gym_logo_rel = gym_logo($gym_meta); +$favicon_rel = str_ends_with(strtolower($gym_logo_rel), '.svg') ? $gym_logo_rel : 'site/assets/img/logos/logo_black.svg'; // A dynamic page (e.g. an event landing page) can set its own title/description // and canonical via these globals before requiring _open.php. [$page_title, $page_desc] = $GLOBALS['_page_meta_override'] ?? $page_meta[$page] ?? $page_meta['']; @@ -37,9 +47,9 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) { - - - + + + <?= htmlspecialchars($page_title) ?> @@ -50,7 +60,9 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) { - + +> @@ -58,21 +70,21 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) { - - + + - + - + @@ -85,6 +97,32 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) { + + + diff --git a/site/includes/navbar.php b/site/includes/navbar.php index 8b2ef63..37bb7d0 100644 --- a/site/includes/navbar.php +++ b/site/includes/navbar.php @@ -4,16 +4,26 @@ 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); +$logo_alt = 'Logo ' . (current_gym()['name'] ?? 'FitMe 3070'); +$logo_html = img_fallback_rel($logo_rel) !== '' + ? img_tag($logo_rel, ['loading' => 'eager', 'decoding' => 'async', 'alt' => $logo_alt]) + : '' . htmlspecialchars($logo_alt) . ''; +// Home page: a second, big copy of the logo shows centred over the hero +// (.is-top); on scroll it fades out and the normal left logo fades in +// (script below toggles .is-top, style.css "Bar layout"). +$center_logo = !empty($nav_center_logo); ?> -
-
+
+ + +
Ginásio em cujo topo da página inicial o destaque aparece.
+
+
diff --git a/views/admin/events/create.php b/views/admin/events/create.php index f5875e1..6d04612 100644 --- a/views/admin/events/create.php +++ b/views/admin/events/create.php @@ -24,6 +24,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'cta_label' => trim($_POST['cta_label'] ?? ''), 'cta_url' => trim($_POST['cta_url'] ?? ''), 'show_lead_form' => isset($_POST['show_lead_form']) ? 1 : 0, + 'gym_id' => (int) ($_POST['gym_id'] ?? 0) ?: null, 'hero_image' => '', ]; diff --git a/views/admin/events/edit.php b/views/admin/events/edit.php index c23c902..b32141f 100644 --- a/views/admin/events/edit.php +++ b/views/admin/events/edit.php @@ -48,6 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_event'])) { 'cta_label' => trim($_POST['cta_label'] ?? ''), 'cta_url' => trim($_POST['cta_url'] ?? ''), 'show_lead_form' => isset($_POST['show_lead_form']) ? 1 : 0, + 'gym_id' => (int) ($_POST['gym_id'] ?? 0) ?: null, 'hero_image' => $item['hero_image'], ]; diff --git a/views/admin/galerias/edit.php b/views/admin/galerias/edit.php index d334886..2bbdce9 100644 --- a/views/admin/galerias/edit.php +++ b/views/admin/galerias/edit.php @@ -25,12 +25,29 @@ if (isset($_POST['rename'])) { } } -// Upload image -if (isset($_FILES['image']) && $_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE) { +// Upload images (several at once: images[]) +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload'])) { csrf_check(); - $result = db_gallery_image_upload($pdo, $id, $_FILES['image']); - if ($result) session()->setFlashdata('success', 'Imagem carregada.'); - else session()->setFlashdata('error', 'Falha no upload. Verifique o formato.'); + $files = []; + foreach ((array) ($_FILES['images']['name'] ?? []) as $i => $n) { + if ($_FILES['images']['error'][$i] === UPLOAD_ERR_NO_FILE) continue; + $files[] = ['name' => $n, 'type' => $_FILES['images']['type'][$i], 'tmp_name' => $_FILES['images']['tmp_name'][$i], + 'error' => $_FILES['images']['error'][$i], 'size' => $_FILES['images']['size'][$i]]; + } + $ok = 0; $failed = []; + foreach ($files as $file) { + if (db_gallery_image_upload($pdo, $id, $file, $why)) $ok++; + else $failed[] = $file['name'] . ' (' . $why . ')'; + } + if ($ok) session()->setFlashdata('success', $ok === 1 ? 'Imagem carregada.' : "$ok imagens carregadas."); + if ($failed) session()->setFlashdata($ok ? 'warning' : 'error', 'Não carregadas: ' . implode(', ', $failed) . '.'); + if (!$files) session()->setFlashdata('error', 'Escolha pelo menos uma imagem.'); + header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id)); + exit; +} +// Over post_max_size PHP drops the whole body ($_POST and $_FILES empty), so say so instead of doing nothing. +if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$_POST && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { + session()->setFlashdata('error', 'O envio é maior do que o servidor aceita (' . ini_get('post_max_size') . ' no total). Carregue menos imagens de cada vez.'); header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id)); exit; } @@ -78,14 +95,14 @@ include __DIR__ . '/../../../includes/header.php';
-

Adicionar imagem

+

Adicionar imagens

- -
JPG, PNG, GIF ou WebP.
+ +
Pode escolher várias. JPG, PNG, GIF, WebP ou JPEG XL, até MB cada (máx. de cada vez, no total).
- +
@@ -110,9 +127,19 @@ include __DIR__ . '/../../../includes/header.php';
+
- + + +
+ + JPEG XL + +
+ + '', 'class' => 'img-thumbnail w-100', 'style' => 'height:140px;object-fit:cover;', 'loading' => 'lazy', 'width' => null]) ?> +
- - -
Caminho de uma imagem do site. Vazio = a do ginásio principal.
+ + +
+ + + +
JPG, PNG, WebP ou JPEG XL (até 6 MB). Vazio = a do ginásio principal.
+ +
+
@@ -86,6 +93,30 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
+
+ + + +
+ + Logótipo atual + + +
+
SVG, PNG, WebP, JPG ou JPEG XL (até 6 MB). Opcional. Aparece nas cores originais no logótipo do menu da página deste ginásio e no botão deste ginásio nas páginas dos outros.
+ +
Falta aplicar a migração migration_20261013_gym_brand_color.sql (Sistema → Atualizar plataforma) para guardar o logótipo e a cor da marca.
+ + +
+ +
+
+ + +
Botões, destaques e links do site deste ginásio.
+
+

Estado
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'; -
+
diff --git a/views/admin/instagram/index.php b/views/admin/instagram/index.php new file mode 100644 index 0000000..9a5cb98 --- /dev/null +++ b/views/admin/instagram/index.php @@ -0,0 +1,176 @@ +setFlashdata('success', 'Definições guardadas.'); + header('Location: ' . $self); + exit; + } + if ($action === 'refresh') { + $r = instagram_refresh(); + session()->setFlashdata(!empty($r['error']) ? 'error' : 'success', + !empty($r['error']) ? 'Falhou: ' . $r['error'] : 'Publicações atualizadas (' . count($r['posts'] ?? []) . ' com imagem).'); + header('Location: ' . $self); + exit; + } + if ($action === 'accounts') { + // Lists the Facebook Pages the token manages and the Instagram account linked to each. + $token = instagram_config()['token']; + if ($token === '') { + $accounts = ['error' => 'Guarda primeiro o token.', 'list' => []]; + } else { + [$json, $err] = _instagram_request(INSTAGRAM_GRAPH . 'me/accounts?' . http_build_query([ + 'fields' => 'name,instagram_business_account{id,username}', 'limit' => 50, 'access_token' => $token, + ])); + $list = []; + foreach ((array) ($json['data'] ?? []) as $pg) { + if (!empty($pg['instagram_business_account']['id'])) { + $list[] = ['page' => (string) ($pg['name'] ?? ''), 'id' => (string) $pg['instagram_business_account']['id'], + 'username' => (string) ($pg['instagram_business_account']['username'] ?? '')]; + } + } + $accounts = ['error' => $err, 'list' => $list]; + } + } +} + +$cfg = instagram_config(); +$env_tok = (string) env('INSTAGRAM_TOKEN_' . $gid, '') !== ''; +$cache = instagram_cache_read(); +$preview = site_instagram(); +$page_title = 'Comunidade (Instagram)'; +include __DIR__ . '/../../../includes/header.php'; +?> +
+
+

Comunidade (Instagram)

+ Ver no site +
+

A secção «Comunidade» (a seguir à galeria) mostra as últimas publicações em que foi identificado no Instagram. Só aparecem publicações de contas públicas. As imagens ficam guardadas no site; se não houver nenhuma, a secção fica escondida. Liga ou desliga a secção em Ginásios → Secções.

+ +
+

Configuração

+ + + +
+
+ + + + + + +
+
+ +
Sem chave de encriptação no .env — o token fica guardado sem encriptação.
+ +
Token de um utilizador do sistema (Meta Business) ou de longa duração, com instagram_basic, instagram_manage_comments e pages_read_engagement.
+
+
+ + +
Conta profissional ligada a uma Página do Facebook. Não sabes? Usa «Encontrar» abaixo.
+
+
+ + +
Igual para todos os ginásios.
+
+
+ + +
Igual para todos os ginásios.
+
+
+ + +
+ +
+

Encontrar o ID da conta

+

Lista as Páginas do Facebook a que o token guardado tem acesso e a conta Instagram ligada a cada uma.

+
+ + + +
+ +
+
Nenhuma Página com conta Instagram profissional ligada.
+ +
    + +
  • +
    @ · Página +
    + +
  • + +
+
Depois de «Usar», carrega em Guardar.
+ + +
+ +
+
+

Estado

+
+
+
+ +

Por configurar — a secção não aparece no site.

+ +

Última atualização:

+
Último erro:
+ +

No site aparecem :

+
+ + + +
+ +

Ainda sem publicações identificadas — a secção está escondida.

+ + +
+
+ +
+

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'; ?>