Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2089f92750 | ||
|
|
f018fa582d | ||
|
|
f297e128fd | ||
|
|
a8683658fa | ||
|
|
22edadf0d7 | ||
|
|
2279970768 | ||
|
|
e804c52b4e | ||
|
|
6ac5213624 | ||
|
|
6abc486630 | ||
|
|
725f5b9c26 | ||
|
|
785d1f6ceb | ||
|
|
0bc14f5f72 | ||
|
|
e7ab661f4c | ||
|
|
4b54a777b7 | ||
|
|
2e8b6d20f6 | ||
|
|
c2b1f98e96 | ||
|
|
04df7f8997 | ||
|
|
f72292fc02 | ||
|
|
bfbd04a2d3 | ||
|
|
4f44bc1609 | ||
|
|
8fe73eb5d6 | ||
|
|
49adbd0ae1 | ||
|
|
1eb2a61031 | ||
|
|
cc677af92d | ||
|
|
7fa2da73dc | ||
|
|
14dbabcad5 | ||
|
|
bab6eb87bf | ||
|
|
3ffcf0fe38 | ||
|
|
9c28be314b | ||
|
|
d445ba64b9 | ||
|
|
a85bec6851 | ||
|
|
965a123834 | ||
|
|
68ac78097a | ||
|
|
3d30f09bb3 | ||
|
|
938347779c | ||
|
|
b2b736734e | ||
|
|
a2219ebd63 | ||
|
|
9e3eee227b | ||
|
|
72cf3dad98 | ||
|
|
e5f4fd8fc9 | ||
|
|
18676ad6d0 | ||
|
|
781a3a324e | ||
|
|
18556ca8b9 | ||
|
|
3d2a696162 | ||
|
|
6c14e644b1 | ||
|
|
99c28f04b4 | ||
|
|
4b6329f529 | ||
|
|
356554db42 | ||
|
|
b884749a4e | ||
|
|
f9ec3644c8 | ||
|
|
da90c5c681 | ||
|
|
9017c849f6 | ||
|
|
83398eb7f5 | ||
|
|
9588f902c6 | ||
|
|
8e889adeb9 | ||
|
|
f28b2b8482 | ||
|
|
4b99991c11 | ||
|
|
db28427c1e | ||
|
|
d40f6d8b2d | ||
|
|
348e7d6848 | ||
|
|
e52709b926 | ||
|
|
7bf2a8542e | ||
|
|
acac3a191a | ||
|
|
251644c393 | ||
|
|
2a4596e7e3 | ||
|
|
9a42c91471 | ||
|
|
2a66f74275 | ||
|
|
a94287e3d6 | ||
|
|
b103aeae58 | ||
|
|
8ede31ce3a | ||
|
|
58d5199883 | ||
|
|
32f8d7a613 | ||
|
|
4b2823d1e5 | ||
|
|
716eec9f6d | ||
|
|
aa55d44406 | ||
|
|
b73c23bf55 | ||
|
|
5f64ae57db |
No files matched your search
@@ -20,6 +20,10 @@ uploads/**/.trash/
|
|||||||
# (config/functions_privacy.php) para o browser não as pedir à Google.
|
# (config/functions_privacy.php) para o browser não as pedir à Google.
|
||||||
uploads/reviews/
|
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
|
# 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
|
# 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 é
|
# (/srv/data/fitme.andmore.pt/uploads) e é esse a fonte; o que está no git é
|
||||||
@@ -57,6 +61,7 @@ Thumbs.db
|
|||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
|
||||||
.claude/
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
# ─── Dependências ───────────────────────────────────────────────────────────
|
# ─── Dependências ───────────────────────────────────────────────────────────
|
||||||
# vendor/ é output do composer install, exceto vendor/PHPMailer: o
|
# vendor/ é output do composer install, exceto vendor/PHPMailer: o
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# Disable directory listing
|
# Disable directory listing
|
||||||
Options -Indexes
|
Options -Indexes
|
||||||
|
|
||||||
|
# JPEG XL uploads (gym header/logo): older mime.types don't know the extension.
|
||||||
|
<IfModule mod_mime.c>
|
||||||
|
AddType image/jxl .jxl
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
# Block direct HTTP access to sensitive files
|
# Block direct HTTP access to sensitive files
|
||||||
<FilesMatch "^\.(env|env\..*|git.*|htaccess|htpasswd)$">
|
<FilesMatch "^\.(env|env\..*|git.*|htaccess|htpasswd)$">
|
||||||
Require all denied
|
Require all denied
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ mas ainda sem versão no nome das pastas dos ficheiros hand-vendored.
|
|||||||
| Biblioteca | Versão | Caminho | Usado em |
|
| Biblioteca | Versão | Caminho | Usado em |
|
||||||
|------------------------|--------|----------------------------------------|----------------------|
|
|------------------------|--------|----------------------------------------|----------------------|
|
||||||
| Bootstrap | 5.3.3 | `assets/vendor/bootstrap5/` | Site público + backoffice (unificado 25/09) |
|
| 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) |
|
| 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) |
|
| 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 —
|
`assets/vendor/bootstrap/` (a cópia 4.3.1) ficou sem nenhuma referência depois da migração —
|
||||||
|
|||||||
@@ -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_<noun>_<verb>()` (`db_gyms_create`, `db_team_update`, `db_gallery_list`). **Views
|
|
||||||
never write SQL directly** — if you're adding a query, it goes in the matching
|
|
||||||
`functions_*.php`, not inline in the page. (Audit 6.3, closed 2026-09-25 — `grep -rl
|
|
||||||
'\$pdo->\(query\|prepare\|exec\)' views/` should always return nothing.)
|
|
||||||
|
|
||||||
`config/functions.php` is the loader: every page requires `config/db.php` (opens
|
|
||||||
`$pdo`, starts the session, connects to platform-tools) then
|
|
||||||
`config/functions.php` (requires every `functions_*.php` in order). Read that file's
|
|
||||||
own header comment before adding a new one — order matters for a few (`auth.php`
|
|
||||||
before anything that calls `require_login()`, etc.).
|
|
||||||
|
|
||||||
## Auth
|
|
||||||
|
|
||||||
Login/session/roles run on `shared/platform-tools/functions_auth.php` (`'auth' =>
|
|
||||||
'shared'` in `config/db.php`, migrated 2026-09-25 — audit 1.3). `config/auth.php` keeps
|
|
||||||
the app's own guard names (`require_login()`, `require_admin()`, `require_staff()`,
|
|
||||||
`require_sysadmin()`, `is_admin()`, etc.) as a thin layer over the shared primitives, so
|
|
||||||
none of the ~30 call sites in `views/` had to change. Role ladder:
|
|
||||||
`sysadmin`(100) > `admin`(80) > `staff`(40) > `client`(10, portal-only). "Manter sessão
|
|
||||||
iniciada" is the shared `auth_remember_*` module.
|
|
||||||
|
|
||||||
The login-attempt throttle (`config/functions_throttle.php`) stays the app's own,
|
|
||||||
deliberately not the shared one — see the comment in `config/db.php` for why (avoids
|
|
||||||
double-counting the same form with two independent throttles). A throttled login feeds
|
|
||||||
`platform_ip_record_offence()`, so repeat offenders get fleet-wide IP-banned.
|
|
||||||
|
|
||||||
## Soft delete / trash
|
|
||||||
|
|
||||||
Deletes are soft (`deleted_at`/`deleted_by`) via `trash_delete()` in
|
|
||||||
`config/functions_trash.php`. Gestão → Lixeira lists everything deleted across entity
|
|
||||||
types and only allows permanent purge after 30 days (enforced in the `DELETE`'s
|
|
||||||
`WHERE`, not just hidden in the UI). Adding a new deletable entity means adding it to
|
|
||||||
`trash_types()` in that file.
|
|
||||||
|
|
||||||
## CSRF
|
|
||||||
|
|
||||||
`csrf_check()` (`config/functions_csrf.php`) refuses any non-POST request outright —
|
|
||||||
state changes must be POST forms carrying `csrf_field()`'s token. There is no PRG
|
|
||||||
(Post-Redirect-Get) convention beyond that; several admin pages still re-render the
|
|
||||||
form directly on POST rather than redirecting (audit flags this as inconsistent, not
|
|
||||||
yet unified — tracked separately, low priority).
|
|
||||||
|
|
||||||
## Audit log
|
|
||||||
|
|
||||||
Models call `audit_row()` before a write and `audit_change()` after (diffs
|
|
||||||
before/after automatically); ad-hoc events elsewhere use `audit('entity.verb', [...])`.
|
|
||||||
Both come from `shared/platform-tools` via `config/functions_audit.php`. Read via
|
|
||||||
Sistema → Auditoria (`platform.php?tool=audit`).
|
|
||||||
|
|
||||||
## Multi-gym
|
|
||||||
|
|
||||||
Since 2026-09-25, one install serves several physical gyms: the `gyms` table, one URL
|
|
||||||
folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the
|
|
||||||
backoffice has an "a editar" gym switcher (`admin_gym_id()` /
|
|
||||||
`admin_gym()` in `config/functions_gyms.php`). `/` and `/home` 301 to the default gym.
|
|
||||||
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`).
|
|
||||||
@@ -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
|
||||||
|
<html> posto pelo script do <head> 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 <html> pelo script do <head>) ── */
|
||||||
|
@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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -46,8 +46,18 @@ function initDataTable(selector, opts) {
|
|||||||
if (!window.jQuery || !jQuery.fn || !jQuery.fn.DataTable) return null;
|
if (!window.jQuery || !jQuery.fn || !jQuery.fn.DataTable) return null;
|
||||||
var $table = jQuery(selector);
|
var $table = jQuery(selector);
|
||||||
if (!$table.length) return null;
|
if (!$table.length) return null;
|
||||||
|
// Listagem vazia: as páginas desenham uma única linha <td colspan> 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, {
|
return $table.DataTable(jQuery.extend(true, {
|
||||||
language: DATATABLES_PT_PT,
|
language: language,
|
||||||
pageLength: 25,
|
pageLength: 25,
|
||||||
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, 'Todos']],
|
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, 'Todos']],
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
|||||||
@@ -96,7 +96,17 @@ try {
|
|||||||
// para deixar a porta aberta a usar o throttle partilhado no futuro. Ver
|
// para deixar a porta aberta a usar o throttle partilhado no futuro. Ver
|
||||||
// views/auth/login.php para a nota completa.
|
// views/auth/login.php para a nota completa.
|
||||||
require_once dirname(__DIR__) . '/shared/platform-tools/functions_connector.php';
|
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',
|
'auth' => 'shared',
|
||||||
'roles' => [
|
'roles' => [
|
||||||
// Preserva a hierarquia que config/auth.php já tinha (sysadmin >
|
// Preserva a hierarquia que config/auth.php já tinha (sysadmin >
|
||||||
|
|||||||
@@ -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_queue.php'; // message_queue + enqueue_*/flush_message
|
||||||
require_once __DIR__ . '/functions_csrf.php';
|
require_once __DIR__ . '/functions_csrf.php';
|
||||||
require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed)
|
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__ . '/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_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)
|
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_info.php'; // contactos (fitme3070_info) editor
|
||||||
require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição
|
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_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_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_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
|
require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function events_defaults(): array {
|
|||||||
'location' => '', 'event_starts_at' => null, 'event_ends_at' => null,
|
'location' => '', 'event_starts_at' => null, 'event_ends_at' => null,
|
||||||
'publish_from' => '', 'publish_until' => '', 'is_active' => 1,
|
'publish_from' => '', 'publish_until' => '', 'is_active' => 1,
|
||||||
'cta_label' => '', 'cta_url' => '', 'show_lead_form' => 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
|
INSERT INTO events
|
||||||
(slug, title, subtitle, summary, description, hero_image, cor, location,
|
(slug, title, subtitle, summary, description, hero_image, cor, location,
|
||||||
event_starts_at, event_ends_at, publish_from, publish_until,
|
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)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
");
|
");
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
$d['slug'], $d['title'], $d['subtitle'], $d['summary'], $d['description'],
|
$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_from']),
|
||||||
str_replace('T', ' ', (string) $d['publish_until']),
|
str_replace('T', ' ', (string) $d['publish_until']),
|
||||||
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
|
(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();
|
$__id = (int) $pdo->lastInsertId();
|
||||||
audit_change($pdo, 'event', 'events', 'e_id', (int) $__id, 'create', null, 'title');
|
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 = ?,
|
slug = ?, title = ?, subtitle = ?, summary = ?, description = ?,
|
||||||
hero_image = ?, cor = ?, location = ?,
|
hero_image = ?, cor = ?, location = ?,
|
||||||
event_starts_at = ?, event_ends_at = ?, publish_from = ?, publish_until = ?,
|
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 = ?
|
WHERE e_id = ?
|
||||||
");
|
");
|
||||||
$stmt->execute([
|
$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_from']),
|
||||||
str_replace('T', ' ', (string) $d['publish_until']),
|
str_replace('T', ' ', (string) $d['publish_until']),
|
||||||
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
|
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
|
||||||
|
$d['gym_id'] ? (int) $d['gym_id'] : null,
|
||||||
$id,
|
$id,
|
||||||
]);
|
]);
|
||||||
$__r = $stmt->rowCount() > 0;
|
$__r = $stmt->rowCount() > 0;
|
||||||
@@ -225,6 +228,7 @@ function events_image_remove(?string $filename): void {
|
|||||||
if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return;
|
if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return;
|
||||||
$path = events_image_dir() . $filename;
|
$path = events_image_dir() . $filename;
|
||||||
if (is_file($path)) @unlink($path);
|
if (is_file($path)) @unlink($path);
|
||||||
|
img_fallback_follow($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Leads ─────────────────────────────────────────────────────────────────────
|
// ─── Leads ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -50,23 +50,34 @@ function db_gallery_images(PDO $pdo, int $gallery_id): array {
|
|||||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
}
|
}
|
||||||
|
|
||||||
function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?string {
|
// Limite por ficheiro: o menor entre 16 MB e o upload_max_filesize do PHP.
|
||||||
if ($file['error'] !== UPLOAD_ERR_OK) return null;
|
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).
|
// Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php).
|
||||||
$ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
|
$ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl']);
|
||||||
if ($ext === null) return null;
|
if ($ext === null) { $error = 'formato não suportado'; return null; }
|
||||||
|
|
||||||
$upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/';
|
$upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/';
|
||||||
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
|
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
|
||||||
|
|
||||||
$filename = bin2hex(random_bytes(16)) . '.' . $ext;
|
$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);
|
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 = $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;
|
return $filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function gym_sections(): array {
|
|||||||
return [
|
return [
|
||||||
'oferta' => 'A Nossa Oferta',
|
'oferta' => 'A Nossa Oferta',
|
||||||
'galeria' => 'Galeria (instalações)',
|
'galeria' => 'Galeria (instalações)',
|
||||||
|
'comunidade' => 'Comunidade (Instagram)',
|
||||||
'modalidades' => 'Modalidades',
|
'modalidades' => 'Modalidades',
|
||||||
'equipa' => 'Equipa',
|
'equipa' => 'Equipa',
|
||||||
'horario' => 'Horário de aulas',
|
'horario' => 'Horário de aulas',
|
||||||
@@ -41,7 +42,7 @@ function gym_defaults(): array {
|
|||||||
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
|
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
|
||||||
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
|
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
|
||||||
'hero_image' => '', 'seo_title' => '', 'seo_description' => '', 'gallery_id' => null,
|
'hero_image' => '', 'seo_title' => '', 'seo_description' => '', 'gallery_id' => null,
|
||||||
'google_place_id' => '', 'has_crossfit' => 0, 'item_order' => 0,
|
'google_place_id' => '', 'has_crossfit' => 0, 'brand_color' => '', '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);
|
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 {
|
function gym_url(array $gym, string $path = ''): string {
|
||||||
return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/'));
|
return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/'));
|
||||||
}
|
}
|
||||||
@@ -159,8 +186,22 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
|
|||||||
'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null,
|
'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null,
|
||||||
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
|
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
|
||||||
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0,
|
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0,
|
||||||
|
'logo' => mb_substr(trim((string) ($post['logo'] ?? '')), 0, 255),
|
||||||
|
'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
|
||||||
'item_order' => (int) ($post['item_order'] ?? 0),
|
'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'] ?? [])));
|
$on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
|
||||||
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on);
|
$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);
|
$other = db_gyms_by_slug($pdo, $slug);
|
||||||
if ($other && (int) $other['id'] !== (int) $id) $errors[] = 'Já existe um ginásio com essa pasta da URL.';
|
if ($other && (int) $other['id'] !== (int) $id) $errors[] = 'Já existe um ginásio com essa pasta da URL.';
|
||||||
}
|
}
|
||||||
|
if ($data['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['is_default'] && !$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado.';
|
||||||
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp)$~i', $data['hero_image'])) {
|
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp|jxl)$~i', $data['hero_image'])) {
|
||||||
$errors[] = 'Imagem do header: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).';
|
$errors[] = 'Imagem do header: imagem inválida.';
|
||||||
}
|
}
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _gyms_params(array $d): array {
|
// Columns written by create/update, in order. brand_color and logo were added by
|
||||||
return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'],
|
// migration_20261013_gym_brand_color.sql: an install that hasn't run it yet keeps
|
||||||
$d['sections'], $d['hero_title'], $d['hero_subtitle'], $d['hero_image'], $d['seo_title'],
|
// working (they are just not saved) instead of failing on an unknown column.
|
||||||
$d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['item_order']];
|
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 {
|
function db_gyms_create(PDO $pdo, array $d): int {
|
||||||
|
$cols = _gyms_cols($pdo, $d);
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
$pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections,
|
$pdo->prepare('INSERT INTO gyms (' . implode(', ', array_keys($cols)) . ', created_at, updated_at) VALUES ('
|
||||||
hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id,
|
. implode(',', array_fill(0, count($cols), '?')) . ', NOW(), NOW())')->execute(array_values($cols));
|
||||||
google_place_id, has_crossfit, item_order, created_at, updated_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
|
|
||||||
$id = (int) $pdo->lastInsertId();
|
$id = (int) $pdo->lastInsertId();
|
||||||
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
||||||
// Its own contacts row (id = gym id), starting from the gym's name.
|
// Its own contacts row (id = gym id), starting from the gym's name.
|
||||||
@@ -206,14 +264,10 @@ function db_gyms_create(PDO $pdo, array $d): int {
|
|||||||
|
|
||||||
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
|
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
|
||||||
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
|
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
|
||||||
|
$cols = _gyms_cols($pdo, $d);
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
$params = _gyms_params($d); $params[] = $id;
|
$st = $pdo->prepare('UPDATE gyms SET ' . implode(' = ?, ', array_keys($cols)) . ' = ?, updated_at = NOW() WHERE id = ?');
|
||||||
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
|
$st->execute(array_merge(array_values($cols), [$id]));
|
||||||
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);
|
|
||||||
$changed = $st->rowCount() > 0;
|
$changed = $st->rowCount() > 0;
|
||||||
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
@@ -222,6 +276,47 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool {
|
|||||||
return $__r;
|
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/<name>", 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
|
// 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()
|
// touches google_place_id, not the rest of the gym record db_gyms_update()
|
||||||
// would validate/audit.
|
// would validate/audit.
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
// Ícones: pacotes partilhados do platform-tools (pt_icon_assets()) com as cópias
|
||||||
|
// locais como recurso quando o submódulo ainda não os tem (produção com o
|
||||||
|
// submódulo antigo). Registo dos pacotes: config/db.php ('icons').
|
||||||
|
//
|
||||||
|
// $packs : ids dos pacotes a carregar (ex.: ['fontawesome'])
|
||||||
|
// $localHrefs: <link> 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 .= '<link href="' . htmlspecialchars(base_url($href), ENT_QUOTES) . '" rel="stylesheet">' . "\n";
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
@@ -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'];
|
$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 === '') {
|
if (isset($raster[$mime]) || $mime === '') {
|
||||||
$info = @getimagesize($tmp);
|
$info = @getimagesize($tmp);
|
||||||
$ext = $info ? ($raster[$info['mime']] ?? null) : null;
|
$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 (!is_file($path)) return $fail('ficheiro não encontrado');
|
||||||
if (!extension_loaded('gd')) return $fail('extensão GD indisponível');
|
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);
|
$info = @getimagesize($path);
|
||||||
if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster
|
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;
|
if (!is_dir($dir)) return $stats;
|
||||||
foreach (scandir($dir) ?: [] as $file) {
|
foreach (scandir($dir) ?: [] as $file) {
|
||||||
$path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $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));
|
$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);
|
$r = img_optimize($path, $max_dim);
|
||||||
$stats[$r['status']]++;
|
$stats[$r['status']]++;
|
||||||
$stats['saved'] += $r['saved'];
|
$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';
|
if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB';
|
||||||
return $bytes . ' B';
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// <img> for $rel; a .jxl with a fallback becomes <picture> with the JXL source
|
||||||
|
// first. $attrs: extra <img> 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 = '<img src="' . htmlspecialchars(base_url($src)) . '"' . $a . '>';
|
||||||
|
if ($fb === '') return $img;
|
||||||
|
return '<picture><source type="image/jxl" srcset="' . htmlspecialchars(base_url($rel)) . '">' . $img . '</picture>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
// «Comunidade»: the latest Instagram posts where the gym was tagged, via the
|
||||||
|
// Instagram Graph API (GET /{ig-user-id}/tags). Per gym, no migration: the
|
||||||
|
// account id and token live in system_settings as instagram_user_id_<gid> /
|
||||||
|
// instagram_token_<gid> (token encrypted, or INSTAGRAM_TOKEN_<gid> in .env).
|
||||||
|
// Same cache pattern as functions_reviews.php: config/cache/instagram_<gid>.cache.php,
|
||||||
|
// lazy refresh under a non-blocking lock, old copy kept on error. Thumbnails
|
||||||
|
// are downloaded at refresh time to uploads/instagram/<gid>/ (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, "<?php\nreturn " . var_export($data, true) . ";\n", LOCK_EX) === false) return;
|
||||||
|
@rename($tmp, $path);
|
||||||
|
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function instagram_cache_clear(): void {
|
||||||
|
$path = _instagram_cache_path();
|
||||||
|
if (is_file($path)) @unlink($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET against the Graph API. Returns [decoded|null, error].
|
||||||
|
function _instagram_request(string $url): array {
|
||||||
|
if (!function_exists('curl_init')) return [null, 'cURL indisponível nesta instalação.'];
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => 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'])));
|
||||||
|
}
|
||||||
@@ -29,14 +29,38 @@ function site_info(PDO $pdo, ?int $gym_id = null): array {
|
|||||||
return $row;
|
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
|
// 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 {
|
function site_navbar(?array $gym = null): array {
|
||||||
$gym = $gym ?? current_gym();
|
$gym = $gym ?? current_gym();
|
||||||
$items = ['Home' => ['#home', null]];
|
$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) {
|
'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 = [];
|
$nav = [];
|
||||||
foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash);
|
foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash);
|
||||||
|
|||||||
@@ -269,9 +269,12 @@ function _trash_image_file(PDO $pdo, int $imageId, string $action): void {
|
|||||||
if ($action === 'delete' && is_file($live)) {
|
if ($action === 'delete' && is_file($live)) {
|
||||||
if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true);
|
if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true);
|
||||||
@rename($live, $trash);
|
@rename($live, $trash);
|
||||||
|
img_fallback_follow($live, $trash); // foto.jxl.webp goes with foto.jxl
|
||||||
} elseif ($action === 'restore' && is_file($trash)) {
|
} elseif ($action === 'restore' && is_file($trash)) {
|
||||||
@rename($trash, $live);
|
@rename($trash, $live);
|
||||||
|
img_fallback_follow($trash, $live);
|
||||||
} elseif ($action === 'purge') {
|
} elseif ($action === 'purge') {
|
||||||
if (is_file($trash)) @unlink($trash);
|
if (is_file($trash)) @unlink($trash);
|
||||||
|
img_fallback_follow($trash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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`;
|
||||||
@@ -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`;
|
||||||
@@ -13,7 +13,8 @@ if (!is_file($_dev_shared)) { http_response_code(404); exit; } // submódulo s
|
|||||||
require_once $_dev_shared;
|
require_once $_dev_shared;
|
||||||
|
|
||||||
platform_dev_login_handle([
|
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'; },
|
'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)
|
'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']),
|
&& $_SESSION['csrf_token'] !== '' && hash_equals($_SESSION['csrf_token'], $_POST['_csrf']),
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
</main>
|
</main>
|
||||||
<script src="<?= base_url('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>"></script>
|
<script src="<?= base_url('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>"></script>
|
||||||
<?= script_tag('assets/js/password-toggle.js') ?>
|
<?= script_tag('assets/js/password-toggle.js') ?>
|
||||||
|
<?php include __DIR__ . '/dev_toolbar.php'; // DEV ONLY: sem saída fora de dev ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -29,7 +29,8 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
|
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
|
||||||
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
|
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/bs-brand.css'), ENT_QUOTES) ?>" rel="stylesheet">
|
||||||
|
<?= fit_icon_assets(['bootstrap-icons'], ['assets/vendor/bootstrap-icons/bootstrap-icons.css']) ?>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bo-accent: #C2D500;
|
--bo-accent: #C2D500;
|
||||||
@@ -65,6 +66,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
|||||||
/* 16px inputs stop iOS from auto-zooming on focus */
|
/* 16px inputs stop iOS from auto-zooming on focus */
|
||||||
@media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } }
|
@media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } }
|
||||||
</style>
|
</style>
|
||||||
|
<?php include __DIR__ . '/env_ribbon.php'; // moldura de aviso fora de produção ?>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="auth-card">
|
<main class="auth-card">
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
// DEV ONLY -- barra de login de um clique (platform-tools). Não imprime nada
|
// DEV ONLY -- barra de login de um clique (platform-tools). Não imprime nada
|
||||||
// fora de dev. Incluída por includes/footer.php.
|
// fora de dev. Incluída por includes/footer.php, auth_footer.php e site/includes/footer.php.
|
||||||
require_once __DIR__ . '/../config/dev_guard.php';
|
require_once __DIR__ . '/../config/dev_guard.php';
|
||||||
if (!function_exists('platform_dev_toolbar')) return;
|
if (!function_exists('platform_dev_toolbar')) return;
|
||||||
platform_dev_toolbar([
|
platform_dev_toolbar([
|
||||||
'login_url' => base_url('dev_login'),
|
'login_url' => base_url('dev_login'),
|
||||||
'logout_url' => base_url('views/auth/logout.php'),
|
'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',
|
'csrf_field' => 'csrf_field',
|
||||||
]);
|
]);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Non-production environment marker.
|
||||||
|
*
|
||||||
|
* Dev and prod are the same codebase on near-identical URLs, so it is genuinely
|
||||||
|
* easy to edit a real client's session while believing you are on dev. This
|
||||||
|
* paints an unmissable hazard frame around every page whenever APP_ENV is not
|
||||||
|
* 'prod', plus a corner chip naming the environment.
|
||||||
|
*
|
||||||
|
* Included from <head> 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;
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
/* Faixa de aviso de ambiente — só fora de produção. */
|
||||||
|
html::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483646;
|
||||||
|
pointer-events: none; /* nunca intercepta cliques */
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#111 0 14px,
|
||||||
|
#f5c400 14px 28px
|
||||||
|
) 6;
|
||||||
|
}
|
||||||
|
html::before {
|
||||||
|
content: '<?= htmlspecialchars(strtoupper($_env), ENT_QUOTES) ?>';
|
||||||
|
position: fixed;
|
||||||
|
top: 6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2147483647;
|
||||||
|
pointer-events: none;
|
||||||
|
padding: .1rem .8rem .15rem;
|
||||||
|
background: #f5c400;
|
||||||
|
color: #111;
|
||||||
|
font: 700 .7rem/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
border-radius: 0 0 .35rem .35rem;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, .35);
|
||||||
|
}
|
||||||
|
/* Em ecrãs pequenos a moldura rouba demasiado espaço horizontal. */
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
html::after { border-width: 4px; border-image-slice: 4; }
|
||||||
|
html::before { font-size: .6rem; top: 4px; }
|
||||||
|
}
|
||||||
|
/* Ao imprimir não faz sentido nenhum. */
|
||||||
|
@media print {
|
||||||
|
html::after, html::before { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -78,23 +78,93 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
mq.addEventListener('change', function () { if (pref === 'auto') apply('auto'); });
|
mq.addEventListener('change', function () { if (pref === 'auto') apply('auto'); });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Mobile off-canvas sidebar: hamburger toggles .sidebar.open + the backdrop;
|
// Barra lateral. Em ecrã grande o hamburger alterna entre completa e mini (so
|
||||||
// tapping the backdrop, a nav link, Escape, or resizing to desktop closes it.
|
// 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 <head>.
|
||||||
(function () {
|
(function () {
|
||||||
var sb = document.getElementById('sidebar');
|
var sb = document.getElementById('sidebar');
|
||||||
var tg = document.getElementById('menuToggle');
|
|
||||||
var bd = document.getElementById('sidebarBackdrop');
|
var bd = document.getElementById('sidebarBackdrop');
|
||||||
if (!sb || !tg) return;
|
var toggles = document.querySelectorAll('[data-sidebar-toggle]');
|
||||||
var mq = window.matchMedia('(max-width: 991.98px)');
|
var root = document.documentElement;
|
||||||
function open() { sb.classList.add('open'); if (bd) bd.classList.add('show'); document.body.style.overflow = 'hidden'; tg.setAttribute('aria-expanded', 'true'); }
|
var nav = sb && sb.querySelector('.bo-sidebar__nav');
|
||||||
function close() { sb.classList.remove('open'); if (bd) bd.classList.remove('show'); document.body.style.overflow = ''; tg.setAttribute('aria-expanded', 'false'); }
|
if (!sb || !toggles.length) return;
|
||||||
tg.addEventListener('click', function () { sb.classList.contains('open') ? close() : open(); });
|
var mq = window.matchMedia('(min-width: 992px)');
|
||||||
if (bd) bd.addEventListener('click', close);
|
function store(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
||||||
sb.querySelectorAll('.nav-link').forEach(function (a) {
|
|
||||||
a.addEventListener('click', function () { if (mq.matches) close(); });
|
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(); });
|
if (bd) bd.addEventListener('click', function () { setDrawer(false); });
|
||||||
window.addEventListener('resize', function () { if (!mq.matches) close(); });
|
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 <head> (atributo no <html>);
|
||||||
|
// 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) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -25,203 +25,111 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
|||||||
document.documentElement.setAttribute('data-theme-pref', pref);
|
document.documentElement.setAttribute('data-theme-pref', pref);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// Estado da barra lateral antes da primeira pintura (senão cada página salta
|
||||||
|
// de 264 para 80px): modo mini no <html> e grupos fechados em data-bo-groups.
|
||||||
|
try{var d=document.documentElement;if(localStorage.getItem('bo-sidebar-mini')==='1')d.classList.add('bo-mini');
|
||||||
|
var g=JSON.parse(localStorage.getItem('bo-nav-groups')||'{}');d.setAttribute('data-bo-groups',Object.keys(g).filter(function(k){return g[k]}).join(' '));}catch(e){}
|
||||||
|
</script>
|
||||||
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
|
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
|
||||||
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
|
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/bs-brand.css'), ENT_QUOTES) ?>" rel="stylesheet">
|
||||||
|
<?= fit_icon_assets(['bootstrap-icons'], ['assets/vendor/bootstrap-icons/bootstrap-icons.css']) ?>
|
||||||
<link href="<?= base_url('assets/vendor/summernote/summernote-bs5.min.css') ?>" rel="stylesheet">
|
<link href="<?= base_url('assets/vendor/summernote/summernote-bs5.min.css') ?>" rel="stylesheet">
|
||||||
<style>
|
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/backoffice.css'), ENT_QUOTES) ?>" rel="stylesheet">
|
||||||
:root {
|
<?php include __DIR__ . '/env_ribbon.php'; // moldura de aviso fora de produção ?>
|
||||||
--bo-accent: #C2D500;
|
|
||||||
--bo-accent-rgb: 194,213,0;
|
|
||||||
--bo-dark: #0a0a0a;
|
|
||||||
--bo-dark-2: #151515;
|
|
||||||
--bo-surface: #eef0f4;
|
|
||||||
}
|
|
||||||
[data-bs-theme="dark"] { --bo-surface: #121416; }
|
|
||||||
body { background: var(--bo-surface); font-size: .925rem; }
|
|
||||||
.sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 100;
|
|
||||||
padding: 68px 0 0; width: 240px; background: var(--bo-dark); overflow-y: auto; }
|
|
||||||
.sidebar .nav-link { color: rgba(255,255,255,.55); padding: .55rem 1.25rem; font-size: .875rem;
|
|
||||||
border-left: 3px solid transparent; transition: all .2s; }
|
|
||||||
.sidebar .nav-link:hover { color: #fff; background: rgba(255,255,255,.06); border-left-color: rgba(255,255,255,.2); }
|
|
||||||
.sidebar .nav-link.active { color: #fff; background: rgba(var(--bo-accent-rgb),.12); border-left-color: var(--bo-accent); }
|
|
||||||
.sidebar .nav-link i { width: 20px; margin-right: .5rem; text-align: center; }
|
|
||||||
.sidebar .sidebar-brand { position: fixed; top: 0; left: 0; width: 240px; padding: 1rem 1.25rem;
|
|
||||||
color: #fff; font-weight: 700; font-size: 1.1rem; background: var(--bo-dark);
|
|
||||||
z-index: 101; letter-spacing: .5px; border-bottom: 1px solid rgba(255,255,255,.06); }
|
|
||||||
.sidebar .sidebar-brand i { color: var(--bo-accent); }
|
|
||||||
.sidebar .sidebar-section { color: rgba(255,255,255,.3); font-size: .65rem; text-transform: uppercase;
|
|
||||||
letter-spacing: 1.5px; padding: 1.25rem 1.25rem .35rem; }
|
|
||||||
main { margin-left: 240px; padding-top: 68px; }
|
|
||||||
.topbar { position: fixed; top: 0; left: 240px; right: 0; z-index: 99; height: 56px;
|
|
||||||
background: var(--bs-body-bg); border-bottom: 1px solid var(--bs-border-color); padding: 0 1.5rem;
|
|
||||||
display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.topbar .user-dropdown { display: flex; align-items: center; gap: .5rem; margin-left: auto; }
|
|
||||||
.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); }
|
|
||||||
.theme-toggle { min-width: 44px; min-height: 44px; }
|
|
||||||
a { color: var(--bs-emphasis-color); }
|
|
||||||
/* Summernote (WYSIWYG) — align with the backoffice look */
|
|
||||||
.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); }
|
|
||||||
/* ── Mobile: off-canvas sidebar + hamburger ──────────────────────────── */
|
|
||||||
.sidebar { transition: transform .25s ease; }
|
|
||||||
.menu-toggle { display: none; width: 44px; height: 44px; padding: 0; border: 1px solid var(--bs-border-color);
|
|
||||||
background: var(--bs-body-bg); color: var(--bs-body-color); border-radius: 8px; font-size: 1.35rem;
|
|
||||||
line-height: 1; align-items: center; justify-content: center; }
|
|
||||||
.sidebar-backdrop { display: none; position: fixed; inset: 0; z-index: 1090; background: rgba(0,0,0,.45); }
|
|
||||||
.sidebar-backdrop.show { display: block; }
|
|
||||||
@media (max-width: 991.98px) {
|
|
||||||
.sidebar { transform: translateX(-100%); width: 264px; z-index: 1100;
|
|
||||||
box-shadow: 2px 0 18px rgba(0,0,0,.3); }
|
|
||||||
.sidebar .sidebar-brand { width: 264px; z-index: 1101; }
|
|
||||||
.sidebar.open { transform: translateX(0); }
|
|
||||||
main { margin-left: 0; }
|
|
||||||
.topbar { left: 0; padding: 0 .75rem; }
|
|
||||||
.menu-toggle { display: inline-flex; }
|
|
||||||
/* Wide tables scroll inside their card instead of overflowing the page */
|
|
||||||
main .card > .card-body { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
|
||||||
}
|
|
||||||
@media (max-width: 575.98px) {
|
|
||||||
.topbar .user-dropdown .badge,
|
|
||||||
.topbar .user-dropdown .u-name { display: none; }
|
|
||||||
/* Page-header rows (title + action buttons) stack instead of overflowing */
|
|
||||||
main .d-flex.justify-content-between.align-items-center { flex-wrap: wrap; gap: .5rem; }
|
|
||||||
/* 16px inputs stop iOS from auto-zooming on focus */
|
|
||||||
.form-control, .form-select { font-size: 16px; }
|
|
||||||
/* Comfortable content gutters on phones */
|
|
||||||
.p-3 { padding: .85rem !important; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="sidebar" id="sidebar">
|
<?php require_once __DIR__ . '/nav_helpers.php'; $dash = bo_dashboard_path(); ?>
|
||||||
<div class="sidebar-brand"><i class="bi bi-lightning-charge-fill me-2"></i><?= htmlspecialchars($app_name) ?></div>
|
<aside class="sidebar" id="sidebar" aria-label="Menu principal">
|
||||||
<div class="sidebar-section">Principal</div>
|
<div class="bo-sidebar__head">
|
||||||
<ul class="nav flex-column">
|
<button type="button" class="bo-sidebar__toggle" data-sidebar-toggle
|
||||||
<li class="nav-item">
|
aria-label="Reduzir menu" aria-expanded="true" aria-controls="sidebar">
|
||||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/admin/dashboard.php' ? ' active' : '' ?>"
|
<i class="bi bi-list" aria-hidden="true"></i>
|
||||||
href="<?= base_url('views/admin/dashboard.php') ?>"><i class="bi bi-speedometer2"></i>Painel</a>
|
</button>
|
||||||
</li>
|
<a class="bo-sidebar__brand" href="<?= base_url($dash) ?>"><i class="bi bi-lightning-charge me-2" aria-hidden="true"></i><?= htmlspecialchars($app_name) ?></a>
|
||||||
<?php if (is_staff()): ?>
|
<?php // Único sítio que abre o site público (nova aba). ?>
|
||||||
<li class="nav-item">
|
<a class="bo-sidebar__site" href="<?= base_url('/') ?>" target="_blank" rel="noopener"
|
||||||
<a class="nav-link" href="<?= base_url('/') ?>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i>Site público</a>
|
title="Ver site público" aria-label="Ver site público"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></a>
|
||||||
</li>
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown bo-sidebar__user">
|
||||||
|
<button class="bo-user-menu dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"
|
||||||
|
title="<?= htmlspecialchars($user['name']) ?>">
|
||||||
|
<span class="bo-user-menu__avatar" aria-hidden="true"><?= htmlspecialchars(bo_initials((string) $user['name'])) ?></span>
|
||||||
|
<span class="bo-user-menu__text">
|
||||||
|
<span class="bo-user-menu__name"><?= htmlspecialchars($user['name']) ?></span>
|
||||||
|
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>"><?= htmlspecialchars(role_label($user['role'])) ?></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li class="px-3 pt-2 pb-1"><div class="fw-semibold small lh-sm"><?= htmlspecialchars($user['name']) ?></div></li>
|
||||||
|
<li><hr class="dropdown-divider my-1"></li>
|
||||||
|
<li><a class="dropdown-item" href="<?= base_url('views/user/profile.php') ?>"><i class="bi bi-person me-2" aria-hidden="true"></i>Perfil</a></li>
|
||||||
|
<li><a class="dropdown-item text-danger" href="<?= base_url('views/auth/logout.php') ?>"><i class="bi bi-box-arrow-right me-2" aria-hidden="true"></i>Terminar sessão</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="bo-sidebar__nav" aria-label="Navegação">
|
||||||
|
<?= bo_nav_link($dash, 'bi-speedometer2', 'Painel', ['mod' => 'primary']) ?>
|
||||||
|
|
||||||
|
<?php if (is_admin()): ?>
|
||||||
|
<?php bo_nav_group_start('gestao', 'Gestão', 'bi-briefcase'); ?>
|
||||||
|
<?php bo_nav_group_start('site', 'Site', 'bi-window', true); ?>
|
||||||
|
<?= bo_nav_link('views/admin/gyms/index.php', 'bi-buildings', 'Ginásios', ['mod' => 'sub2', 'match' => 'views/admin/gyms/']) ?>
|
||||||
|
<?= bo_nav_link('views/admin/modalidades/index.php', 'bi-grid-3x3-gap', 'Modalidades', ['mod' => 'sub2', 'match' => 'views/admin/modalidades/']) ?>
|
||||||
|
<?= bo_nav_link('views/admin/galerias/index.php', 'bi-images', 'Galerias', ['mod' => 'sub2', 'match' => 'views/admin/galerias/']) ?>
|
||||||
|
<?= bo_nav_link('views/admin/events/index.php', 'bi-megaphone', 'Eventos', ['mod' => 'sub2', 'match' => 'views/admin/events/']) ?>
|
||||||
|
<?= bo_nav_link('views/admin/info/edit.php', 'bi-telephone', 'Contactos', ['mod' => 'sub2', 'match' => 'views/admin/info/']) ?>
|
||||||
|
<?= bo_nav_link('views/admin/reviews/index.php', 'bi-star', 'Reviews Google', ['mod' => 'sub2', 'match' => 'views/admin/reviews/']) ?>
|
||||||
|
<?= bo_nav_link('views/admin/instagram/index.php', 'bi-instagram', 'Comunidade (Instagram)', ['mod' => 'sub2', 'match' => 'views/admin/instagram/']) ?>
|
||||||
|
<?php bo_nav_group_end(); ?>
|
||||||
|
<?php bo_nav_group_start('horarios', 'Horários', 'bi-clock-history', true); ?>
|
||||||
|
<?= bo_nav_link('views/admin/work_hours/index.php', 'bi-clock', 'Horário de Funcionamento', ['mod' => 'sub2', 'match' => 'views/admin/work_hours/']) ?>
|
||||||
|
<?= bo_nav_link('views/admin/horario/index.php', 'bi-calendar-week', 'Horário de Aulas', ['mod' => 'sub2', 'match' => 'views/admin/horario/']) ?>
|
||||||
|
<?php bo_nav_group_end(); ?>
|
||||||
|
<?php bo_nav_group_start('comercial', 'Comercial', 'bi-cash-coin', true); ?>
|
||||||
|
<?= bo_nav_link('views/admin/memberships/index.php', 'bi-credit-card-2-front', 'Mensalidades', ['mod' => 'sub2', 'match' => 'views/admin/memberships/']) ?>
|
||||||
|
<?php $__tn = function_exists('db_trial_leads_count_new') ? db_trial_leads_count_new($pdo, admin_gym_id()) : 0; ?>
|
||||||
|
<?= bo_nav_link('views/admin/trials/index.php', 'bi-person-check', 'Aulas experimentais', ['mod' => 'sub2', 'match' => 'views/admin/trials/', 'badge' => $__tn]) ?>
|
||||||
|
<?= bo_nav_link('views/admin/team/index.php', 'bi-person-workspace', 'Equipa', ['mod' => 'sub2', 'match' => 'views/admin/team/']) ?>
|
||||||
|
<?php bo_nav_group_end(); ?>
|
||||||
|
<?php bo_nav_group_start('legal', 'Legal', 'bi-shield-check', true); ?>
|
||||||
|
<?= bo_nav_link('views/admin/rgpd/index.php', 'bi-file-earmark-lock', 'Pedidos RGPD', ['mod' => 'sub2', 'match' => 'views/admin/rgpd/']) ?>
|
||||||
|
<?= bo_nav_link('views/admin/trash/index.php', 'bi-trash3', 'Lixeira', ['mod' => 'sub2', 'match' => 'views/admin/trash/']) ?>
|
||||||
|
<?php bo_nav_group_end(); ?>
|
||||||
|
<?php bo_nav_group_end(); ?>
|
||||||
|
|
||||||
|
<?php bo_nav_group_start('utilizadores', 'Utilizadores', 'bi-people'); ?>
|
||||||
|
<?= bo_nav_link('views/user/index.php', 'bi-person-vcard', 'Utilizadores', ['mod' => 'sub', 'match' => ['views/user/index.php', 'views/user/edit.php']]) ?>
|
||||||
|
<?php bo_nav_group_end(); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</ul>
|
|
||||||
|
|
||||||
<?php if (is_admin()): ?>
|
<?php if (is_sysadmin()): ?>
|
||||||
<div class="sidebar-section">Gestão</div>
|
<?php bo_nav_group_start('sistema', 'Sistema', 'bi-gear'); ?>
|
||||||
<ul class="nav flex-column">
|
<?= bo_nav_link('views/sysadmin/system_settings.php', 'bi-sliders', 'Definições', ['mod' => 'sub']) ?>
|
||||||
<li class="nav-item">
|
<?= bo_nav_link('views/sysadmin/messages.php', 'bi-envelope-paper', 'Mensagens', ['mod' => 'sub']) ?>
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/gyms/') !== false) ? ' active' : '' ?>"
|
<?= bo_nav_link('views/sysadmin/comms.php', 'bi-send', 'Comunicações', ['mod' => 'sub']) ?>
|
||||||
href="<?= base_url('views/admin/gyms/index.php') ?>"><i class="bi bi-buildings-fill"></i>Ginásios</a>
|
<?php // Backups/Auditoria/IP bans/Geo/Origem das visitas/Atualizações/Hub: um só
|
||||||
</li>
|
// link para o índice partilhado do platform-tools (como a fotografia/design/
|
||||||
<li class="nav-item">
|
// dance fazem), em vez de um por ferramenta — uma ferramenta nova no
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/team/') !== false) ? ' active' : '' ?>"
|
// platform-tools passa a aparecer sozinha, sem precisar de link novo aqui. ?>
|
||||||
href="<?= base_url('views/admin/team/index.php') ?>"><i class="bi bi-people-fill"></i>Equipa</a>
|
<?= bo_nav_link('platform.php?tool=index', 'bi-cpu', 'Sistema', ['mod' => 'sub',
|
||||||
</li>
|
'active' => (($_GET['tool'] ?? 'index') === 'index' && str_contains($_SERVER['REQUEST_URI'] ?? '', 'platform.php'))]) ?>
|
||||||
<li class="nav-item">
|
<?php if (function_exists('pt_icon_packs')): // pacotes de ícones só existem no submódulo novo ?>
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/modalidades/') !== false) ? ' active' : '' ?>"
|
<?= bo_nav_link('platform.php?tool=icons', 'bi-grid-3x3-gap', 'Pacotes de ícones', ['mod' => 'sub',
|
||||||
href="<?= base_url('views/admin/modalidades/index.php') ?>"><i class="bi bi-grid-3x3-gap-fill"></i>Modalidades</a>
|
'active' => (($_GET['tool'] ?? '') === 'icons')]) ?>
|
||||||
</li>
|
<?php endif; ?>
|
||||||
<li class="nav-item">
|
<?php bo_nav_group_end(); ?>
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/galerias/') !== false) ? ' active' : '' ?>"
|
<?php endif; ?>
|
||||||
href="<?= base_url('views/admin/galerias/index.php') ?>"><i class="bi bi-images"></i>Galerias</a>
|
</nav>
|
||||||
</li>
|
</aside>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/events/') !== false) ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/admin/events/index.php') ?>"><i class="bi bi-megaphone-fill"></i>Eventos</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/work_hours/') !== false) ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/admin/work_hours/index.php') ?>"><i class="bi bi-clock-fill"></i>Horário de Funcionamento</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/horario/') !== false) ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/admin/horario/index.php') ?>"><i class="bi bi-calendar-week-fill"></i>Horário de Aulas</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/memberships/') !== false) ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/admin/memberships/index.php') ?>"><i class="bi bi-credit-card-2-front-fill"></i>Mensalidades</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trials/') !== false) ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/admin/trials/index.php') ?>"><i class="bi bi-person-check-fill"></i>Aulas experimentais<?php
|
|
||||||
$__tn = function_exists('db_trial_leads_count_new') ? db_trial_leads_count_new($pdo, admin_gym_id()) : 0;
|
|
||||||
if ($__tn): ?> <span class="badge bg-danger ms-1"><?= (int) $__tn ?></span><?php endif; ?></a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/reviews/') !== false) ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/admin/reviews/index.php') ?>"><i class="bi bi-star-fill"></i>Reviews Google</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/info/') !== false) ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/admin/info/edit.php') ?>"><i class="bi bi-telephone-fill"></i>Contactos</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trash/') !== false) ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/admin/trash/index.php') ?>"><i class="bi bi-trash3-fill"></i>Lixeira</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/rgpd/') !== false) ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/admin/rgpd/index.php') ?>"><i class="bi bi-shield-lock-fill"></i>Pedidos RGPD</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (is_admin()): ?>
|
|
||||||
<div class="sidebar-section">Utilizadores</div>
|
|
||||||
<ul class="nav flex-column">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/user/index.php' ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/user/index.php') ?>"><i class="bi bi-person-badge"></i>Utilizadores</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (is_sysadmin()): ?>
|
|
||||||
<div class="sidebar-section">Sistema</div>
|
|
||||||
<ul class="nav flex-column">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/system_settings.php' ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/sysadmin/system_settings.php') ?>"><i class="bi bi-sliders"></i>Definições</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/messages.php' ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/sysadmin/messages.php') ?>"><i class="bi bi-envelope-paper"></i>Mensagens</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/comms.php' ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('views/sysadmin/comms.php') ?>"><i class="bi bi-send"></i>Comunicações</a>
|
|
||||||
</li>
|
|
||||||
<?php // Backups/Auditoria/IP bans/Geo/Origem das visitas/Atualizações/Hub: um só
|
|
||||||
// link para o índice partilhado do platform-tools (como a fotografia/design/
|
|
||||||
// dance fazem), em vez de um por ferramenta — uma ferramenta nova no
|
|
||||||
// platform-tools passa a aparecer sozinha, sem precisar de link novo aqui. ?>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link<?= (($_GET['tool'] ?? 'index') === 'index' && str_contains($_SERVER['REQUEST_URI'] ?? '', 'platform.php')) ? ' active' : '' ?>"
|
|
||||||
href="<?= base_url('platform.php?tool=index') ?>"><i class="bi bi-shield-lock"></i>Sistema</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
</nav>
|
|
||||||
<div class="sidebar-backdrop" id="sidebarBackdrop" aria-hidden="true"></div>
|
<div class="sidebar-backdrop" id="sidebarBackdrop" aria-hidden="true"></div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button type="button" class="menu-toggle" id="menuToggle" aria-label="Abrir menu" aria-controls="sidebar">
|
<?php // Só em telemóvel: abre a gaveta (a barra está fora do ecrã). ?>
|
||||||
<i class="bi bi-list"></i>
|
<button type="button" class="menu-toggle" data-sidebar-toggle aria-label="Abrir menu" aria-expanded="false" aria-controls="sidebar">
|
||||||
|
<i class="bi bi-list" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<?php
|
<?php
|
||||||
// Which gym the content pages edit (functions_gyms.php). Switching goes back
|
// Which gym the content pages edit (functions_gyms.php). Switching goes back
|
||||||
@@ -244,7 +152,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
|||||||
<noscript><button class="btn btn-sm btn-outline-secondary">Mudar</button></noscript>
|
<noscript><button class="btn btn-sm btn-outline-secondary">Mudar</button></noscript>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="user-dropdown">
|
<div class="bo-topbar__tools">
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-sm btn-outline-secondary theme-toggle" type="button" data-bs-toggle="dropdown"
|
<button class="btn btn-sm btn-outline-secondary theme-toggle" type="button" data-bs-toggle="dropdown"
|
||||||
aria-expanded="false" aria-label="Tema: automático" id="themeToggle">
|
aria-expanded="false" aria-label="Tema: automático" id="themeToggle">
|
||||||
@@ -256,12 +164,6 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
|
|||||||
<li><button type="button" class="dropdown-item" data-theme-set="auto"><i class="bi bi-circle-half me-2" aria-hidden="true"></i>Automático</button></li>
|
<li><button type="button" class="dropdown-item" data-theme-set="auto"><i class="bi bi-circle-half me-2" aria-hidden="true"></i>Automático</button></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>">
|
|
||||||
<?= htmlspecialchars(role_label($user['role'])) ?>
|
|
||||||
</span>
|
|
||||||
<span class="u-name"><?= htmlspecialchars($user['name']) ?></span>
|
|
||||||
<a href="<?= base_url('views/user/profile.php') ?>" class="btn btn-sm btn-outline-secondary"><i class="bi bi-person"></i></a>
|
|
||||||
<a href="<?= base_url('views/auth/logout.php') ?>" class="btn btn-sm btn-outline-danger"><i class="bi bi-box-arrow-right"></i></a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
// Auxiliares da barra lateral do backoffice (incluído por includes/header.php).
|
||||||
|
// Ficam aqui, e não no HTML, para o menu ser só uma lista de itens: o ativo,
|
||||||
|
// o aria-current, o title/aria-label (o modo mini só mostra o ícone) e a
|
||||||
|
// abertura dos grupos decidem-se num sítio.
|
||||||
|
|
||||||
|
/** Painel de cada papel: o admin tem o seu, o staff o dele, o cliente a área de sócio. */
|
||||||
|
function bo_dashboard_path(): string
|
||||||
|
{
|
||||||
|
if (is_admin()) return 'views/admin/dashboard.php';
|
||||||
|
if (is_staff()) return 'views/staff/dashboard.php';
|
||||||
|
return 'views/user/dashboard.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O pedido atual está nesta página? Compara pelo FIM do caminho (e não com ===):
|
||||||
|
* em dev o site vive num subcaminho do host (/GitTea/repos/fitme3070.pt/...).
|
||||||
|
* Um caminho terminado em «/» é uma pasta inteira (inclui edit.php, create.php...).
|
||||||
|
* Aceita uma lista: basta uma coincidir.
|
||||||
|
*
|
||||||
|
* @param string|string[] $paths
|
||||||
|
*/
|
||||||
|
function bo_nav_active($paths): bool
|
||||||
|
{
|
||||||
|
$script = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
|
||||||
|
foreach ((array) $paths as $p) {
|
||||||
|
$p = '/' . ltrim($p, '/');
|
||||||
|
if (str_ends_with($p, '/') ? str_contains($script, $p) : str_ends_with($script, $p)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uma ligação da barra lateral.
|
||||||
|
* Opções: match (caminho/s para o «ativo», por omissão o próprio href), mod
|
||||||
|
* ('sub' | 'sub2' | 'primary'), badge (inteiro; só aparece se > 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 '<a class="nav-link bo-nav-item' . $mod . ($active ? ' active' : '') . '" href="' . htmlspecialchars(base_url($href), ENT_QUOTES, 'UTF-8') . '"'
|
||||||
|
. ' title="' . $l . '" aria-label="' . $l . '"' . ($active ? ' aria-current="page"' : '') . '>'
|
||||||
|
. '<i class="bi ' . htmlspecialchars($icon, ENT_QUOTES, 'UTF-8') . '" aria-hidden="true"></i>'
|
||||||
|
. '<span class="bo-nav-item__label">' . $l . '</span>'
|
||||||
|
. ($badge > 0 ? '<span class="badge bg-danger ms-auto bo-nav-item__badge">' . $badge . '</span>' : '')
|
||||||
|
. '</a>' . "\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 '<div class="bo-nav-group' . ($sub ? ' bo-nav-group--sub' : '') . ($hasActive ? ' has-active' : '') . '" data-group="' . $k . '">'
|
||||||
|
. '<button class="bo-nav-group__label" type="button" data-short="' . $short . '" aria-expanded="true"'
|
||||||
|
. ' aria-controls="' . $id . '" data-group-toggle="' . $k . '" title="' . $l . '" aria-label="' . $l . '">'
|
||||||
|
. '<i class="bi ' . $i . ' bo-nav-group__icon" aria-hidden="true"></i>'
|
||||||
|
. '<span class="bo-nav-group__text">' . $l . '</span>'
|
||||||
|
. '<i class="bi bi-chevron-down bo-nav-group__chevron" aria-hidden="true"></i></button>'
|
||||||
|
. '<div class="bo-nav-group__body" id="' . $id . '"><div class="bo-nav-group__inner">' . $items . '</div></div>'
|
||||||
|
. '</div>' . "\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);
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
// Standalone top navbar — only outputs when header.php hasn't been included yet.
|
|
||||||
// (system_settings.php includes both header.php + navbar.php, so this defers.)
|
|
||||||
if (defined('ADMIN_SHELL_OPENED') && ADMIN_SHELL_OPENED) return;
|
|
||||||
?>
|
|
||||||
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
|
|
||||||
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
|
|
||||||
<nav class="navbar navbar-expand navbar-dark px-3 mb-0" style="position:sticky;top:0;z-index:99;background:var(--bo-accent, #C2D500);">
|
|
||||||
<a class="navbar-brand fw-bold text-dark" href="<?= base_url('views/admin/dashboard.php') ?>">
|
|
||||||
<i class="bi bi-lightning-charge-fill me-2"></i><?= htmlspecialchars(defined('APP_NAME') ? APP_NAME : 'FitMe 3070') ?>
|
|
||||||
</a>
|
|
||||||
<ul class="navbar-nav ms-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<span class="nav-link text-dark"><?= htmlspecialchars($user['name'] ?? '') ?></span>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item"><a class="nav-link text-dark" href="<?= base_url('views/auth/logout.php') ?>"><i class="bi bi-box-arrow-right"></i></a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Style lab (DEV ONLY): the public site's components on one page, with sliders
|
||||||
|
* for the visual effects added to style.css, so values can be tuned by eye and
|
||||||
|
* then copied back into the CSS. Nothing here is saved; reload resets it.
|
||||||
|
* Refuses to run when APP_ENV is prod.
|
||||||
|
*/
|
||||||
|
require __DIR__ . '/config/db.php';
|
||||||
|
require __DIR__ . '/config/functions.php';
|
||||||
|
|
||||||
|
$env = strtolower((string) (function_exists('env') ? env('APP_ENV', 'prod') : 'prod'));
|
||||||
|
if ($env === 'prod' || $env === 'production') { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
$gyms = function_exists('db_gyms_published') ? db_gyms_published($pdo) : [];
|
||||||
|
$img_team = base_url('uploads/team/foto/1.jpg');
|
||||||
|
$img_gal = [base_url('site/assets/img/home.jpg'), base_url('site/assets/img/crossfit.jpg'), base_url('site/assets/img/footer-bg-gym.jpg')];
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="pt">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Laboratório de estilo — FitMe</title>
|
||||||
|
<?= link_tag('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>
|
||||||
|
<?= link_tag('site/assets/fonts/fontawesome/css/all.css') ?>
|
||||||
|
<?= link_tag('site/assets/css/style.css') ?>
|
||||||
|
<style id="lab-overrides"></style>
|
||||||
|
<style>
|
||||||
|
body { padding-bottom: 40px; }
|
||||||
|
.lab-panel { position: fixed; top: 12px; right: 12px; z-index: 2000; width: 300px; max-height: calc(100vh - 24px); overflow: auto;
|
||||||
|
background: rgba(11,11,13,.94); color: #fff; padding: 14px 16px; border-radius: 8px; box-shadow: 0 8px 30px rgba(0,0,0,.5); font-size: .85rem; }
|
||||||
|
.lab-panel h2 { font-size: 1rem; margin: 0 0 10px; color: var(--fitme-accent, #C2D500); filter: none; }
|
||||||
|
.lab-panel label { display: flex; justify-content: space-between; gap: 8px; margin: 10px 0 2px; }
|
||||||
|
.lab-panel output { color: var(--fitme-accent, #C2D500); font-variant-numeric: tabular-nums; }
|
||||||
|
.lab-panel input[type=range] { width: 100%; accent-color: var(--fitme-accent, #C2D500); }
|
||||||
|
.lab-panel input[type=color] { width: 100%; height: 34px; border: 0; background: none; padding: 0; }
|
||||||
|
.lab-panel button.reset { margin-top: 12px; width: 100%; border: 1px solid #555; background: none; color: #fff; padding: 6px; border-radius: 4px; }
|
||||||
|
.lab-panel .hint { color: #aaa; font-size: .75rem; margin-top: 10px; }
|
||||||
|
.lab-sec { padding: 56px 0; }
|
||||||
|
.lab-sec.dark { background: var(--fitme-dark-2); color: #fff; }
|
||||||
|
.lab-sec.dark h1, .lab-sec.dark h2 { color: #fff; }
|
||||||
|
.lab-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 18px 0; }
|
||||||
|
.lab-label { font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; opacity: .6; margin: 26px 0 6px; }
|
||||||
|
.lab-gal { display: flex; gap: 12px; }
|
||||||
|
.lab-gal .gal-tile { width: 220px; height: 150px; }
|
||||||
|
.lab-card-wrap { max-width: 270px; }
|
||||||
|
@media (max-width: 991px) { .lab-panel { position: static; width: auto; max-height: none; margin: 12px; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php include __DIR__ . '/includes/env_ribbon.php'; ?>
|
||||||
|
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||||
|
<filter id="pt-noise" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||||||
|
<feTurbulence id="lab-turb" type="fractalNoise" baseFrequency=".9" numOctaves="2" seed="4" result="n"/>
|
||||||
|
<feColorMatrix id="lab-sat" in="n" type="saturate" values="0" result="g"/>
|
||||||
|
<feGaussianBlur id="lab-blur" in="g" stdDeviation="0" result="gb"/>
|
||||||
|
<feComponentTransfer in="gb" result="grain">
|
||||||
|
<feFuncR id="lab-fr" type="linear" slope=".15" intercept=".86"/>
|
||||||
|
<feFuncG id="lab-fg" type="linear" slope=".15" intercept=".86"/>
|
||||||
|
<feFuncB id="lab-fb" type="linear" slope=".15" intercept=".86"/>
|
||||||
|
<feFuncA type="linear" slope="0" intercept="1"/>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feBlend id="lab-blend" in="SourceGraphic" in2="grain" mode="multiply" result="mix"/>
|
||||||
|
<feComposite in="mix" in2="SourceGraphic" operator="in"/>
|
||||||
|
</filter>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<aside class="lab-panel" aria-label="Controlos">
|
||||||
|
<h2>Laboratório de estilo</h2>
|
||||||
|
|
||||||
|
<label for="c-color">Cor da marca <output id="o-color">#C2D500</output></label>
|
||||||
|
<input type="color" id="c-color" value="#c2d500">
|
||||||
|
|
||||||
|
<label for="c-btn">Opacidade do grão — botões <output id="o-btn"></output></label>
|
||||||
|
<input type="range" id="c-btn" min="0" max="50" value="16">
|
||||||
|
|
||||||
|
<label for="c-head">Opacidade do grão — títulos h1/h2 <output id="o-head"></output></label>
|
||||||
|
<input type="range" id="c-head" min="0" max="100" value="25">
|
||||||
|
|
||||||
|
<label for="c-sat">Saturação das fotos <output id="o-sat"></output></label>
|
||||||
|
<input type="range" id="c-sat" min="40" max="100" value="90">
|
||||||
|
|
||||||
|
<label for="c-glow">Brilho dos links (hover) <output id="o-glow"></output></label>
|
||||||
|
<input type="range" id="c-glow" min="0" max="100" value="60">
|
||||||
|
|
||||||
|
<label for="c-spin">Volta da aresta rotativa <output id="o-spin"></output></label>
|
||||||
|
<input type="range" id="c-spin" min="10" max="100" value="32">
|
||||||
|
|
||||||
|
<label for="c-ring">Espessura da aresta (px) <output id="o-ring"></output></label>
|
||||||
|
<input type="range" id="c-ring" min="1" max="6" value="2">
|
||||||
|
|
||||||
|
<h2 style="margin-top:18px">Textura do grão</h2>
|
||||||
|
|
||||||
|
<label for="c-type">Tipo</label>
|
||||||
|
<select id="c-type" class="form-select form-select-sm"><option value="fractalNoise">Fino (fractalNoise)</option><option value="turbulence">Manchado (turbulence)</option></select>
|
||||||
|
|
||||||
|
<label for="c-freq">Tamanho do grão (frequência) <output id="o-freq"></output></label>
|
||||||
|
<input type="range" id="c-freq" min="5" max="200" value="90">
|
||||||
|
|
||||||
|
<label for="c-oct">Detalhe (oitavas) <output id="o-oct"></output></label>
|
||||||
|
<input type="range" id="c-oct" min="1" max="5" value="2">
|
||||||
|
|
||||||
|
<label for="c-seed">Semente <output id="o-seed"></output></label>
|
||||||
|
<input type="range" id="c-seed" min="0" max="50" value="4">
|
||||||
|
|
||||||
|
<label for="c-contrast">Contraste do grão <output id="o-contrast"></output></label>
|
||||||
|
<input type="range" id="c-contrast" min="20" max="250" value="100">
|
||||||
|
|
||||||
|
<label for="c-soft">Suavidade (desfoque) <output id="o-soft"></output></label>
|
||||||
|
<input type="range" id="c-soft" min="0" max="20" value="0">
|
||||||
|
|
||||||
|
<label for="c-tile">Mosaico dos botões (px) <output id="o-tile"></output></label>
|
||||||
|
<input type="range" id="c-tile" min="40" max="400" value="140">
|
||||||
|
|
||||||
|
<label for="c-bbm">Mistura nos botões</label>
|
||||||
|
<select id="c-bbm" class="form-select form-select-sm"><option>normal</option><option>multiply</option><option>overlay</option><option>soft-light</option><option>hard-light</option><option>screen</option><option>darken</option><option>lighten</option></select>
|
||||||
|
|
||||||
|
<label for="c-hbm">Mistura nos títulos</label>
|
||||||
|
<select id="c-hbm" class="form-select form-select-sm"><option>multiply</option><option>overlay</option><option>soft-light</option><option>screen</option><option>darken</option><option>lighten</option></select>
|
||||||
|
|
||||||
|
<label style="justify-content:flex-start;align-items:center"><input type="checkbox" id="c-mono" checked> Só cinzento (sem cor no grão)</label>
|
||||||
|
|
||||||
|
<label for="c-css">CSS gerado (copiar)</label>
|
||||||
|
<textarea id="c-css" class="form-control form-control-sm" rows="9" readonly style="font-family:monospace;font-size:.7rem"></textarea>
|
||||||
|
|
||||||
|
<button type="button" class="reset" id="c-reset">Repor valores</button>
|
||||||
|
<p class="hint">Passa o rato pelos botões, links, fotos, moldura e cartão para ver os efeitos. Os valores não são guardados.</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="lab-sec dark">
|
||||||
|
<div class="container">
|
||||||
|
<h1>Ginásio, Box, Aulas <span style="color:var(--fitme-accent)">& Acompanhamento</span></h1>
|
||||||
|
<div class="lab-row">
|
||||||
|
<a href="#" class="site-btn">Inscreve-te já!</a>
|
||||||
|
<a href="#" class="site-btn sb-ghost">Ghost</a>
|
||||||
|
<a href="#" class="site-btn sb-dark">Dark</a>
|
||||||
|
<a href="#" class="site-btn sb-line" style="color:#fff;border-color:#fff">Line</a>
|
||||||
|
<button type="button" class="hero-text-link">Marca uma aula experimental</button>
|
||||||
|
</div>
|
||||||
|
<p>Um <a href="#">link no texto</a> sobre fundo escuro.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lab-sec">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-title text-center deco">
|
||||||
|
<span class="section-eyebrow">Secção</span>
|
||||||
|
<h2>Título de secção (h2)</h2>
|
||||||
|
<p class="section-subtitle">Subtítulo de exemplo para ver o grão no h2 sobre fundo claro.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lab-label">Botões e links</div>
|
||||||
|
<div class="lab-row">
|
||||||
|
<a href="#" class="site-btn">Botão principal</a>
|
||||||
|
<a href="#" class="nav-link crossfit-nav-link" style="display:inline-block;margin:0 !important">CrossFit</a>
|
||||||
|
<a href="#" class="gym-switch-btn">FitMe Mira</a>
|
||||||
|
<?php foreach (array_slice($gyms, 0, 2) as $g): ?><a href="#" class="gym-switch-btn" style="<?= htmlspecialchars(gym_brand_vars($g)) ?>"><?= htmlspecialchars($g['name']) ?></a><?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<p>Texto corrido com um <a href="#">link normal</a> e outro <a href="#">link mais comprido para testar</a>.</p>
|
||||||
|
|
||||||
|
<div class="lab-label">Galeria (fotos)</div>
|
||||||
|
<div class="lab-gal">
|
||||||
|
<?php foreach ($img_gal as $i => $src): ?>
|
||||||
|
<div class="gal-tile"><a href="#" class="gal-link"><img src="<?= htmlspecialchars($src) ?>" alt="" draggable="false"></a></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lab-label">Equipa (moldura)</div>
|
||||||
|
<div class="trainer-item" style="display:inline-block">
|
||||||
|
<div class="trainer-pic"><img src="<?= htmlspecialchars($img_team) ?>" alt=""></div>
|
||||||
|
<h3 style="color:inherit">Nome do membro</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lab-label">Modalidades (ícone)</div>
|
||||||
|
<div class="row" style="max-width:520px">
|
||||||
|
<div class="col-6"><div class="icon-box-item"><div class="ib-icon"><i class="fas fa-dumbbell"></i></div><h3>Musculação</h3></div></div>
|
||||||
|
<div class="col-6"><div class="icon-box-item"><div class="ib-icon"><i class="fas fa-bicycle"></i></div><h3>Cycling</h3></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lab-label">Mensalidades (cartão)</div>
|
||||||
|
<div class="lab-card-wrap">
|
||||||
|
<div class="price-card"><h3>Plano exemplo</h3><p>Passa o rato por cima.</p><a href="#" class="site-btn">Inscrever</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var root = document.documentElement, ov = document.getElementById('lab-overrides');
|
||||||
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
var DEF = { color: '#c2d500', btn: 16, head: 25, sat: 90, glow: 60, spin: 32, ring: 2,
|
||||||
|
type: 'fractalNoise', freq: 90, oct: 2, seed: 4, contrast: 100, soft: 0, tile: 140, bbm: 'normal', hbm: 'multiply', mono: true };
|
||||||
|
var st = Object.assign({}, DEF);
|
||||||
|
|
||||||
|
function noiseUrl() {
|
||||||
|
var f = (st.freq / 100).toFixed(2), op = (st.btn / 100).toFixed(3), c = st.contrast / 100;
|
||||||
|
var svg = "<svg xmlns='http://www.w3.org/2000/svg' width='" + st.tile + "' height='" + st.tile + "'>" +
|
||||||
|
"<filter id='n' x='0' y='0' width='100%' height='100%'>" +
|
||||||
|
"<feTurbulence type='" + st.type + "' baseFrequency='" + f + "' numOctaves='" + st.oct + "' seed='" + st.seed + "' stitchTiles='stitch'/>" +
|
||||||
|
(st.mono ? "<feColorMatrix type='saturate' values='0'/>" : "") +
|
||||||
|
(st.soft > 0 ? "<feGaussianBlur stdDeviation='" + (st.soft / 10).toFixed(1) + "'/>" : "") +
|
||||||
|
(c !== 1 ? "<feComponentTransfer><feFuncR type='linear' slope='" + c + "' intercept='" + ((1 - c) / 2).toFixed(3) + "'/><feFuncG type='linear' slope='" + c + "' intercept='" + ((1 - c) / 2).toFixed(3) + "'/><feFuncB type='linear' slope='" + c + "' intercept='" + ((1 - c) / 2).toFixed(3) + "'/></feComponentTransfer>" : "") +
|
||||||
|
"</filter><rect width='100%' height='100%' filter='url(#n)' opacity='" + op + "'/></svg>";
|
||||||
|
return 'url("data:image/svg+xml,' + svg.replace(/</g, '%3C').replace(/>/g, '%3E').replace(/#/g, '%23').replace(/%(?![0-9A-F]{2})/g, '%25') + '")';
|
||||||
|
}
|
||||||
|
function brandVars(hex) {
|
||||||
|
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
var sh = function (k) { return '#' + [r, g, b].map(function (v) { return ('0' + Math.round(v * k).toString(16)).slice(-2); }).join('').toUpperCase(); };
|
||||||
|
var lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||||
|
return { '--fitme-accent': hex.toUpperCase(), '--fitme-accent-rgb': r + ', ' + g + ', ' + b, '--fitme-accent-dark': sh(.86),
|
||||||
|
'--fitme-accent-ink': sh(lum > .5 ? .5 : .85), '--fitme-on-accent': lum > .5 ? '#0B0B0D' : '#FFFFFF' };
|
||||||
|
}
|
||||||
|
function apply() {
|
||||||
|
var v = brandVars(st.color);
|
||||||
|
Object.keys(v).forEach(function (k) { root.style.setProperty(k, v[k]); });
|
||||||
|
var url = noiseUrl();
|
||||||
|
root.style.setProperty('--noise', url);
|
||||||
|
|
||||||
|
// headings: the SVG filter
|
||||||
|
var s = st.head / 100, c = st.contrast / 100;
|
||||||
|
var slope = (s * .6 * c).toFixed(3), icpt = (1 - s * .6 * c / 2 - s * .6 * c / 2).toFixed(3);
|
||||||
|
['lab-fr', 'lab-fg', 'lab-fb'].forEach(function (id) { $(id).setAttribute('slope', slope); $(id).setAttribute('intercept', icpt); });
|
||||||
|
$('lab-turb').setAttribute('type', st.type);
|
||||||
|
$('lab-turb').setAttribute('baseFrequency', (st.freq / 100).toFixed(2));
|
||||||
|
$('lab-turb').setAttribute('numOctaves', st.oct);
|
||||||
|
$('lab-turb').setAttribute('seed', st.seed);
|
||||||
|
$('lab-sat').setAttribute('values', st.mono ? '0' : '1');
|
||||||
|
$('lab-blur').setAttribute('stdDeviation', (st.soft / 10).toFixed(1));
|
||||||
|
$('lab-blend').setAttribute('mode', st.hbm);
|
||||||
|
|
||||||
|
var glow = st.glow / 100, spin = (st.spin / 10).toFixed(1) + 's';
|
||||||
|
ov.textContent =
|
||||||
|
'.site-btn,.gym-switch-btn,.crossfit-nav-link { background-size: ' + st.tile + 'px ' + st.tile + 'px !important; background-blend-mode: ' + st.bbm + ' !important; }' +
|
||||||
|
'.gal-link img, .trainer-item .trainer-pic img { filter: saturate(' + (st.sat / 100) + '); }' +
|
||||||
|
'.gal-link:hover img, .trainer-item .trainer-pic:hover img { filter: saturate(1) brightness(1.05); }' +
|
||||||
|
'a:where(:not(.site-btn,.gym-switch-btn,.gal-link,.nav-link)):is(:hover,:focus-visible) { text-shadow: 0 0 10px rgba(var(--fitme-accent-rgb),' + (.6 * glow * 1.67).toFixed(2) + '), 0 0 22px rgba(var(--fitme-accent-rgb),' + (.3 * glow * 1.67).toFixed(2) + ') !important; }' +
|
||||||
|
'.site-btn::before,.site-btn::after,.gym-switch-btn::before,.gym-switch-btn::after,.price-card::before,.price-card::after,.gal-link::after,.icon-box-item .ib-icon::after,.trainer-item .trainer-pic:hover { animation-duration: ' + spin + ' !important; }' +
|
||||||
|
'.site-btn,.gym-switch-btn { --ring: ' + st.ring + 'px; }';
|
||||||
|
|
||||||
|
$('o-color').textContent = st.color.toUpperCase();
|
||||||
|
$('o-btn').textContent = st.btn + '%';
|
||||||
|
$('o-head').textContent = st.head + '%';
|
||||||
|
$('o-sat').textContent = st.sat + '%';
|
||||||
|
$('o-glow').textContent = st.glow + '%';
|
||||||
|
$('o-spin').textContent = spin;
|
||||||
|
$('o-ring').textContent = st.ring;
|
||||||
|
$('o-freq').textContent = (st.freq / 100).toFixed(2);
|
||||||
|
$('o-oct').textContent = st.oct;
|
||||||
|
$('o-seed').textContent = st.seed;
|
||||||
|
$('o-contrast').textContent = st.contrast + '%';
|
||||||
|
$('o-soft').textContent = (st.soft / 10).toFixed(1);
|
||||||
|
$('o-tile').textContent = st.tile;
|
||||||
|
|
||||||
|
$('c-css').value =
|
||||||
|
':root {\n --noise: ' + url + ';\n}\n' +
|
||||||
|
'.site-btn, .gym-switch-btn, .crossfit-nav-link {\n background-image: var(--noise) !important;\n background-size: ' + st.tile + 'px ' + st.tile + 'px !important;' +
|
||||||
|
(st.bbm !== 'normal' ? '\n background-blend-mode: ' + st.bbm + ' !important;' : '') + '\n}\n' +
|
||||||
|
'/* títulos: <filter id="pt-noise"> */\n' +
|
||||||
|
'feTurbulence type="' + st.type + '" baseFrequency="' + (st.freq / 100).toFixed(2) + '" numOctaves="' + st.oct + '" seed="' + st.seed + '"\n' +
|
||||||
|
'feColorMatrix saturate ' + (st.mono ? 0 : 1) + ' · feGaussianBlur ' + (st.soft / 10).toFixed(1) + '\n' +
|
||||||
|
'feFunc* slope="' + slope + '" intercept="' + icpt + '" · feBlend mode="' + st.hbm + '"';
|
||||||
|
}
|
||||||
|
var map = { 'c-color': 'color', 'c-btn': 'btn', 'c-head': 'head', 'c-sat': 'sat', 'c-glow': 'glow', 'c-spin': 'spin', 'c-ring': 'ring',
|
||||||
|
'c-type': 'type', 'c-freq': 'freq', 'c-oct': 'oct', 'c-seed': 'seed', 'c-contrast': 'contrast', 'c-soft': 'soft', 'c-tile': 'tile',
|
||||||
|
'c-bbm': 'bbm', 'c-hbm': 'hbm', 'c-mono': 'mono' };
|
||||||
|
var textual = { 'c-color': 1, 'c-type': 1, 'c-bbm': 1, 'c-hbm': 1 };
|
||||||
|
Object.keys(map).forEach(function (id) {
|
||||||
|
$(id).addEventListener('input', function () {
|
||||||
|
st[map[id]] = id === 'c-mono' ? this.checked : (textual[id] ? this.value : +this.value);
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$('c-reset').addEventListener('click', function () {
|
||||||
|
st = Object.assign({}, DEF);
|
||||||
|
Object.keys(map).forEach(function (id) { if (id === 'c-mono') $(id).checked = DEF.mono; else $(id).value = DEF[map[id]]; });
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
apply();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -9,4 +9,8 @@ declare(strict_types=1);
|
|||||||
$_dev_shared = dirname(__DIR__) . '/shared/platform-tools/functions_dev.php';
|
$_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); }
|
if (!is_file($_dev_shared)) { fwrite(STDERR, "REFUSED: platform-tools sem functions_dev.php\n"); exit(1); }
|
||||||
require_once $_dev_shared;
|
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'],
|
||||||
|
]));
|
||||||
@@ -16,7 +16,8 @@ $GLOBALS['_gym'] = $gym;
|
|||||||
$info = site_info($pdo, (int) $gym['id']);
|
$info = site_info($pdo, (int) $gym['id']);
|
||||||
$navbar = site_navbar($gym);
|
$navbar = site_navbar($gym);
|
||||||
$current_uri_2 = $current_uri_2 ?? '';
|
$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 <head>
|
echo view('include'); // renders view('seo') inside <head>
|
||||||
echo view('navbar');
|
echo view('navbar');
|
||||||
|
|||||||
@@ -710,10 +710,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
opens the <dialog> instead: prev / next buttons, ← → keys, Esc, swipe on
|
opens the <dialog> instead: prev / next buttons, ← → keys, Esc, swipe on
|
||||||
touch, and a click on the dark backdrop closes it. */
|
touch, and a click on the dark backdrop closes it. */
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
document.querySelectorAll('.gal-masonry').forEach(function (grid) {
|
document.querySelectorAll('.gal-marquee').forEach(function (grid) {
|
||||||
var box = grid.parentElement.nextElementSibling;
|
var box = grid.parentElement.nextElementSibling;
|
||||||
if (!box || !box.classList.contains('gal-lightbox') || typeof box.showModal !== 'function') return;
|
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 img = box.querySelector('.gal-lb-figure img');
|
||||||
var count = box.querySelector('.gal-lb-count');
|
var count = box.querySelector('.gal-lb-count');
|
||||||
var current = 0;
|
var current = 0;
|
||||||
@@ -721,17 +722,18 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
function show(i) {
|
function show(i) {
|
||||||
current = (i + links.length) % links.length;
|
current = (i + links.length) % links.length;
|
||||||
var src = links[current].querySelector('img');
|
var src = links[current].querySelector('img');
|
||||||
img.src = links[current].getAttribute('href');
|
// The tile's chosen source (JXL inside <picture> where supported), else the link (fallback).
|
||||||
|
img.src = (src && src.currentSrc) || links[current].getAttribute('href');
|
||||||
img.alt = src ? src.alt : '';
|
img.alt = src ? src.alt : '';
|
||||||
// Restart the fade-in for each photo.
|
// Restart the fade-in for each photo.
|
||||||
img.style.animation = 'none'; void img.offsetWidth; img.style.animation = '';
|
img.style.animation = 'none'; void img.offsetWidth; img.style.animation = '';
|
||||||
count.textContent = (current + 1) + ' / ' + links.length;
|
count.textContent = (current + 1) + ' / ' + links.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
links.forEach(function (a, i) {
|
all.forEach(function (a) {
|
||||||
a.addEventListener('click', function (e) {
|
a.addEventListener('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
show(i);
|
show(+a.getAttribute('data-index') || 0);
|
||||||
box.showModal();
|
box.showModal();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -744,7 +746,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (e.key === 'ArrowLeft') show(current - 1);
|
if (e.key === 'ArrowLeft') show(current - 1);
|
||||||
else if (e.key === 'ArrowRight') 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;
|
var x0 = null;
|
||||||
box.addEventListener('touchstart', function (e) { x0 = e.touches[0].clientX; }, { passive: true });
|
box.addEventListener('touchstart', function (e) { x0 = e.touches[0].clientX; }, { passive: true });
|
||||||
@@ -757,91 +759,116 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Galeria: masonry layout ─────────────────────────────────────────────────
|
/* ── Scroll "hover": what sits in the middle of the screen looks hovered ───────
|
||||||
A small hand-rolled masonry (no library): the grid is split into columns
|
Trainer photos, cards and icons get .is-centered while their box crosses the
|
||||||
(4 / 3 / 2 by width), and each tile — at its photo's true aspect ratio, from
|
middle of the viewport (±12% of its height); inside a sliding strip
|
||||||
the width/height attributes the PHP writes — is dropped into the shortest
|
(Modalidades) they must also cross the horizontal middle. Gallery photos are
|
||||||
column; tiles marked .gal-w take two adjacent columns (the pair whose taller
|
left out: they only light up on a real hover. Throttled, paused
|
||||||
one is lowest), which is what staggers the rows. Tiles are absolutely
|
while the tab is hidden, skipped with "reduce motion". The CSS mirrors the
|
||||||
positioned with transforms, so a relayout on resize glides into place.
|
:hover rules (style.css, "In the middle of the screen"). */
|
||||||
The first time the gallery scrolls into view the tiles rise in one by one. */
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
document.querySelectorAll('.gal-masonry').forEach(function (grid) {
|
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
var tiles = Array.prototype.slice.call(grid.querySelectorAll('.gal-tile'));
|
var items = Array.prototype.slice.call(document.querySelectorAll(
|
||||||
if (!tiles.length) return;
|
'.trainer-item .trainer-pic, .icon-box-item, .price-card')).map(function (el) {
|
||||||
grid.classList.add('is-masonry');
|
return { el: el, strip: !!el.closest('.mod-scroll'), on: false };
|
||||||
|
});
|
||||||
function ratio(tile) {
|
if (!items.length) return;
|
||||||
var img = tile.querySelector('img');
|
var last = 0;
|
||||||
var w = +img.getAttribute('width') || img.naturalWidth || 4;
|
function tick(now) {
|
||||||
var h = +img.getAttribute('height') || img.naturalHeight || 3;
|
requestAnimationFrame(tick);
|
||||||
return h / w;
|
if (document.hidden || now - last < 120) return;
|
||||||
}
|
last = now;
|
||||||
function layout() {
|
var vw = window.innerWidth, vh = window.innerHeight, cy = vh / 2, band = vh * 0.12;
|
||||||
var width = grid.clientWidth;
|
items.forEach(function (it) {
|
||||||
var cols = width >= 900 ? 4 : width >= 520 ? 3 : 2;
|
var r = it.el.getBoundingClientRect();
|
||||||
var gap = width >= 520 ? 12 : 8;
|
var on = r.bottom > 0 && r.top < vh &&
|
||||||
var colW = (width - gap * (cols - 1)) / cols;
|
r.top <= cy + band && r.bottom >= cy - band &&
|
||||||
var heights = [];
|
(!it.strip || (r.left <= vw / 2 && r.right >= vw / 2));
|
||||||
for (var c = 0; c < cols; c++) heights.push(0);
|
if (on !== it.on) { it.on = on; it.el.classList.toggle('is-centered', on); }
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
});
|
||||||
|
|
||||||
// Staggered entrance.
|
/* ── Galeria: auto-scroll + click-and-drag ───────────────────────────────────
|
||||||
var enter = function () {
|
The strip (.gal-wrap) is four identical copies long. It advances by itself,
|
||||||
tiles.forEach(function (tile, i) {
|
follows a mouse drag (touch uses the native swipe), pauses while you hover or
|
||||||
setTimeout(function () { tile.classList.add('is-in'); }, Math.min(i, 12) * 70);
|
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
|
||||||
if ('IntersectionObserver' in window) {
|
lightbox. */
|
||||||
var io = new IntersectionObserver(function (entries) {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
if (entries.some(function (e) { return e.isIntersecting; })) { enter(); io.disconnect(); }
|
var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
}, { rootMargin: '0px 0px -80px 0px' });
|
document.querySelectorAll('.gal-marquee').forEach(function (strip) {
|
||||||
io.observe(grid);
|
var wrap = strip.parentElement;
|
||||||
} else {
|
var set = strip.querySelector('.gal-set');
|
||||||
enter();
|
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.addEventListener('pointermove', function (e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
var dx = e.clientX - startX;
|
||||||
|
moved = Math.max(moved, Math.abs(dx));
|
||||||
|
if (moved <= 5) return;
|
||||||
|
// Capture only once it is a real drag: capturing on pointerdown sends the
|
||||||
|
// click to .gal-wrap instead of the photo, so the lightbox never opened.
|
||||||
|
if (!wrap.classList.contains('is-dragging')) {
|
||||||
|
wrap.classList.add('is-dragging');
|
||||||
|
try { wrap.setPointerCapture(e.pointerId); } catch (err) {}
|
||||||
|
}
|
||||||
|
wrap.scrollLeft = startLeft - dx;
|
||||||
|
});
|
||||||
|
// The tiles are links, which the browser drags natively (cancelling our drag).
|
||||||
|
wrap.addEventListener('dragstart', function (e) { e.preventDefault(); });
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
// Was Home::crossfit.
|
// Was Home::crossfit.
|
||||||
$current_uri_2 = 'crossfit';
|
$current_uri_2 = 'crossfit';
|
||||||
|
$nav_center_logo = true; // big centred logo over the video, docks left on scroll (navbar.php)
|
||||||
|
|
||||||
// Old /home/crossfit links → the default gym's CrossFit page.
|
// Old /home/crossfit links → the default gym's CrossFit page.
|
||||||
if (($_GET['gym'] ?? '') === '') {
|
if (($_GET['gym'] ?? '') === '') {
|
||||||
@@ -11,9 +12,13 @@ if (($_GET['gym'] ?? '') === '') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only gyms with a CrossFit box have this page: a real 404 status, so decide before any output.
|
||||||
|
require_once __DIR__ . '/../config/db.php';
|
||||||
|
require_once __DIR__ . '/../config/functions.php';
|
||||||
|
$cf_gym = site_gym($pdo);
|
||||||
|
if ($cf_gym && empty($cf_gym['has_crossfit'])) { require __DIR__ . '/404.php'; exit; }
|
||||||
|
|
||||||
require __DIR__ . '/_open.php';
|
require __DIR__ . '/_open.php';
|
||||||
// Only gyms with a CrossFit box have this page.
|
|
||||||
if (empty($gym['has_crossfit'])) { ob_get_level() && ob_end_clean(); require __DIR__ . '/404.php'; exit; }
|
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'title' => 'CrossFit',
|
'title' => 'CrossFit',
|
||||||
|
|||||||
@@ -105,8 +105,12 @@ $seo_desc = $event['summary'] !== ''
|
|||||||
: trim(mb_substr(strip_tags((string) ($event['subtitle'] ?: $event['description'])), 0, 200));
|
: trim(mb_substr(strip_tags((string) ($event['subtitle'] ?: $event['description'])), 0, 200));
|
||||||
|
|
||||||
$current_uri_2 = 'evento';
|
$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'] = [
|
$GLOBALS['_page_meta_override'] = [
|
||||||
($event['title'] !== '' ? $event['title'] : 'Evento') . ' | FitMe 3070',
|
($event['title'] !== '' ? $event['title'] : 'Evento') . ' | ' . $ev_gym['name'],
|
||||||
$seo_desc,
|
$seo_desc,
|
||||||
];
|
];
|
||||||
$GLOBALS['_canonical_override'] = $event_url;
|
$GLOBALS['_canonical_override'] = $event_url;
|
||||||
@@ -114,7 +118,7 @@ $GLOBALS['_og_type_override'] = 'article';
|
|||||||
if ($hero_url !== '') $GLOBALS['_og_image_override'] = $hero_url;
|
if ($hero_url !== '') $GLOBALS['_og_image_override'] = $hero_url;
|
||||||
|
|
||||||
// Event structured data (schema.org/Event) for rich link previews / search.
|
// 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 = [
|
$ld_event = [
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
'@type' => 'Event',
|
'@type' => 'Event',
|
||||||
@@ -130,11 +134,11 @@ if (!empty($event['event_ends_at'])) $ld_event['endDate'] = str_replace(' ',
|
|||||||
$ld_event['location'] = $event['location'] !== ''
|
$ld_event['location'] = $event['location'] !== ''
|
||||||
? ['@type' => 'Place', 'name' => $event['location']]
|
? ['@type' => 'Place', 'name' => $event['location']]
|
||||||
: ['@type' => 'Place',
|
: ['@type' => 'Place',
|
||||||
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070',
|
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : $ev_gym['name'],
|
||||||
'address' => $info['adress'] ?? ''];
|
'address' => $info['adress'] ?? ''];
|
||||||
$ld_event['organizer'] = [
|
$ld_event['organizer'] = [
|
||||||
'@type' => 'Organization',
|
'@type' => 'Organization',
|
||||||
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070',
|
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : $ev_gym['name'],
|
||||||
'url' => base_url(),
|
'url' => base_url(),
|
||||||
];
|
];
|
||||||
$GLOBALS['_jsonld_extra'] = $ld_event;
|
$GLOBALS['_jsonld_extra'] = $ld_event;
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ $session = session();
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-6">
|
||||||
<h2 id="contactos-title" class="contact-title text-center text-md-left">Informações</h2>
|
<h2 id="contactos-title" class="contact-title text-center text-md-left">Informações</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-6">
|
||||||
<div class="contact-info-warp text-center text-md-left">
|
<div class="contact-info-warp text-center text-md-left">
|
||||||
<h3>Localização</h3>
|
<h3>Localização</h3>
|
||||||
<div class="contact-info">
|
<div class="contact-info">
|
||||||
@@ -58,10 +60,12 @@ $session = session();
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<?php if ($whours): ?>
|
<?php if ($whours): ?>
|
||||||
<div class="contact-info-warp text-center">
|
<div class="col-sm-6">
|
||||||
|
<div class="contact-info-warp text-center text-md-left">
|
||||||
<h3>Horário de Funcionamento</h3>
|
<h3>Horário de Funcionamento</h3>
|
||||||
<div class="contact-info wh-text text-center">
|
<div class="contact-info wh-text text-center text-md-left">
|
||||||
<?php foreach ($work_hours_days as $key => $value):
|
<?php foreach ($work_hours_days as $key => $value):
|
||||||
$ranges = [];
|
$ranges = [];
|
||||||
foreach ($whours as $h) {
|
foreach ($whours as $h) {
|
||||||
@@ -70,48 +74,35 @@ $session = session();
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<p><strong><?= htmlspecialchars($key) ?>:</strong>
|
<div class="wh-day">
|
||||||
<?php if ($ranges): ?><?= htmlspecialchars(implode(', ', $ranges)) ?>
|
<strong><?= htmlspecialchars($key) ?></strong>
|
||||||
<?php else: ?><span class="wh-closed">Encerrado</span><?php endif; ?>
|
<?php if ($ranges): foreach ($ranges as $r): ?><p><?= htmlspecialchars($r) ?></p><?php endforeach; ?>
|
||||||
</p>
|
<?php else: ?><p class="wh-closed">Encerrado</p><?php endif; ?>
|
||||||
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-6">
|
||||||
<div id="contact-form">
|
<div id="contact-form">
|
||||||
<h3 class="contact-title text-center text-md-left">Envia-nos uma mensagem</h3>
|
<?php /* Formulário da app FitMe (app.fitme3070.pt), em iframe; a altura acompanha o conteúdo via postMessage. */ ?>
|
||||||
<?php if (isset($_GET['enviado'])): ?>
|
<div id="fitme-form-site-contacto-wrap"></div>
|
||||||
<div class="alert alert-success" role="alert">
|
<script>
|
||||||
Mensagem enviada com sucesso! Entraremos em contacto brevemente.
|
(function () {
|
||||||
</div>
|
var p = new URLSearchParams(location.search), q = new URLSearchParams({ embed: "1", page: location.href });
|
||||||
<?php endif; ?>
|
["utm_source","utm_medium","utm_campaign","utm_content","utm_term"].forEach(function (k) { if (p.get(k)) q.set(k, p.get(k)); });
|
||||||
<form class="contact-form" method="post" action="https://forms.zohopublic.eu/fitme30701/form/FormulriodeContacto/formperma/NiwsTjwWGX2sgpZIXqE5otyAn8D5nuRQjpYDuGkS2wM/htmlRecords/submit">
|
var f = document.createElement("iframe");
|
||||||
<input type="hidden" name="zf_redirect_url" value="<?= base_url('?enviado=1#contact-form') ?>">
|
f.src = "https://app.fitme3070.pt/f/site-contacto?" + q.toString(); f.id = "fitme-form-site-contacto"; f.title = "Envia-nos uma Mensagem";
|
||||||
<div class="row">
|
f.style.cssText = "width:100%;max-width:480px;border:0;"; f.height = "640"; f.loading = "lazy";
|
||||||
<div class="col-md-6">
|
document.getElementById("fitme-form-site-contacto-wrap").appendChild(f);
|
||||||
<input type="text" required name="SingleLine" placeholder="Nome" aria-label="Nome">
|
window.addEventListener("message", function (e) {
|
||||||
</div>
|
if (e.origin === "https://app.fitme3070.pt" && e.data && e.data.type === "fitme-form-height" && e.data.slug === "site-contacto") f.style.height = e.data.height + "px";
|
||||||
<div class="col-md-6">
|
});
|
||||||
<input type="text" required name="PhoneNumber_countrycode" placeholder="Telemóvel" aria-label="Telemóvel">
|
})();
|
||||||
</div>
|
</script>
|
||||||
</div>
|
|
||||||
<input type="text" name="SingleLine1" placeholder="Razão" required value="<?= htmlspecialchars($assunto) ?>" aria-label="Razão do contacto">
|
|
||||||
<textarea name="MultiLine" placeholder="Mensagem" required aria-label="Mensagem"></textarea>
|
|
||||||
<?php if ($session->getFlashdata('email') !== null): ?>
|
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
|
||||||
<?= $session->getFlashdata('email') ?>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<label class="consent-check">
|
|
||||||
<?php /* no name attribute: gates submission client-side, not stored in Zoho */ ?>
|
|
||||||
<input type="checkbox" required>
|
|
||||||
<span>Li e aceito a <a href="<?= base_url('home/privacidade') ?>" target="_blank" rel="noopener">Política de Privacidade</a>.</span>
|
|
||||||
</label>
|
|
||||||
<button class="site-btn" id="btn_email" type="submit">Enviar Mensagem</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
<span class="footer-legal-sep" aria-hidden="true">·</span>
|
<span class="footer-legal-sep" aria-hidden="true">·</span>
|
||||||
<a href="<?= base_url('views/auth/login.php') ?>" class="footer-admin-link" rel="nofollow" aria-label="Acesso reservado" title="Backoffice"><i class="fas fa-lock"></i></a>
|
<a href="<?= base_url('views/auth/login.php') ?>" class="footer-admin-link" rel="nofollow" aria-label="Acesso reservado" title="Backoffice"><i class="fas fa-lock"></i></a>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="small footer-legal">Powered by <a href="https://andmore.pt" target="_blank" rel="noopener">andmore.pt</a></p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -39,5 +40,6 @@
|
|||||||
<?= script_tag('assets/vendor/jquery/jquery-3.7.1.min.js') ?>
|
<?= script_tag('assets/vendor/jquery/jquery-3.7.1.min.js') ?>
|
||||||
<?= script_tag('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>
|
<?= script_tag('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>
|
||||||
<?= script_tag('site/assets/js/main.js') ?>
|
<?= script_tag('site/assets/js/main.js') ?>
|
||||||
|
<?php include dirname(__DIR__, 2) . '/includes/dev_toolbar.php'; // DEV ONLY: sem saída fora de dev ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
// Gallery partial — masonry (home "instalações" and the CrossFit page).
|
// Gallery partial — an endless horizontal strip (home "instalações" and the
|
||||||
// main.js lays the tiles out itself: each photo keeps its own aspect ratio (no
|
// CrossFit page): full-height photos alternate with 2x2 grids of four. main.js
|
||||||
// cropping) and drops into the shortest column; every few tiles one spans two
|
// scrolls it by itself, lets you click-and-drag (mouse) or swipe (touch), pauses
|
||||||
// columns, which is what staggers the heights — the photos are all landscape,
|
// while you interact and loops seamlessly over the repeated copies. Clicking a
|
||||||
// so equal-width tiles alone would just form a grid. Without JS the list falls
|
// tile (without dragging) opens it full size in a <dialog> lightbox with prev /
|
||||||
// back to a plain CSS grid. Clicking a tile opens it full size in a <dialog>
|
// next, arrow keys, Esc and swipe; the repeated copies are hidden from assistive
|
||||||
// lightbox with prev / next, arrow keys, Esc and swipe (main.js).
|
// tech and open the same photo.
|
||||||
$files = glob(rtrim($gallery_path, '/') . '/*.jpg');
|
// Every format the admin upload accepts (functions_galerias.php), without the
|
||||||
if (!$files || !is_array($files)) $files = [];
|
// .jxl.webp/.jpg fallbacks; a .jxl tile is <picture> (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.
|
// Column span per tile, cycled: w = two columns, s = one.
|
||||||
$gal_shapes = ['w', 's', 's', 's', 's', 'w', 's', 's', 's', 's', 's'];
|
$gal_shapes = ['w', 's', 's', 's', 's', 'w', 's', 's', 's', 's', 's'];
|
||||||
$gal_label = $gallery_h2 ?? 'Instalações';
|
$gal_label = $gallery_h2 ?? 'Instalações';
|
||||||
@@ -21,23 +23,44 @@ $gal_label = $gallery_h2 ?? 'Instalações';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php if ($files): ?>
|
<?php if ($files):
|
||||||
<div class="container">
|
// Units of the strip: a full-height photo, then a 2x2 grid of four, and so on.
|
||||||
<ul class="gal-masonry">
|
// (Fewer than four left over → a full-height photo.)
|
||||||
<?php foreach ($files as $gi => $f):
|
$units = []; $i = 0; $n = count($files); $k = 0;
|
||||||
$src = base_url($f);
|
while ($i < $n) {
|
||||||
$alt = $gal_label . ' — FitMe 3070, foto ' . ($gi + 1); // distinct alt per photo
|
$grid = ($k % 2 === 1) && ($n - $i >= 4);
|
||||||
$dim = @getimagesize($f) ?: [0, 0];
|
$take = $grid ? 4 : 1;
|
||||||
?>
|
$units[] = ['grid' => $grid, 'idx' => range($i, $i + $take - 1)];
|
||||||
<li class="gal-tile gal-<?= $gal_shapes[$gi % count($gal_shapes)] ?>">
|
$i += $take; $k++;
|
||||||
<a href="<?= htmlspecialchars($src) ?>" class="gal-link" data-index="<?= $gi ?>" aria-label="Ampliar: <?= htmlspecialchars($alt) ?>">
|
}
|
||||||
<img src="<?= htmlspecialchars($src) ?>" alt="<?= htmlspecialchars($alt) ?>"
|
// The strip is rendered four times: main.js keeps the scroll position inside
|
||||||
<?= $dim[0] ? 'width="' . (int) $dim[0] . '" height="' . (int) $dim[1] . '"' : '' ?>
|
// the middle copies and jumps by exactly one copy, so it loops without a seam.
|
||||||
<?= $gi > 1 ? 'loading="lazy"' : '' ?> decoding="async">
|
$copies = 4;
|
||||||
</a>
|
?>
|
||||||
</li>
|
<div class="gal-wrap">
|
||||||
<?php endforeach; ?>
|
<div class="gal-marquee">
|
||||||
</ul>
|
<div class="gal-track">
|
||||||
|
<?php for ($c = 0; $c < $copies; $c++): $dup = $c > 0; ?>
|
||||||
|
<div class="gal-set"<?= $dup ? ' aria-hidden="true"' : '' ?>>
|
||||||
|
<?php foreach ($units as $u): ?>
|
||||||
|
<div class="gal-unit <?= $u['grid'] ? 'gal-unit--grid' : 'gal-unit--full' ?>">
|
||||||
|
<?php foreach ($u['idx'] as $gi):
|
||||||
|
$f = $files[$gi];
|
||||||
|
$src = base_url(img_compat_rel($f));
|
||||||
|
$alt = $gal_label . ' — FitMe 3070, foto ' . ($gi + 1); // distinct alt per photo
|
||||||
|
?>
|
||||||
|
<div class="gal-tile">
|
||||||
|
<a href="<?= htmlspecialchars($src) ?>" class="gal-link" data-index="<?= $gi ?>"<?= $dup ? ' data-dup="1" tabindex="-1"' : '' ?> aria-label="Ampliar: <?= htmlspecialchars($alt) ?>">
|
||||||
|
<?= img_tag($f, ['alt' => $dup ? '' : $alt, 'loading' => $gi > 2 || $dup ? 'lazy' : null, 'decoding' => 'async', 'draggable' => 'false']) ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dialog class="gal-lightbox" aria-label="<?= htmlspecialchars($gal_label) ?>">
|
<dialog class="gal-lightbox" aria-label="<?= htmlspecialchars($gal_label) ?>">
|
||||||
<button type="button" class="gal-lb-close" aria-label="Fechar">×</button>
|
<button type="button" class="gal-lb-close" aria-label="Fechar">×</button>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="container h-100">
|
<div class="container h-100">
|
||||||
<div class="d-flex h-100 text-center align-items-center">
|
<div class="d-flex h-100 text-center align-items-center">
|
||||||
<div class="w-100 text-white">
|
<div class="w-100 text-white">
|
||||||
<h1 class="display-3"><img src="<?= base_url('site/assets/img/logos/logo_crossfit_white.svg')?>" alt="CrossFit 3070 — box de CrossFit em Mira" style="opacity: .85; max-width: 320px;" decoding="async"></h1>
|
<h1 class="visually-hidden">CrossFit 3070 — box de CrossFit em Mira</h1>
|
||||||
<p class="lead mb-0"></p>
|
<p class="lead mb-0"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,27 +2,37 @@
|
|||||||
// Per-page metadata, keyed by $current_uri_2. The canonical URL comes from
|
// Per-page metadata, keyed by $current_uri_2. The canonical URL comes from
|
||||||
// base_url(), which matches the https + non-www redirect in .htaccess.
|
// base_url(), which matches the https + non-www redirect in .htaccess.
|
||||||
$page = $current_uri_2 ?? '';
|
$page = $current_uri_2 ?? '';
|
||||||
$page_meta = [
|
// Titles ~50-60 characters, descriptions ~140-160 (what Google shows).
|
||||||
'' => ['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.
|
|
||||||
$gym_meta = current_gym();
|
$gym_meta = current_gym();
|
||||||
if ($page === '' && ($gym_meta['seo_title'] ?? '') !== '') {
|
$gym_name = ($gym_meta['name'] ?? '') !== '' ? $gym_meta['name'] : 'FitMe 3070';
|
||||||
$page_meta[''] = [$gym_meta['seo_title'], ($gym_meta['seo_description'] ?? '') !== '' ? $gym_meta['seo_description'] : $page_meta[''][1]];
|
$is_main = (int) ($gym_meta['id'] ?? 1) === 1;
|
||||||
} elseif ($page === '' && (int) $gym_meta['id'] !== 1) {
|
$loc = ($gym_meta['locality'] ?? '') !== '' ? $gym_meta['locality'] : 'Mira';
|
||||||
$loc = ($gym_meta['locality'] ?? '') !== '' ? ' em ' . $gym_meta['locality'] : '';
|
$loc_in = (preg_match('/^(zona|rua|avenida|praia|vila)/iu', $loc) ? 'na ' : 'em ') . $loc; // «na Zona Industrial de Mira»
|
||||||
$page_meta[''] = [$gym_meta['name'] . ' — Ginásio' . $loc,
|
$page_meta = [
|
||||||
($gym_meta['seo_description'] ?? '') !== '' ? $gym_meta['seo_description'] : 'Ginásio ' . $gym_meta['name'] . $loc . ': treino, aulas de grupo e acompanhamento.'];
|
'' => $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
|
// A dynamic page (e.g. an event landing page) can set its own title/description
|
||||||
// and canonical via these globals before requiring _open.php.
|
// and canonical via these globals before requiring _open.php.
|
||||||
[$page_title, $page_desc] = $GLOBALS['_page_meta_override'] ?? $page_meta[$page] ?? $page_meta[''];
|
[$page_title, $page_desc] = $GLOBALS['_page_meta_override'] ?? $page_meta[$page] ?? $page_meta[''];
|
||||||
@@ -37,9 +47,9 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<meta name="description" content="<?= htmlspecialchars($page_desc) ?>">
|
<meta name="description" content="<?= htmlspecialchars($page_desc) ?>">
|
||||||
<meta name="keywords" content="fitness, ginásio, exercício, personal trainer, crossfit, box, aveiro, mira">
|
<meta name="keywords" content="<?= htmlspecialchars('ginásio, fitness, musculação, personal trainer, ' . (!empty($gym_meta['has_crossfit']) ? 'crossfit, box, ' : '') . mb_strtolower($loc) . ', ' . $gym_name) ?>">
|
||||||
<meta name="author" content="FitMe 3070">
|
<meta name="author" content="<?= htmlspecialchars($gym_name) ?>">
|
||||||
<meta name="theme-color" content="#C2D500">
|
<meta name="theme-color" content="<?= htmlspecialchars(($gym_meta['brand_color'] ?? '') !== '' ? $gym_meta['brand_color'] : '#C2D500') ?>">
|
||||||
<title><?= htmlspecialchars($page_title) ?></title>
|
<title><?= htmlspecialchars($page_title) ?></title>
|
||||||
<link rel="canonical" href="<?= htmlspecialchars($canonical) ?>">
|
<link rel="canonical" href="<?= htmlspecialchars($canonical) ?>">
|
||||||
<?php if (seo_noindex()): /* staging copies (e.g. fitme.andmore.pt) must not compete with the real domain */ ?>
|
<?php if (seo_noindex()): /* staging copies (e.g. fitme.andmore.pt) must not compete with the real domain */ ?>
|
||||||
@@ -50,7 +60,9 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
|
|||||||
<link rel="dns-prefetch" href="https://forms.zohopublic.eu">
|
<link rel="dns-prefetch" href="https://forms.zohopublic.eu">
|
||||||
<?php if (($current_uri_2 ?? '') === ''): ?>
|
<?php if (($current_uri_2 ?? '') === ''): ?>
|
||||||
<!-- Homepage hero is the LCP image — start fetching it before the CSS/JS -->
|
<!-- Homepage hero is the LCP image — start fetching it before the CSS/JS -->
|
||||||
<link rel="preload" as="image" fetchpriority="high" href="<?= htmlspecialchars(base_url(($gym_meta['hero_image'] ?? '') !== '' ? $gym_meta['hero_image'] : 'site/assets/img/home.jpg')) ?>">
|
<?php $pre_hero = ($gym_meta['hero_image'] ?? '') !== '' ? $gym_meta['hero_image'] : 'site/assets/img/home.jpg';
|
||||||
|
// A .jxl hero (img_css_bg) is preloaded only where JXL is supported (type=); the rest fetch the fallback from the CSS. ?>
|
||||||
|
<link rel="preload" as="image" fetchpriority="high" href="<?= htmlspecialchars(base_url($pre_hero)) ?>"<?= img_fallback_rel($pre_hero) !== '' ? ' type="image/jxl"' : '' ?>>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Fonts (Roboto self-hosted — see @font-face in style.css) -->
|
<!-- Fonts (Roboto self-hosted — see @font-face in style.css) -->
|
||||||
@@ -58,21 +70,21 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
|
|||||||
<!-- Vendor: Bootstrap 5.3.3 -->
|
<!-- Vendor: Bootstrap 5.3.3 -->
|
||||||
<?= link_tag('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>
|
<?= link_tag('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>
|
||||||
|
|
||||||
<!-- Vendor: Font Awesome 5 -->
|
<!-- Icons: Font Awesome 6 from the shared platform-tools pack (local FA 5.13.1 as fallback) -->
|
||||||
<?= link_tag('site/assets/fonts/fontawesome/css/all.css') ?>
|
<?= fit_icon_assets(['fontawesome'], ['site/assets/fonts/fontawesome/css/all.css']) ?>
|
||||||
|
|
||||||
|
|
||||||
<!-- App styles (Bootstrap overrides) -->
|
<!-- App styles (Bootstrap overrides) -->
|
||||||
<?= link_tag('site/assets/css/style.css') ?>
|
<?= link_tag('site/assets/css/style.css') ?>
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/svg+xml" href="<?= base_url('site/assets/img/logos/logo_black.svg') ?>">
|
<link rel="icon" type="image/svg+xml" href="<?= htmlspecialchars(base_url($favicon_rel)) ?>">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="<?= base_url('site/assets/img/logos/apple-touch-icon.png') ?>">
|
<link rel="apple-touch-icon" sizes="180x180" href="<?= base_url('site/assets/img/logos/apple-touch-icon.png') ?>">
|
||||||
|
|
||||||
<!-- Open Graph / link previews -->
|
<!-- Open Graph / link previews -->
|
||||||
<meta property="og:url" content="<?= htmlspecialchars($canonical) ?>">
|
<meta property="og:url" content="<?= htmlspecialchars($canonical) ?>">
|
||||||
<meta property="og:type" content="<?= htmlspecialchars($GLOBALS['_og_type_override'] ?? 'website') ?>">
|
<meta property="og:type" content="<?= htmlspecialchars($GLOBALS['_og_type_override'] ?? 'website') ?>">
|
||||||
<meta property="og:site_name" content="FitMe 3070">
|
<meta property="og:site_name" content="<?= htmlspecialchars($gym_name) ?>">
|
||||||
<meta property="og:locale" content="pt_PT">
|
<meta property="og:locale" content="pt_PT">
|
||||||
<meta property="og:title" content="<?= htmlspecialchars($page_title) ?>">
|
<meta property="og:title" content="<?= htmlspecialchars($page_title) ?>">
|
||||||
<meta property="og:description" content="<?= htmlspecialchars($page_desc) ?>">
|
<meta property="og:description" content="<?= htmlspecialchars($page_desc) ?>">
|
||||||
@@ -85,6 +97,32 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
|
|||||||
|
|
||||||
<!-- Structured data -->
|
<!-- Structured data -->
|
||||||
<?= view('seo') ?>
|
<?= view('seo') ?>
|
||||||
|
<?php if (($brand_css = gym_brand_css($gym_meta)) !== ''): ?><style><?= $brand_css ?></style><?php endif; ?>
|
||||||
|
<?php include dirname(__DIR__, 2) . '/includes/env_ribbon.php'; // moldura de aviso fora de produção ?>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||||
|
<!-- Grain for the h1/h2 lettering (style.css, "Noise texture"): fractal noise -> grey ->
|
||||||
|
squeezed to 88-100% brightness, multiplied into the glyphs and clipped to their shape. -->
|
||||||
|
<filter id="pt-noise" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="1.98" numOctaves="1" seed="7" result="n"/>
|
||||||
|
<feColorMatrix in="n" type="saturate" values="0" result="g"/>
|
||||||
|
<feComponentTransfer in="g" result="grain">
|
||||||
|
<feFuncR type="linear" slope=".123" intercept=".877"/>
|
||||||
|
<feFuncG type="linear" slope=".123" intercept=".877"/>
|
||||||
|
<feFuncB type="linear" slope=".123" intercept=".877"/>
|
||||||
|
<feFuncA type="linear" slope="0" intercept="1"/>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feBlend in="SourceGraphic" in2="grain" mode="multiply" result="mix"/>
|
||||||
|
<feComposite in="mix" in2="SourceGraphic" operator="in"/>
|
||||||
|
</filter>
|
||||||
|
<!-- Liquid glass for the navbar backdrop (style.css .header-section, Chromium):
|
||||||
|
soft low-frequency noise, blurred so it has no grain, drives a displacement
|
||||||
|
that bends the page behind the bar like uneven glass. -->
|
||||||
|
<filter id="nav-glass" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency=".008 .04" numOctaves="2" seed="3" result="t"/>
|
||||||
|
<feGaussianBlur in="t" stdDeviation="2" result="map"/>
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="map" scale="38" xChannelSelector="R" yChannelSelector="G"/>
|
||||||
|
</filter>
|
||||||
|
</svg>
|
||||||
<a href="#conteudo" class="skip-link">Saltar para o conteúdo</a>
|
<a href="#conteudo" class="skip-link">Saltar para o conteúdo</a>
|
||||||
@@ -4,16 +4,26 @@ if($current_uri_2 == 'crossfit'){
|
|||||||
}
|
}
|
||||||
else
|
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 = base_url($logo_rel);
|
||||||
$logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $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])
|
||||||
|
: '<img loading="eager" decoding="async" alt="' . htmlspecialchars($logo_alt) . '" src="' . htmlspecialchars($logo) . '" ' . $logo_dims . '>';
|
||||||
|
// 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);
|
||||||
?>
|
?>
|
||||||
<header class="header-section lazy">
|
<header class="header-section lazy<?= $center_logo ? ' has-center-logo is-top' : '' ?>">
|
||||||
<nav class="navbar navbar-expand-lg navbar-light" style="padding-left:1rem; padding-top: 0; padding-bottom: 0;padding-right: 0; ">
|
<nav class="navbar navbar-expand-lg navbar-light">
|
||||||
<a class="navbar-brand" href="<?= htmlspecialchars(gym_url(current_gym())) ?>">
|
<a class="navbar-brand" href="<?= htmlspecialchars(gym_url(current_gym())) ?>"><?= $logo_html ?></a>
|
||||||
<img loading="eager" decoding="async" alt="Logo FitMe3070" src="<?= $logo ?>" <?= $logo_dims ?> style="width: 150px; height: auto;">
|
<?php if ($center_logo): ?>
|
||||||
</a>
|
<a class="navbar-brand-hero" href="<?= htmlspecialchars(gym_url(current_gym())) ?>" aria-hidden="true" tabindex="-1"><?= $logo_html ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button>
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button>
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav mx-auto ">
|
<ul class="navbar-nav mx-auto ">
|
||||||
@@ -32,7 +42,7 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
|
|||||||
?>
|
?>
|
||||||
<li class="nav-item <?= $is_active ? 'active' : '' ?> main-menu-li" >
|
<li class="nav-item <?= $is_active ? 'active' : '' ?> main-menu-li" >
|
||||||
<?php if ($is_crossfit): ?>
|
<?php if ($is_crossfit): ?>
|
||||||
<a class="nav-link crossfit-nav-link" href="<?= htmlspecialchars($n) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
|
<a class="nav-link crossfit-nav-link fx-slash" href="<?= htmlspecialchars($n) ?>"<?= $is_active ? ' aria-current="page"' : '' ?>>
|
||||||
<?= $key ?>
|
<?= $key ?>
|
||||||
</a>
|
</a>
|
||||||
<?php elseif ($is_anchor): ?>
|
<?php elseif ($is_anchor): ?>
|
||||||
@@ -47,6 +57,33 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
|
|||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
|
<?php
|
||||||
|
// Other published gyms: the main gym opens first, the rest are
|
||||||
|
// highlighted buttons at the right end of the bar.
|
||||||
|
$other_gyms = [];
|
||||||
|
if (!empty($GLOBALS['pdo'])) {
|
||||||
|
foreach (db_gyms_published($GLOBALS['pdo']) as $g) {
|
||||||
|
if ((int) $g['id'] !== (int) current_gym()['id']) $other_gyms[] = $g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($other_gyms):
|
||||||
|
// The match head (style.css) is painted in the first other gym's brand.
|
||||||
|
$head_vars = gym_brand_vars($other_gyms[0]);
|
||||||
|
$head_ink = str_contains($head_vars, 'on-accent:#FFFFFF') ? ' gym-switch--light-ink' : '';
|
||||||
|
?>
|
||||||
|
<div class="gym-switch<?= $head_ink ?> d-flex flex-wrap gap-2 ms-lg-auto py-2 py-lg-0 pe-lg-3" style="<?= htmlspecialchars($head_vars) ?>">
|
||||||
|
<?php foreach ($other_gyms as $g): ?>
|
||||||
|
<?php $g_logo = gym_logo($g); ?>
|
||||||
|
<a class="gym-switch-btn<?= $g_logo !== '' ? ' gym-switch-btn--logo' : '' ?>" href="<?= htmlspecialchars(gym_url($g)) ?>" style="<?= htmlspecialchars(gym_brand_vars($g)) ?>">
|
||||||
|
<?php if ($g_logo !== ''): ?>
|
||||||
|
<?= img_tag($g_logo, ['class' => 'gym-switch-logo', 'alt' => $g['name'], 'decoding' => 'async', 'width' => null]) ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<span><?= htmlspecialchars($g['name']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -61,6 +98,29 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
|
|||||||
// querySelector('#equipa') returns null, `sections` stays empty, and neither
|
// querySelector('#equipa') returns null, `sections` stays empty, and neither
|
||||||
// the smooth-scroll nor the scroll-spy (active nav link) ever bind.
|
// the smooth-scroll nor the scroll-spy (active nav link) ever bind.
|
||||||
function init() {
|
function init() {
|
||||||
|
/* ── Centred logo: docks into the bar once the page leaves the top ───── */
|
||||||
|
var header = document.querySelector('.header-section.has-center-logo');
|
||||||
|
if (header) {
|
||||||
|
var dock = function() { header.classList.toggle('is-top', window.scrollY < 40); };
|
||||||
|
window.addEventListener('scroll', dock, { passive: true });
|
||||||
|
dock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile menu fills the screen below the bar (style.css --nav-h) ──── */
|
||||||
|
var hdr = document.querySelector('.header-section');
|
||||||
|
if (hdr) {
|
||||||
|
// Desktop: the bottom sweep line stops at the other-gym head (style.css --switch-w).
|
||||||
|
var sw = hdr.querySelector('.gym-switch');
|
||||||
|
var setNavH = function() {
|
||||||
|
hdr.style.setProperty('--nav-h', hdr.offsetHeight + 'px');
|
||||||
|
var w = (sw && window.innerWidth >= 992) ? hdr.getBoundingClientRect().right - sw.getBoundingClientRect().left + 5 : 0;
|
||||||
|
hdr.style.setProperty('--switch-w', Math.max(0, w) + 'px');
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', setNavH, { passive: true });
|
||||||
|
window.addEventListener('load', setNavH);
|
||||||
|
setNavH();
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Eased smooth-scroll helper ──────────────────────────────────────── */
|
/* ── Eased smooth-scroll helper ──────────────────────────────────────── */
|
||||||
function smoothScrollTo(targetEl, duration) {
|
function smoothScrollTo(targetEl, duration) {
|
||||||
var start = window.scrollY;
|
var start = window.scrollY;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ $ld = [
|
|||||||
'@type' => 'ExerciseGym',
|
'@type' => 'ExerciseGym',
|
||||||
'name' => $ld_gym['name'] !== '' ? $ld_gym['name'] : (($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070'),
|
'name' => $ld_gym['name'] !== '' ? $ld_gym['name'] : (($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070'),
|
||||||
'url' => gym_url($ld_gym),
|
'url' => gym_url($ld_gym),
|
||||||
'image' => base_url(($ld_gym['hero_image'] ?? '') !== '' ? $ld_gym['hero_image'] : 'site/assets/img/home.jpg'),
|
'image' => base_url(img_compat_rel(($ld_gym['hero_image'] ?? '') !== '' ? $ld_gym['hero_image'] : 'site/assets/img/home.jpg')),
|
||||||
'logo' => base_url('site/assets/img/logos/logo_black.svg'),
|
'logo' => base_url(img_compat_rel(gym_logo($ld_gym) ?: 'site/assets/img/logos/logo_black.svg')),
|
||||||
];
|
];
|
||||||
if (!empty($info['phone'])) $ld['telephone'] = site_tel($info['phone']);
|
if (!empty($info['phone'])) $ld['telephone'] = site_tel($info['phone']);
|
||||||
if (!empty($info['email'])) $ld['email'] = $info['email'];
|
if (!empty($info['email'])) $ld['email'] = $info['email'];
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
// One-page site: Home + Equipa + Modalidades + Horário + Contactos.
|
// One-page site: Home + Equipa + Modalidades + Horário + Contactos.
|
||||||
// CrossFit is a separate page (site/crossfit.php).
|
// CrossFit is a separate page (site/crossfit.php).
|
||||||
$current_uri_2 = '';
|
$current_uri_2 = '';
|
||||||
|
$nav_center_logo = true; // big centred logo over the hero, docks left on scroll (navbar.php)
|
||||||
|
|
||||||
// No gym in the URL (/, /home, old links): send people to a gym page. With a
|
// No gym in the URL (/, /home, old links): send people to a gym page. With a
|
||||||
// single published gym that is a 301 to it (query string kept, e.g.
|
// single published gym that is a 301 to it (query string kept, e.g.
|
||||||
// ?agendar=ok); with more, the gym chooser.
|
// ?agendar=ok); with more, the main (default) gym — the others are reached
|
||||||
|
// from the navbar's «Ginásios» menu.
|
||||||
if (($_GET['gym'] ?? '') === '') {
|
if (($_GET['gym'] ?? '') === '') {
|
||||||
require_once __DIR__ . '/../config/db.php';
|
require_once __DIR__ . '/../config/db.php';
|
||||||
require_once __DIR__ . '/../config/functions.php';
|
require_once __DIR__ . '/../config/functions.php';
|
||||||
$published = db_gyms_published($pdo);
|
$published = db_gyms_published($pdo);
|
||||||
if (count($published) > 1) { require __DIR__ . '/gym_chooser.php'; exit; }
|
$main = null;
|
||||||
$to = $published[0] ?? db_gyms_default($pdo) ?? gym_fallback();
|
foreach ($published as $g) { if ((int) ($g['is_default'] ?? 0) === 1) { $main = $g; break; } }
|
||||||
|
$to = $main ?? $published[0] ?? db_gyms_default($pdo) ?? gym_fallback();
|
||||||
$qs = $_SERVER['QUERY_STRING'] ?? '';
|
$qs = $_SERVER['QUERY_STRING'] ?? '';
|
||||||
header('Location: ' . gym_url($to) . ($qs !== '' ? '?' . $qs : ''), true, 301);
|
header('Location: ' . gym_url($to) . ($qs !== '' ? '?' . $qs : ''), true, 301);
|
||||||
exit;
|
exit;
|
||||||
@@ -25,7 +28,7 @@ require __DIR__ . '/_open.php'; // resolves $gym (404 if unknown / not visible
|
|||||||
$gid = (int) $gym['id'];
|
$gid = (int) $gym['id'];
|
||||||
$on = fn(string $key) => gym_section_on($gym, $key);
|
$on = fn(string $key) => gym_section_on($gym, $key);
|
||||||
$offer = $on('oferta') ? site_offer($pdo, $gid) : [];
|
$offer = $on('oferta') ? site_offer($pdo, $gid) : [];
|
||||||
$team = $on('equipa') ? site_team($pdo, $gid) : [];
|
$team = $on('equipa') ? site_team_active($pdo, $gid) : [];
|
||||||
$modalidades = $on('modalidades') ? site_modalidades($pdo, $gid) : [];
|
$modalidades = $on('modalidades') ? site_modalidades($pdo, $gid) : [];
|
||||||
$horario = $on('horario') ? site_horario($pdo, '', $gid) : [];
|
$horario = $on('horario') ? site_horario($pdo, '', $gid) : [];
|
||||||
$whours = $on('workhours') ? site_work_hours($pdo, $gid) : [];
|
$whours = $on('workhours') ? site_work_hours($pdo, $gid) : [];
|
||||||
@@ -34,6 +37,7 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
|
|||||||
$events = site_active_events($pdo, $gid); // time-limited events, banner below the hero
|
$events = site_active_events($pdo, $gid); // time-limited events, banner below the hero
|
||||||
$memberships = $on('mensalidades') ? site_memberships($pdo, $gid) : [];
|
$memberships = $on('mensalidades') ? site_memberships($pdo, $gid) : [];
|
||||||
$reviews = $on('reviews') ? site_reviews($gym) : null; // null when not configured / nothing qualifies
|
$reviews = $on('reviews') ? site_reviews($gym) : null; // null when not configured / nothing qualifies
|
||||||
|
$community = $on('comunidade') ? site_instagram($gym) : []; // tagged Instagram posts, [] when not configured
|
||||||
$hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img/home.jpg';
|
$hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img/home.jpg';
|
||||||
|
|
||||||
// ── Hero section ────────────────────────────────────────────────────────────────
|
// ── Hero section ────────────────────────────────────────────────────────────────
|
||||||
@@ -49,7 +53,7 @@ $hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img
|
|||||||
|
|
||||||
<!-- Hero background is inline (no data-setbg) so it paints without waiting for JS;
|
<!-- Hero background is inline (no data-setbg) so it paints without waiting for JS;
|
||||||
the set-bg class is kept for its cover/center styling. -->
|
the set-bg class is kept for its cover/center styling. -->
|
||||||
<section id="home" class="hero-section set-bg fade-in" style="background-image:url('<?= htmlspecialchars(base_url($hero_img), ENT_QUOTES) ?>')">
|
<section id="home" class="hero-section set-bg fade-in" style="<?= htmlspecialchars(img_css_bg($hero_img), ENT_QUOTES) ?>">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="hero-text">
|
<div class="hero-text">
|
||||||
<?php /* Eventos a decorrer — destacados no topo do header. Cada um liga
|
<?php /* Eventos a decorrer — destacados no topo do header. Cada um liga
|
||||||
@@ -58,10 +62,12 @@ $hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img
|
|||||||
<div class="hero-events">
|
<div class="hero-events">
|
||||||
<?php foreach ($events as $ev):
|
<?php foreach ($events as $ev):
|
||||||
$ac = $ev['cor'] !== '' ? $ev['cor'] : '#C2D500';
|
$ac = $ev['cor'] !== '' ? $ev['cor'] : '#C2D500';
|
||||||
|
// Button text follows the event's colour, not the page gym's (red event on lime 3070).
|
||||||
|
$ev_on = str_contains(gym_brand_vars(['brand_color' => $ac]), 'on-accent:#FFFFFF') ? '#FFFFFF' : '#0B0B0D';
|
||||||
$ev_img = $ev['hero_image'] !== '' ? base_url('uploads/events/' . rawurlencode($ev['hero_image'])) : '';
|
$ev_img = $ev['hero_image'] !== '' ? base_url('uploads/events/' . rawurlencode($ev['hero_image'])) : '';
|
||||||
?>
|
?>
|
||||||
<a class="hero-event<?= $ev_img ? ' has-img' : '' ?>" href="<?= base_url('evento/' . rawurlencode($ev['slug'])) ?>"
|
<a class="hero-event<?= $ev_img ? ' has-img' : '' ?>" href="<?= base_url('evento/' . rawurlencode($ev['slug'])) ?>"
|
||||||
style="--ev-accent:<?= htmlspecialchars($ac, ENT_QUOTES) ?><?= $ev_img ? ';--ev-img:url(\'' . htmlspecialchars($ev_img, ENT_QUOTES) . '\')' : '' ?>">
|
style="--ev-accent:<?= htmlspecialchars($ac, ENT_QUOTES) ?>;--ev-on:<?= $ev_on ?><?= $ev_img ? ';--ev-img:url(\'' . htmlspecialchars($ev_img, ENT_QUOTES) . '\')' : '' ?>">
|
||||||
<span class="hero-event-badge"><span class="event-live-dot"></span><i class="fas fa-bolt"></i> Evento</span>
|
<span class="hero-event-badge"><span class="event-live-dot"></span><i class="fas fa-bolt"></i> Evento</span>
|
||||||
<span class="hero-event-text">
|
<span class="hero-event-text">
|
||||||
<strong class="hero-event-title"><?= htmlspecialchars($ev['title']) ?></strong>
|
<strong class="hero-event-title"><?= htmlspecialchars($ev['title']) ?></strong>
|
||||||
@@ -75,7 +81,6 @@ $hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img
|
|||||||
<h1><?= htmlspecialchars($gym['hero_title'] !== '' ? $gym['hero_title'] : $gym['name']) ?><?php if ($gym['hero_subtitle'] !== ''): ?><br><span><?= htmlspecialchars($gym['hero_subtitle']) ?></span><?php endif; ?></h1>
|
<h1><?= htmlspecialchars($gym['hero_title'] !== '' ? $gym['hero_title'] : $gym['name']) ?><?php if ($gym['hero_subtitle'] !== ''): ?><br><span><?= htmlspecialchars($gym['hero_subtitle']) ?></span><?php endif; ?></h1>
|
||||||
<div class="hero-cta-row">
|
<div class="hero-cta-row">
|
||||||
<a href="?assunto=insc#contactos" class="site-btn">Inscreve-te Já!</a>
|
<a href="?assunto=insc#contactos" class="site-btn">Inscreve-te Já!</a>
|
||||||
<button type="button" class="site-btn sb-ghost" data-bs-toggle="modal" data-bs-target="#trialModal">Marca uma Aula Experimental</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,15 +120,56 @@ if ($offer):
|
|||||||
<?php
|
<?php
|
||||||
// ── Galeria (Instalações) ───────────────────────────────────────────────────────
|
// ── Galeria (Instalações) ───────────────────────────────────────────────────────
|
||||||
$gallery_path = !empty($gym['gallery_id']) ? 'uploads/gallery/' . (int) $gym['gallery_id'] . '/' : '';
|
$gallery_path = !empty($gym['gallery_id']) ? 'uploads/gallery/' . (int) $gym['gallery_id'] . '/' : '';
|
||||||
$gallery_files = ($on('galeria') && $gallery_path !== '') ? glob($gallery_path . '*.jpg') : [];
|
$gallery_files = ($on('galeria') && $gallery_path !== '') ? img_list_dir($gallery_path) : [];
|
||||||
if ($gallery_files && count($gallery_files) > 0):
|
if ($gallery_files && count($gallery_files) > 0):
|
||||||
$gallery_h2 = 'Conhece as nossas instalações';
|
$gallery_h2 = 'Conhece as nossas instalações';
|
||||||
?>
|
?>
|
||||||
<section id="galeria" class="gallery-section" style="padding:70px 0 0;background:var(--fitme-white)">
|
<section id="galeria" class="gallery-section" style="padding:70px 0 0;background:color-mix(in srgb, var(--fitme-accent) 7%, #fff)">
|
||||||
<?php include __DIR__ . '/includes/gallery.php'; ?>
|
<?php include __DIR__ . '/includes/gallery.php'; ?>
|
||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// ── Comunidade (Instagram) ──────────────────────────────────────────────────────
|
||||||
|
// Latest public posts where the gym was tagged (functions_instagram.php), cached
|
||||||
|
// with local thumbnails; each tile opens the post on Instagram.
|
||||||
|
if ($community):
|
||||||
|
?>
|
||||||
|
<section id="comunidade" class="community-section" aria-labelledby="comunidade-title">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-md-center mb-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="section-title text-center deco">
|
||||||
|
<span class="section-eyebrow">Comunidade</span>
|
||||||
|
<h2 id="comunidade-title">A nossa comunidade</h2>
|
||||||
|
<p class="section-subtitle">Os treinos de quem nos identifica no Instagram.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="community-grid list-unstyled">
|
||||||
|
<?php foreach ($community as $p):
|
||||||
|
$cm_alt = trim(mb_strimwidth(preg_replace('/[[:space:]]+/u', ' ', $p['caption']), 0, 120, '…'));
|
||||||
|
?>
|
||||||
|
<li>
|
||||||
|
<a class="community-post" href="<?= htmlspecialchars($p['permalink']) ?>" target="_blank" rel="noopener"
|
||||||
|
aria-label="<?= htmlspecialchars('Publicação de @' . $p['username'] . ' no Instagram') ?>">
|
||||||
|
<img src="<?= htmlspecialchars(base_url($p['image'])) ?>" alt="<?= htmlspecialchars($cm_alt) ?>" loading="lazy" decoding="async">
|
||||||
|
<?php if ($p['type'] === 'VIDEO'): ?><i class="fas fa-play community-type" aria-hidden="true"></i>
|
||||||
|
<?php elseif ($p['type'] === 'CAROUSEL_ALBUM'): ?><i class="far fa-clone community-type" aria-hidden="true"></i><?php endif; ?>
|
||||||
|
<span class="community-user"><i class="fab fa-instagram" aria-hidden="true"></i> @<?= htmlspecialchars($p['username']) ?></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php if (!empty($info['instagram'])): ?>
|
||||||
|
<p class="text-center mt-4 mb-0">
|
||||||
|
<a class="btn btn-outline-dark" href="<?= htmlspecialchars($info['instagram']) ?>" target="_blank" rel="noopener"><i class="fab fa-instagram me-1"></i> Identifica-nos no Instagram</a>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
// ── Modalidades ─────────────────────────────────────────────────────────────────
|
// ── Modalidades ─────────────────────────────────────────────────────────────────
|
||||||
if ($modalidades):
|
if ($modalidades):
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="M11 6h26l-3 14 2 22H12l2-22z"></path><path fill="currentColor" stroke="none" d="M17.5 10h5.5v5h-5.5zM25 10h5.5v5H25zM17.5 18h5.5v5h-5.5zM25 18h5.5v5H25zM17.5 26h5.5v5h-5.5zM25 26h5.5v5H25z"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 38 33"><path fill="currentColor" fill-rule="evenodd" d="M9.50 0 13.12 0 13 1 12.38 2 12.38 6.62 13.75 8 16.12 8.38 23.50 8.38 24.75 8 27 5.75 27.75 4 27.88 1.50 27.38 0 38 0 38 7.88 37.38 8.25 37.12 9.12 36.88 12.50 36.12 15.12 35.88 18.25 35.12 20.75 34.88 23.88 34.12 26.38 33.88 29.62 33.12 31.88 33.12 33 19.12 33 19.25 32.25 23.25 28.62 24.12 27.12 24 22.25 22.88 21.62 20.88 21.50 15.62 21.50 14.12 21.88 13.25 22.50 12.38 24.38 12.38 31.75 12.62 33 10.12 33 10 23.25 8.50 21.88 4.88 21.50 2.75 21.62 2.12 20.88 2.12 19.88 2.62 19.12 8.25 19 10 17.88 10.25 13.75 9.88 12.25 8.62 11 7.50 10.62 4.62 10.75 4.12 10.25 4.12 9 4.62 8.50 9 8 10 7.12 10.25 2.38ZM15.38 10.62 14.12 11 13.25 11.75 12.38 13.25 12.38 17.50 14.25 18.88 22.62 19 24 17.62 24.12 17.12 24.88 16.62 25.88 15 26 13.75 25.75 13.12 23.62 10.88 22.62 10.62ZM0 30.38 1.62 30.50 3.12 32.12 3.38 33 0 33Z"/></svg>
|
||||||
|
Before Width: | Height: | Size: 410 B After Width: | Height: | Size: 973 B |
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="M14 8h16l6 6v14l-5 6H14l-4-4V12z"></path><path d="M10 21h8l3 5"></path><path fill="currentColor" d="M14 34h18v6H14z"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 38 33"><path fill="currentColor" fill-rule="evenodd" d="M5.88 0 29 0 29 1 29.88 1.12 30.12 1.88 30.88 2.12 31.12 2.88 32.12 3 32.88 2.62 32.88 2.25 32.12 1.75 32.12 1.12 33.62 1.12 33.88 0 38 0 38 7.12 37.38 7.50 37.12 8.38 36.88 11.75 36.12 14.38 35.88 17.50 35.12 20 34.88 23.25 34.12 25.62 33.88 28.88 33.25 30.75 33 33 19.75 33 19.62 32.25 19.25 32 16.50 31.75 15.38 31.88 15.12 33 0 33 0 30.50 0.50 30.25 0.88 29.25 1.12 25.88 1.88 23.25 2.25 19.50 3 18.88 5.75 18.12 6.25 16.88 7.62 15.38 7.75 14.38 8.38 13.75 8.88 13.88 8.88 15.25 8.62 15.75 8 16 8 16.62 8.75 16.88 9.12 16.12 9.88 16.12 10.12 17.75 11.88 18.12 12.88 18.88 13.75 19 14.25 19.62 14.12 25.62 14.50 28.62 15.25 29.12 18.50 29.12 18.88 28.75 18.75 22 19.12 16.12 20.25 14.12 20.88 13.75 21.12 13 21.88 12.38 23.25 10.25 26.50 7.12 29 5.25 29.38 4.38 29.25 3.88 28.50 3.75 28 2.88 27.25 2.75 27.12 2 27.75 2 28.38 2.88 30 4.12 30.75 4 31.12 3.38 31.12 2.88 30.38 2.88 30 2 29 1.62 29 1 27.38 1.12 27.12 1.88 25.62 2.12 23.88 3.88 23.38 4 19.25 8.12 17.88 10.25 16.88 11 15.88 10.75 15.62 10.25 12.62 8.75 12.75 8.12 16.62 6.75 16.75 6.12 16.12 5.62 16.12 4.50 15.88 4.25 15 4.25 13.50 5 9.25 5.75 8.75 6.88 8.25 7.25 7.75 8.75 7 9 5.88 10 4.38 10.12 4.25 9.25 5.88 8.88 8 6.38 7.88 3.88 7.12 3 7 2.38 6 1.75ZM19.25 1.25 18.88 1.75 18.88 2.75 18.12 3 17.75 4 17.12 4.12 16.88 4.50 17 6 17.88 6 18.25 5.62 18.12 4.25 18.62 4.25 19.12 5 20 4.62 19.88 4 19.12 3.75 19.12 3.12 20.62 3.38 21 2.88 21 2.25 20.50 1.62 20 1.25ZM5.50 15 6.12 15.12 5.88 16.12 5 15.75 5 15.38ZM15.88 29.75 14.88 30.25 14.88 30.88 15.38 31.25 18.75 31.12 19.12 30.75 19.12 30.25 18.62 29.75Z"/></svg>
|
||||||
|
Before Width: | Height: | Size: 338 B After Width: | Height: | Size: 1.7 KiB |
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path fill="currentColor" d="M24 8l6 11-6 11-6-11z"></path><path d="M19 30h-8L8 18l11 6"></path><path d="M29 30h8l3-12-11 6"></path><path d="M8 37h32"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 37 33"><path fill="currentColor" fill-rule="evenodd" d="M4.62 0 9.25 0 8.25 2.50 8.38 3.75 9.75 5.88 11.38 6.62 12.75 6.62 14.50 5.88 15.62 4.25 15.88 2 15 0.88 14.88 0 37 0 37 6 36.50 6.12 36.12 7.25 35.88 10.62 35.12 13.12 34.88 16.38 34.12 18.88 33.88 22.12 33.12 24.50 32.88 27.75 32.12 30.12 31.88 33 28 33 28 32.38 28.88 31.25 29 29 30.38 28.25 30.50 27.25 30 26.25 28.88 25.12 27 24.12 25.62 23.88 24.75 23.12 23.88 23 22 21.75 21.12 20.50 20.88 18.88 20.12 17.75 19.25 13.88 19 10.38 17.88 9.25 16.12 8.75 15.62 8.25 10.62 8 7.25 9.12 6 10.62 4.88 17.38 4.12 18.50 3.88 20.12 2.75 21.88 1.62 22.62 1 22.50 1.12 19.12 1.88 16.62 2.12 13.38 2.88 10.88 3.12 7.62 3.88 5.25 4 2.88ZM16.25 15.25 16.75 15.25 17.12 16.62 17.75 17.50 19.12 22.88 21.12 24.75 24 25.25 24.38 25.50 24.25 26 18.12 26 17.12 26.88 15 26.88 14.62 26.62 14.88 26.12 18.62 25 19 24.50 18.88 24 18.12 23.50 17.88 22.38 17.25 21.75 16.88 20.25 16.12 19.25 16 15.88ZM8.38 15.50 8.75 15.75 8.75 18.75 7.88 21.62 7.25 22.38 6.75 24.62 5.88 25.25 3.25 25.88 0.88 25.75 0.75 25.50 1.38 25.12 3.62 25 4 24.25 5.12 23.62 5.88 22.50 7.12 17.25ZM25.25 26 27.62 26.25 27.88 27 28.62 27.50 28.75 28.25 28.38 28.38 27.12 27.12 25.25 26.75 24.88 26.38ZM18 27 19.38 27.25 19.50 28 18 27.75 17.75 27.50ZM19.88 28.12 20.62 28.25 21 28.75 21.12 29.62 20.75 30.12 19.88 30.12 18.62 29.62ZM12 30 13.88 30 14.12 30.62 11.75 31.12 11.62 32.25 16.62 32.25 17.38 33 11.50 33 11.50 32.25 10.38 32.25 10 33 7.38 33 7.62 32.25 9.25 31.75 10 31 11 30.88Z"/></svg>
|
||||||
|
Before Width: | Height: | Size: 363 B After Width: | Height: | Size: 1.6 KiB |
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path fill="currentColor" d="M5 30h38l-3 6H8z"></path><path d="M11 36v5M37 36v5"></path><path d="M4 19h9l3-7 5 13 4-9 2 3h17"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 38 33"><path fill="currentColor" fill-rule="evenodd" d="M5.88 0 38 0 38 6.50 37.50 6.75 37.12 7.75 36.88 11.12 36.12 13.75 35.75 17.62 35.12 19.38 34.88 22.62 34.12 25.12 33.88 28.38 33.12 30.75 33 33 0 33 0 30.50 0.50 30.25 0.88 29.25 1.12 25.75 2.25 21.12 2.88 21.25 4 23 6.12 25.25 6.25 25.75 7 26.25 7.12 26.75 8 27.38 8.12 27.88 8.88 28.38 9.12 29 9.75 29.38 10 30 10.50 30.50 11.50 30.75 13.12 28.50 14.88 26.88 15 26.38 15.75 25.88 18.38 22.38 21.88 18.62 22.25 17.75 21.88 16.12 21.25 15.38 20.38 15.12 19.62 16.12 18.75 16.12 18.25 15.75 17.88 14.12 17.12 13.12 17 12.12 16.38 11.62 15.25 13.50 15 14.88 14.12 16.25 13.88 17.75 13.12 18.75 12.62 20.38 12 20.38 11.12 19.75 9.75 16.38 8.62 15.38 8.25 15.50 7.88 16.62 7.12 17.25 6.75 18.25 6.12 18.88 5.38 19 3.25 15 3.38 13.25 4.75 14 5.25 15.75 5.75 15.88 6.75 15.25 8.25 12.62 9 12.50 9.88 13.12 11.25 16.50 11.88 16.88 12.62 16.75 14.12 12.25 14.75 11.50 15.12 9.75 16.38 8 16.88 8.12 17.12 9.25 17.88 10.25 18.12 11.62 19.38 13.62 20.38 12.75 21.50 12.38 22.88 14.88 23.75 15.12 24.88 12.12 24.88 9 24 6.38 23 5 20.88 3.12 19.12 2.75 15.50 3.12 13 5.25 11.75 7 10.75 7.12 10 6.62 9.88 6.12 9.12 5.62 8 4.12 7.12 3.75 6.88 3.25 5.38 2.62ZM24.62 16.38 24.12 16.88 24.38 17.62 26.12 17.62 26.62 17.12 26.25 16.38Z"/></svg>
|
||||||
|
Before Width: | Height: | Size: 338 B After Width: | Height: | Size: 1.3 KiB |
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><path d="M3 24h5M18 24h12M40 24h5"></path><path d="M8 17h5v14H8zM35 17h5v14h-5z"></path><path fill="currentColor" d="M13 12h5v24h-5zM30 12h5v24h-5z"></path></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" color="#141414" viewBox="0 0 38 33"><path fill="currentColor" fill-rule="evenodd" d="M5.88 0 38 0 38 8 37.50 8.12 37.12 9.25 36.88 12.62 36.12 15.12 35.88 18.38 35.12 20.75 34.88 24 34.12 26.38 33.88 29.62 33.12 32 33.12 33 0 33 0 30.50 0.62 30.12 0.88 29.25 1.12 25.88 1.88 23.38 2.25 19.50 2.75 18.12 3.38 17.50 11.75 17.50 12.88 17.12 13.25 17.88 13.25 28.50 14.50 29.75 16 29.62 17 28.88 17 4.12 16 3.38 14.50 3.25 13.25 4.50 13.25 15.12 12.88 15.88 11.75 15.50 3.75 15.50 3.25 15.12 3.12 14.25 3.88 12 4.12 8.75 4.75 7 5.12 3.12ZM19.62 6.12 18.50 6.25 17.88 7.25 17.88 25.75 18.50 26.75 20.25 26.88 21 26.38 21.50 25.38 21.50 7.62 20.62 6.25ZM23.50 9.12 21.75 10.25 21.75 22.75 23.38 23.88 24.62 23.88 25.88 22.88 25.88 18.25 26.25 16.88 25.88 14.75 25.88 10.12 25 9.25ZM27.50 15.50 26.62 16.25 26.62 16.75 27.38 17.50 30.25 17.50 30.88 17 30.88 16 30.25 15.50Z"/></svg>
|
||||||
|
Before Width: | Height: | Size: 361 B After Width: | Height: | Size: 939 B |
@@ -21,6 +21,17 @@ $dt = static function ($v): string {
|
|||||||
<input type="color" class="form-control form-control-color" name="cor" value="<?= htmlspecialchars($data['cor'] ?? '#C2D500') ?>">
|
<input type="color" class="form-control form-control-color" name="cor" value="<?= htmlspecialchars($data['cor'] ?? '#C2D500') ?>">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Mostrar em</label>
|
||||||
|
<select class="form-select" name="gym_id">
|
||||||
|
<option value="">Todos os ginásios</option>
|
||||||
|
<?php foreach (db_gyms_list($pdo) as $g): ?>
|
||||||
|
<option value="<?= (int) $g['id'] ?>"<?= (int) ($data['gym_id'] ?? 0) === (int) $g['id'] ? ' selected' : '' ?>><?= htmlspecialchars($g['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Ginásio em cujo topo da página inicial o destaque aparece.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label">Subtítulo</label>
|
<label class="form-label">Subtítulo</label>
|
||||||
<input class="form-control" name="subtitle" maxlength="255" value="<?= htmlspecialchars($data['subtitle'] ?? '') ?>">
|
<input class="form-control" name="subtitle" maxlength="255" value="<?= htmlspecialchars($data['subtitle'] ?? '') ?>">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
'cta_label' => trim($_POST['cta_label'] ?? ''),
|
'cta_label' => trim($_POST['cta_label'] ?? ''),
|
||||||
'cta_url' => trim($_POST['cta_url'] ?? ''),
|
'cta_url' => trim($_POST['cta_url'] ?? ''),
|
||||||
'show_lead_form' => isset($_POST['show_lead_form']) ? 1 : 0,
|
'show_lead_form' => isset($_POST['show_lead_form']) ? 1 : 0,
|
||||||
|
'gym_id' => (int) ($_POST['gym_id'] ?? 0) ?: null,
|
||||||
'hero_image' => '',
|
'hero_image' => '',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_event'])) {
|
|||||||
'cta_label' => trim($_POST['cta_label'] ?? ''),
|
'cta_label' => trim($_POST['cta_label'] ?? ''),
|
||||||
'cta_url' => trim($_POST['cta_url'] ?? ''),
|
'cta_url' => trim($_POST['cta_url'] ?? ''),
|
||||||
'show_lead_form' => isset($_POST['show_lead_form']) ? 1 : 0,
|
'show_lead_form' => isset($_POST['show_lead_form']) ? 1 : 0,
|
||||||
|
'gym_id' => (int) ($_POST['gym_id'] ?? 0) ?: null,
|
||||||
'hero_image' => $item['hero_image'],
|
'hero_image' => $item['hero_image'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,29 @@ if (isset($_POST['rename'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload image
|
// Upload images (several at once: images[])
|
||||||
if (isset($_FILES['image']) && $_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload'])) {
|
||||||
csrf_check();
|
csrf_check();
|
||||||
$result = db_gallery_image_upload($pdo, $id, $_FILES['image']);
|
$files = [];
|
||||||
if ($result) session()->setFlashdata('success', 'Imagem carregada.');
|
foreach ((array) ($_FILES['images']['name'] ?? []) as $i => $n) {
|
||||||
else session()->setFlashdata('error', 'Falha no upload. Verifique o formato.');
|
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));
|
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -78,14 +95,14 @@ include __DIR__ . '/../../../includes/header.php';
|
|||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
<div class="card shadow-sm mt-3"><div class="card-body">
|
<div class="card shadow-sm mt-3"><div class="card-body">
|
||||||
<h2 class="h6 mb-3">Adicionar imagem</h2>
|
<h2 class="h6 mb-3">Adicionar imagens</h2>
|
||||||
<form method="POST" enctype="multipart/form-data">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<input type="file" class="form-control" name="image" accept=".jpg,.jpeg,.png,.gif,.webp">
|
<input type="file" class="form-control" name="images[]" multiple accept=".jpg,.jpeg,.png,.gif,.webp,.jxl,image/jpeg,image/png,image/gif,image/webp,image/jxl">
|
||||||
<div class="form-text">JPG, PNG, GIF ou WebP.</div>
|
<div class="form-text">Pode escolher várias. JPG, PNG, GIF, WebP ou JPEG XL, até <?= round(gallery_upload_max_bytes() / 1048576) ?> MB cada (máx. <?= (int) ini_get('max_file_uploads') ?> de cada vez, <?= htmlspecialchars((string) ini_get('post_max_size')) ?> no total).</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-cloud-arrow-up me-1"></i>Carregar</button>
|
<button type="submit" name="upload" value="1" class="btn btn-primary btn-sm"><i class="bi bi-cloud-arrow-up me-1"></i>Carregar</button>
|
||||||
</form>
|
</form>
|
||||||
</div></div>
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,9 +127,19 @@ include __DIR__ . '/../../../includes/header.php';
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<?php foreach ($images as $img): ?>
|
<?php foreach ($images as $img): ?>
|
||||||
|
<?php $rel = 'uploads/gallery/' . $id . '/' . $img['i_file_name']; ?>
|
||||||
<div class="col-6 col-lg-4 position-relative">
|
<div class="col-6 col-lg-4 position-relative">
|
||||||
<img src="<?= base_url('uploads/gallery/' . $id . '/' . $img['i_file_name']) ?>"
|
<?php if (str_ends_with(strtolower($rel), '.jxl') && img_fallback_rel($rel) === ''): ?>
|
||||||
alt="" class="img-thumbnail w-100" style="height:140px;object-fit:cover;" loading="lazy">
|
<?php /* JPEG XL still without its WebP/JPG copy (no decoder on the server yet): most browsers
|
||||||
|
can't draw it, so show what is stored instead of a blank tile. */ ?>
|
||||||
|
<div class="img-thumbnail w-100 d-flex flex-column align-items-center justify-content-center text-center small text-body-secondary" style="height:140px;">
|
||||||
|
<i class="bi bi-file-earmark-image fs-3"></i>
|
||||||
|
<span class="badge text-bg-secondary my-1">JPEG XL</span>
|
||||||
|
<span class="text-truncate w-100 px-1" title="<?= htmlspecialchars($img['i_file_name']) ?>"><?= htmlspecialchars($img['i_file_name']) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= img_tag($rel, ['alt' => '', 'class' => 'img-thumbnail w-100', 'style' => 'height:140px;object-fit:cover;', 'loading' => 'lazy', 'width' => null]) ?>
|
||||||
|
<?php endif; ?>
|
||||||
<form method="POST" class="position-absolute top-0 end-0 m-1" onsubmit="return confirm('Remover esta imagem? Fica na lixeira durante 30 dias.')">
|
<form method="POST" class="position-absolute top-0 end-0 m-1" onsubmit="return confirm('Remover esta imagem? Fica na lixeira durante 30 dias.')">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<button class="btn btn-sm btn-danger rounded-circle" name="del_img" value="<?= (int) $img['i_id'] ?>" aria-label="Remover imagem"
|
<button class="btn btn-sm btn-danger rounded-circle" name="del_img" value="<?= (int) $img['i_id'] ?>" aria-label="Remover imagem"
|
||||||
|
|||||||
@@ -41,9 +41,16 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
|
|||||||
<input class="form-control" name="hero_subtitle" maxlength="150" placeholder="& Acompanhamento" value="<?= htmlspecialchars($data['hero_subtitle'] ?? '') ?>">
|
<input class="form-control" name="hero_subtitle" maxlength="150" placeholder="& Acompanhamento" value="<?= htmlspecialchars($data['hero_subtitle'] ?? '') ?>">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Imagem do header</label>
|
<label class="form-label" for="hero_file">Imagem do header</label>
|
||||||
<input class="form-control font-monospace" name="hero_image" maxlength="255" placeholder="site/assets/img/home.jpg" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
|
<?php if (($data['hero_image'] ?? '') !== ''): ?>
|
||||||
<div class="form-text">Caminho de uma imagem do site. Vazio = a do ginásio principal.</div>
|
<div class="mb-2"><img src="<?= htmlspecialchars(base_url(img_compat_rel($data['hero_image']))) ?>" alt="" class="rounded border" style="max-width:100%;max-height:110px;object-fit:cover"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="hidden" name="hero_image" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
|
||||||
|
<input type="file" class="form-control" id="hero_file" name="hero_file" accept="image/jpeg,image/png,image/webp,image/jxl,.jxl">
|
||||||
|
<div class="form-text">JPG, PNG, WebP ou JPEG XL (até 6 MB). Vazio = a do ginásio principal.</div>
|
||||||
|
<?php if (($data['hero_image'] ?? '') !== ''): ?>
|
||||||
|
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="hero_remove" value="1" id="hero_remove"><label class="form-check-label small" for="hero_remove">Remover a imagem atual</label></div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Galeria das instalações</label>
|
<label class="form-label">Galeria das instalações</label>
|
||||||
@@ -86,6 +93,30 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="logo_file">Logótipo (menu do site)</label>
|
||||||
|
<input type="hidden" name="logo" value="<?= htmlspecialchars($data['logo'] ?? '') ?>">
|
||||||
|
<?php $logo_now = gym_logo($data); // saved logo, or the site logo the main gym falls back to ?>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<?php if ($logo_now !== ''): ?>
|
||||||
|
<img src="<?= htmlspecialchars(base_url($logo_now)) ?>" alt="Logótipo atual" title="<?= ($data['logo'] ?? '') === '' ? 'Logótipo do site (nenhum carregado)' : 'Logótipo atual' ?>" class="rounded border bg-white p-1 flex-shrink-0" style="height:38px;width:auto;max-width:120px;object-fit:contain">
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="file" class="form-control" id="logo_file" name="logo_file" accept="image/svg+xml,image/png,image/webp,image/jpeg,image/jxl,.jxl">
|
||||||
|
</div>
|
||||||
|
<div class="form-text">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.</div>
|
||||||
|
<?php if (!_gyms_has_col($pdo, 'gyms', 'logo')): ?>
|
||||||
|
<div class="form-text text-warning">Falta aplicar a migração <code>migration_20261013_gym_brand_color.sql</code> (Sistema → Atualizar plataforma) para guardar o logótipo e a cor da marca.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (($data['logo'] ?? '') !== ''): ?>
|
||||||
|
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="logo_remove" value="1" id="logo_remove"><label class="form-check-label small" for="logo_remove">Remover o logótipo atual</label></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6 col-lg-4">
|
||||||
|
<label class="form-label" for="brand_color">Cor da marca</label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="brand_color" name="brand_color" value="<?= htmlspecialchars(($data['brand_color'] ?? '') !== '' ? $data['brand_color'] : '#C2D500') ?>">
|
||||||
|
<div class="form-text">Botões, destaques e links do site deste ginásio.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-12"><hr class="my-1"><strong>Estado</strong></div>
|
<div class="col-12"><hr class="my-1"><strong>Estado</strong></div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ include __DIR__ . '/../../../includes/header.php';
|
|||||||
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
|
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
|
||||||
</div>
|
</div>
|
||||||
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
|
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
|
||||||
<form method="POST" class="card shadow-sm"><div class="card-body">
|
<form method="POST" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<?php include __DIR__ . '/_form.php'; ?>
|
<?php include __DIR__ . '/_form.php'; ?>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
if (!$errors) {
|
if (!$errors) {
|
||||||
$changed = db_gyms_update($pdo, $id, $data);
|
$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.');
|
session()->setFlashdata($changed ? 'success' : 'warning', $changed ? 'Ginásio atualizado.' : 'Nenhuma alteração.');
|
||||||
header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id));
|
header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id));
|
||||||
exit;
|
exit;
|
||||||
@@ -47,7 +49,7 @@ include __DIR__ . '/../../../includes/header.php';
|
|||||||
<button class="btn btn-sm btn-primary text-nowrap">Editar conteúdo deste</button>
|
<button class="btn btn-sm btn-primary text-nowrap">Editar conteúdo deste</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<form method="POST" class="card shadow-sm"><div class="card-body">
|
<form method="POST" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<?php include __DIR__ . '/_form.php'; ?>
|
<?php include __DIR__ . '/_form.php'; ?>
|
||||||
<div class="mt-4 d-flex gap-2">
|
<div class="mt-4 d-flex gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../../../config/db.php';
|
||||||
|
require __DIR__ . '/../../../config/functions.php';
|
||||||
|
$user = require_admin();
|
||||||
|
// Account, token and cache are per gym: this page works on the gym being edited.
|
||||||
|
$igym = admin_gym($pdo);
|
||||||
|
instagram_use_gym($igym);
|
||||||
|
$gid = (int) $igym['id'];
|
||||||
|
$self = base_url('views/admin/instagram/index.php');
|
||||||
|
|
||||||
|
$accounts = null;
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
csrf_check();
|
||||||
|
$uid = (int) ($user['id'] ?? 0) ?: null;
|
||||||
|
$action = (string) ($_POST['action'] ?? '');
|
||||||
|
|
||||||
|
if ($action === 'save') {
|
||||||
|
$before = instagram_config();
|
||||||
|
// Token: blank keeps the stored one; the "remover" box clears it.
|
||||||
|
$token = trim((string) ($_POST['instagram_token'] ?? ''));
|
||||||
|
if (!empty($_POST['clear_token'])) {
|
||||||
|
db_update_setting($pdo, 'instagram_token_' . $gid, '', $uid);
|
||||||
|
} elseif ($token !== '') {
|
||||||
|
db_update_setting($pdo, 'instagram_token_' . $gid, secret_encrypt($token), $uid);
|
||||||
|
}
|
||||||
|
$ig_id = preg_replace('/\D+/', '', (string) ($_POST['instagram_user_id'] ?? ''));
|
||||||
|
db_update_setting($pdo, 'instagram_user_id_' . $gid, $ig_id, $uid);
|
||||||
|
db_update_setting($pdo, 'instagram_max', (string) max(3, min(24, (int) ($_POST['max'] ?? 8))), $uid);
|
||||||
|
db_update_setting($pdo, 'instagram_cache_hours', (string) max(1, (int) ($_POST['cache_hours'] ?? 6)), $uid);
|
||||||
|
// A new account or token starts from scratch (setting() is cached per request, so compare with what was posted).
|
||||||
|
if (!empty($_POST['clear_token']) || ($token !== '' && $token !== $before['token']) || $ig_id !== $before['user_id']) {
|
||||||
|
instagram_cache_clear();
|
||||||
|
}
|
||||||
|
session()->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';
|
||||||
|
?>
|
||||||
|
<div class="container-fluid" style="max-width:900px;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h1 class="h3 mb-0">Comunidade (Instagram)</h1>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars(gym_url($igym, '#comunidade')) ?>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Ver no site</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small">A secção «Comunidade» (a seguir à galeria) mostra as últimas publicações em que <strong><?= htmlspecialchars($igym['name']) ?></strong> 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.</p>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4"><div class="card-body">
|
||||||
|
<h2 class="h5 mb-3">Configuração</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="action" value="save">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label class="form-label" for="instagram_token">Token de acesso (Graph API)</label>
|
||||||
|
<?php if ($env_tok): ?>
|
||||||
|
<input class="form-control" id="instagram_token" disabled value="Definido no .env (INSTAGRAM_TOKEN_<?= $gid ?>)">
|
||||||
|
<?php else: ?>
|
||||||
|
<input class="form-control font-monospace" type="password" id="instagram_token" name="instagram_token" autocomplete="off"
|
||||||
|
placeholder="<?= $cfg['token'] !== '' ? 'Guardado — deixa vazio para manter' : 'EAA…' ?>">
|
||||||
|
<?php if ($cfg['token'] !== ''): ?>
|
||||||
|
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="clear_token" id="clear_token" value="1">
|
||||||
|
<label class="form-check-label small" for="clear_token">Remover token guardado</label></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!secrets_available()): ?><div class="form-text text-warning">Sem chave de encriptação no .env — o token fica guardado sem encriptação.</div><?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="form-text">Token de um utilizador do sistema (Meta Business) ou de longa duração, com <code>instagram_basic</code>, <code>instagram_manage_comments</code> e <code>pages_read_engagement</code>.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label" for="instagram_user_id">ID da conta Instagram</label>
|
||||||
|
<input class="form-control font-monospace" id="instagram_user_id" name="instagram_user_id" inputmode="numeric" value="<?= htmlspecialchars($cfg['user_id']) ?>" placeholder="1784…">
|
||||||
|
<div class="form-text">Conta profissional ligada a uma Página do Facebook. Não sabes? Usa «Encontrar» abaixo.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="ig_max">Número de publicações</label>
|
||||||
|
<input type="number" class="form-control" id="ig_max" name="max" min="3" max="24" value="<?= $cfg['max'] ?>">
|
||||||
|
<div class="form-text">Igual para todos os ginásios.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="ig_hours">Atualizar a cada (horas)</label>
|
||||||
|
<input type="number" class="form-control" id="ig_hours" name="cache_hours" min="1" value="<?= $cfg['cache_hours'] ?>">
|
||||||
|
<div class="form-text">Igual para todos os ginásios.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary mt-3">Guardar</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4"><div class="card-body">
|
||||||
|
<h2 class="h5 mb-3">Encontrar o ID da conta</h2>
|
||||||
|
<p class="small text-muted">Lista as Páginas do Facebook a que o token guardado tem acesso e a conta Instagram ligada a cada uma.</p>
|
||||||
|
<form method="POST" class="mb-0">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="action" value="accounts">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" <?= $cfg['token'] !== '' ? '' : 'disabled' ?>><i class="bi bi-search me-1"></i>Encontrar</button>
|
||||||
|
</form>
|
||||||
|
<?php if ($accounts !== null): ?>
|
||||||
|
<?php if ($accounts['error'] !== ''): ?><div class="alert alert-danger mt-3 mb-0"><?= htmlspecialchars($accounts['error']) ?></div>
|
||||||
|
<?php elseif (!$accounts['list']): ?><div class="alert alert-warning mt-3 mb-0">Nenhuma Página com conta Instagram profissional ligada.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="list-group mt-3">
|
||||||
|
<?php foreach ($accounts['list'] as $a): ?>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center gap-2">
|
||||||
|
<div><strong>@<?= htmlspecialchars($a['username']) ?></strong> <span class="small text-muted">· Página <?= htmlspecialchars($a['page']) ?></span>
|
||||||
|
<div><code class="small"><?= htmlspecialchars($a['id']) ?></code></div></div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-success text-nowrap" onclick="document.getElementById('instagram_user_id').value='<?= htmlspecialchars($a['id'], ENT_QUOTES) ?>';window.scrollTo(0,0);">Usar</button>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<div class="form-text">Depois de «Usar», carrega em Guardar.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm"><div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h5 mb-0">Estado</h2>
|
||||||
|
<form method="POST" class="mb-0"><?= csrf_field() ?><input type="hidden" name="action" value="refresh">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" <?= instagram_configured() ? '' : 'disabled' ?>><i class="bi bi-arrow-clockwise me-1"></i>Atualizar agora</button></form>
|
||||||
|
</div>
|
||||||
|
<?php if (!instagram_configured()): ?>
|
||||||
|
<p class="text-muted mb-0">Por configurar — a secção não aparece no site.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="small mb-2">Última atualização: <?= !empty($cache['fetched_at']) ? date('d/m/Y H:i', (int) $cache['fetched_at']) : '—' ?></p>
|
||||||
|
<?php if (!empty($cache['error'])): ?><div class="alert alert-danger small">Último erro: <?= htmlspecialchars($cache['error']) ?></div><?php endif; ?>
|
||||||
|
<?php if ($preview): ?>
|
||||||
|
<p class="small text-muted mb-2">No site aparecem <?= count($preview) ?>:</p>
|
||||||
|
<div class="row g-2">
|
||||||
|
<?php foreach ($preview as $p): ?>
|
||||||
|
<div class="col-4 col-md-3">
|
||||||
|
<a href="<?= htmlspecialchars($p['permalink']) ?>" target="_blank" rel="noopener" class="d-block text-decoration-none small">
|
||||||
|
<img src="<?= htmlspecialchars(base_url($p['image'])) ?>" alt="" class="img-thumbnail w-100" style="aspect-ratio:1;object-fit:cover;" loading="lazy">
|
||||||
|
<span class="d-block text-truncate">@<?= htmlspecialchars($p['username']) ?></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="text-muted small mb-0">Ainda sem publicações identificadas — a secção está escondida.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<?php include __DIR__ . '/../../../includes/footer.php';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
// Painel da equipa (role staff). O staff ainda não tem páginas de gestão:
|
||||||
|
// tudo em views/admin/ exige admin. Fica a saudação e o acesso ao perfil.
|
||||||
|
require __DIR__ . '/../../config/db.php';
|
||||||
|
require __DIR__ . '/../../config/functions.php';
|
||||||
|
$user = require_staff();
|
||||||
|
|
||||||
|
$page_title = 'Painel';
|
||||||
|
include __DIR__ . '/../../includes/header.php';
|
||||||
|
?>
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="h3 mb-4">Olá, <?= htmlspecialchars($user['name']) ?></h1>
|
||||||
|
|
||||||
|
<div class="card shadow-sm"><div class="card-body">
|
||||||
|
<p class="mb-3">Bem-vindo à área da equipa FitMe 3070.</p>
|
||||||
|
<a class="btn btn-outline-primary" href="<?= base_url('views/user/profile.php') ?>"><i class="bi bi-person me-1"></i>O meu perfil</a>
|
||||||
|
<p class="text-muted small mt-3 mb-0">As ferramentas da equipa serão disponibilizadas num passo seguinte.</p>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<?php include __DIR__ . '/../../includes/footer.php'; ?>
|
||||||
@@ -128,7 +128,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
include __DIR__ . '/../../includes/header.php';
|
include __DIR__ . '/../../includes/header.php';
|
||||||
include __DIR__ . '/../../includes/navbar.php';
|
|
||||||
?>
|
?>
|
||||||
<div class="container py-4" style="max-width:900px;">
|
<div class="container py-4" style="max-width:900px;">
|
||||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||||||
|
|||||||