33 Commits
Author SHA1 Message Date
andre c461396654 Merge pull request 'style(contactos): info and hours beside the form' (#6) from beta into main 2026-10-27 14:02:58 +00:00
andre 2089f92750 style(contactos): info and hours in two columns beside the form
Drops the duplicate «Envia-nos uma mensagem» heading (the app form has its
own) and gives the narrower form the right half.
2026-10-27 13:55:13 +00:00
andre dcef3cda45 Merge pull request 'feat(contactos): embed the FitMe app contact form' (#5) from beta into main 2026-10-27 13:53:08 +00:00
andre f018fa582d feat(contactos): embed the FitMe app contact form
Replaces the Zoho form in the contacts section with the app.fitme3070.pt
site-contacto iframe (passes page URL and UTM params, auto-height).
2026-10-27 13:47:35 +00:00
andre 18d037ac16 Merge pull request 'fix(gallery): lightbox click and mouse drag' (#4) from beta into main 2026-10-26 20:41:18 +00:00
andre f297e128fd fix(gallery): open lightbox on click and keep mouse drag alive
Pointer capture on pointerdown redirected the click to the strip, so photos
never opened; capture now starts only after a 5px drag. Native link drag is
blocked so it no longer cancels the mouse drag.
2026-10-26 20:40:17 +00:00
andre b22033e1bd Merge pull request 'release: promote beta to main (gyms, brand colours, JPEG XL, SEO per gym)' (#3) from beta into main
Merge pull request #3: promote beta to main
2026-10-26 19:47:48 +00:00
andre a8683658fa chore: merge main into beta (keep beta's dev toolbar) 2026-10-26 19:46:55 +00:00
andre 22edadf0d7 chore: stop tracking assistant notes 2026-10-26 16:55:12 +00:00
andre 2279970768 feat(footer): add Powered by andmore.pt credit 2026-10-26 16:52:20 +00:00
andre e804c52b4e feat(site): Comunidade section with Instagram posts the gym was tagged in
Per-gym Graph API /tags fetch with file cache and local thumbnails, section after the gallery, navbar link and admin page (Gestão > Site).
2026-10-26 14:19:43 +00:00
andre 6ac5213624 feat(galerias): upload several images at once, clear errors and JPEG XL placeholders 2026-10-26 13:48:02 +00:00
andre 6abc486630 feat(site): YourFit brand glyphs as modalidade icons, light plate for the other-gym logo button 2026-10-26 12:55:36 +00:00
andre 725f5b9c26 feat(site): per-gym SEO texts, event gym selector, hero/navbar polish
- Generated SEO titles/descriptions per gym; gym seo fields override each on its own
- Keywords, author, og:site_name, favicon and JSON-LD logo follow the gym
- CrossFit page 404s before output for gyms without a box
- Events: «Mostrar em» gym selector; event page uses its gym's brand
- Hero event button text colour from the event colour (--ev-on)
- Navbar bottom line stops at the other-gym head (--switch-w)
- Diagonal hero blur mask; remove the trial link
2026-10-26 12:15:37 +00:00
andre 785d1f6ceb style(hero): event card border in its button colour, no glow on hero text, lighter subtitle 2026-10-26 12:06:15 +00:00
andre 0bc14f5f72 style(navbar): match head slash in the colour of the gym it links to 2026-10-26 12:03:30 +00:00
andre e7ab661f4c feat(navbar): match head shows the other gym's brand, centred logo on CrossFit, mobile band
The match head is painted in the other gym's colours with a strip in this site's accent; below 992px it is a slashed band at the bottom of the open menu (menu height from the real header height). The CrossFit page gets the centred logo crossfade, the hero logo has a fixed 52px height, and the hero h1 shadow is softer with the frosted blur at .8.
2026-10-26 12:00:56 +00:00
andre 4b54a777b7 style(navbar): logo crossfade, match-head end, full-screen mobile menu, brand slash template 2026-10-26 11:53:44 +00:00
andre 2e8b6d20f6 style(navbar): smaller centred logo, hero clears the bar, liquid-glass backdrop 2026-10-26 11:34:11 +00:00
andre c2b1f98e96 docs: libjxl-tools in the production Dockerfile, pending rebuild 2026-10-26 11:30:35 +00:00
andre 04df7f8997 style(site): truly centred navbar, smoother logo docking, grain on logos and buttons, slashed title underlines 2026-10-26 11:27:28 +00:00
andre f72292fc02 docs: centred home logo, nav underline and hero event card 2026-10-26 11:24:15 +00:00
andre bfbd04a2d3 feat(site): centred home logo that docks on scroll, slashed nav underline, square defocused hero event 2026-10-26 11:23:28 +00:00
andre 4f44bc1609 feat(images): JPEG XL optimizer with webp/jpg fallback and <picture> serving
img_optimize() decodes .jxl with djxl or Imagick, resizes with GD, re-encodes
with cjxl and writes foto.jxl.webp beside it; skips cleanly without a decoder.
Gallery, hero, navbar logos and JSON-LD serve the JXL with the fallback, and
trash/removal move the fallback with its .jxl.
2026-10-26 11:18:13 +00:00
andre 8fe73eb5d6 style(navbar): dark outline instead of white halo on gym logo buttons 2026-10-26 10:15:22 +00:00
andre 49adbd0ae1 feat(site): navbar links only sections with content, Galeria link, brand-tinted gallery background 2026-10-26 10:14:04 +00:00
andre 1eb2a61031 fix(admin): empty DataTables listings no longer error; their message becomes emptyTable
docs: JPEG XL optimizer plan and image-input rule
2026-10-26 10:13:29 +00:00
andre cc677af92d style(contactos): opening hours stacked by day, one line per period, centred 2026-10-26 10:01:57 +00:00
andre 7fa2da73dc feat(galerias): accept JPEG XL photos and list every uploaded format 2026-10-26 10:00:18 +00:00
andre 14dbabcad5 style(site): logo gym buttons without face or glow, gallery off scroll hover, Mensalidades brand glow 2026-10-26 09:59:51 +00:00
andreandClaude Sonnet 5.5 4ed2dd3709 feat(dev): adopt shared platform-tools dev toolbar and one-click dev login
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 22:16:14 +01:00
andre 4c847267ea Merge pull request 'Promote beta: local-only dev setup and notes' (#2) from beta into main 2026-10-09 21:58:41 +01:00
andre 7dc47f79cc Merge pull request 'Promote beta' (#1) from beta into main 2026-10-09 21:19:50 +01:00
36 changed files with 1334 additions and 379 deletions

No files matched your search

+5
View File
@@ -20,6 +20,10 @@ uploads/**/.trash/
# (config/functions_privacy.php) para o browser não as pedir à Google.
uploads/reviews/
# Miniaturas das publicações do Instagram da secção «Comunidade»
# (config/functions_instagram.php), descarregadas a cada atualização.
uploads/instagram/
# Nota: as fotos da equipa, galerias e ícones em uploads/ ainda estão
# versionados (vieram assim do site antigo). Em produção uploads/ é um volume
# (/srv/data/fitme.andmore.pt/uploads) e é esse a fonte; o que está no git é
@@ -57,6 +61,7 @@ Thumbs.db
.claude/worktrees/
.claude/
CLAUDE.md
# ─── Dependências ───────────────────────────────────────────────────────────
# vendor/ é output do composer install, exceto vendor/PHPMailer: o
-164
View File
@@ -1,164 +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 (`is_default`); the other published gyms appear as highlighted buttons (`.gym-switch-btn`) at the right end of the public navbar (`site/includes/navbar.php`). `site/gym_chooser.php` is no longer used.
Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. The public navbar's bottom edge is an always-on, slow (14s) sweep in the brand colour (`.header-section::before/::after`, `nav-sweep`, static with reduced motion). Each gym can have a `logo` (same migration, plus `migration_20261026_gym_logo.sql`: the column was added to the 2026-10-13 file after it had already run on dev, so dev had no `logo` column until 2026-10-26 — never edit a migration that has run, add a new one); the header image and the logo are uploaded in Gestão → Ginásios (`gyms_save_image()` → `uploads/gyms/`, validated by content, SVG through `svg_is_safe()`), not typed as paths; `db_gyms_create/update` skip `brand_color`/`logo` while the migration isn't applied (`_gyms_cols()`); the navbar button for another gym is drawn in that gym's brand colour (`gym_brand_vars()` inline); with a logo (`gym_logo()`: the gym's own, or for the default gym the bundled `logo_black.svg`) the button shows the logo in full colour on a white face (`.gym-switch-btn--logo`, name as alt), without one it shows the name. The navbar brand on each gym's page uses that gym's logo too. Uploads (header + logo) also accept JPEG XL (`.jxl`, detected by signature in `upload_image_ext()` when the caller allows `'jxl'`; only the gyms form does; `AddType image/jxl` in `.htaccess`; GD can't resize it, so it is stored as uploaded; Chrome/Firefox may not display JXL, Safari does).
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`).
## Ambiente de dev: moldura e login de um clique (2026-10-13)
- `includes/env_ribbon.php` (moldura às riscas + etiqueta DEV, só fora de `APP_ENV=prod`) incluído no `<head>` de `includes/header.php`, `includes/auth_header.php` e `site/includes/include.php`.
- A barra de login de dev (`includes/dev_toolbar.php`) vai também em `includes/auth_footer.php` e `site/includes/footer.php`. Botões Cliente / Staff / Admin / Sysadmin (`client@mail.pt` e `staff@mail.pt` criados por `scripts/dev_accounts.php`; o painel do staff é `views/staff/dashboard.php`, criado em 2026-10-13).
- Auditoria da sidebar: `docs/audits/2026-10-13-fitme3070-sidebar.md` (em Y:/GitTea; aplicada, ver «Barra lateral» abaixo).
## Barra lateral (2026-10-13)
- Padrão da frota (`.claude/skills/sysadmin-navbar-audit`). CSS todo em `assets/css/backoffice.css` (tokens `--bo-*`, casca, mini, view transitions; carregado com `?v=filemtime` por `_asset_versioned`); já não há `<style>` inline no `header.php`. Prefixo `bo-` nas peças novas; `.sidebar`, `.topbar`, `.nav-link` mantêm-se.
- Cabeçalho fixo `[☰][marca][↗ site público]` (↗ abre `base_url('/')` em nova aba, único sítio), utilizador logo abaixo (avatar com iniciais, nome, crachá de papel, menu Perfil / Terminar sessão). A topbar fica só com o seletor «A editar», o tema e, em telemóvel, o botão que abre a gaveta. Um só manipulador (`data-sidebar-toggle`, em `includes/footer.php`): desktop alterna mini (80px, `localStorage bo-sidebar-mini`), telemóvel abre o off-canvas.
- Menu em `includes/header.php` com os auxiliares de `includes/nav_helpers.php`: `bo_nav_link()` (ativo por sufixo do `SCRIPT_NAME`, `aria-current`, title + aria-label), `bo_nav_group_start/end()` (grupo recolhível, `data-short` = 4 letras), `bo_dashboard_path()`. Ordem: Painel (admin: `views/admin/dashboard.php`; staff: `views/staff/dashboard.php`; cliente: `views/user/dashboard.php`), Gestão (subgrupos Site, Horários, Comercial, Legal), Utilizadores, Sistema por último. Cada ligação mantém o seu `is_admin()`/`is_sysadmin()`.
- Estado sem flash: script no `<head>` põe `html.bo-mini` e `data-bo-groups`; estado dos grupos em `localStorage bo-nav-groups`; scroll da lista em `sessionStorage bo-nav-scroll`. Grupo novo = chave nova na lista `:is(...)` de grupos fechados em `backoffice.css`.
- Mini: categorias em texto (`data-short`), nunca ícones. Ícones: só Bootstrap Icons de contorno.
- `includes/navbar.php` (barra antiga) foi apagado; `system_settings.php` já não o inclui.
- `views/staff/dashboard.php`: saudação mínima (o staff ainda não alcança nada em `views/admin/`).
- Gotcha de dev: a tabela `trial_leads` não existe na BD de dev, por isso `views/admin/trials/index.php` dá 500 (migração por aplicar; anterior a esta mudança).
## Bootstrap re-tematizado (2026-10-13)
- `assets/css/bs-brand.css` (carregado depois do Bootstrap em `includes/header.php` e `includes/auth_header.php`) sobrescreve as cores do Bootstrap só com variáveis `--bs-*` e as variáveis de componente (`.btn-*`, `.form-check-input`, `.page-link`…), claro e escuro. A cor de marca é `--fitme-accent` (fallback `#C2D500`); todos os tons derivam dela com `color-mix()`; `--brand-on` é o texto sobre a cor. Secondary/success/danger/warning/info têm valores próprios. Verificação: `platform.php?tool=branding` (Montra da marca) não deve mostrar nenhum azul por omissão. O site público ainda não carrega este ficheiro.
## Pacotes de ícones partilhados (2026-10-13)
- `config/db.php` regista `'icons' => ['packs' => ['bootstrap-icons','fontawesome'], 'default' => 'bootstrap-icons']` só se `shared/platform-tools/functions_icons.php` existe. Flaticon NÃO está registado: o site usa PNGs (`uploads/modalidades/icons`), não a fonte.
- `fit_icon_assets($packs, $fallbackLocal)` (`config/functions_icons.php`) imprime `pt_icon_assets()` ou, sem submódulo novo, os `<link>` locais. Backoffice (`includes/header.php`, `includes/auth_header.php`): `bootstrap-icons`; site público (`site/includes/include.php`): `fontawesome` (FA 6.7.2; as classes `fas/far/fab` e os nomes FA5 funcionam por aliases).
- Sidebar > Sistema: «Pacotes de ícones» (sysadmin) -> `platform.php?tool=icons`.
- Sem uso quando o submódulo é novo (não apagados): `assets/vendor/bootstrap-icons/`, `site/assets/fonts/fontawesome/`, `site/assets/icon-fonts/`, `site/assets/css/flaticon.css`. `config/error_handler.php` ainda usa bootstrap-icons por CDN (página de erro autónoma).
+11 -1
View File
@@ -46,8 +46,18 @@ function initDataTable(selector, opts) {
if (!window.jQuery || !jQuery.fn || !jQuery.fn.DataTable) return null;
var $table = jQuery(selector);
if (!$table.length) return null;
// Listagem vazia: as páginas desenham uma única linha <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, {
language: DATATABLES_PT_PT,
language: language,
pageLength: 25,
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, 'Todos']],
responsive: true,
+1
View File
@@ -41,6 +41,7 @@ require_once __DIR__ . '/functions_events.php'; // time-limited events +
require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_info) editor
require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição
require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache
require_once __DIR__ . '/functions_instagram.php'; // «Comunidade»: posts do Instagram onde o ginásio é identificado
require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental
require_once __DIR__ . '/functions_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente
require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
+7 -3
View File
@@ -15,6 +15,7 @@ function events_defaults(): array {
'location' => '', 'event_starts_at' => null, 'event_ends_at' => null,
'publish_from' => '', 'publish_until' => '', 'is_active' => 1,
'cta_label' => '', 'cta_url' => '', 'show_lead_form' => 1,
'gym_id' => null, // NULL = shown on every gym's homepage
];
}
@@ -117,8 +118,8 @@ function db_events_create(PDO $pdo, array $data): int {
INSERT INTO events
(slug, title, subtitle, summary, description, hero_image, cor, location,
event_starts_at, event_ends_at, publish_from, publish_until,
is_active, cta_label, cta_url, show_lead_form)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
is_active, cta_label, cta_url, show_lead_form, gym_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
");
$stmt->execute([
$d['slug'], $d['title'], $d['subtitle'], $d['summary'], $d['description'],
@@ -127,6 +128,7 @@ function db_events_create(PDO $pdo, array $data): int {
str_replace('T', ' ', (string) $d['publish_from']),
str_replace('T', ' ', (string) $d['publish_until']),
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
$d['gym_id'] ? (int) $d['gym_id'] : null,
]);
$__id = (int) $pdo->lastInsertId();
audit_change($pdo, 'event', 'events', 'e_id', (int) $__id, 'create', null, 'title');
@@ -141,7 +143,7 @@ function db_events_update(PDO $pdo, int $id, array $data): bool {
slug = ?, title = ?, subtitle = ?, summary = ?, description = ?,
hero_image = ?, cor = ?, location = ?,
event_starts_at = ?, event_ends_at = ?, publish_from = ?, publish_until = ?,
is_active = ?, cta_label = ?, cta_url = ?, show_lead_form = ?
is_active = ?, cta_label = ?, cta_url = ?, show_lead_form = ?, gym_id = ?
WHERE e_id = ?
");
$stmt->execute([
@@ -151,6 +153,7 @@ function db_events_update(PDO $pdo, int $id, array $data): bool {
str_replace('T', ' ', (string) $d['publish_from']),
str_replace('T', ' ', (string) $d['publish_until']),
(int) $d['is_active'], $d['cta_label'], $d['cta_url'], (int) $d['show_lead_form'],
$d['gym_id'] ? (int) $d['gym_id'] : null,
$id,
]);
$__r = $stmt->rowCount() > 0;
@@ -225,6 +228,7 @@ function events_image_remove(?string $filename): void {
if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return;
$path = events_image_dir() . $filename;
if (is_file($path)) @unlink($path);
img_fallback_follow($path);
}
// ─── Leads ─────────────────────────────────────────────────────────────────────
+18 -7
View File
@@ -50,23 +50,34 @@ function db_gallery_images(PDO $pdo, int $gallery_id): array {
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?string {
if ($file['error'] !== UPLOAD_ERR_OK) return null;
// Limite por ficheiro: o menor entre 16 MB e o upload_max_filesize do PHP.
function gallery_upload_max_bytes(): int {
$ini = trim((string) ini_get('upload_max_filesize'));
$n = (int) $ini;
$n *= match (strtolower(substr($ini, -1))) { 'g' => 1 << 30, 'm' => 1 << 20, 'k' => 1 << 10, default => 1 };
return $n > 0 ? min($n, 16 * 1024 * 1024) : 16 * 1024 * 1024;
}
if (($file['size'] ?? 0) > 16 * 1024 * 1024) return null;
// $error recebe o motivo (para a mensagem ao utilizador) quando devolve null.
function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file, ?string &$error = null): ?string {
$max_mb = round(gallery_upload_max_bytes() / 1048576);
if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) { $error = "maior que {$max_mb} MB"; return null; }
if ($file['error'] !== UPLOAD_ERR_OK) { $error = 'o envio falhou'; return null; }
if (($file['size'] ?? 0) > gallery_upload_max_bytes()) { $error = "maior que {$max_mb} MB"; return null; }
// Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php).
$ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
if ($ext === null) return null;
$ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl']);
if ($ext === null) { $error = 'formato não suportado'; return null; }
$upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/';
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
$filename = bin2hex(random_bytes(16)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) return null;
if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) { $error = 'não foi possível gravar'; return null; }
img_optimize($upload_dir . $filename, IMG_MAX_GALLERY);
$stmt = $pdo->prepare("INSERT INTO gallery (i_file_name, i_file_type, i_project_id) VALUES (?, ?, ?)");
$stmt->execute([$filename, $file['type'] ?? 'image/jpeg', $gallery_id]);
$stmt->execute([$filename, $ext === 'jxl' ? 'image/jxl' : ($file['type'] ?? 'image/jpeg'), $gallery_id]);
return $filename;
}
+2
View File
@@ -19,6 +19,7 @@ function gym_sections(): array {
return [
'oferta' => 'A Nossa Oferta',
'galeria' => 'Galeria (instalações)',
'comunidade' => 'Comunidade (Instagram)',
'modalidades' => 'Modalidades',
'equipa' => 'Equipa',
'horario' => 'Horário de aulas',
@@ -313,6 +314,7 @@ function gyms_remove_image(?string $rel): void {
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
+226 -2
View File
@@ -89,6 +89,7 @@ function img_optimize(string $path, int $max_dim): array {
if (!is_file($path)) return $fail('ficheiro não encontrado');
if (!extension_loaded('gd')) return $fail('extensão GD indisponível');
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'jxl') return img_jxl_optimize($path, $max_dim);
$info = @getimagesize($path);
if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster
@@ -186,9 +187,9 @@ function img_optimize_dir(string $dir, int $max_dim): array {
if (!is_dir($dir)) return $stats;
foreach (scandir($dir) ?: [] as $file) {
$path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file;
if (!is_file($path)) continue;
if (!is_file($path) || img_is_jxl_fallback($file)) continue;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) continue;
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl'], true)) continue;
$r = img_optimize($path, $max_dim);
$stats[$r['status']]++;
$stats['saved'] += $r['saved'];
@@ -214,3 +215,226 @@ function img_human_bytes(int $bytes): string {
if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB';
return $bytes . ' B';
}
// ─── JPEG XL ─────────────────────────────────────────────────────────────────
// O GD não lê JXL. Descodifica-se com os binários do libjxl (djxl/cjxl, pacote
// Debian libjxl-tools) ou com o Imagick se tiver o formato JXL; sem nenhum, o
// .jxl fica como foi enviado (status "skipped") e o upload não falha.
// Ao lado de cada foto.jxl grava-se foto.jxl.webp (ou .jpg sem WebP no GD):
// é a versão para os browsers sem JXL, servida por img_tag()/img_css_bg() e
// escondida das listagens (img_list_dir). Redimensiona-se a partir da versão
// descodificada e, havendo encoder, o .jxl é regravado ao novo tamanho.
const IMG_JXL_DISTANCE = '1.0'; // cjxl -d: 1.0 = visualmente sem perdas
// Backends on this server: ['imagick' => bool, 'djxl' => path|'', 'cjxl' => path|''].
function img_jxl_tools(): array {
static $tools = null;
if ($tools !== null) return $tools;
$imagick = class_exists('Imagick') && in_array('JXL', \Imagick::queryFormats('JXL'), true);
$find = static function (string $bin): string {
if (!function_exists('proc_open')) return '';
$dirs = array_merge(explode(PATH_SEPARATOR, (string) getenv('PATH')), ['/usr/bin', '/usr/local/bin']);
foreach (array_unique(array_filter($dirs)) as $d) {
$p = rtrim($d, '/\\') . DIRECTORY_SEPARATOR . $bin;
if (@is_file($p) && @is_executable($p)) return $p;
}
return '';
};
return $tools = ['imagick' => $imagick, 'djxl' => $find('djxl'), 'cjxl' => $find('cjxl')];
}
function img_jxl_can_decode(): bool {
$t = img_jxl_tools();
return $t['imagick'] || $t['djxl'] !== '';
}
// Runs a binary with an argument list (no shell). True when it exits 0 and wrote $out.
function _img_run(string $bin, array $args, string $out): bool {
$proc = @proc_open(array_merge([$bin], $args), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($proc)) return false;
stream_get_contents($pipes[1]); stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
clearstatcache(true, $out);
return proc_close($proc) === 0 && is_file($out) && filesize($out) > 0;
}
function img_jxl_decode(string $jxl, string $png): bool {
$t = img_jxl_tools();
if ($t['djxl'] !== '' && _img_run($t['djxl'], [$jxl, $png], $png)) return true;
if ($t['imagick']) {
try {
$im = new \Imagick($jxl);
$im->setImageFormat('PNG');
return $im->writeImage($png) && is_file($png);
} catch (\Throwable $e) { return false; }
}
return false;
}
function img_jxl_encode(string $png, string $jxl): bool {
$t = img_jxl_tools();
if ($t['cjxl'] !== '' && _img_run($t['cjxl'], [$png, $jxl, '-d', IMG_JXL_DISTANCE, '-e', '7'], $jxl)) return true;
if ($t['imagick']) {
try {
$im = new \Imagick($png);
$im->setImageFormat('JXL');
$im->setImageCompressionQuality(90);
return $im->writeImage($jxl) && is_file($jxl);
} catch (\Throwable $e) { return false; }
}
return false;
}
// "foto.jxl.webp" / "foto.jxl.jpg": the fallback copies, never listed as photos.
function img_is_jxl_fallback(string $name): bool {
return (bool) preg_match('/\.jxl\.(webp|jpe?g|png)$/i', $name);
}
// Absolute path of the fallback beside $jxl_path, or '' when there is none.
function img_jxl_fallback(string $jxl_path): string {
foreach (['webp', 'jpg', 'png'] as $e) {
if (is_file($jxl_path . '.' . $e)) return $jxl_path . '.' . $e;
}
return '';
}
// Moves (or, with $to = '', deletes) the fallback that goes with an image file.
// Callers that move/delete an upload call this too, so the pair stays together.
function img_fallback_follow(string $from, string $to = ''): void {
if (!preg_match('/\.jxl$/i', $from)) return;
foreach (['webp', 'jpg', 'png'] as $e) {
if (!is_file($from . '.' . $e)) continue;
if ($to === '') @unlink($from . '.' . $e);
else @rename($from . '.' . $e, $to . '.' . $e);
}
}
function img_jxl_optimize(string $path, int $max_dim): array {
$skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why];
$fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why];
// Fallback newer than the .jxl and within the cap: nothing to do (idempotent).
$old = img_jxl_fallback($path);
if ($old !== '' && filemtime($old) >= filemtime($path)) {
$d = @getimagesize($old);
if ($d && $d[0] <= $max_dim && $d[1] <= $max_dim) return $skip('já dentro do limite');
}
if (!img_jxl_can_decode()) return $skip('JPEG XL sem descodificador no servidor (libjxl-tools)');
$dec = $path . '.dec.png';
if (!img_jxl_decode($path, $dec)) { @unlink($dec); return $fail('JPEG XL ilegível'); }
$info = @getimagesize($dec);
if (!$info) { @unlink($dec); return $fail('JPEG XL ilegível'); }
[$w, $h] = $info;
if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M');
$src = @imagecreatefrompng($dec);
if (!$src) { @unlink($dec); return $fail('JPEG XL ilegível'); }
$scale = min(1, $max_dim / max($w, $h, 1));
$nw = max(1, (int) round($w * $scale));
$nh = max(1, (int) round($h * $scale));
if ($scale < 1) {
$dst = imagecreatetruecolor($nw, $nh);
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127));
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($src);
$src = $dst;
}
// Fallback: WebP keeps transparency; without WebP in GD, JPEG.
img_fallback_follow($path);
if (function_exists('imagewebp')) {
imagesavealpha($src, true);
$ok = imagewebp($src, $path . '.webp', IMG_WEBP_QUALITY);
} else {
imageinterlace($src, true);
$ok = imagejpeg($src, $path . '.jpg', IMG_JPEG_QUALITY);
}
// Smaller JXL: re-encode from the resized pixels when there is an encoder.
$saved = 0;
if ($ok && $scale < 1 && imagepng($src, $dec)) {
$tmp = $path . '.opt.tmp';
$before = (int) filesize($path);
if (img_jxl_encode($dec, $tmp) && filesize($tmp) < $before) {
if (!@rename($tmp, $path)) { @unlink($path); @rename($tmp, $path); }
clearstatcache(true, $path);
$saved = max(0, $before - (int) filesize($path));
}
@unlink($tmp);
}
imagedestroy($src);
@unlink($dec);
if (!$ok) return $fail('falha ao gravar a versão alternativa');
// The fallback must not look older than the .jxl (idempotence check above).
@touch(img_jxl_fallback($path));
return ['status' => 'resized', 'saved' => $saved,
'reason' => ($scale < 1 ? "{$w}×{$h} → {$nw}×{$nh}, " : '') . 'versão alternativa criada'];
}
// ─── Servir imagens (JPEG XL com alternativa) ────────────────────────────────
// $rel é o caminho relativo à raiz do projeto (o que a BD guarda / o glob dá).
function _img_abs(string $rel): string {
return dirname(__DIR__) . '/' . ltrim($rel, '/');
}
// Fallback of a .jxl as a project-relative path, or '' (not JXL / no fallback).
function img_fallback_rel(string $rel): string {
if (!preg_match('/\.jxl$/i', $rel)) return '';
$fb = img_jxl_fallback(_img_abs($rel));
return $fb === '' ? '' : $rel . substr($fb, -strlen(strrchr($fb, '.')));
}
// Where only one image fits (lightbox, og:image, preload, e-mail): the
// fallback of a .jxl when there is one, else the file itself.
function img_compat_rel(string $rel): string {
return img_fallback_rel($rel) ?: $rel;
}
// <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;
}
+188
View File
@@ -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'])));
}
+25 -4
View File
@@ -29,16 +29,37 @@ function site_info(PDO $pdo, ?int $gym_id = null): array {
return $row;
}
/** Whether the home page renders section $sec for $gym (same data checks as site/index.php). */
function site_section_has_content(array $gym, string $sec): bool {
$pdo = $GLOBALS['pdo'] ?? null;
$gid = (int) $gym['id'];
if (!$pdo && $sec !== 'galeria' && $sec !== 'comunidade') return true;
return match ($sec) {
'modalidades' => (bool) site_modalidades($pdo, $gid),
'equipa' => (bool) site_team_active($pdo, $gid),
'horario' => (bool) site_horario($pdo, '', $gid),
'mensalidades' => (bool) site_memberships($pdo, $gid),
'galeria' => !empty($gym['gallery_id']) && img_list_dir('uploads/gallery/' . (int) $gym['gallery_id']),
'comunidade' => (bool) site_instagram($gym),
default => true,
};
}
/** Team members shown on the site (status «ativo»). */
function site_team_active(PDO $pdo, ?int $gym_id = null): array {
return array_values(array_filter(site_team($pdo, $gym_id), fn($d) => ($d['status'] ?? '') === 'ativo'));
}
// Top navigation (label => absolute URL): anchors on the gym's own page, only
// for the sections that gym shows, plus its CrossFit page when it has one.
// for the sections that gym shows and has content for, plus its CrossFit page when it has one.
function site_navbar(?array $gym = null): array {
$gym = $gym ?? current_gym();
$items = ['Home' => ['#home', null]];
foreach (['Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
foreach (['Galeria' => 'galeria', 'Comunidade' => 'comunidade', 'Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) {
if (!gym_section_on($gym, $sec)) continue;
// No active plans → the section isn't rendered, so no link to it either.
if ($sec === 'mensalidades' && !empty($GLOBALS['pdo']) && !site_memberships($GLOBALS['pdo'], (int) $gym['id'])) 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 = [];
+3
View File
@@ -269,9 +269,12 @@ function _trash_image_file(PDO $pdo, int $imageId, string $action): void {
if ($action === 'delete' && is_file($live)) {
if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true);
@rename($live, $trash);
img_fallback_follow($live, $trash); // foto.jxl.webp goes with foto.jxl
} elseif ($action === 'restore' && is_file($trash)) {
@rename($trash, $live);
img_fallback_follow($trash, $live);
} elseif ($action === 'purge') {
if (is_file($trash)) @unlink($trash);
img_fallback_follow($trash);
}
}
+1
View File
@@ -81,6 +81,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<?= 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/']) ?>
+2 -1
View File
@@ -16,7 +16,8 @@ $GLOBALS['_gym'] = $gym;
$info = site_info($pdo, (int) $gym['id']);
$navbar = site_navbar($gym);
$current_uri_2 = $current_uri_2 ?? '';
$GLOBALS['_view_shared'] = compact('info', 'navbar', 'current_uri_2', 'gym');
$nav_center_logo = $nav_center_logo ?? false; // index.php: big centred logo (navbar.php)
$GLOBALS['_view_shared'] = compact('info', 'navbar', 'current_uri_2', 'gym', 'nav_center_logo');
echo view('include'); // renders view('seo') inside <head>
echo view('navbar');
+401 -78
View File
@@ -359,19 +359,37 @@ section[id] {
/* ── Bootstrap Nav Overrides ───────────────────────────────────────────────── */
.nav-link {
font-weight: 700;
font-size: .875rem;
letter-spacing: .02em;
color: var(--fitme-text);
margin: 0 .6rem !important;
transition: color var(--fitme-transition);
position: relative;
}
/* ── Brand slash (template) ──────────────────────────────────────────────────
The cut of the "in" box in the FitMe in logo: top-left and bottom-right
corners sliced off. Put class="fx-slash" on any element (or add a pseudo
element to this list) instead of writing the polygon again; --slash sets the
cut size. .fx-slash-start cuts the left side only (open to the right edge). */
:root { --slash: 4px; }
.fx-slash,
.nav-link::after,
.section-title.deco h2::after {
clip-path: polygon(var(--slash) 0, 100% 0, calc(100% - var(--slash)) 100%, 0 100%);
}
.fx-slash-start {
clip-path: polygon(var(--slash) 0, 100% 0, 100% 100%, 0 100%);
}
/* Underline: a short bar with the brand slash, tucked right under the text. */
.nav-link:after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 100%;
height: 2px;
bottom: 3px;
left: var(--bs-navbar-nav-link-padding-x, .5rem);
right: var(--bs-navbar-nav-link-padding-x, .5rem);
height: 4px;
background: var(--fitme-accent);
transform: scaleX(0);
transform-origin: left;
@@ -399,8 +417,8 @@ section[id] {
text-transform: uppercase;
letter-spacing: .5px;
border: 2px solid var(--fitme-accent);
border-radius: var(--fitme-radius);
padding: 6px 14px !important;
--slash: 8px; /* shape: .fx-slash on the link (navbar.php) */
padding: 6px 16px !important;
transition: background-color .4s ease, color .4s ease, border-color .4s ease;
}
@@ -437,14 +455,7 @@ section[id] {
.gym-switch-btn { display: inline-flex; align-items: center; gap: .5rem; }
/* With a logo the button shows the gym's wordmark in its own colours on a
light face, framed by the brand colour; the name is the image's alt. The
hover face stays light too, so dark parts of the logo never vanish. */
.gym-switch-btn--logo {
--btn-face: #fff;
background: #fff;
padding: 4px 12px;
}
.gym-switch-logo {
display: block;
height: 30px;
@@ -520,6 +531,35 @@ section[id] {
.gym-switch-btn:is(:hover, :focus-visible)::before, .gym-switch-btn:is(:hover, :focus-visible)::after { animation: none; }
}
/* With a logo the button shows the gym's wordmark in its own colours straight
on the navbar (no face, like the main logo), framed by a thin brand-colour
outline; the name is the image's alt. On hover nothing fills in behind the
logo: ::before is masked down to just its ring, so only the spinning edge
shows. */
.gym-switch-btn--logo,
.gym-switch-btn--logo:is(:hover, :focus-visible) {
background: transparent;
}
.gym-switch-btn--logo {
padding: 4px 12px;
border-width: 1px;
}
.gym-switch-btn--logo::before {
background: conic-gradient(from var(--pc-a), rgba(var(--fitme-accent-rgb), 0) 0deg, rgba(var(--fitme-accent-rgb), 0) 150deg,
var(--fitme-accent) 250deg, #f4ff9c 300deg, var(--fitme-accent) 330deg,
rgba(var(--fitme-accent-rgb), 0) 360deg) border-box;
-webkit-mask: linear-gradient(#000 0 0) padding-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) padding-box exclude, linear-gradient(#000 0 0);
}
/* No glow either: blurred brand colour behind a dark-red wordmark muddies it.
A thin light halo keeps the logo legible over whatever shows through the
frosted navbar. */
.gym-switch-btn--logo::after,
.gym-switch-btn--logo:is(:hover, :focus-visible)::after { display: none; }
/* Thin dark outline, not a white halo: a white glow washed out the lime FitMe 3070 logo. */
.gym-switch-btn--logo .gym-switch-logo { filter: url(#pt-noise) drop-shadow(0 0 .6px rgba(0, 0, 0, .55)) drop-shadow(0 1px 1px rgba(0, 0, 0, .18)); }
/* ── Sticky Navbar ─────────────────────────────────────────────────────────── */
.navbar-toggler {
min-width: 44px;
@@ -531,10 +571,24 @@ section[id] {
top: 0;
width: 100%;
z-index: 1000;
-webkit-backdrop-filter: blur(14px) saturate(140%);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(180%) brightness(1.06);
backdrop-filter: blur(14px) saturate(180%) brightness(1.06);
background: var(--fitme-nav-bg);
border-bottom: 1px solid rgba(0, 0, 0, .04);
/* glass edge: a bright specular line on top, a faint inner rim below */
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .7), inset 0 -1px 0 rgba(255, 255, 255, .25);
}
/* Liquid glass (Chromium only: the only engine that runs an SVG filter in
backdrop-filter). #nav-glass (include.php) bends the page behind the bar
with smooth low-frequency displacement before the blur, so it reads as
rippled glass, not just frost; the tint is lighter so the bending shows.
-webkit-app-region is a Chromium-only property, used as the engine test;
Safari/Firefox keep the plain frosted blur above. */
@supports (-webkit-app-region: drag) {
.header-section {
backdrop-filter: url(#nav-glass) blur(6px) saturate(180%) brightness(1.06);
background: rgba(247, 248, 250, .5);
}
}
/* Bottom edge: the Mensalidades sweep in the current gym's colour (:root
@@ -546,7 +600,7 @@ section[id] {
content: '';
position: absolute;
left: 0;
right: 0;
right: var(--switch-w, 0px); /* ends where the other-gym head starts (navbar.php JS) */
bottom: -1px;
height: 2px;
pointer-events: none;
@@ -568,6 +622,159 @@ section[id] {
to { background-position: 150% 0; }
}
/* Bar layout. Desktop: the links are centred on the full bar width, the logo
sits out of flow at the left and the other-gym buttons out of flow at the
right, vertically centred on the whole header. On the home page
(.has-center-logo) the header starts taller with a big centred copy of the
logo (.navbar-brand-hero, .is-top); on scroll the bar shrinks, the big logo
fades out and the left one fades in (navbar.php toggles .is-top). */
.header-section .navbar { padding: 0 0 0 1rem; }
.navbar-brand img {
width: 150px;
height: auto;
}
.navbar-brand-hero { display: none; }
/* Match head paint, shared by the desktop bar end and the mobile menu band
(geometry per breakpoint below): everything in the OTHER gym's brand (its
vars are inline on .gym-switch): a strip in its accent on the slashed edge,
a face a shade darker so the strip reads, its logo in that gym's on-accent
ink. */
.header-section .gym-switch::before,
.header-section .gym-switch::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
}
.header-section .gym-switch::before { background: var(--fitme-accent); }
/* the button is a light plate on the head, so the logo keeps its own colours */
.header-section .gym-switch .gym-switch-btn--logo,
.header-section .gym-switch .gym-switch-btn--logo:is(:hover, :focus-visible) {
background: color-mix(in srgb, var(--fitme-white) 92%, transparent);
border-color: rgba(255, 255, 255, .6);
}
.header-section .gym-switch .gym-switch-btn--logo .gym-switch-logo { filter: url(#pt-noise); }
@media (min-width: 992px) {
.header-section .navbar {
padding: 0;
transition: padding-top .5s cubic-bezier(.4, 0, .2, 1);
}
.header-section .navbar-brand {
position: absolute;
z-index: 1;
left: 1rem;
top: 50%;
margin: 0;
transform: translate(0, -50%);
}
.header-section .navbar-brand,
.navbar-brand-hero {
transition: opacity .45s ease, visibility .45s;
}
.has-center-logo .navbar-brand-hero {
display: block;
position: absolute;
z-index: 1;
left: 50%;
top: 12px;
transform: translateX(-50%);
opacity: 0;
visibility: hidden;
}
/* capped height too: the CrossFit logo is taller and would cover the links */
.navbar-brand-hero img { width: auto; height: 52px; max-width: none; }
.header-section .navbar-collapse {
min-height: 58px;
align-items: center;
}
.header-section .gym-switch { position: absolute; } /* the match head, below */
.header-section.is-top .navbar { padding-top: 62px; } /* room for the 180px logo */
/* Match head: the right end of the bar is a block behind the other-gym
button, its left side cut on the same slash as the underlines (the bar is
the matchstick, this is the head). It is painted in the OTHER gym's brand
(navbar.php puts its vars on .gym-switch), a glimpse into that site: its
accent strip on the cut, a face a shade darker and a shadow just inside it.
::before = strip, ::after = head. */
.header-section .gym-switch {
--slash: 22px;
top: 0;
bottom: 0;
right: 0;
transform: none;
z-index: 1;
align-items: center;
padding: 0 .85rem 0 calc(var(--slash) + .85rem) !important;
}
.header-section .gym-switch::before,
.header-section .gym-switch::after {
clip-path: polygon(var(--slash) 0, 100% 0, 100% 100%, 0 100%); /* .fx-slash-start */
}
.header-section .gym-switch::before { left: -5px; }
.header-section .gym-switch::after {
background:
radial-gradient(70% 90% at 10% 50%, rgba(0, 0, 0, .28), transparent 70%),
linear-gradient(180deg, var(--fitme-accent-dark), color-mix(in srgb, var(--fitme-accent-dark) 82%, #000));
}
.header-section.is-top .navbar-brand { opacity: 0; visibility: hidden; }
.header-section.is-top .navbar-brand-hero { opacity: 1; visibility: visible; }
/* keep the centred hero content (event card) clear of the taller top bar */
.hero-section { padding-top: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.header-section .navbar, .header-section .navbar-brand, .navbar-brand-hero { transition: none; }
}
/* Mobile/tablet menu: the open collapse fills the screen below the bar, links
and gym buttons centred both ways, fading in and out. It is position:absolute
(top:100% of the .navbar), not fixed: the header's backdrop-filter makes the
header the containing block for fixed children anyway. Links shrink to their
text so the underline is only as wide as the word. */
@media (max-width: 991.98px) {
.header-section .navbar-collapse {
position: absolute;
top: 100%;
left: 0;
right: 0;
height: calc(100dvh - var(--nav-h, 60px)) !important;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1.5rem;
transition: opacity .3s ease;
}
.header-section .navbar-collapse:not(.show):not(.collapsing) { display: none; }
.header-section .navbar-collapse.collapsing { opacity: 0; transition: opacity .3s ease; }
.header-section .navbar-nav { align-items: center; gap: .25rem; }
.header-section .navbar-nav .nav-link { font-size: 1.25rem; margin: 0 !important; }
.header-section .navbar-nav .nav-link::after { left: 0; right: 0; } /* no side padding here */
/* the match head as a band across the bottom of the menu, its top edge on the slash */
.header-section .navbar-collapse { padding-bottom: 96px; }
.header-section .gym-switch {
--slash: 18px;
justify-content: center;
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
padding: calc(var(--slash) + .9rem) 1rem 1.1rem !important;
}
.header-section .gym-switch::before,
.header-section .gym-switch::after { clip-path: polygon(0 var(--slash), 100% 0, 100% 100%, 0 100%); }
.header-section .gym-switch::before { top: -5px; }
.header-section .gym-switch::after {
background:
radial-gradient(90% 80% at 50% 0, rgba(0, 0, 0, .28), transparent 70%),
linear-gradient(180deg, var(--fitme-accent-dark), color-mix(in srgb, var(--fitme-accent-dark) 82%, #000));
}
}
@media (max-width: 991.98px) and (prefers-reduced-motion: reduce) {
.header-section .navbar-collapse, .header-section .navbar-collapse.collapsing { transition: none; }
}
@media (prefers-reduced-motion: reduce) {
.header-section::before, .header-section::after { animation: none; }
}
@@ -684,9 +891,18 @@ section[id] {
-webkit-mask-image: linear-gradient(to bottom, transparent 18%, rgba(0, 0, 0, .55) 36%, #000 50%, rgba(0, 0, 0, .55) 64%, transparent 82%);
mask-image: linear-gradient(to bottom, transparent 18%, rgba(0, 0, 0, .55) 36%, #000 50%, rgba(0, 0, 0, .55) 64%, transparent 82%);
}
/* The blur fades in diagonally: lightest at the top left, full at the bottom
right (second mask layer, intersected with the band above). */
.hero-section .container::before {
-webkit-mask-image: linear-gradient(to bottom, transparent 18%, rgba(0, 0, 0, .55) 36%, #000 50%, rgba(0, 0, 0, .55) 64%, transparent 82%),
linear-gradient(135deg, rgba(0, 0, 0, .15) 15%, #000 75%);
mask-image: linear-gradient(to bottom, transparent 18%, rgba(0, 0, 0, .55) 36%, #000 50%, rgba(0, 0, 0, .55) 64%, transparent 82%),
linear-gradient(135deg, rgba(0, 0, 0, .15) 15%, #000 75%);
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-backdrop-filter: blur(22px) saturate(1.1);
backdrop-filter: blur(22px) saturate(1.1);
opacity: .8;
}
.hero-section .container::after {
background:
@@ -707,12 +923,20 @@ section[id] {
font-weight: 900;
color: #fff;
/* tight + soft shadow so the lettering separates from the frosted band */
text-shadow: 0 2px 10px rgba(0, 0, 0, .55), 0 4px 28px rgba(0, 0, 0, .5), 0 6px 64px rgba(0, 0, 0, .45);
text-shadow: 0 1px 2px rgba(0, 0, 0, .25);
line-height: 1.05;
letter-spacing: -.5px;
}
/* Subtitle (gym hero_subtitle): same h1 but a much smaller, tracked-out line
so the title leads and this reads as its strapline. */
.hero-text h1 span {
display: block;
margin-top: .55em;
font-size: .42em;
font-weight: 600;
line-height: 1.3;
letter-spacing: .12em;
color: var(--fitme-accent);
}
@@ -725,7 +949,7 @@ section[id] {
color: var(--fitme-accent);
margin-bottom: 1rem;
padding-left: .35em;
text-shadow: 0 2px 16px rgba(0, 0, 0, .5);
text-shadow: 0 1px 2px rgba(0, 0, 0, .3);
}
.hero-text .site-btn {
@@ -738,36 +962,17 @@ section[id] {
font-weight: 500;
max-width: 600px;
margin: 1.25rem auto 0;
text-shadow: 0 2px 24px rgba(0, 0, 0, .6);
text-shadow: 0 1px 2px rgba(0, 0, 0, .3);
}
.hero-cta-row {
display: flex;
flex-direction: column; /* the button, then the trial link as plain text under it */
flex-direction: column;
align-items: center;
gap: 14px;
margin-top: 2.5rem;
}
/* "Marca uma aula experimental": text only, under the main button. */
.hero-text-link {
background: none;
border: 0;
padding: 4px 8px;
color: #fff;
font: inherit;
font-weight: 300;
letter-spacing: .02em;
text-shadow: 0 1px 6px rgba(0, 0, 0, .6);
cursor: pointer;
transition: color .3s ease;
}
.hero-text-link:hover,
.hero-text-link:focus-visible {
color: var(--fitme-accent);
text-decoration: none;
}
.hero-cta-row .site-btn {
margin-top: 0;
}
@@ -850,16 +1055,18 @@ section[id] {
/* Horário de Funcionamento, nested inside Informações e contactos (not its
own section) — plain text lines, centered, matching the "less graphics"
direction the rest of that column took once the icons came out. */
.wh-text p {
margin: 0 0 8px;
color: rgba(255, 255, 255, .7);
}
/* One block per day, centred: the day on its own line, then one line per
opening period (Sábado: morning and afternoon). */
.wh-day + .wh-day { margin-top: 14px; }
.wh-text p:last-child {
margin-bottom: 0;
.wh-text p {
margin: 0;
color: rgba(255, 255, 255, .7);
line-height: 1.6;
}
.wh-text strong {
display: block;
color: var(--fitme-accent);
font-weight: 700;
}
@@ -1446,9 +1653,13 @@ section[id] {
margin-bottom: 2.5rem;
}
/* The form heading is an h3 under the section's h2 — keep the h2 look. */
h3.contact-title {
font-size: 2.25rem;
/* The FitMe app form (iframe, max 480px): centred when it stacks under the info. */
#fitme-form-site-contacto {
display: block;
margin-inline: auto;
}
@media (max-width: 991px) {
#contact-form { margin-top: 1rem; }
}
.contact-info-warp {
@@ -2113,6 +2324,9 @@ h3.contact-title {
/* ── 992px–1199px ──────────────────────────────────────────────────────────── */
@media only screen and (min-width: 992px) and (max-width: 1199px) {
/* tighter link spacing so a long menu (3070) clears the logo and match head */
.header-section .navbar-nav { --bs-navbar-nav-link-padding-x: .35rem; }
.header-section .nav-link { margin: 0 .2rem !important; font-size: .8125rem; }
.header-right .hr-box {
display: none !important;
}
@@ -2477,6 +2691,7 @@ h3.contact-title {
4.5:1), so those use the ink green; dark sections keep the bright accent. */
.feature-section .section-eyebrow,
.gallery-section .section-eyebrow,
.community-section .section-eyebrow,
.service-section .section-eyebrow,
.schedule-section .section-eyebrow,
.workhours-section .section-eyebrow {
@@ -2496,9 +2711,8 @@ h3.contact-title {
bottom: 0;
transform: translateX(-50%);
width: 64px;
height: 3px;
border-radius: 2px;
background: var(--fitme-accent);
height: 4px;
background: var(--fitme-accent); /* slashed ends: "Brand slash" template */
}
.section-subtitle {
@@ -3223,19 +3437,33 @@ h3.contact-title {
align-items: center;
gap: 16px;
width: min(640px, 92vw);
padding: 12px 14px 12px 18px;
background: rgba(11, 11, 13, .55);
border: 1.5px solid rgba(var(--fitme-accent-rgb), .55);
border-radius: 999px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
min-height: 88px;
padding: 18px 18px 18px 22px;
background: linear-gradient(90deg, rgba(11, 11, 13, .62), rgba(11, 11, 13, .42));
border: 1.5px solid var(--ev-accent); /* same colour as its button */
text-decoration: none;
text-align: left;
animation: hero-event-glow 2.8s ease-in-out infinite;
transition: transform var(--fitme-transition), box-shadow var(--fitme-transition), border-color var(--fitme-transition);
}
/* The event's own image as a frosted (blurred + darkened) backdrop inside the
pill — only when --ev-img is set; otherwise the translucent dark bg shows. */
/* Defocus gradient: ::after blurs whatever is behind it (the event image in
::before, or the hero photo through the translucent bg), masked so the left is
sharp and the right most out of focus. .hero-event itself must not get
filter/backdrop-filter/opacity/mask, or it becomes the backdrop root and the
blur stops reaching the hero photo. */
.hero-event::after {
content: '';
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
-webkit-mask-image: linear-gradient(90deg, transparent 10%, #000 90%);
mask-image: linear-gradient(90deg, transparent 10%, #000 90%);
}
/* The event's own image as a darkened backdrop inside the card, only when
--ev-img is set; otherwise the translucent dark bg shows. */
.hero-event.has-img::before {
content: '';
position: absolute;
@@ -3244,11 +3472,11 @@ h3.contact-title {
background-image: var(--ev-img);
background-size: cover;
background-position: center;
filter: blur(6px) saturate(1.15) brightness(.5);
transform: scale(1.18); /* hide the blur's transparent edges */
filter: saturate(1.15) brightness(.5);
transform: scale(1.04);
transition: transform var(--fitme-transition);
}
.hero-event.has-img:hover::before { transform: scale(1.24); }
.hero-event.has-img:hover::before { transform: scale(1.1); }
/* Keep the content above the image layer. */
.hero-event > * { position: relative; z-index: 1; }
@keyframes hero-event-glow {
@@ -3272,7 +3500,6 @@ h3.contact-title {
text-transform: uppercase;
color: var(--fitme-accent);
background: rgba(var(--fitme-accent-rgb), .14);
border-radius: 999px;
}
.hero-event-text {
flex: 1 1 auto;
@@ -3305,9 +3532,8 @@ h3.contact-title {
font-size: .92rem;
font-weight: 700;
white-space: nowrap;
color: var(--fitme-dark);
color: var(--ev-on, var(--fitme-on-accent)); /* set from the event colour (index.php) */
background: var(--ev-accent);
border-radius: 999px;
}
.hero-event-go i { transition: transform var(--fitme-transition); }
.hero-event:hover .hero-event-go i { transform: translateX(4px); }
@@ -3317,9 +3543,8 @@ h3.contact-title {
flex-wrap: wrap;
justify-content: center;
text-align: center;
border-radius: 18px;
gap: 10px;
padding: 14px;
padding: 18px 14px;
}
.hero-event-text { flex-basis: 100%; align-items: center; }
.hero-event-title, .hero-event-sub { white-space: normal; }
@@ -3836,6 +4061,33 @@ h3.contact-title {
background-image: var(--metal-layers);
background-size: var(--metal-sizes);
color: var(--fitme-ash);
position: relative;
isolation: isolate;
}
/* Slow brand-colour light drifting over the metal: three soft blobs in the
current gym's colour (and a paler tint of it) on a 200% layer whose position
eases back and forth. Sits above the section background, below the content. */
.pricing-section::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(38% 50% at 18% 28%, rgba(var(--fitme-accent-rgb), .4), transparent 70%),
radial-gradient(34% 46% at 82% 72%, color-mix(in srgb, color-mix(in srgb, var(--fitme-accent) 55%, #fff) 26%, transparent), transparent 70%),
radial-gradient(30% 42% at 55% 105%, rgba(var(--fitme-accent-rgb), .32), transparent 70%);
background-size: 200% 200%;
animation: pricing-drift 26s ease-in-out infinite alternate;
}
@keyframes pricing-drift {
0% { background-position: 0% 0%; }
50% { background-position: 100% 40%; }
100% { background-position: 30% 100%; }
}
@media (prefers-reduced-motion: reduce) {
.pricing-section::before { animation: none; }
}
.pricing-section .section-title h2 { color: #fff; }
@@ -4405,6 +4657,8 @@ h3.contact-title {
cursor: zoom-in;
}
/* .jxl images are <picture> (img_tag): let the <img> size against its parent as before. */
.gal-link picture, .navbar-brand picture, .gym-switch-btn picture { display: contents; }
.gal-link img {
width: 100%;
height: 100%;
@@ -4610,14 +4864,10 @@ h3.contact-title {
}
/* ── "In the middle of the screen" = hovered ─────────────────────────────────────
main.js adds .is-centered to the photo / card / icon that sits on the vertical
middle of the viewport (and, inside the sliding strips, on the horizontal
middle too), so scrolling past shows the same state as a hover. These mirror
main.js adds .is-centered to the trainer photo / card / icon that sits on the vertical
middle of the viewport (and, inside the Modalidades strip, on the horizontal
middle too; the gallery photos only react to a real hover), so scrolling past shows the same state as a hover. These mirror
the :hover rules above. ─────────────────────────────────────────────────── */
.gal-tile.is-centered .gal-link img { transform: scale(1.06); filter: saturate(1) brightness(1.05); }
.gal-tile.is-centered .gal-link::before { opacity: 1; }
.gal-tile.is-centered .gal-link::after { opacity: 1; animation: pc-spin 3.2s linear infinite; }
.trainer-item .trainer-pic.is-centered,
#powerRotation>div:nth-child(even) .trainer-pic.is-centered {
transform: rotate(0deg) translateY(-8px) scale(1.05);
@@ -4658,7 +4908,6 @@ h3.contact-title {
}
.price-card.is-centered::after { opacity: .75; animation: pc-spin 3.2s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.gal-tile.is-centered .gal-link::after,
.trainer-item .trainer-pic.is-centered,
.icon-box-item.is-centered .ib-icon::after,
.price-card.is-centered::before, .price-card.is-centered::after { animation: none; }
@@ -4696,9 +4945,83 @@ a:where(:not(.site-btn, .gym-switch-btn, .gal-link, .skip-link, .splide__arrow,
}
.site-btn,
.gym-switch-btn,
.crossfit-nav-link {
.crossfit-nav-link,
.hero-event-go,
.cta-btn,
.btn-primary {
background-image: var(--noise) !important;
background-size: 155px 155px !important;
background-blend-mode: overlay !important;
}
h1, h2 { filter: url(#pt-noise); }
/* Logos get the same grain as the headings (the logo gym buttons add their
drop-shadow after it, see .gym-switch-btn--logo). */
h1, h2,
.navbar-brand img,
.gym-switch-logo { filter: url(#pt-noise); }
/* ── Comunidade (Instagram posts the gym was tagged in, site/index.php) ── */
.community-section {
padding: 80px 0 70px;
background: var(--fitme-white);
}
.community-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(220px, 45%), 1fr));
gap: 12px;
margin: 0;
}
.community-post {
position: relative;
display: block;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--fitme-radius);
background: var(--fitme-surface-2);
}
.community-post img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .4s ease;
}
.community-type {
position: absolute;
top: 10px;
right: 10px;
color: #fff;
text-shadow: 0 1px 4px rgba(var(--fitme-dark-rgb), .6);
}
.community-user {
position: absolute;
inset: auto 0 0;
padding: 28px 12px 10px;
color: #fff;
font-size: .85rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: linear-gradient(transparent, rgba(var(--fitme-dark-rgb), .75));
opacity: 0;
transition: opacity var(--fitme-transition);
}
.community-post:hover img,
.community-post:focus-visible img { transform: scale(1.05); }
.community-post:hover .community-user,
.community-post:focus-visible .community-user { opacity: 1; }
/* Touch screens have no hover: keep the @user visible. */
@media (hover: none) {
.community-user { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.community-post img { transition: none; }
}
+17 -8
View File
@@ -722,7 +722,8 @@ document.addEventListener('DOMContentLoaded', function () {
function show(i) {
current = (i + links.length) % links.length;
var src = links[current].querySelector('img');
img.src = links[current].getAttribute('href');
// The tile's chosen source (JXL inside <picture> where supported), else the link (fallback).
img.src = (src && src.currentSrc) || links[current].getAttribute('href');
img.alt = src ? src.alt : '';
// Restart the fade-in for each photo.
img.style.animation = 'none'; void img.offsetWidth; img.style.animation = '';
@@ -759,16 +760,17 @@ document.addEventListener('DOMContentLoaded', function () {
});
/* ── Scroll "hover": what sits in the middle of the screen looks hovered ───────
Photos, cards and icons get .is-centered while their box crosses the middle
of the viewport (±12% of its height); inside a sliding strip (gallery,
Modalidades) they must also cross the horizontal middle. Throttled, paused
Trainer photos, cards and icons get .is-centered while their box crosses the
middle of the viewport (±12% of its height); inside a sliding strip
(Modalidades) they must also cross the horizontal middle. Gallery photos are
left out: they only light up on a real hover. Throttled, paused
while the tab is hidden, skipped with "reduce motion". The CSS mirrors the
:hover rules (style.css, "In the middle of the screen"). */
document.addEventListener('DOMContentLoaded', function () {
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var items = Array.prototype.slice.call(document.querySelectorAll(
'.gal-tile, .trainer-item .trainer-pic, .icon-box-item, .price-card')).map(function (el) {
return { el: el, strip: !!el.closest('.gal-marquee, .mod-scroll'), on: false };
'.trainer-item .trainer-pic, .icon-box-item, .price-card')).map(function (el) {
return { el: el, strip: !!el.closest('.mod-scroll'), on: false };
});
if (!items.length) return;
var last = 0;
@@ -827,15 +829,22 @@ document.addEventListener('DOMContentLoaded', function () {
if (e.pointerType === 'touch' || e.button !== 0) return;
dragging = true; moved = 0;
startX = e.clientX; startLeft = wrap.scrollLeft;
wrap.classList.add('is-dragging');
wrap.setPointerCapture(e.pointerId);
});
wrap.addEventListener('pointermove', function (e) {
if (!dragging) return;
var dx = e.clientX - startX;
moved = Math.max(moved, Math.abs(dx));
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;
+7 -2
View File
@@ -1,6 +1,7 @@
<?php
// Was Home::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.
if (($_GET['gym'] ?? '') === '') {
@@ -11,9 +12,13 @@ if (($_GET['gym'] ?? '') === '') {
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';
// 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 = [
'title' => 'CrossFit',
+8 -4
View File
@@ -105,8 +105,12 @@ $seo_desc = $event['summary'] !== ''
: trim(mb_substr(strip_tags((string) ($event['subtitle'] ?: $event['description'])), 0, 200));
$current_uri_2 = 'evento';
// An event tied to one gym (Eventos → «Mostrar em») renders in that gym's brand.
$ev_gym = !empty($event['gym_id']) ? db_gyms_get($pdo, (int) $event['gym_id']) : null;
if ($ev_gym && empty($_GET['gym'])) $_GET['gym'] = $ev_gym['slug'];
$ev_gym = $ev_gym ?? site_gym($pdo) ?? gym_fallback();
$GLOBALS['_page_meta_override'] = [
($event['title'] !== '' ? $event['title'] : 'Evento') . ' | FitMe 3070',
($event['title'] !== '' ? $event['title'] : 'Evento') . ' | ' . $ev_gym['name'],
$seo_desc,
];
$GLOBALS['_canonical_override'] = $event_url;
@@ -114,7 +118,7 @@ $GLOBALS['_og_type_override'] = 'article';
if ($hero_url !== '') $GLOBALS['_og_image_override'] = $hero_url;
// Event structured data (schema.org/Event) for rich link previews / search.
$info = site_info($pdo);
$info = site_info($pdo, (int) $ev_gym['id']);
$ld_event = [
'@context' => 'https://schema.org',
'@type' => 'Event',
@@ -130,11 +134,11 @@ if (!empty($event['event_ends_at'])) $ld_event['endDate'] = str_replace(' ',
$ld_event['location'] = $event['location'] !== ''
? ['@type' => 'Place', 'name' => $event['location']]
: ['@type' => 'Place',
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070',
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : $ev_gym['name'],
'address' => $info['adress'] ?? ''];
$ld_event['organizer'] = [
'@type' => 'Organization',
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070',
'name' => ($info['name'] ?? '') !== '' ? $info['name'] : $ev_gym['name'],
'url' => base_url(),
];
$GLOBALS['_jsonld_extra'] = $ld_event;
+30 -39
View File
@@ -24,8 +24,10 @@ $session = session();
</div>
<?php endif; ?>
<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>
<div class="row">
<div class="col-sm-6">
<div class="contact-info-warp text-center text-md-left">
<h3>Localização</h3>
<div class="contact-info">
@@ -58,10 +60,12 @@ $session = session();
</a>
</div>
</div>
</div>
<?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>
<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):
$ranges = [];
foreach ($whours as $h) {
@@ -70,48 +74,35 @@ $session = session();
}
}
?>
<p><strong><?= htmlspecialchars($key) ?>:</strong>
<?php if ($ranges): ?><?= htmlspecialchars(implode(', ', $ranges)) ?>
<?php else: ?><span class="wh-closed">Encerrado</span><?php endif; ?>
</p>
<div class="wh-day">
<strong><?= htmlspecialchars($key) ?></strong>
<?php if ($ranges): foreach ($ranges as $r): ?><p><?= htmlspecialchars($r) ?></p><?php endforeach; ?>
<?php else: ?><p class="wh-closed">Encerrado</p><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
</div>
<div class="col-lg-8">
</div>
<div class="col-lg-6">
<div id="contact-form">
<h3 class="contact-title text-center text-md-left">Envia-nos uma mensagem</h3>
<?php if (isset($_GET['enviado'])): ?>
<div class="alert alert-success" role="alert">
Mensagem enviada com sucesso! Entraremos em contacto brevemente.
</div>
<?php endif; ?>
<form class="contact-form" method="post" action="https://forms.zohopublic.eu/fitme30701/form/FormulriodeContacto/formperma/NiwsTjwWGX2sgpZIXqE5otyAn8D5nuRQjpYDuGkS2wM/htmlRecords/submit">
<input type="hidden" name="zf_redirect_url" value="<?= base_url('?enviado=1#contact-form') ?>">
<div class="row">
<div class="col-md-6">
<input type="text" required name="SingleLine" placeholder="Nome" aria-label="Nome">
</div>
<div class="col-md-6">
<input type="text" required name="PhoneNumber_countrycode" placeholder="Telemóvel" aria-label="Telemóvel">
</div>
</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>
<?php /* Formulário da app FitMe (app.fitme3070.pt), em iframe; a altura acompanha o conteúdo via postMessage. */ ?>
<div id="fitme-form-site-contacto-wrap"></div>
<script>
(function () {
var p = new URLSearchParams(location.search), q = new URLSearchParams({ embed: "1", page: location.href });
["utm_source","utm_medium","utm_campaign","utm_content","utm_term"].forEach(function (k) { if (p.get(k)) q.set(k, p.get(k)); });
var f = document.createElement("iframe");
f.src = "https://app.fitme3070.pt/f/site-contacto?" + q.toString(); f.id = "fitme-form-site-contacto"; f.title = "Envia-nos uma Mensagem";
f.style.cssText = "width:100%;max-width:480px;border:0;"; f.height = "640"; f.loading = "lazy";
document.getElementById("fitme-form-site-contacto-wrap").appendChild(f);
window.addEventListener("message", function (e) {
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";
});
})();
</script>
</div>
</div>
</div>
+1
View File
@@ -32,6 +32,7 @@
<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>
</p>
<p class="small footer-legal">Powered by <a href="https://andmore.pt" target="_blank" rel="noopener">andmore.pt</a></p>
</div>
</footer>
+6 -7
View File
@@ -6,8 +6,10 @@
// tile (without dragging) opens it full size in a <dialog> lightbox with prev /
// next, arrow keys, Esc and swipe; the repeated copies are hidden from assistive
// tech and open the same photo.
$files = glob(rtrim($gallery_path, '/') . '/*.jpg');
if (!$files || !is_array($files)) $files = [];
// Every format the admin upload accepts (functions_galerias.php), without the
// .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.
$gal_shapes = ['w', 's', 's', 's', 's', 'w', 's', 's', 's', 's', 's'];
$gal_label = $gallery_h2 ?? 'Instalações';
@@ -44,15 +46,12 @@ $gal_label = $gallery_h2 ?? 'Instalações';
<div class="gal-unit <?= $u['grid'] ? 'gal-unit--grid' : 'gal-unit--full' ?>">
<?php foreach ($u['idx'] as $gi):
$f = $files[$gi];
$src = base_url($f);
$src = base_url(img_compat_rel($f));
$alt = $gal_label . ' — FitMe 3070, foto ' . ($gi + 1); // distinct alt per photo
$dim = @getimagesize($f) ?: [0, 0];
?>
<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 src="<?= htmlspecialchars($src) ?>" alt="<?= $dup ? '' : htmlspecialchars($alt) ?>"
<?= $dim[0] ? 'width="' . (int) $dim[0] . '" height="' . (int) $dim[1] . '"' : '' ?>
<?= $gi > 2 || $dup ? 'loading="lazy"' : '' ?> decoding="async" draggable="false">
<?= img_tag($f, ['alt' => $dup ? '' : $alt, 'loading' => $gi > 2 || $dup ? 'lazy' : null, 'decoding' => 'async', 'draggable' => 'false']) ?>
</a>
</div>
<?php endforeach; ?>
+1 -1
View File
@@ -7,7 +7,7 @@
<div class="container h-100">
<div class="d-flex h-100 text-center align-items-center">
<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>
</div>
</div>
+44 -24
View File
@@ -2,27 +2,37 @@
// Per-page metadata, keyed by $current_uri_2. The canonical URL comes from
// base_url(), which matches the https + non-www redirect in .htaccess.
$page = $current_uri_2 ?? '';
$page_meta = [
'' => ['FitMe 3070 — Ginásio & CrossFit em Mira',
'Ginásio em Mira (Lagoa) com musculação, CrossFit, aulas de grupo, personal trainers, nutrição e fisioterapia. Inscreve-te já no FitMe 3070!'],
'crossfit' => ['CrossFit 3070 — Box de CrossFit em Mira | FitMe 3070',
'Box de CrossFit em Mira (Lagoa). Aulas para todos os níveis com treinadores certificados. Marca a tua aula experimental no CrossFit 3070!'],
'privacidade' => ['Política de Privacidade | FitMe 3070',
'Política de privacidade do FitMe 3070 — como recolhemos, usamos e protegemos os teus dados pessoais.'],
'termos' => ['Termos e Condições | FitMe 3070',
'Termos e condições de utilização do website do FitMe 3070.'],
];
// The gym's homepage uses the gym's own SEO title/description when set
// (Gestão → Ginásios); a new gym without them gets a generic one from its name
// and locality rather than the 3070 texts above.
// Titles ~50-60 characters, descriptions ~140-160 (what Google shows).
$gym_meta = current_gym();
if ($page === '' && ($gym_meta['seo_title'] ?? '') !== '') {
$page_meta[''] = [$gym_meta['seo_title'], ($gym_meta['seo_description'] ?? '') !== '' ? $gym_meta['seo_description'] : $page_meta[''][1]];
} elseif ($page === '' && (int) $gym_meta['id'] !== 1) {
$loc = ($gym_meta['locality'] ?? '') !== '' ? ' em ' . $gym_meta['locality'] : '';
$page_meta[''] = [$gym_meta['name'] . ' — Ginásio' . $loc,
($gym_meta['seo_description'] ?? '') !== '' ? $gym_meta['seo_description'] : 'Ginásio ' . $gym_meta['name'] . $loc . ': treino, aulas de grupo e acompanhamento.'];
$gym_name = ($gym_meta['name'] ?? '') !== '' ? $gym_meta['name'] : 'FitMe 3070';
$is_main = (int) ($gym_meta['id'] ?? 1) === 1;
$loc = ($gym_meta['locality'] ?? '') !== '' ? $gym_meta['locality'] : 'Mira';
$loc_in = (preg_match('/^(zona|rua|avenida|praia|vila)/iu', $loc) ? 'na ' : 'em ') . $loc; // «na Zona Industrial de Mira»
$page_meta = [
'' => $is_main
? ['FitMe 3070 — Ginásio e CrossFit em Mira',
'Ginásio em Mira (Lagoa) com musculação, CrossFit, aulas de grupo, personal trainers, nutrição e fisioterapia. Vê horários e mensalidades e inscreve-te!']
// Other gyms without their own texts get them from name and locality, never the 3070 ones.
: [$gym_name . ' — Ginásio ' . $loc_in,
$gym_name . ', ginásio ' . $loc_in . ': musculação, cardio e treino funcional com acompanhamento. Vê horários e mensalidades e inscreve-te já!'],
'crossfit' => $is_main
? ['CrossFit 3070 — Box de CrossFit em Mira | FitMe 3070',
'Box de CrossFit em Mira (Lagoa) com treinadores certificados e aulas para todos os níveis, do primeiro treino à competição. Vê o horário e treina connosco!']
: ['CrossFit — Box ' . $loc_in . ' | ' . $gym_name,
'Box de CrossFit do ' . $gym_name . ' ' . $loc_in . ': aulas para todos os níveis com treinadores certificados. Vê o horário e treina connosco!'],
'privacidade' => ['Política de Privacidade | ' . $gym_name,
'Política de privacidade do ' . $gym_name . ': que dados pessoais recolhemos, para quê, durante quanto tempo e como exerces os teus direitos (RGPD).'],
'termos' => ['Termos e Condições | ' . $gym_name,
'Termos e condições de utilização do site do ' . $gym_name . ': pedidos de inscrição, preços, horários, eventos e limites de responsabilidade.'],
];
// The gym's own SEO title/description (Gestão → Ginásios) win on its homepage, each on its own.
if ($page === '') {
if (($gym_meta['seo_title'] ?? '') !== '') $page_meta[''][0] = $gym_meta['seo_title'];
if (($gym_meta['seo_description'] ?? '') !== '') $page_meta[''][1] = $gym_meta['seo_description'];
}
// Favicon: the gym's own logo when it is an SVG.
$gym_logo_rel = gym_logo($gym_meta);
$favicon_rel = str_ends_with(strtolower($gym_logo_rel), '.svg') ? $gym_logo_rel : 'site/assets/img/logos/logo_black.svg';
// A dynamic page (e.g. an event landing page) can set its own title/description
// and canonical via these globals before requiring _open.php.
[$page_title, $page_desc] = $GLOBALS['_page_meta_override'] ?? $page_meta[$page] ?? $page_meta[''];
@@ -37,8 +47,8 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="<?= htmlspecialchars($page_desc) ?>">
<meta name="keywords" content="fitness, ginásio, exercício, personal trainer, crossfit, box, aveiro, mira">
<meta name="author" content="FitMe 3070">
<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="<?= htmlspecialchars($gym_name) ?>">
<meta name="theme-color" content="<?= htmlspecialchars(($gym_meta['brand_color'] ?? '') !== '' ? $gym_meta['brand_color'] : '#C2D500') ?>">
<title><?= htmlspecialchars($page_title) ?></title>
<link rel="canonical" href="<?= htmlspecialchars($canonical) ?>">
@@ -50,7 +60,9 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
<link rel="dns-prefetch" href="https://forms.zohopublic.eu">
<?php if (($current_uri_2 ?? '') === ''): ?>
<!-- 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; ?>
<!-- Fonts (Roboto self-hosted — see @font-face in style.css) -->
@@ -66,13 +78,13 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
<?= link_tag('site/assets/css/style.css') ?>
<!-- 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') ?>">
<!-- Open Graph / link previews -->
<meta property="og:url" content="<?= htmlspecialchars($canonical) ?>">
<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:title" content="<?= htmlspecialchars($page_title) ?>">
<meta property="og:description" content="<?= htmlspecialchars($page_desc) ?>">
@@ -104,5 +116,13 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
<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>
+45 -9
View File
@@ -9,12 +9,21 @@ else
}
$logo = base_url($logo_rel);
$logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
$logo_alt = 'Logo ' . (current_gym()['name'] ?? 'FitMe 3070');
$logo_html = img_fallback_rel($logo_rel) !== ''
? img_tag($logo_rel, ['loading' => 'eager', 'decoding' => 'async', 'alt' => $logo_alt])
: '<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">
<nav class="navbar navbar-expand-lg navbar-light" style="padding-left:1rem; padding-top: 0; padding-bottom: 0;padding-right: 0; ">
<a class="navbar-brand" href="<?= htmlspecialchars(gym_url(current_gym())) ?>">
<img loading="eager" decoding="async" alt="Logo <?= htmlspecialchars(current_gym()['name'] ?? 'FitMe 3070') ?>" src="<?= htmlspecialchars($logo) ?>" <?= $logo_dims ?> style="width: 150px; height: auto;">
</a>
<header class="header-section lazy<?= $center_logo ? ' has-center-logo is-top' : '' ?>">
<nav class="navbar navbar-expand-lg navbar-light">
<a class="navbar-brand" href="<?= htmlspecialchars(gym_url(current_gym())) ?>"><?= $logo_html ?></a>
<?php if ($center_logo): ?>
<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>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mx-auto ">
@@ -33,7 +42,7 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
?>
<li class="nav-item <?= $is_active ? 'active' : '' ?> main-menu-li" >
<?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 ?>
</a>
<?php elseif ($is_anchor): ?>
@@ -57,13 +66,17 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
if ((int) $g['id'] !== (int) current_gym()['id']) $other_gyms[] = $g;
}
}
if ($other_gyms): ?>
<div class="gym-switch d-flex flex-wrap gap-2 ms-lg-auto py-2 py-lg-0 pe-lg-3">
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 class="gym-switch-logo" src="<?= htmlspecialchars(base_url($g_logo)) ?>" alt="<?= htmlspecialchars($g['name']) ?>" decoding="async">
<?= img_tag($g_logo, ['class' => 'gym-switch-logo', 'alt' => $g['name'], 'decoding' => 'async', 'width' => null]) ?>
<?php else: ?>
<span><?= htmlspecialchars($g['name']) ?></span>
<?php endif; ?>
@@ -85,6 +98,29 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
// querySelector('#equipa') returns null, `sections` stays empty, and neither
// the smooth-scroll nor the scroll-spy (active nav link) ever bind.
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 ──────────────────────────────────────── */
function smoothScrollTo(targetEl, duration) {
var start = window.scrollY;
+2 -2
View File
@@ -7,8 +7,8 @@ $ld = [
'@type' => 'ExerciseGym',
'name' => $ld_gym['name'] !== '' ? $ld_gym['name'] : (($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070'),
'url' => gym_url($ld_gym),
'image' => base_url(($ld_gym['hero_image'] ?? '') !== '' ? $ld_gym['hero_image'] : 'site/assets/img/home.jpg'),
'logo' => base_url('site/assets/img/logos/logo_black.svg'),
'image' => base_url(img_compat_rel(($ld_gym['hero_image'] ?? '') !== '' ? $ld_gym['hero_image'] : 'site/assets/img/home.jpg')),
'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['email'])) $ld['email'] = $info['email'];
+50 -6
View File
@@ -2,6 +2,7 @@
// One-page site: Home + Equipa + Modalidades + Horário + Contactos.
// CrossFit is a separate page (site/crossfit.php).
$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
// single published gym that is a 301 to it (query string kept, e.g.
@@ -27,7 +28,7 @@ require __DIR__ . '/_open.php'; // resolves $gym (404 if unknown / not visible
$gid = (int) $gym['id'];
$on = fn(string $key) => gym_section_on($gym, $key);
$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) : [];
$horario = $on('horario') ? site_horario($pdo, '', $gid) : [];
$whours = $on('workhours') ? site_work_hours($pdo, $gid) : [];
@@ -36,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
$memberships = $on('mensalidades') ? site_memberships($pdo, $gid) : [];
$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 section ────────────────────────────────────────────────────────────────
@@ -51,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;
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="hero-text">
<?php /* Eventos a decorrer — destacados no topo do header. Cada um liga
@@ -60,10 +62,12 @@ $hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img
<div class="hero-events">
<?php foreach ($events as $ev):
$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'])) : '';
?>
<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-text">
<strong class="hero-event-title"><?= htmlspecialchars($ev['title']) ?></strong>
@@ -77,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>
<div class="hero-cta-row">
<a href="?assunto=insc#contactos" class="site-btn">Inscreve-te Já!</a>
<button type="button" class="hero-text-link" data-bs-toggle="modal" data-bs-target="#trialModal">Marca uma aula experimental</button>
</div>
</div>
</div>
@@ -117,15 +120,56 @@ if ($offer):
<?php
// ── Galeria (Instalações) ───────────────────────────────────────────────────────
$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):
$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'; ?>
</section>
<?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
// ── 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

+11
View File
@@ -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') ?>">
</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">
<label class="form-label">Subtítulo</label>
<input class="form-control" name="subtitle" maxlength="255" value="<?= htmlspecialchars($data['subtitle'] ?? '') ?>">
+1
View File
@@ -24,6 +24,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'cta_label' => trim($_POST['cta_label'] ?? ''),
'cta_url' => trim($_POST['cta_url'] ?? ''),
'show_lead_form' => isset($_POST['show_lead_form']) ? 1 : 0,
'gym_id' => (int) ($_POST['gym_id'] ?? 0) ?: null,
'hero_image' => '',
];
+1
View File
@@ -48,6 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_event'])) {
'cta_label' => trim($_POST['cta_label'] ?? ''),
'cta_url' => trim($_POST['cta_url'] ?? ''),
'show_lead_form' => isset($_POST['show_lead_form']) ? 1 : 0,
'gym_id' => (int) ($_POST['gym_id'] ?? 0) ?: null,
'hero_image' => $item['hero_image'],
];
+38 -11
View File
@@ -25,12 +25,29 @@ if (isset($_POST['rename'])) {
}
}
// Upload image
if (isset($_FILES['image']) && $_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE) {
// Upload images (several at once: images[])
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload'])) {
csrf_check();
$result = db_gallery_image_upload($pdo, $id, $_FILES['image']);
if ($result) session()->setFlashdata('success', 'Imagem carregada.');
else session()->setFlashdata('error', 'Falha no upload. Verifique o formato.');
$files = [];
foreach ((array) ($_FILES['images']['name'] ?? []) as $i => $n) {
if ($_FILES['images']['error'][$i] === UPLOAD_ERR_NO_FILE) continue;
$files[] = ['name' => $n, 'type' => $_FILES['images']['type'][$i], 'tmp_name' => $_FILES['images']['tmp_name'][$i],
'error' => $_FILES['images']['error'][$i], 'size' => $_FILES['images']['size'][$i]];
}
$ok = 0; $failed = [];
foreach ($files as $file) {
if (db_gallery_image_upload($pdo, $id, $file, $why)) $ok++;
else $failed[] = $file['name'] . ' (' . $why . ')';
}
if ($ok) session()->setFlashdata('success', $ok === 1 ? 'Imagem carregada.' : "$ok imagens carregadas.");
if ($failed) session()->setFlashdata($ok ? 'warning' : 'error', 'Não carregadas: ' . implode(', ', $failed) . '.');
if (!$files) session()->setFlashdata('error', 'Escolha pelo menos uma imagem.');
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
exit;
}
// Over post_max_size PHP drops the whole body ($_POST and $_FILES empty), so say so instead of doing nothing.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$_POST && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
session()->setFlashdata('error', 'O envio é maior do que o servidor aceita (' . ini_get('post_max_size') . ' no total). Carregue menos imagens de cada vez.');
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
exit;
}
@@ -78,14 +95,14 @@ include __DIR__ . '/../../../includes/header.php';
</div></div>
<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">
<?= csrf_field() ?>
<div class="mb-3">
<input type="file" class="form-control" name="image" accept=".jpg,.jpeg,.png,.gif,.webp">
<div class="form-text">JPG, PNG, GIF ou WebP.</div>
<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">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>
<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>
</div></div>
</div>
@@ -110,9 +127,19 @@ include __DIR__ . '/../../../includes/header.php';
<?php else: ?>
<div class="row g-2">
<?php foreach ($images as $img): ?>
<?php $rel = 'uploads/gallery/' . $id . '/' . $img['i_file_name']; ?>
<div class="col-6 col-lg-4 position-relative">
<img src="<?= base_url('uploads/gallery/' . $id . '/' . $img['i_file_name']) ?>"
alt="" class="img-thumbnail w-100" style="height:140px;object-fit:cover;" loading="lazy">
<?php if (str_ends_with(strtolower($rel), '.jxl') && img_fallback_rel($rel) === ''): ?>
<?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.')">
<?= csrf_field() ?>
<button class="btn btn-sm btn-danger rounded-circle" name="del_img" value="<?= (int) $img['i_id'] ?>" aria-label="Remover imagem"
+1 -1
View File
@@ -43,7 +43,7 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
<div class="col-md-6">
<label class="form-label" for="hero_file">Imagem do header</label>
<?php if (($data['hero_image'] ?? '') !== ''): ?>
<div class="mb-2"><img src="<?= htmlspecialchars(base_url($data['hero_image'])) ?>" alt="" class="rounded border" style="max-width:100%;max-height:110px;object-fit:cover"></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">
+176
View File
@@ -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';