77 Commits
Author SHA1 Message Date
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 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 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 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
andre bab6eb87bf feat(site): slow brand-colour sweep along the bottom of the navbar 2026-10-26 09:54:32 +00:00
andre 3ffcf0fe38 fix(gyms): migration adding gyms.logo where the edited brand-colour migration had already run 2026-10-26 09:48:53 +00:00
andre 9c28be314b feat(gyms): current logo thumbnail next to the upload input; updated help text 2026-10-26 09:46:13 +00:00
andre d445ba64b9 feat(gyms): show gym logos in navbar buttons and brand; accept JPEG XL uploads 2026-10-26 09:35:46 +00:00
andreandClaude Sonnet 5.5 a85bec6851 fix(gyms): saving no longer fails before the brand-colour migration; upload header image and logo instead of typing paths
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:12:25 +01:00
andreandClaude Sonnet 5.5 965a123834 style(site): softer, denser h1 shadow (more blur and opacity)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:07:55 +01:00
andreandClaude Sonnet 5.5 68ac78097a style(site): finer grain on the hero frost
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:07:21 +01:00
andreandClaude Sonnet 5.5 3d30f09bb3 style(site): green frost tint on the hero band, discreet text shadow on the h1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:07:01 +01:00
andreandClaude Sonnet 5.5 938347779c style(site): hero blur as a horizontal frosted band (blur 100->0 outward, grain veil)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:06:17 +01:00
andreandClaude Sonnet 5.5 b2b736734e style(site): gradient blur behind the hero text, fading outward
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:05:02 +01:00
andreandClaude Sonnet 5.5 a2219ebd63 style(site): apply tuned grain from the lab (freq 1.98, 1 octave, overlay on buttons, 155px tile)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:01:18 +01:00
andreandClaude Sonnet 5.5 9e3eee227b chore(dev): lab - label the grain sliders as opacity
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:59:49 +01:00
andreandClaude Sonnet 5.5 72cf3dad98 feat(dev): lab - full grain controls (type, frequency, octaves, seed, contrast, softness, tile, blend, mono) + generated CSS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:57:21 +01:00
andreandClaude Sonnet 5.5 e5f4fd8fc9 feat(dev): style lab page with sliders for the visual effects (dev only)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:55:26 +01:00
andre 18676ad6d0 feat(icons): load Bootstrap Icons and Font Awesome from the shared platform-tools packs
Register the icons packs in platform_connect, add fit_icon_assets() with local
fallback, and add a sysadmin 'Pacotes de icones' link to platform?tool=icons.
2026-10-13 18:55:12 +01:00
andreandClaude Sonnet 5.5 781a3a324e style(site): subtler noise (buttons slightly, headings 50%)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:52:15 +01:00
andreandClaude Sonnet 5.5 18556ca8b9 style(site): noise texture on buttons and h1/h2 lettering
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:51:17 +01:00
andreandClaude Sonnet 5.5 3d2a696162 fix(site): restore CSS lost in 6c14e64; gallery click-and-drag; no link underline + colour/glow hover; brand-colour CrossFit pill; lighter trial link; no gloss on team photos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:49:53 +01:00
andreandClaude Sonnet 5.5 6c14e644b1 feat(site): auto-scrolling gallery strip (full-height + 2x2), centre-of-screen hover state, trial link as text under the hero button
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:44:31 +01:00
andreandClaude Sonnet 5.5 99c28f04b4 style(site): gallery tint is a brand gradient fading to transparent
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:03 +01:00
andreandClaude Sonnet 5.5 4b6329f529 style(site): YOURFIT icons keep their aspect ratio (wide box), black until hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:40:27 +01:00
andreandClaude Sonnet 5.5 356554db42 style(site): spinning brand edge + glow on open detail cards (team, Modalidades)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:39:24 +01:00
andreandClaude Sonnet 5.5 b884749a4e style(site): photos 10% desaturated with a discreet brand tint until hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:38:47 +01:00
andreandClaude Sonnet 5.5 f9ec3644c8 style(site): spinning-edge hover on Modalidades icon boxes and gallery tiles
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:37:59 +01:00
andreandClaude Sonnet 5.5 da90c5c681 style(site): square team photo frames (no border radius)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:35:43 +01:00
andreandClaude Sonnet 5.5 9017c849f6 style(site): team photo frames use the spinning-edge hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:35:16 +01:00
andreandClaude Sonnet 5.5 83398eb7f5 style(site): slower button lift transition
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:34:21 +01:00
andreandClaude Sonnet 5.5 9588f902c6 style(site): button hover like the Mensalidades cards (spinning brand edge, dark face, white text)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:33:27 +01:00
andreandClaude Sonnet 5.5 8e889adeb9 revert(site): drop the 3D neon button/photo-frame hover effect
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:31:34 +01:00
andreandClaude Sonnet 5.5 f28b2b8482 style(site): lighting model for the 3D neon buttons (AO, graded walls, bloom, shaded face)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:29:34 +01:00
andreandClaude Sonnet 5.5 4b99991c11 style(site): neon extrusion on the team photo frames
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:26:43 +01:00
andreandClaude Sonnet 5.5 db28427c1e style(site): single neon blink after a moment of hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:24:57 +01:00
andreandClaude Sonnet 5.5 d40f6d8b2d style(site): neon flicker on button hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:24:22 +01:00
andreandClaude Sonnet 5.5 348e7d6848 style(site): flatter wall gradient, softer box-shadow glow on buttons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:24:01 +01:00
andreandClaude Sonnet 5.5 e52709b926 style(site): stronger neon on button hover (inner glow, halo, neon lettering)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:21:44 +01:00
andreandClaude Sonnet 5.5 7bf2a8542e style(site): angled 3D button (bottom + right walls), slower colour fade on hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:20:01 +01:00
andreandClaude Sonnet 5.5 acac3a191a style(site): pushable 3D neon buttons (lifting face + ::before wall, spring curves)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:18:17 +01:00
andreandClaude Sonnet 5.5 251644c393 style(site): button hover wall 4px with graded sides and lit bevel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:13:20 +01:00
andreandClaude Sonnet 5.5 2a4596e7e3 style(site): neon lives in the extruded sides, plain face (no double outline)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:12:35 +01:00
andreandClaude Sonnet 5.5 9a42c91471 style(site): button hover as one continuous extruded wall, no ghost copy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:11:47 +01:00
andreandClaude Sonnet 5.5 2a66f74275 style(site): neon outline on button hover face
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:11:06 +01:00
andreandClaude Sonnet 5.5 a94287e3d6 style(site): near-black button hover keeping 10% brand colour; no underline on buttons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:10:09 +01:00
andreandClaude Sonnet 5.5 b103aeae58 style(site): white text on a deeper brand colour on button hover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:06:43 +01:00
andreandClaude Sonnet 5.5 8ede31ce3a style(site): subtler 3D neon button hover (3px depth)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:06:18 +01:00
andreandClaude Sonnet 5.5 58d5199883 feat(gyms): gym logo field; other-gym navbar button uses that gym's brand colour and logo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 17:45:17 +01:00
andreandClaude Sonnet 5.5 32f8d7a613 feat(branding): re-theme Bootstrap to the brand colour; 3D neon button hover; hide Mensalidades link without plans
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 17:16:50 +01:00
andreandClaude Sonnet 5.5 4b2823d1e5 feat(gyms): per-gym brand colour; CrossFit pill follows it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 17:08:30 +01:00
andreandClaude Sonnet 5.5 716eec9f6d feat(site): root opens the main gym; other gyms as navbar buttons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 17:06:59 +01:00
andreandClaude Sonnet 5.5 aa55d44406 feat(sidebar): fleet sidebar standard (header row, user in sidebar, collapsible groups, mini mode, no-flash transitions)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 16:56:46 +01:00
andreandClaude Sonnet 5.5 b73c23bf55 feat(dev): environment ribbon and one-click dev login on the public site and auth pages
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 16:56:40 +01:00
andreandClaude Sonnet 5.5 5f64ae57db 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:10:58 +01:00
58 changed files with 3447 additions and 742 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
+5
View File
@@ -1,6 +1,11 @@
# Disable directory listing
Options -Indexes
# JPEG XL uploads (gym header/logo): older mime.types don't know the extension.
<IfModule mod_mime.c>
AddType image/jxl .jxl
</IfModule>
# Block direct HTTP access to sensitive files
<FilesMatch "^\.(env|env\..*|git.*|htaccess|htpasswd)$">
Require all denied
+2 -2
View File
@@ -9,9 +9,9 @@ mas ainda sem versão no nome das pastas dos ficheiros hand-vendored.
| Biblioteca | Versão | Caminho | Usado em |
|------------------------|--------|----------------------------------------|----------------------|
| Bootstrap | 5.3.3 | `assets/vendor/bootstrap5/` | Site público + backoffice (unificado 25/09) |
| Bootstrap Icons | — | `assets/vendor/bootstrap-icons/` | Backoffice |
| Bootstrap Icons | — | `assets/vendor/bootstrap-icons/` | Backoffice (agora carregado do pacote partilhado `bootstrap-icons` 1.13.1; esta cópia só como recurso) |
| jQuery | 3.7.1 | `assets/vendor/jquery/jquery-3.7.1.min.js` | Site público + backoffice (deduplicado 25/09) |
| Font Awesome Free | 5.13.1 | `site/assets/fonts/fontawesome/` | Site público (~18 MB, todo o pacote) |
| Font Awesome Free | 5.13.1 | `site/assets/fonts/fontawesome/` | Site público: agora FA 6.7.2 do pacote partilhado `fontawesome`; esta cópia (~18 MB) só como recurso, sem uso se o submódulo tiver `functions_icons.php` |
| PHPMailer | 7.1.1 | `vendor/PHPMailer/` | Envio de email (`config/functions_mail.php`, partilhado) |
`assets/vendor/bootstrap/` (a cópia 4.3.1) ficou sem nenhuma referência depois da migração —
-139
View File
@@ -1,139 +0,0 @@
# fitme3070 — architecture
Backoffice + public site for a gym (ginásio) chain, plain PHP 8.4, no framework. This
file exists because the pattern below was inherited from a CodeIgniter 4 port and had
never been written down as a decision (audit 2026-09-24, item 6.1) — it describes what
the codebase actually does, kept deliberately, not a design proposal for a rewrite.
## Request lifecycle
**Every page is its own controller-and-view combined.** A page in `views/admin/*.php`
or `site/*.php` does, in order: bootstrap (`config/db.php` + `config/functions.php`),
an auth guard, read `$_POST`/`$_GET` and call model functions, then fall through into
its own HTML. There is no separate template layer and no front controller — 71+ PHP
files are directly reachable by URL (docroot is the repo root; `.htaccess` denies
partials, `shared/platform-tools/*.php`, and `*.md`, but doesn't allowlist).
**This is a known, accepted gap, not an oversight** — see "What's not done" below.
Introducing a real front controller/router for the app's own pages would touch most of
`views/`, `.htaccess`'s friendly-URL block, and every hardcoded `base_url('views/...')`
call; it's a genuine rewrite, tracked separately, not bundled into this doc.
## Data layer: `config/functions_*.php` as models
SQL lives in `config/functions_*.php`, one file per domain (`functions_team.php`,
`functions_galerias.php`, `functions_gyms.php`, `functions_memberships.php`, …) plus
`functions_db.php` for cross-cutting `users` queries. Functions are named
`db_<noun>_<verb>()` (`db_gyms_create`, `db_team_update`, `db_gallery_list`). **Views
never write SQL directly** — if you're adding a query, it goes in the matching
`functions_*.php`, not inline in the page. (Audit 6.3, closed 2026-09-25 — `grep -rl
'\$pdo->\(query\|prepare\|exec\)' views/` should always return nothing.)
`config/functions.php` is the loader: every page requires `config/db.php` (opens
`$pdo`, starts the session, connects to platform-tools) then
`config/functions.php` (requires every `functions_*.php` in order). Read that file's
own header comment before adding a new one — order matters for a few (`auth.php`
before anything that calls `require_login()`, etc.).
## Auth
Login/session/roles run on `shared/platform-tools/functions_auth.php` (`'auth' =>
'shared'` in `config/db.php`, migrated 2026-09-25 — audit 1.3). `config/auth.php` keeps
the app's own guard names (`require_login()`, `require_admin()`, `require_staff()`,
`require_sysadmin()`, `is_admin()`, etc.) as a thin layer over the shared primitives, so
none of the ~30 call sites in `views/` had to change. Role ladder:
`sysadmin`(100) > `admin`(80) > `staff`(40) > `client`(10, portal-only). "Manter sessão
iniciada" is the shared `auth_remember_*` module.
The login-attempt throttle (`config/functions_throttle.php`) stays the app's own,
deliberately not the shared one — see the comment in `config/db.php` for why (avoids
double-counting the same form with two independent throttles). A throttled login feeds
`platform_ip_record_offence()`, so repeat offenders get fleet-wide IP-banned.
## Soft delete / trash
Deletes are soft (`deleted_at`/`deleted_by`) via `trash_delete()` in
`config/functions_trash.php`. Gestão → Lixeira lists everything deleted across entity
types and only allows permanent purge after 30 days (enforced in the `DELETE`'s
`WHERE`, not just hidden in the UI). Adding a new deletable entity means adding it to
`trash_types()` in that file.
## CSRF
`csrf_check()` (`config/functions_csrf.php`) refuses any non-POST request outright —
state changes must be POST forms carrying `csrf_field()`'s token. There is no PRG
(Post-Redirect-Get) convention beyond that; several admin pages still re-render the
form directly on POST rather than redirecting (audit flags this as inconsistent, not
yet unified — tracked separately, low priority).
## Audit log
Models call `audit_row()` before a write and `audit_change()` after (diffs
before/after automatically); ad-hoc events elsewhere use `audit('entity.verb', [...])`.
Both come from `shared/platform-tools` via `config/functions_audit.php`. Read via
Sistema → Auditoria (`platform.php?tool=audit`).
## Multi-gym
Since 2026-09-25, one install serves several physical gyms: the `gyms` table, one URL
folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the
backoffice has an "a editar" gym switcher (`admin_gym_id()` /
`admin_gym()` in `config/functions_gyms.php`). `/` and `/home` 301 to the default gym.
An unpublished gym 404s publicly but previews for staff.
## platform-tools
`shared/platform-tools` is a **git submodule** (pinned `branch = main` in
`.gitmodules`), not vendored — see `PLATFORM_TOOLS.md` for why it went vendored→submodule→
back, and don't repeat that mistake without reading it first. `config/db.php` wires it
via `platform_connect()`: shared auth (above), fleet IP-ban guard (runs on every
request), the sysadmin router (`platform.php` / `api/platform.php` — Backups,
Actualizações, Auditoria, IP bans, Geo, Origem das visitas, all one front controller,
just for *those* pages, not the app's own).
## Deploy
Push to `master` → Gitea webhook → `api/platform.php?endpoint=auto_update` → the VPS
pulls, syncs the submodule, applies pending `database/migration_*.sql` files, done in
seconds. No staging branch; the VPS checkout **is** what's live. `deploy.sh` (on the
VPS, not in this repo) is the manual fallback. See `PLATFORM_TOOLS.md`.
## Dependencies
`composer.json`/`composer.lock` are real (`composer install` works, `composer
audit`/`outdated` are meaningful) — added 2026-09-25, matching the fotografia
platform's setup. `vendor/PHPMailer/` is the one hand-vendored exception, gitignore-
allowlisted: `shared/platform-tools/functions_mail.php` hard-codes that exact path
fleet-wide, so Composer's own `vendor/phpmailer/phpmailer/` exists in parallel but
isn't what actually gets `require_once`'d. See `ASSETS.md` for frontend asset versions.
Both the public site and the backoffice load Bootstrap 5.3.3 (unified 2026-09-25 — the
public site ran 4.3.1 before that); jQuery is one shared copy, not duplicated.
## What's not done (tracked, not silently missing)
The full audit and its live status live in a separate report, not in this repo
(`CLAUDE_GITEA/audits/fitme3070-auditoria-2026-09-24.html` on the maintainer's
machine). As of 2026-09-25: no real front controller (6.2, above), no central listing
registry, no DataTables on backoffice listings, no `srcset` (deliberate — uploads are
already close to their CSS display size, see the audit item), `views/admin/galerias/`
still has separate create/edit (deliberately — they're not actually a duplicated pair,
see the audit item). None of these are urgent; none should be "fixed" as a side effect
of an unrelated change — each is its own piece of work.
## Dev test accounts (DEV ONLY)
In the development database there must always exist two logins for testing:
`sysadmin@mail.pt` (role sysadmin) and `admin@mail.pt` (role admin), password `123`.
Ensure/repair them with `php scripts/dev_accounts.php` (CLI only, idempotent upsert with
`password_hash`, active flags set). The script fails closed: it refuses unless `.env` has
`APP_ENV=dev` AND `DB_HOST` is `192.168.1.3:3181` or localhost. It must never be run, deployed
or adapted for production, and these accounts must never exist in a production database.
## Brand feed (2026-10-09)
- `api/brand.php` is a one-line stub to `shared/platform-tools/api/brand.php` (public JSON: name, logos, colours; README "Brand feed"). Declared in `platform_connect(['brand' => ...])` in `config/db.php`; colours are the `:root` tokens (`--fitme-accent` #c2d500, `--fitme-dark` #0b0b0d, `--fitme-accent-dark` #a6b600). `logo_light` = light-coloured logo (`logo_crossfit_white.svg`), `logo_dark` = `logo_crossfit.svg`.
- `site_logo_rel()` (config/functions.php) resolves logos for both the feed and `load_email_template()`: setting file in `uploads/public/logo/` (convention), then legacy `assets/img/logo/`, then the static logo. `views/sysadmin/system_settings.php` now uploads logos to `uploads/public/logo/` (dir created on demand, 0755; `uploads/.htaccess` blocks script execution); old files in `assets/img/logo/` still resolve as fallback and in the settings preview. Brand block in `config/db.php` already prefers the setting file via `site_logo_rel()`, else the static SVGs.
## Dev toolbar partilhada (2026-10-09)
- DEV ONLY, do platform-tools (`functions_dev.php`, README "Dev tooling"; produção segue `main`, que não tem o ficheiro: tudo degrada para "não é dev").
- Ficheiros: `config/dev_guard.php` (shim), `includes/dev_toolbar.php` (incluído no fim de `includes/footer.php`), `dev_login.php` (na RAIZ: o site não tem `public/`; `.htaccess` tem a regra `^dev_login/?$` com `-f`), `scripts/dev_accounts.php` (`platform_dev_accounts_run`).
- Contas `admin@mail.pt` / `sysadmin@mail.pt` (pw `123`). O login usa `auth_login()` + `fit_stamp_login_ua()` como o login real; CSRF do site = campo `_csrf` / `$_SESSION['csrf_token']` (verificação sem `exit`).
+351
View File
@@ -0,0 +1,351 @@
/* Backoffice FitMe 3070: tokens da marca, casca (barra lateral, topbar, gaveta
em telemóvel) e acertos dos componentes. Carregado por includes/header.php
depois do Bootstrap. O Bootstrap nunca se edita: o que muda faz-se com
variáveis e com classes próprias (prefixo bo-). */
:root {
--bo-accent: #C2D500;
--bo-accent-rgb: 194,213,0;
--bo-dark: #0a0a0a;
--bo-dark-2: #151515;
--bo-surface: #eef0f4;
--bo-sidebar-w: 264px;
--bo-sidebar-w-mini: 80px;
--bo-topbar-h: 56px;
--bo-ease: .2s ease;
/* Texto da barra lateral (escura nos dois temas) */
--bo-nav-text: rgba(255,255,255,.62);
--bo-nav-text-muted: rgba(255,255,255,.5);
--bo-nav-line: rgba(255,255,255,.1);
--bo-nav-strong: #fff;
}
[data-bs-theme="dark"] { --bo-surface: #121416; }
body { background: var(--bo-surface); font-size: .925rem; }
a { color: var(--bs-emphasis-color); }
main { margin-left: var(--bo-sidebar-w); padding-top: calc(var(--bo-topbar-h) + 12px); }
/* ── Barra lateral ───────────────────────────────────────────────────────── */
/* Sem overflow aqui: o menu do utilizador sai da barra no modo mini.
Quem rola é só .bo-sidebar__nav; cabeçalho e utilizador ficam fixos. */
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 100;
width: var(--bo-sidebar-w);
display: flex;
flex-direction: column;
background: var(--bo-dark);
}
/* Cabeçalho numa linha: [☰] [marca] [↗ site público] */
.bo-sidebar__head {
flex: none;
display: flex;
align-items: center;
gap: .5rem;
height: var(--bo-topbar-h);
padding: 0 .75rem;
border-bottom: 1px solid var(--bo-nav-line);
}
.bo-sidebar__toggle,
.bo-sidebar__site {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
min-height: 40px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--bo-nav-text);
font-size: 1.25rem;
line-height: 1;
text-decoration: none;
transition: background var(--bo-ease), color var(--bo-ease);
}
.bo-sidebar__site { font-size: 1rem; }
.bo-sidebar__toggle:hover,
.bo-sidebar__site:hover { background: rgba(255,255,255,.08); color: var(--bo-nav-strong); }
.bo-sidebar__brand {
flex: 1;
min-width: 0;
color: var(--bo-nav-strong);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .5px;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-sidebar__brand i { color: var(--bo-accent); }
/* Utilizador logo abaixo do cabeçalho */
.bo-sidebar__user {
flex: none;
padding: .6rem .75rem;
border-bottom: 1px solid var(--bo-nav-line);
}
.bo-user-menu {
width: 100%;
display: flex;
align-items: center;
gap: .6rem;
padding: .35rem .45rem;
border: 1px solid var(--bo-nav-line);
border-radius: 8px;
background: transparent;
color: var(--bo-nav-strong);
text-align: left;
}
.bo-user-menu:hover,
.bo-user-menu.show { background: rgba(255,255,255,.08); color: var(--bo-nav-strong); }
.bo-user-menu::after { margin-left: auto; flex: none; }
.bo-user-menu__avatar {
flex: none;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bo-accent);
color: var(--bo-dark);
font-size: .8rem;
font-weight: 700;
}
.bo-user-menu__text { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; line-height: 1.25; }
.bo-user-menu__name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; font-weight: 600; }
.bo-user-menu__text .badge { font-size: .62rem; font-weight: 600; }
.bo-sidebar__user .dropdown-menu { min-width: 14rem; z-index: 1110; }
/* Lista de navegação: a única parte que rola */
.bo-sidebar__nav {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: .5rem 0 1rem;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar .bo-nav-item {
position: relative;
display: flex;
align-items: center;
gap: .6rem;
min-height: 40px;
padding: .5rem 1.25rem;
border-left: 3px solid transparent;
color: var(--bo-nav-text);
font-size: .875rem;
transition: background var(--bo-ease), color var(--bo-ease), border-color var(--bo-ease);
}
.sidebar .bo-nav-item i { flex: none; width: 1.25rem; text-align: center; font-size: 1.05rem; }
.sidebar .bo-nav-item:hover { color: var(--bo-nav-strong); background: rgba(255,255,255,.06); border-left-color: rgba(255,255,255,.2); }
.sidebar .bo-nav-item.active { color: var(--bo-nav-strong); background: rgba(var(--bo-accent-rgb),.12); border-left-color: var(--bo-accent); }
.sidebar .bo-nav-item.active i { color: var(--bo-accent); }
.sidebar .bo-nav-item--primary i { font-size: 1.25rem; }
/* Indentação = cadeia de dados: filhos de um grupo, e de um subgrupo. */
.sidebar .bo-nav-item--sub { padding-left: 2.3rem; font-size: .84rem; }
.sidebar .bo-nav-item--sub2 { padding-left: 3.2rem; font-size: .84rem; }
.bo-nav-item__label { min-width: 0; }
.sidebar .bo-nav-item:focus-visible,
.bo-nav-group__label:focus-visible,
.bo-sidebar__toggle:focus-visible,
.bo-sidebar__site:focus-visible,
.bo-user-menu:focus-visible,
.bo-sidebar__brand:focus-visible { outline: 2px solid var(--bo-accent); outline-offset: -2px; }
/* Grupos recolhíveis */
.bo-nav-group { margin-top: .25rem; }
.bo-nav-group__label {
display: flex;
align-items: center;
gap: .6rem;
width: 100%;
padding: .55rem 1.25rem;
border: 0;
background: transparent;
color: var(--bo-nav-text-muted);
font-family: inherit;
font-size: .68rem;
font-weight: 700;
letter-spacing: .14em;
text-align: left;
text-transform: uppercase;
cursor: pointer;
}
.bo-nav-group__label:hover { color: var(--bo-nav-strong); background: rgba(255,255,255,.04); }
.bo-nav-group__icon { flex: none; width: 1.25rem; text-align: center; font-size: 1.1rem; letter-spacing: 0; }
.bo-nav-group__text { flex: 1; min-width: 0; }
.bo-nav-group__chevron { font-size: .75rem; letter-spacing: 0; transition: rotate var(--bo-ease); }
.bo-nav-group__label[aria-expanded="false"] .bo-nav-group__chevron { rotate: -90deg; }
.bo-nav-group--sub > .bo-nav-group__label { padding-left: 1.9rem; }
/* Corpo recolhível: grelha 1fr -> 0fr anima a altura sem JavaScript a medir. */
.bo-nav-group__body {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows var(--bo-ease), visibility var(--bo-ease);
}
.bo-nav-group__inner {
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 2px;
}
/* Fechado = classe (clique no grupo da página ativa) ou chave no atributo do
<html> posto pelo script do <head> antes da pintura. O grupo da página ativa
só fecha por clique (.is-closed), nunca pelo estado guardado. visibility
tira os links fechados da ordem de tabulação. Grupo novo = chave nova aqui. */
:is(.bo-nav-group.is-closed,
html[data-bo-groups~="gestao"] .bo-nav-group[data-group="gestao"]:not(.has-active),
html[data-bo-groups~="site"] .bo-nav-group[data-group="site"]:not(.has-active),
html[data-bo-groups~="horarios"] .bo-nav-group[data-group="horarios"]:not(.has-active),
html[data-bo-groups~="comercial"] .bo-nav-group[data-group="comercial"]:not(.has-active),
html[data-bo-groups~="legal"] .bo-nav-group[data-group="legal"]:not(.has-active),
html[data-bo-groups~="utilizadores"] .bo-nav-group[data-group="utilizadores"]:not(.has-active),
html[data-bo-groups~="sistema"] .bo-nav-group[data-group="sistema"]:not(.has-active)) > .bo-nav-group__body {
grid-template-rows: 0fr;
visibility: hidden;
}
/* Véu por trás da gaveta em telemóvel */
.sidebar-backdrop { display: none; position: fixed; inset: 0; z-index: 1090; background: rgba(0,0,0,.45); }
.sidebar-backdrop.show { display: block; }
/* ── Topbar ──────────────────────────────────────────────────────────────── */
.topbar {
position: fixed;
top: 0;
left: var(--bo-sidebar-w);
right: 0;
z-index: 99;
height: var(--bo-topbar-h);
padding: 0 1.5rem;
display: flex;
align-items: center;
gap: .5rem;
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
}
.bo-topbar__tools { display: flex; align-items: center; gap: .5rem; margin-left: auto; }
.menu-toggle {
display: none;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid var(--bs-border-color);
border-radius: 8px;
background: var(--bs-body-bg);
color: var(--bs-body-color);
font-size: 1.35rem;
line-height: 1;
align-items: center;
justify-content: center;
}
.theme-toggle { min-width: 44px; min-height: 44px; }
/* ── Marca nos componentes ───────────────────────────────────────────────── */
.badge-role-sysadmin { background: var(--bo-accent); color: var(--bo-dark); }
.badge-role-admin { background: var(--bo-accent); color: var(--bo-dark); opacity: .85; }
.badge-role-staff { background: #6f42c1; }
.badge-role-client { background: #adb5bd; color: #212529; }
.btn-outline-primary { color: var(--bo-accent); border-color: var(--bo-accent); }
.btn-outline-primary:hover { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
.btn-primary:hover { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); }
.card { border: none; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
[data-bs-theme="dark"] .table-light { --bs-table-bg: var(--bs-tertiary-bg); --bs-table-color: var(--bs-body-color); }
/* Summernote (WYSIWYG): alinhado com o aspeto do backoffice */
.note-editor.note-frame { border-color: #ced4da; border-radius: .375rem; }
.note-editor .note-editing-area .note-editable { font-size: .95rem; }
.note-editor.note-frame.note-focused { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); }
.note-toolbar { background: var(--bs-tertiary-bg); }
.note-btn.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
/* ── Telemóvel: barra lateral em gaveta ──────────────────────────────────── */
@media (max-width: 991.98px) {
.sidebar {
transform: translateX(-100%);
transition: transform .25s ease;
z-index: 1100;
box-shadow: 2px 0 18px rgba(0,0,0,.3);
}
.sidebar.open { transform: translateX(0); }
main { margin-left: 0; }
.topbar { left: 0; padding: 0 .75rem; }
.menu-toggle { display: inline-flex; }
/* Tabelas largas rolam dentro do cartão em vez de alargar a página */
main .card > .card-body { overflow-x: auto; -webkit-overflow-scrolling: touch; }
}
@media (max-width: 575.98px) {
/* Cabeçalhos de página (título + botões) empilham em vez de transbordar */
main .d-flex.justify-content-between.align-items-center { flex-wrap: wrap; gap: .5rem; }
/* 16px nos campos: o iOS não faz zoom ao focar */
.form-control, .form-select { font-size: 16px; }
/* Margens confortáveis em telemóvel */
/* !important: .p-3 é uma utilitária do Bootstrap e tem !important de origem */
.p-3 { padding: .85rem !important; }
}
/* ── Modo mini (só ecrã grande; classe posta no <html> pelo script do <head>) ── */
@media (min-width: 992px) {
html.bo-mini { --bo-sidebar-w: var(--bo-sidebar-w-mini); }
html.bo-mini .bo-sidebar__head {
flex-direction: column;
justify-content: center;
height: auto;
padding: .5rem 0;
gap: .25rem;
}
html.bo-mini .bo-sidebar__brand { display: none; }
html.bo-mini .bo-sidebar__user { padding: .5rem; }
html.bo-mini .bo-user-menu { justify-content: center; padding: .25rem; }
html.bo-mini .bo-user-menu__text,
html.bo-mini .bo-user-menu::after { display: none; }
html.bo-mini .bo-sidebar__nav { padding: .5rem 0; }
html.bo-mini .sidebar .bo-nav-item,
html.bo-mini .sidebar .bo-nav-item--sub,
html.bo-mini .sidebar .bo-nav-item--sub2 { justify-content: center; padding: .5rem; }
html.bo-mini .bo-nav-item__label { display: none; }
html.bo-mini .bo-nav-item__badge {
position: absolute;
top: 2px;
right: 8px;
margin: 0;
padding: .15em .4em;
font-size: .6rem;
}
/* Categoria no mini: sigla de texto (data-short), sem ícone nem chevron. */
html.bo-mini .bo-nav-group__label,
html.bo-mini .bo-nav-group--sub > .bo-nav-group__label { justify-content: center; padding: .55rem .25rem; font-size: 0; }
html.bo-mini .bo-nav-group__label::before { content: attr(data-short); font-size: .66rem; font-weight: 700; letter-spacing: .08em; }
html.bo-mini .bo-nav-group__text,
html.bo-mini .bo-nav-group__icon,
html.bo-mini .bo-nav-group__chevron { display: none; }
}
/* ── Navegação sem piscar: só o conteúdo faz fade; barra e topbar ficam ── */
@media (prefers-reduced-motion: no-preference) {
@view-transition { navigation: auto; }
.sidebar { view-transition-name: bo-sidebar; }
.topbar { view-transition-name: bo-topbar; }
::view-transition-old(root),
::view-transition-new(root) { animation-duration: .14s; }
}
@media (prefers-reduced-motion: reduce) {
.sidebar,
.sidebar .bo-nav-item,
.bo-nav-group__body,
.bo-nav-group__chevron { transition: none; }
}
+253
View File
@@ -0,0 +1,253 @@
/* ── Bootstrap re-themed to the FitMe brand ──────────────────────────────────
Loaded after bootstrap.min.css (and before backoffice.css). Bootstrap's source
is never edited: everything here is --bs-* variables plus the component-scoped
variables Bootstrap 5.3 exposes (.btn-*, .form-check-input, .page-link …).
The brand colour is a single token, --fitme-accent (a gym's own colour on the
public site, see gym_brand_css()); every shade below is derived from it with
color-mix(), so changing the brand colour re-themes all of Bootstrap at once.
The other semantic colours (success/danger/warning/info) are tuned to sit next
to the brand without being confused with it. */
:root,
[data-bs-theme="light"],
[data-bs-theme="dark"] {
--brand: var(--fitme-accent, #C2D500);
--brand-ink: color-mix(in srgb, var(--brand) 55%, #000); /* text on light surfaces */
--brand-on: var(--fitme-on-accent, #0B0B0D); /* text on the brand colour */
--bs-primary: var(--brand);
--bs-primary-rgb: var(--fitme-accent-rgb, 194, 213, 0);
--bs-secondary: #3D403E;
--bs-secondary-rgb: 61, 64, 62;
--bs-success: #2E9E5B;
--bs-success-rgb: 46, 158, 91;
--bs-danger: #D6322E;
--bs-danger-rgb: 214, 50, 46;
--bs-warning: #F2A900;
--bs-warning-rgb: 242, 169, 0;
--bs-info: #2C9FD0;
--bs-info-rgb: 44, 159, 208;
--bs-light: #F7F8FA;
--bs-light-rgb: 247, 248, 250;
--bs-dark: #0B0B0D;
--bs-dark-rgb: 11, 11, 13;
--bs-focus-ring-color: color-mix(in srgb, var(--brand) 40%, transparent);
}
/* Subtle / emphasis / border tints: light theme (tint 80 / 60, shade 60) … */
:root,
[data-bs-theme="light"] {
--bs-primary-text-emphasis: color-mix(in srgb, var(--brand) 40%, #000);
--bs-primary-bg-subtle: color-mix(in srgb, var(--brand) 20%, #fff);
--bs-primary-border-subtle: color-mix(in srgb, var(--brand) 40%, #fff);
--bs-secondary-text-emphasis: #191a19;
--bs-secondary-bg-subtle: #e5e6e5;
--bs-secondary-border-subtle: #c5c6c5;
--bs-success-text-emphasis: #124f2c;
--bs-success-bg-subtle: #d5ecde;
--bs-success-border-subtle: #acdabd;
--bs-danger-text-emphasis: #561411;
--bs-danger-bg-subtle: #f7d6d5;
--bs-danger-border-subtle: #efadac;
--bs-warning-text-emphasis: #613f00;
--bs-warning-bg-subtle: #fceecc;
--bs-warning-border-subtle: #fadf99;
--bs-info-text-emphasis: #11404e;
--bs-info-bg-subtle: #d5ecf6;
--bs-info-border-subtle: #abd9ec;
--bs-link-color: var(--brand-ink);
--bs-link-color-rgb: 0, 0, 0;
--bs-link-hover-color: color-mix(in srgb, var(--brand) 40%, #000);
}
/* … dark theme (tint 40, shade 80 / 60) */
[data-bs-theme="dark"] {
--bs-primary-text-emphasis: color-mix(in srgb, var(--brand) 60%, #fff);
--bs-primary-bg-subtle: color-mix(in srgb, var(--brand) 20%, #000);
--bs-primary-border-subtle: color-mix(in srgb, var(--brand) 40%, #000);
--bs-secondary-text-emphasis: #b1b2b1;
--bs-secondary-bg-subtle: #131413;
--bs-secondary-border-subtle: #252726;
--bs-success-text-emphasis: #82c59d;
--bs-success-bg-subtle: #09200f;
--bs-success-border-subtle: #1c5f36;
--bs-danger-text-emphasis: #e88380;
--bs-danger-bg-subtle: #2b0a09;
--bs-danger-border-subtle: #801e1c;
--bs-warning-text-emphasis: #f7ca66;
--bs-warning-bg-subtle: #302200;
--bs-warning-border-subtle: #916800;
--bs-info-text-emphasis: #80c5e3;
--bs-info-bg-subtle: #0e2833;
--bs-info-border-subtle: #1a5f7d;
--bs-link-color: var(--brand);
--bs-link-color-rgb: var(--fitme-accent-rgb, 194, 213, 0);
--bs-link-hover-color: color-mix(in srgb, var(--brand) 75%, #fff);
}
/* ── Components: Bootstrap hard-codes white text on solid primary/semantic
fills; the brand colour is light, so the text must be dark. ─────────────── */
.btn-primary {
--bs-btn-color: var(--brand-on);
--bs-btn-bg: var(--brand);
--bs-btn-border-color: var(--brand);
--bs-btn-hover-color: var(--brand-on);
--bs-btn-hover-bg: color-mix(in srgb, var(--brand) 86%, #000);
--bs-btn-hover-border-color: color-mix(in srgb, var(--brand) 80%, #000);
--bs-btn-focus-shadow-rgb: var(--fitme-accent-rgb, 194, 213, 0);
--bs-btn-active-color: var(--brand-on);
--bs-btn-active-bg: color-mix(in srgb, var(--brand) 78%, #000);
--bs-btn-active-border-color: color-mix(in srgb, var(--brand) 70%, #000);
--bs-btn-disabled-color: var(--brand-on);
--bs-btn-disabled-bg: var(--brand);
--bs-btn-disabled-border-color: var(--brand);
}
.btn-outline-primary {
--bs-btn-color: var(--brand-ink);
--bs-btn-border-color: var(--brand);
--bs-btn-hover-color: var(--brand-on);
--bs-btn-hover-bg: var(--brand);
--bs-btn-hover-border-color: var(--brand);
--bs-btn-focus-shadow-rgb: var(--fitme-accent-rgb, 194, 213, 0);
--bs-btn-active-color: var(--brand-on);
--bs-btn-active-bg: var(--brand);
--bs-btn-active-border-color: var(--brand);
--bs-btn-disabled-color: var(--brand-ink);
--bs-btn-disabled-border-color: var(--brand);
}
[data-bs-theme="dark"] .btn-outline-primary { --bs-btn-color: var(--brand); --bs-btn-disabled-color: var(--brand); }
.btn-secondary {
--bs-btn-bg: var(--bs-secondary);
--bs-btn-border-color: var(--bs-secondary);
--bs-btn-hover-bg: #2f3230;
--bs-btn-hover-border-color: #2b2d2c;
--bs-btn-active-bg: #2b2d2c;
--bs-btn-active-border-color: #262826;
--bs-btn-disabled-bg: var(--bs-secondary);
--bs-btn-disabled-border-color: var(--bs-secondary);
}
.btn-outline-secondary {
--bs-btn-color: var(--bs-secondary);
--bs-btn-border-color: var(--bs-secondary);
--bs-btn-hover-bg: var(--bs-secondary);
--bs-btn-hover-border-color: var(--bs-secondary);
--bs-btn-active-bg: var(--bs-secondary);
--bs-btn-active-border-color: var(--bs-secondary);
--bs-btn-disabled-color: var(--bs-secondary);
--bs-btn-disabled-border-color: var(--bs-secondary);
}
[data-bs-theme="dark"] .btn-outline-secondary { --bs-btn-color: #9a9d9b; --bs-btn-border-color: #5b5f5d; }
.btn-success { --bs-btn-bg: var(--bs-success); --bs-btn-border-color: var(--bs-success); --bs-btn-hover-bg: #27864d; --bs-btn-hover-border-color: #257d49; --bs-btn-active-bg: #257d49; --bs-btn-active-border-color: #227545; --bs-btn-disabled-bg: var(--bs-success); --bs-btn-disabled-border-color: var(--bs-success); }
.btn-danger { --bs-btn-bg: var(--bs-danger); --bs-btn-border-color: var(--bs-danger); --bs-btn-hover-bg: #b62a27; --bs-btn-hover-border-color: #ab2825; --bs-btn-active-bg: #ab2825; --bs-btn-active-border-color: #a02622; --bs-btn-disabled-bg: var(--bs-danger); --bs-btn-disabled-border-color: var(--bs-danger); }
.btn-warning { --bs-btn-bg: var(--bs-warning); --bs-btn-border-color: var(--bs-warning); --bs-btn-hover-bg: #f5b61f; --bs-btn-hover-border-color: #f4b212; --bs-btn-active-bg: #f6bb2b; --bs-btn-active-border-color: #f4b212; --bs-btn-disabled-bg: var(--bs-warning); --bs-btn-disabled-border-color: var(--bs-warning); }
.btn-info { --bs-btn-bg: var(--bs-info); --bs-btn-border-color: var(--bs-info); --bs-btn-hover-bg: #52b0d9; --bs-btn-hover-border-color: #42a9d6; --bs-btn-active-bg: #5bb5dc; --bs-btn-active-border-color: #42a9d6; --bs-btn-disabled-bg: var(--bs-info); --bs-btn-disabled-border-color: var(--bs-info); }
.btn-outline-success { --bs-btn-color: var(--bs-success); --bs-btn-border-color: var(--bs-success); --bs-btn-hover-bg: var(--bs-success); --bs-btn-hover-border-color: var(--bs-success); --bs-btn-active-bg: var(--bs-success); --bs-btn-active-border-color: var(--bs-success); --bs-btn-disabled-color: var(--bs-success); --bs-btn-disabled-border-color: var(--bs-success); }
.btn-outline-danger { --bs-btn-color: var(--bs-danger); --bs-btn-border-color: var(--bs-danger); --bs-btn-hover-bg: var(--bs-danger); --bs-btn-hover-border-color: var(--bs-danger); --bs-btn-active-bg: var(--bs-danger); --bs-btn-active-border-color: var(--bs-danger); --bs-btn-disabled-color: var(--bs-danger); --bs-btn-disabled-border-color: var(--bs-danger); }
.btn-outline-warning { --bs-btn-color: #b07a00; --bs-btn-border-color: var(--bs-warning); --bs-btn-hover-bg: var(--bs-warning); --bs-btn-hover-border-color: var(--bs-warning); --bs-btn-active-bg: var(--bs-warning); --bs-btn-active-border-color: var(--bs-warning); --bs-btn-disabled-color: #b07a00; --bs-btn-disabled-border-color: var(--bs-warning); }
.btn-outline-info { --bs-btn-color: #1f7ea6; --bs-btn-border-color: var(--bs-info); --bs-btn-hover-bg: var(--bs-info); --bs-btn-hover-border-color: var(--bs-info); --bs-btn-active-bg: var(--bs-info); --bs-btn-active-border-color: var(--bs-info); --bs-btn-disabled-color: #1f7ea6; --bs-btn-disabled-border-color: var(--bs-info); }
[data-bs-theme="dark"] .btn-outline-warning { --bs-btn-color: var(--bs-warning); --bs-btn-disabled-color: var(--bs-warning); }
[data-bs-theme="dark"] .btn-outline-info { --bs-btn-color: var(--bs-info); --bs-btn-disabled-color: var(--bs-info); }
.btn-success, .btn-danger, .btn-info, .btn-outline-success, .btn-outline-danger, .btn-outline-info { --bs-btn-hover-color: #fff; --bs-btn-active-color: #fff; }
.btn-success, .btn-danger, .btn-info { --bs-btn-color: #fff; --bs-btn-disabled-color: #fff; }
.btn-warning, .btn-outline-warning { --bs-btn-hover-color: #0B0B0D; --bs-btn-active-color: #0B0B0D; }
.btn-warning { --bs-btn-color: #0B0B0D; --bs-btn-disabled-color: #0B0B0D; }
/* ── Solid fills that use the colour as background ───────────────────────── */
.text-bg-primary { color: var(--brand-on) !important; background-color: var(--brand) !important; }
.text-bg-secondary { background-color: var(--bs-secondary) !important; }
.text-bg-success { background-color: var(--bs-success) !important; }
.text-bg-danger { background-color: var(--bs-danger) !important; }
.text-bg-warning { color: #0B0B0D !important; background-color: var(--bs-warning) !important; }
.text-bg-info { color: #fff !important; background-color: var(--bs-info) !important; }
.link-primary { color: var(--brand-ink) !important; }
[data-bs-theme="dark"] .link-primary { color: var(--brand) !important; }
/* ── Forms ───────────────────────────────────────────────────────────────── */
.form-control:focus, .form-select:focus {
border-color: color-mix(in srgb, var(--brand) 70%, #000);
box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color);
}
.form-check-input:checked,
.form-check-input[type="checkbox"]:indeterminate {
background-color: var(--brand);
border-color: var(--brand);
}
/* the tick / dot / knob are inline SVGs with a hard-coded white fill */
.form-check-input:checked[type="checkbox"] {
--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%230B0B0D' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e");
}
.form-check-input:checked[type="radio"] {
--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%230B0B0D'/%3e%3c/svg%3e");
}
.form-check-input:focus { border-color: var(--brand); box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color); }
.form-switch .form-check-input:focus {
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23B9C65A'/%3e%3c/svg%3e");
}
.form-switch .form-check-input:checked {
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%230B0B0D'/%3e%3c/svg%3e");
}
.form-range::-webkit-slider-thumb { background-color: var(--brand); }
.form-range::-webkit-slider-thumb:active { background-color: color-mix(in srgb, var(--brand) 60%, #fff); }
.form-range::-moz-range-thumb { background-color: var(--brand); }
.form-range::-moz-range-thumb:active { background-color: color-mix(in srgb, var(--brand) 60%, #fff); }
.form-range:focus::-webkit-slider-thumb { box-shadow: 0 0 0 1px #fff, 0 0 0 .25rem var(--bs-focus-ring-color); }
.form-range:focus::-moz-range-thumb { box-shadow: 0 0 0 1px #fff, 0 0 0 .25rem var(--bs-focus-ring-color); }
.was-validated .form-control:valid, .form-control.is-valid,
.was-validated .form-select:valid, .form-select.is-valid { border-color: var(--bs-success); }
.was-validated .form-control:invalid, .form-control.is-invalid,
.was-validated .form-select:invalid, .form-select.is-invalid { border-color: var(--bs-danger); }
/* ── Navigation & lists ──────────────────────────────────────────────────── */
.nav {
--bs-nav-link-color: var(--bs-link-color);
--bs-nav-link-hover-color: var(--bs-link-hover-color);
}
.nav-pills {
--bs-nav-pills-link-active-color: var(--brand-on);
--bs-nav-pills-link-active-bg: var(--brand);
}
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link { border-top: 2px solid var(--brand); }
.pagination {
--bs-pagination-color: var(--bs-link-color);
--bs-pagination-hover-color: var(--bs-link-hover-color);
--bs-pagination-focus-color: var(--bs-link-hover-color);
--bs-pagination-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color);
--bs-pagination-active-color: var(--brand-on);
--bs-pagination-active-bg: var(--brand);
--bs-pagination-active-border-color: var(--brand);
}
.list-group { --bs-list-group-active-color: var(--brand-on); --bs-list-group-active-bg: var(--brand); --bs-list-group-active-border-color: var(--brand); }
.dropdown-menu { --bs-dropdown-link-active-color: var(--brand-on); --bs-dropdown-link-active-bg: var(--brand); }
.accordion {
--bs-accordion-active-color: var(--bs-primary-text-emphasis);
--bs-accordion-active-bg: var(--bs-primary-bg-subtle);
--bs-accordion-btn-focus-border-color: var(--brand);
--bs-accordion-btn-focus-box-shadow: 0 0 0 .25rem var(--bs-focus-ring-color);
}
/* ── Feedback ────────────────────────────────────────────────────────────── */
.progress { --bs-progress-bar-bg: var(--brand); --bs-progress-bar-color: var(--brand-on); }
.alert-primary {
--bs-alert-color: var(--bs-primary-text-emphasis);
--bs-alert-bg: var(--bs-primary-bg-subtle);
--bs-alert-border-color: var(--bs-primary-border-subtle);
--bs-alert-link-color: var(--bs-primary-text-emphasis);
}
.table-primary {
--bs-table-color: var(--bs-primary-text-emphasis);
--bs-table-bg: var(--bs-primary-bg-subtle);
--bs-table-border-color: var(--bs-primary-border-subtle);
--bs-table-striped-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 94%, #000);
--bs-table-hover-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 92%, #000);
--bs-table-active-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 90%, #000);
}
.border-primary { border-color: var(--brand) !important; }
.bg-primary { background-color: var(--brand) !important; color: var(--brand-on); }
.text-primary { color: var(--brand-ink) !important; }
[data-bs-theme="dark"] .text-primary { color: var(--brand) !important; }
.bg-primary-subtle { background-color: var(--bs-primary-bg-subtle) !important; }
.text-primary-emphasis { color: var(--bs-primary-text-emphasis) !important; }
.border-primary-subtle { border-color: var(--bs-primary-border-subtle) !important; }
+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,
+11 -1
View File
@@ -96,7 +96,17 @@ try {
// para deixar a porta aberta a usar o throttle partilhado no futuro. Ver
// views/auth/login.php para a nota completa.
require_once dirname(__DIR__) . '/shared/platform-tools/functions_connector.php';
platform_connect($pdo, [
// Pacotes de ícones partilhados (platform?tool=icons, pt_icon_assets()). Só se o
// submódulo já os tem; sem ele os layouts caem nas cópias locais (fit_icon_assets()).
// Flaticon NÃO está registado: o site usa PNGs (uploads/modalidades/icons), não a fonte.
$iconsFile = dirname(__DIR__) . '/shared/platform-tools/functions_icons.php';
if (is_file($iconsFile)) {
require_once $iconsFile;
}
$iconsOpt = is_file($iconsFile)
? ['icons' => ['packs' => ['bootstrap-icons', 'fontawesome'], 'default' => 'bootstrap-icons']]
: [];
platform_connect($pdo, $iconsOpt + [
'auth' => 'shared',
'roles' => [
// Preserva a hierarquia que config/auth.php já tinha (sysadmin >
+2
View File
@@ -27,6 +27,7 @@ require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrat
require_once __DIR__ . '/functions_queue.php'; // message_queue + enqueue_*/flush_message
require_once __DIR__ . '/functions_csrf.php';
require_once __DIR__ . '/functions_html.php'; // allowlist HTML sanitiser (clean_richtext/clean_map_embed)
require_once __DIR__ . '/functions_icons.php'; // fit_icon_assets(): pacotes de ícones partilhados + recurso local
require_once __DIR__ . '/ci_compat.php'; // CI4 view-helper shim (script_tag/link_tag/view/esc/session)
require_once __DIR__ . '/functions_gyms.php'; // multi-gym: gyms, URL folders, admin gym switcher
require_once __DIR__ . '/functions_site.php'; // public-site data (site_info/offer/team/modalidades/horario)
@@ -40,6 +41,7 @@ require_once __DIR__ . '/functions_events.php'; // time-limited events +
require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_info) editor
require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição
require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache
require_once __DIR__ . '/functions_instagram.php'; // «Comunidade»: posts do Instagram onde o ginásio é identificado
require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental
require_once __DIR__ . '/functions_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente
require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
+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;
}
+113 -18
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',
@@ -41,7 +42,7 @@ function gym_defaults(): array {
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
'hero_image' => '', 'seo_title' => '', 'seo_description' => '', 'gallery_id' => null,
'google_place_id' => '', 'has_crossfit' => 0, 'item_order' => 0,
'google_place_id' => '', 'has_crossfit' => 0, 'brand_color' => '', 'logo' => '', 'item_order' => 0,
];
}
@@ -103,6 +104,32 @@ function gym_section_on(array $gym, string $key): bool {
return !is_array($list) || in_array($key, $list, true);
}
// Brand colour of a gym as :root overrides for the public site. Derives the
// hover shade, the text-on-light ink and the text colour that stays readable on
// the colour (dark on light brands, white on dark ones). '' = stylesheet default.
function gym_brand_vars(array $gym): string {
$hex = strtoupper((string) ($gym['brand_color'] ?? ''));
if (!preg_match('/^#[0-9A-F]{6}$/', $hex)) return '';
[$r, $g, $b] = [hexdec(substr($hex, 1, 2)), hexdec(substr($hex, 3, 2)), hexdec(substr($hex, 5, 2))];
$shade = fn(float $k) => sprintf('#%02X%02X%02X', (int) round($r * $k), (int) round($g * $k), (int) round($b * $k));
$lum = (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255;
return '--fitme-accent:' . $hex . ';--fitme-accent-rgb:' . "$r, $g, $b" . ';--fitme-accent-dark:' . $shade(.86)
. ';--fitme-accent-ink:' . $shade($lum > .5 ? .5 : .85) . ';--fitme-on-accent:' . ($lum > .5 ? '#0B0B0D' : '#FFFFFF');
}
// The gym's logo (relative path). The main gym falls back to the site's own
// FitMe 3070 logo, so its navbar button has one before anything is uploaded.
function gym_logo(array $gym): string {
$logo = (string) ($gym['logo'] ?? '');
if ($logo === '' && !empty($gym['is_default'])) $logo = 'site/assets/img/logos/logo_black.svg';
return $logo;
}
function gym_brand_css(array $gym): string {
$v = gym_brand_vars($gym);
return $v === '' ? '' : ':root{' . $v . '}';
}
function gym_url(array $gym, string $path = ''): string {
return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/'));
}
@@ -159,8 +186,22 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null,
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0,
'logo' => mb_substr(trim((string) ($post['logo'] ?? '')), 0, 255),
'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
'item_order' => (int) ($post['item_order'] ?? 0),
];
// Header image + logo come from file inputs (a fresh upload wins, "remover"
// clears it, otherwise the current value posted in the hidden field stays).
foreach (['hero' => 'hero_image', 'logo' => 'logo'] as $kind => $field) {
if (!empty($post[$kind . '_remove'])) $data[$field] = '';
$file = $_FILES[$kind . '_file'] ?? null;
if ($file && ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$err = null;
$saved = gyms_save_image($file, $kind, $err);
if ($saved !== null) $data[$field] = $saved;
else $errors[] = ($kind === 'hero' ? 'Imagem do header: ' : 'Logótipo: ') . ($err ?? 'ficheiro inválido.');
}
}
$on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on);
@@ -173,25 +214,42 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null):
$other = db_gyms_by_slug($pdo, $slug);
if ($other && (int) $other['id'] !== (int) $id) $errors[] = 'Já existe um ginásio com essa pasta da URL.';
}
if ($data['logo'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(svg|png|webp|jpe?g|jxl)$~i', $data['logo'])) {
$errors[] = 'Logótipo: imagem inválida.';
}
if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) {
$errors[] = 'Cor da marca: formato #RRGGBB (ex.: #C2D500).';
}
if ($data['is_default'] && !$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado.';
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp)$~i', $data['hero_image'])) {
$errors[] = 'Imagem do header: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).';
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp|jxl)$~i', $data['hero_image'])) {
$errors[] = 'Imagem do header: imagem inválida.';
}
return $data;
}
function _gyms_params(array $d): array {
return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'],
$d['sections'], $d['hero_title'], $d['hero_subtitle'], $d['hero_image'], $d['seo_title'],
$d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['item_order']];
// Columns written by create/update, in order. brand_color and logo were added by
// migration_20261013_gym_brand_color.sql: an install that hasn't run it yet keeps
// working (they are just not saved) instead of failing on an unknown column.
function _gyms_cols(PDO $pdo, array $d): array {
$cols = [
'slug' => $d['slug'], 'name' => $d['name'], 'locality' => $d['locality'], 'postal_code' => $d['postal_code'],
'is_published' => $d['is_published'], 'is_default' => $d['is_default'], 'sections' => $d['sections'],
'hero_title' => $d['hero_title'], 'hero_subtitle' => $d['hero_subtitle'], 'hero_image' => $d['hero_image'],
'seo_title' => $d['seo_title'], 'seo_description' => $d['seo_description'], 'gallery_id' => $d['gallery_id'],
'google_place_id' => $d['google_place_id'], 'has_crossfit' => $d['has_crossfit'],
];
foreach (['brand_color', 'logo'] as $opt) {
if (_gyms_has_col($pdo, 'gyms', $opt)) $cols[$opt] = $d[$opt] ?? '';
}
$cols['item_order'] = $d['item_order'];
return $cols;
}
function db_gyms_create(PDO $pdo, array $d): int {
$cols = _gyms_cols($pdo, $d);
$pdo->beginTransaction();
$pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections,
hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id,
google_place_id, has_crossfit, item_order, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
$pdo->prepare('INSERT INTO gyms (' . implode(', ', array_keys($cols)) . ', created_at, updated_at) VALUES ('
. implode(',', array_fill(0, count($cols), '?')) . ', NOW(), NOW())')->execute(array_values($cols));
$id = (int) $pdo->lastInsertId();
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
// Its own contacts row (id = gym id), starting from the gym's name.
@@ -206,14 +264,10 @@ function db_gyms_create(PDO $pdo, array $d): int {
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
$cols = _gyms_cols($pdo, $d);
$pdo->beginTransaction();
$params = _gyms_params($d); $params[] = $id;
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
is_default = ?, sections = ?, hero_title = ?, hero_subtitle = ?, hero_image = ?,
seo_title = ?, seo_description = ?, gallery_id = ?, google_place_id = ?,
has_crossfit = ?, item_order = ?, updated_at = NOW()
WHERE id = ?");
$st->execute($params);
$st = $pdo->prepare('UPDATE gyms SET ' . implode(' = ?, ', array_keys($cols)) . ' = ?, updated_at = NOW() WHERE id = ?');
$st->execute(array_merge(array_values($cols), [$id]));
$changed = $st->rowCount() > 0;
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
$pdo->commit();
@@ -222,6 +276,47 @@ function db_gyms_update(PDO $pdo, int $id, array $d): bool {
return $__r;
}
// ─── Header image / logo uploads ──────────────────────────────────────────────
// Files live in uploads/gyms/ (uploads/.htaccess blocks script execution) and the
// DB keeps the site-relative path, which is what the public pages already read.
function gyms_upload_dir(): string {
return dirname(__DIR__) . '/uploads/gyms/';
}
// Validates by content (upload_image_ext, SVGs through svg_is_safe), stores the
// file under a random name and returns "uploads/gyms/<name>", or null with $error.
function gyms_save_image(array $file, string $kind, ?string &$error = null): ?string {
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
$error = ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE)
? 'o ficheiro é demasiado grande.' : 'o envio falhou.';
return null;
}
if (($file['size'] ?? 0) > 6 * 1024 * 1024) { $error = 'máximo 6 MB.'; return null; }
$allowed = $kind === 'hero' ? ['jpg', 'jpeg', 'png', 'webp', 'jxl'] : ['svg', 'png', 'webp', 'jpg', 'jpeg', 'jxl'];
$ext = upload_image_ext($file, $allowed);
if ($ext === null) {
$error = $kind === 'hero' ? 'usa JPG, PNG, WebP ou JPEG XL.' : 'usa SVG, PNG, WebP, JPG ou JPEG XL (SVG sem scripts).';
return null;
}
$dir = gyms_upload_dir();
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { $error = 'não foi possível criar a pasta de envio.'; return null; }
$name = $kind . '_' . bin2hex(random_bytes(6)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $dir . $name)) { $error = 'não foi possível guardar o ficheiro.'; return null; }
if ($ext !== 'svg' && function_exists('img_optimize')) {
img_optimize($dir . $name, $kind === 'hero' ? IMG_MAX_GALLERY : 600);
}
return 'uploads/gyms/' . $name;
}
// Deletes an uploaded image that is no longer referenced (never a bundled one).
function gyms_remove_image(?string $rel): void {
$rel = (string) $rel;
if (!preg_match('~^uploads/gyms/[A-Za-z0-9_.-]+$~', $rel)) return;
$path = gyms_upload_dir() . basename($rel);
if (is_file($path)) @unlink($path);
img_fallback_follow($path);
}
// Single-field setter for views/admin/reviews/index.php — that page only
// touches google_place_id, not the rest of the gym record db_gyms_update()
// would validate/audit.
+18
View File
@@ -0,0 +1,18 @@
<?php
// Ícones: pacotes partilhados do platform-tools (pt_icon_assets()) com as cópias
// locais como recurso quando o submódulo ainda não os tem (produção com o
// submódulo antigo). Registo dos pacotes: config/db.php ('icons').
//
// $packs : ids dos pacotes a carregar (ex.: ['fontawesome'])
// $localHrefs: <link> locais de recurso, caminhos para link_tag()/base_url()
function fit_icon_assets(array $packs, array $localHrefs): string {
if (function_exists('pt_icon_assets')) {
$html = pt_icon_assets($packs);
if ($html !== '') return $html;
}
$out = '';
foreach ($localHrefs as $href) {
$out .= '<link href="' . htmlspecialchars(base_url($href), ENT_QUOTES) . '" rel="stylesheet">' . "\n";
}
return $out;
}
+235 -2
View File
@@ -39,6 +39,15 @@ function upload_image_ext(array $file, array $allowed): ?string {
}
$raster = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
// JPEG XL: o getimagesize()/GD não o conhecem e o finfo só nas versões
// recentes, por isso vê-se pela assinatura (codestream FF 0A ou contentor
// ISOBMFF "JXL "). Só entra onde quem chama pôs 'jxl' em $allowed; o
// img_optimize() salta-o (não há GD para JXL).
if (in_array('jxl', $allowed, true)) {
$head = (string) @file_get_contents($tmp, false, null, 0, 12);
if (str_starts_with($head, "\xFF\x0A") || $head === "\x00\x00\x00\x0CJXL \x0D\x0A\x87\x0A") return 'jxl';
}
if (isset($raster[$mime]) || $mime === '') {
$info = @getimagesize($tmp);
$ext = $info ? ($raster[$info['mime']] ?? null) : null;
@@ -80,6 +89,7 @@ function img_optimize(string $path, int $max_dim): array {
if (!is_file($path)) return $fail('ficheiro não encontrado');
if (!extension_loaded('gd')) return $fail('extensão GD indisponível');
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'jxl') return img_jxl_optimize($path, $max_dim);
$info = @getimagesize($path);
if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster
@@ -177,9 +187,9 @@ function img_optimize_dir(string $dir, int $max_dim): array {
if (!is_dir($dir)) return $stats;
foreach (scandir($dir) ?: [] as $file) {
$path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file;
if (!is_file($path)) continue;
if (!is_file($path) || img_is_jxl_fallback($file)) continue;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) continue;
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl'], true)) continue;
$r = img_optimize($path, $max_dim);
$stats[$r['status']]++;
$stats['saved'] += $r['saved'];
@@ -205,3 +215,226 @@ function img_human_bytes(int $bytes): string {
if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB';
return $bytes . ' B';
}
// ─── JPEG XL ─────────────────────────────────────────────────────────────────
// O GD não lê JXL. Descodifica-se com os binários do libjxl (djxl/cjxl, pacote
// Debian libjxl-tools) ou com o Imagick se tiver o formato JXL; sem nenhum, o
// .jxl fica como foi enviado (status "skipped") e o upload não falha.
// Ao lado de cada foto.jxl grava-se foto.jxl.webp (ou .jpg sem WebP no GD):
// é a versão para os browsers sem JXL, servida por img_tag()/img_css_bg() e
// escondida das listagens (img_list_dir). Redimensiona-se a partir da versão
// descodificada e, havendo encoder, o .jxl é regravado ao novo tamanho.
const IMG_JXL_DISTANCE = '1.0'; // cjxl -d: 1.0 = visualmente sem perdas
// Backends on this server: ['imagick' => bool, 'djxl' => path|'', 'cjxl' => path|''].
function img_jxl_tools(): array {
static $tools = null;
if ($tools !== null) return $tools;
$imagick = class_exists('Imagick') && in_array('JXL', \Imagick::queryFormats('JXL'), true);
$find = static function (string $bin): string {
if (!function_exists('proc_open')) return '';
$dirs = array_merge(explode(PATH_SEPARATOR, (string) getenv('PATH')), ['/usr/bin', '/usr/local/bin']);
foreach (array_unique(array_filter($dirs)) as $d) {
$p = rtrim($d, '/\\') . DIRECTORY_SEPARATOR . $bin;
if (@is_file($p) && @is_executable($p)) return $p;
}
return '';
};
return $tools = ['imagick' => $imagick, 'djxl' => $find('djxl'), 'cjxl' => $find('cjxl')];
}
function img_jxl_can_decode(): bool {
$t = img_jxl_tools();
return $t['imagick'] || $t['djxl'] !== '';
}
// Runs a binary with an argument list (no shell). True when it exits 0 and wrote $out.
function _img_run(string $bin, array $args, string $out): bool {
$proc = @proc_open(array_merge([$bin], $args), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($proc)) return false;
stream_get_contents($pipes[1]); stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
clearstatcache(true, $out);
return proc_close($proc) === 0 && is_file($out) && filesize($out) > 0;
}
function img_jxl_decode(string $jxl, string $png): bool {
$t = img_jxl_tools();
if ($t['djxl'] !== '' && _img_run($t['djxl'], [$jxl, $png], $png)) return true;
if ($t['imagick']) {
try {
$im = new \Imagick($jxl);
$im->setImageFormat('PNG');
return $im->writeImage($png) && is_file($png);
} catch (\Throwable $e) { return false; }
}
return false;
}
function img_jxl_encode(string $png, string $jxl): bool {
$t = img_jxl_tools();
if ($t['cjxl'] !== '' && _img_run($t['cjxl'], [$png, $jxl, '-d', IMG_JXL_DISTANCE, '-e', '7'], $jxl)) return true;
if ($t['imagick']) {
try {
$im = new \Imagick($png);
$im->setImageFormat('JXL');
$im->setImageCompressionQuality(90);
return $im->writeImage($jxl) && is_file($jxl);
} catch (\Throwable $e) { return false; }
}
return false;
}
// "foto.jxl.webp" / "foto.jxl.jpg": the fallback copies, never listed as photos.
function img_is_jxl_fallback(string $name): bool {
return (bool) preg_match('/\.jxl\.(webp|jpe?g|png)$/i', $name);
}
// Absolute path of the fallback beside $jxl_path, or '' when there is none.
function img_jxl_fallback(string $jxl_path): string {
foreach (['webp', 'jpg', 'png'] as $e) {
if (is_file($jxl_path . '.' . $e)) return $jxl_path . '.' . $e;
}
return '';
}
// Moves (or, with $to = '', deletes) the fallback that goes with an image file.
// Callers that move/delete an upload call this too, so the pair stays together.
function img_fallback_follow(string $from, string $to = ''): void {
if (!preg_match('/\.jxl$/i', $from)) return;
foreach (['webp', 'jpg', 'png'] as $e) {
if (!is_file($from . '.' . $e)) continue;
if ($to === '') @unlink($from . '.' . $e);
else @rename($from . '.' . $e, $to . '.' . $e);
}
}
function img_jxl_optimize(string $path, int $max_dim): array {
$skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why];
$fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why];
// Fallback newer than the .jxl and within the cap: nothing to do (idempotent).
$old = img_jxl_fallback($path);
if ($old !== '' && filemtime($old) >= filemtime($path)) {
$d = @getimagesize($old);
if ($d && $d[0] <= $max_dim && $d[1] <= $max_dim) return $skip('já dentro do limite');
}
if (!img_jxl_can_decode()) return $skip('JPEG XL sem descodificador no servidor (libjxl-tools)');
$dec = $path . '.dec.png';
if (!img_jxl_decode($path, $dec)) { @unlink($dec); return $fail('JPEG XL ilegível'); }
$info = @getimagesize($dec);
if (!$info) { @unlink($dec); return $fail('JPEG XL ilegível'); }
[$w, $h] = $info;
if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M');
$src = @imagecreatefrompng($dec);
if (!$src) { @unlink($dec); return $fail('JPEG XL ilegível'); }
$scale = min(1, $max_dim / max($w, $h, 1));
$nw = max(1, (int) round($w * $scale));
$nh = max(1, (int) round($h * $scale));
if ($scale < 1) {
$dst = imagecreatetruecolor($nw, $nh);
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127));
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($src);
$src = $dst;
}
// Fallback: WebP keeps transparency; without WebP in GD, JPEG.
img_fallback_follow($path);
if (function_exists('imagewebp')) {
imagesavealpha($src, true);
$ok = imagewebp($src, $path . '.webp', IMG_WEBP_QUALITY);
} else {
imageinterlace($src, true);
$ok = imagejpeg($src, $path . '.jpg', IMG_JPEG_QUALITY);
}
// Smaller JXL: re-encode from the resized pixels when there is an encoder.
$saved = 0;
if ($ok && $scale < 1 && imagepng($src, $dec)) {
$tmp = $path . '.opt.tmp';
$before = (int) filesize($path);
if (img_jxl_encode($dec, $tmp) && filesize($tmp) < $before) {
if (!@rename($tmp, $path)) { @unlink($path); @rename($tmp, $path); }
clearstatcache(true, $path);
$saved = max(0, $before - (int) filesize($path));
}
@unlink($tmp);
}
imagedestroy($src);
@unlink($dec);
if (!$ok) return $fail('falha ao gravar a versão alternativa');
// The fallback must not look older than the .jxl (idempotence check above).
@touch(img_jxl_fallback($path));
return ['status' => 'resized', 'saved' => $saved,
'reason' => ($scale < 1 ? "{$w}×{$h} → {$nw}×{$nh}, " : '') . 'versão alternativa criada'];
}
// ─── Servir imagens (JPEG XL com alternativa) ────────────────────────────────
// $rel é o caminho relativo à raiz do projeto (o que a BD guarda / o glob dá).
function _img_abs(string $rel): string {
return dirname(__DIR__) . '/' . ltrim($rel, '/');
}
// Fallback of a .jxl as a project-relative path, or '' (not JXL / no fallback).
function img_fallback_rel(string $rel): string {
if (!preg_match('/\.jxl$/i', $rel)) return '';
$fb = img_jxl_fallback(_img_abs($rel));
return $fb === '' ? '' : $rel . substr($fb, -strlen(strrchr($fb, '.')));
}
// Where only one image fits (lightbox, og:image, preload, e-mail): the
// fallback of a .jxl when there is one, else the file itself.
function img_compat_rel(string $rel): string {
return img_fallback_rel($rel) ?: $rel;
}
// <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'])));
}
+27 -3
View File
@@ -29,14 +29,38 @@ function site_info(PDO $pdo, ?int $gym_id = null): array {
return $row;
}
/** Whether the home page renders section $sec for $gym (same data checks as site/index.php). */
function site_section_has_content(array $gym, string $sec): bool {
$pdo = $GLOBALS['pdo'] ?? null;
$gid = (int) $gym['id'];
if (!$pdo && $sec !== 'galeria' && $sec !== 'comunidade') return true;
return match ($sec) {
'modalidades' => (bool) site_modalidades($pdo, $gid),
'equipa' => (bool) site_team_active($pdo, $gid),
'horario' => (bool) site_horario($pdo, '', $gid),
'mensalidades' => (bool) site_memberships($pdo, $gid),
'galeria' => !empty($gym['gallery_id']) && img_list_dir('uploads/gallery/' . (int) $gym['gallery_id']),
'comunidade' => (bool) site_instagram($gym),
default => true,
};
}
/** Team members shown on the site (status «ativo»). */
function site_team_active(PDO $pdo, ?int $gym_id = null): array {
return array_values(array_filter(site_team($pdo, $gym_id), fn($d) => ($d['status'] ?? '') === 'ativo'));
}
// Top navigation (label => absolute URL): anchors on the gym's own page, only
// for the sections that gym shows, plus its CrossFit page when it has one.
// for the sections that gym shows and has content for, plus its CrossFit page when it has one.
function site_navbar(?array $gym = null): array {
$gym = $gym ?? current_gym();
$items = ['Home' => ['#home', null]];
foreach (['Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
foreach (['Galeria' => 'galeria', 'Comunidade' => 'comunidade', 'Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) {
if (gym_section_on($gym, $sec)) $items[$label] = ['#' . $sec, $sec];
if (!gym_section_on($gym, $sec)) continue;
// A section with nothing to show isn't rendered, so no link to it either.
if (!site_section_has_content($gym, $sec)) continue;
$items[$label] = ['#' . $sec, $sec];
}
$nav = [];
foreach ($items as $label => [$hash]) $nav[$label] = gym_url($gym, $hash);
+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);
}
}
@@ -0,0 +1,8 @@
-- Cor de marca por ginásio (hex #RRGGBB). Vazio = verde FitMe por defeito.
-- Ginásio 1 = verde; o ginásio 2 usa vermelho (definir em Gestão → Ginásios).
-- Safe to re-run.
ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `brand_color` VARCHAR(7) NOT NULL DEFAULT '' AFTER `has_crossfit`;
UPDATE `gyms` SET `brand_color` = '#C2D500' WHERE `id` = 1 AND `brand_color` = '';
-- Logótipo do ginásio (caminho relativo, ex.: site/assets/img/logos/ginasio.svg).
ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `logo` VARCHAR(255) NOT NULL DEFAULT '' AFTER `brand_color`;
+4
View File
@@ -0,0 +1,4 @@
-- Logótipo do ginásio. A coluna foi acrescentada a migration_20261013_gym_brand_color.sql
-- depois de essa migração já ter corrido nalgumas bases (dev incluída), que por isso
-- ficaram sem ela. Safe to re-run.
ALTER TABLE `gyms` ADD COLUMN IF NOT EXISTS `logo` VARCHAR(255) NOT NULL DEFAULT '' AFTER `brand_color`;
+2 -1
View File
@@ -13,7 +13,8 @@ if (!is_file($_dev_shared)) { http_response_code(404); exit; } // submódulo s
require_once $_dev_shared;
platform_dev_login_handle([
'roles' => ['admin' => 'admin@mail.pt', 'sysadmin' => 'sysadmin@mail.pt'],
'roles' => ['client' => 'client@mail.pt', 'staff' => 'staff@mail.pt',
'admin' => 'admin@mail.pt', 'sysadmin' => 'sysadmin@mail.pt'],
'bootstrap' => function () { global $pdo; require __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/functions.php'; },
'csrf_ok' => fn(): bool => is_string($_POST['_csrf'] ?? null) && is_string($_SESSION['csrf_token'] ?? null)
&& $_SESSION['csrf_token'] !== '' && hash_equals($_SESSION['csrf_token'], $_POST['_csrf']),
+1
View File
@@ -4,5 +4,6 @@
</main>
<script src="<?= base_url('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>"></script>
<?= script_tag('assets/js/password-toggle.js') ?>
<?php include __DIR__ . '/dev_toolbar.php'; // DEV ONLY: sem saída fora de dev ?>
</body>
</html>
+3 -1
View File
@@ -29,7 +29,8 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
})();
</script>
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/bs-brand.css'), ENT_QUOTES) ?>" rel="stylesheet">
<?= fit_icon_assets(['bootstrap-icons'], ['assets/vendor/bootstrap-icons/bootstrap-icons.css']) ?>
<style>
:root {
--bo-accent: #C2D500;
@@ -65,6 +66,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
/* 16px inputs stop iOS from auto-zooming on focus */
@media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } }
</style>
<?php include __DIR__ . '/env_ribbon.php'; // moldura de aviso fora de produção ?>
</head>
<body>
<main class="auth-card">
+2 -2
View File
@@ -1,11 +1,11 @@
<?php
// DEV ONLY -- barra de login de um clique (platform-tools). Não imprime nada
// fora de dev. Incluída por includes/footer.php.
// fora de dev. Incluída por includes/footer.php, auth_footer.php e site/includes/footer.php.
require_once __DIR__ . '/../config/dev_guard.php';
if (!function_exists('platform_dev_toolbar')) return;
platform_dev_toolbar([
'login_url' => base_url('dev_login'),
'logout_url' => base_url('views/auth/logout.php'),
'roles' => ['admin' => 'Admin', 'sysadmin' => 'Sysadmin'],
'roles' => ['client' => 'Cliente', 'staff' => 'Staff', 'admin' => 'Admin', 'sysadmin' => 'Sysadmin'],
'csrf_field' => 'csrf_field',
]);
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* Non-production environment marker.
*
* Dev and prod are the same codebase on near-identical URLs, so it is genuinely
* easy to edit a real client's session while believing you are on dev. This
* paints an unmissable hazard frame around every page whenever APP_ENV is not
* 'prod', plus a corner chip naming the environment.
*
* Included from <head> in both includes/header.php (backoffice) and
* site/includes/head.php (public site). It hangs off html::before / html::after
* rather than a wrapper element, so no page markup has to change and nothing
* can shift the layout: the frame is fixed, on top, and click-through.
*
* Reads env() directly rather than the APP_ENV constant, because the public
* site does not always load config/functions.php.
*/
$_env = strtolower((string) (function_exists('env') ? env('APP_ENV', 'prod') : 'prod'));
if ($_env === 'prod' || $_env === 'production') return;
?>
<style>
/* Faixa de aviso de ambiente — só fora de produção. */
html::after {
content: '';
position: fixed;
inset: 0;
z-index: 2147483646;
pointer-events: none; /* nunca intercepta cliques */
border: 6px solid transparent;
border-image: repeating-linear-gradient(
45deg,
#111 0 14px,
#f5c400 14px 28px
) 6;
}
html::before {
content: '<?= htmlspecialchars(strtoupper($_env), ENT_QUOTES) ?>';
position: fixed;
top: 6px;
left: 50%;
transform: translateX(-50%);
z-index: 2147483647;
pointer-events: none;
padding: .1rem .8rem .15rem;
background: #f5c400;
color: #111;
font: 700 .7rem/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: .18em;
border-radius: 0 0 .35rem .35rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, .35);
}
/* Em ecrãs pequenos a moldura rouba demasiado espaço horizontal. */
@media (max-width: 575.98px) {
html::after { border-width: 4px; border-image-slice: 4; }
html::before { font-size: .6rem; top: 4px; }
}
/* Ao imprimir não faz sentido nenhum. */
@media print {
html::after, html::before { display: none; }
}
</style>
+83 -13
View File
@@ -78,23 +78,93 @@ document.addEventListener('DOMContentLoaded', function () {
mq.addEventListener('change', function () { if (pref === 'auto') apply('auto'); });
})();
// Mobile off-canvas sidebar: hamburger toggles .sidebar.open + the backdrop;
// tapping the backdrop, a nav link, Escape, or resizing to desktop closes it.
// Barra lateral. Em ecrã grande o hamburger alterna entre completa e mini (so
// o texto das categorias, lembrado em localStorage); em telemovel abre/fecha a
// gaveta por cima do conteudo. Um so manipulador para os dois botoes. O CSS
// decide o que cada estado mostra; o estado inicial ja veio do <head>.
(function () {
var sb = document.getElementById('sidebar');
var tg = document.getElementById('menuToggle');
var bd = document.getElementById('sidebarBackdrop');
if (!sb || !tg) return;
var mq = window.matchMedia('(max-width: 991.98px)');
function open() { sb.classList.add('open'); if (bd) bd.classList.add('show'); document.body.style.overflow = 'hidden'; tg.setAttribute('aria-expanded', 'true'); }
function close() { sb.classList.remove('open'); if (bd) bd.classList.remove('show'); document.body.style.overflow = ''; tg.setAttribute('aria-expanded', 'false'); }
tg.addEventListener('click', function () { sb.classList.contains('open') ? close() : open(); });
if (bd) bd.addEventListener('click', close);
sb.querySelectorAll('.nav-link').forEach(function (a) {
a.addEventListener('click', function () { if (mq.matches) close(); });
var toggles = document.querySelectorAll('[data-sidebar-toggle]');
var root = document.documentElement;
var nav = sb && sb.querySelector('.bo-sidebar__nav');
if (!sb || !toggles.length) return;
var mq = window.matchMedia('(min-width: 992px)');
function store(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function sync() {
var desktop = mq.matches;
var open = desktop ? !root.classList.contains('bo-mini') : sb.classList.contains('open');
toggles.forEach(function (t) {
t.setAttribute('aria-expanded', open ? 'true' : 'false');
t.setAttribute('aria-label', open ? (desktop ? 'Reduzir menu' : 'Fechar menu') : (desktop ? 'Expandir menu' : 'Abrir menu'));
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
window.addEventListener('resize', function () { if (!mq.matches) close(); });
if (bd) bd.classList.toggle('show', !desktop && open);
document.body.style.overflow = (!desktop && open) ? 'hidden' : '';
}
function setDrawer(open) { sb.classList.toggle('open', open); sync(); }
toggles.forEach(function (t) {
t.addEventListener('click', function () {
if (mq.matches) {
store('bo-sidebar-mini', root.classList.toggle('bo-mini') ? '1' : '0');
sync();
} else {
setDrawer(!sb.classList.contains('open'));
}
});
});
if (bd) bd.addEventListener('click', function () { setDrawer(false); });
sb.querySelectorAll('.bo-nav-item').forEach(function (a) {
a.addEventListener('click', function () { if (!mq.matches) setDrawer(false); });
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && sb.classList.contains('open')) {
setDrawer(false);
var o = document.querySelector('.menu-toggle');
if (o && o.offsetParent) o.focus();
}
});
if (mq.addEventListener) mq.addEventListener('change', function () { setDrawer(false); });
sync();
// Grupos recolhiveis. O estado inicial veio do <head> (atributo no <html>);
// aqui so se liga o clique e se mantem atributo, armazenamento e aria alinhados.
var saved = {};
try { saved = JSON.parse(localStorage.getItem('bo-nav-groups') || '{}'); } catch (e) {}
function groupOpen(g) {
if (g.classList.contains('has-active')) return !g.classList.contains('is-closed');
return !saved[g.getAttribute('data-group')];
}
function paint(g) {
g.querySelector('[data-group-toggle]').setAttribute('aria-expanded', groupOpen(g) ? 'true' : 'false');
}
var groups = sb.querySelectorAll('.bo-nav-group[data-group]');
groups.forEach(function (g) {
g.querySelector('[data-group-toggle]').addEventListener('click', function () {
var key = g.getAttribute('data-group');
var willClose = groupOpen(g);
if (g.classList.contains('has-active')) g.classList.toggle('is-closed', willClose);
if (willClose) saved[key] = true; else delete saved[key];
store('bo-nav-groups', JSON.stringify(saved));
root.setAttribute('data-bo-groups', Object.keys(saved).join(' '));
groups.forEach(paint);
});
});
groups.forEach(paint);
// A lista rola sozinha: lembrar a posicao entre paginas e mostrar o item ativo.
if (nav) {
try { nav.scrollTop = +sessionStorage.getItem('bo-nav-scroll') || 0; } catch (e) {}
var cur = nav.querySelector('[aria-current="page"]');
if (cur) {
var r = cur.getBoundingClientRect(), n = nav.getBoundingClientRect();
if (r.top < n.top || r.bottom > n.bottom) cur.scrollIntoView({ block: 'center' });
}
addEventListener('pagehide', function () {
try { sessionStorage.setItem('bo-nav-scroll', nav.scrollTop); } catch (e) {}
});
}
})();
</script>
<style>
+85 -183
View File
@@ -25,203 +25,111 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
document.documentElement.setAttribute('data-theme-pref', pref);
})();
</script>
<script>
// Estado da barra lateral antes da primeira pintura (senão cada página salta
// de 264 para 80px): modo mini no <html> e grupos fechados em data-bo-groups.
try{var d=document.documentElement;if(localStorage.getItem('bo-sidebar-mini')==='1')d.classList.add('bo-mini');
var g=JSON.parse(localStorage.getItem('bo-nav-groups')||'{}');d.setAttribute('data-bo-groups',Object.keys(g).filter(function(k){return g[k]}).join(' '));}catch(e){}
</script>
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/bs-brand.css'), ENT_QUOTES) ?>" rel="stylesheet">
<?= fit_icon_assets(['bootstrap-icons'], ['assets/vendor/bootstrap-icons/bootstrap-icons.css']) ?>
<link href="<?= base_url('assets/vendor/summernote/summernote-bs5.min.css') ?>" rel="stylesheet">
<style>
:root {
--bo-accent: #C2D500;
--bo-accent-rgb: 194,213,0;
--bo-dark: #0a0a0a;
--bo-dark-2: #151515;
--bo-surface: #eef0f4;
}
[data-bs-theme="dark"] { --bo-surface: #121416; }
body { background: var(--bo-surface); font-size: .925rem; }
.sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 100;
padding: 68px 0 0; width: 240px; background: var(--bo-dark); overflow-y: auto; }
.sidebar .nav-link { color: rgba(255,255,255,.55); padding: .55rem 1.25rem; font-size: .875rem;
border-left: 3px solid transparent; transition: all .2s; }
.sidebar .nav-link:hover { color: #fff; background: rgba(255,255,255,.06); border-left-color: rgba(255,255,255,.2); }
.sidebar .nav-link.active { color: #fff; background: rgba(var(--bo-accent-rgb),.12); border-left-color: var(--bo-accent); }
.sidebar .nav-link i { width: 20px; margin-right: .5rem; text-align: center; }
.sidebar .sidebar-brand { position: fixed; top: 0; left: 0; width: 240px; padding: 1rem 1.25rem;
color: #fff; font-weight: 700; font-size: 1.1rem; background: var(--bo-dark);
z-index: 101; letter-spacing: .5px; border-bottom: 1px solid rgba(255,255,255,.06); }
.sidebar .sidebar-brand i { color: var(--bo-accent); }
.sidebar .sidebar-section { color: rgba(255,255,255,.3); font-size: .65rem; text-transform: uppercase;
letter-spacing: 1.5px; padding: 1.25rem 1.25rem .35rem; }
main { margin-left: 240px; padding-top: 68px; }
.topbar { position: fixed; top: 0; left: 240px; right: 0; z-index: 99; height: 56px;
background: var(--bs-body-bg); border-bottom: 1px solid var(--bs-border-color); padding: 0 1.5rem;
display: flex; align-items: center; justify-content: space-between; }
.topbar .user-dropdown { display: flex; align-items: center; gap: .5rem; margin-left: auto; }
.badge-role-sysadmin { background: var(--bo-accent); color: var(--bo-dark); }
.badge-role-admin { background: var(--bo-accent); color: var(--bo-dark); opacity: .85; }
.badge-role-staff { background: #6f42c1; }
.badge-role-client { background: #adb5bd; color: #212529; }
.btn-outline-primary { color: var(--bo-accent); border-color: var(--bo-accent); }
.btn-outline-primary:hover { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
.btn-primary:hover { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); }
.card { border: none; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
[data-bs-theme="dark"] .table-light { --bs-table-bg: var(--bs-tertiary-bg); --bs-table-color: var(--bs-body-color); }
.theme-toggle { min-width: 44px; min-height: 44px; }
a { color: var(--bs-emphasis-color); }
/* Summernote (WYSIWYG) — align with the backoffice look */
.note-editor.note-frame { border-color: #ced4da; border-radius: .375rem; }
.note-editor .note-editing-area .note-editable { font-size: .95rem; }
.note-editor.note-frame.note-focused { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); }
.note-toolbar { background: var(--bs-tertiary-bg); }
.note-btn.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
/* ── Mobile: off-canvas sidebar + hamburger ──────────────────────────── */
.sidebar { transition: transform .25s ease; }
.menu-toggle { display: none; width: 44px; height: 44px; padding: 0; border: 1px solid var(--bs-border-color);
background: var(--bs-body-bg); color: var(--bs-body-color); border-radius: 8px; font-size: 1.35rem;
line-height: 1; align-items: center; justify-content: center; }
.sidebar-backdrop { display: none; position: fixed; inset: 0; z-index: 1090; background: rgba(0,0,0,.45); }
.sidebar-backdrop.show { display: block; }
@media (max-width: 991.98px) {
.sidebar { transform: translateX(-100%); width: 264px; z-index: 1100;
box-shadow: 2px 0 18px rgba(0,0,0,.3); }
.sidebar .sidebar-brand { width: 264px; z-index: 1101; }
.sidebar.open { transform: translateX(0); }
main { margin-left: 0; }
.topbar { left: 0; padding: 0 .75rem; }
.menu-toggle { display: inline-flex; }
/* Wide tables scroll inside their card instead of overflowing the page */
main .card > .card-body { overflow-x: auto; -webkit-overflow-scrolling: touch; }
}
@media (max-width: 575.98px) {
.topbar .user-dropdown .badge,
.topbar .user-dropdown .u-name { display: none; }
/* Page-header rows (title + action buttons) stack instead of overflowing */
main .d-flex.justify-content-between.align-items-center { flex-wrap: wrap; gap: .5rem; }
/* 16px inputs stop iOS from auto-zooming on focus */
.form-control, .form-select { font-size: 16px; }
/* Comfortable content gutters on phones */
.p-3 { padding: .85rem !important; }
}
</style>
<link href="<?= htmlspecialchars(_asset_versioned('assets/css/backoffice.css'), ENT_QUOTES) ?>" rel="stylesheet">
<?php include __DIR__ . '/env_ribbon.php'; // moldura de aviso fora de produção ?>
</head>
<body>
<nav class="sidebar" id="sidebar">
<div class="sidebar-brand"><i class="bi bi-lightning-charge-fill me-2"></i><?= htmlspecialchars($app_name) ?></div>
<div class="sidebar-section">Principal</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/admin/dashboard.php' ? ' active' : '' ?>"
href="<?= base_url('views/admin/dashboard.php') ?>"><i class="bi bi-speedometer2"></i>Painel</a>
</li>
<?php if (is_staff()): ?>
<li class="nav-item">
<a class="nav-link" href="<?= base_url('/') ?>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i>Site público</a>
</li>
<?php endif; ?>
<?php require_once __DIR__ . '/nav_helpers.php'; $dash = bo_dashboard_path(); ?>
<aside class="sidebar" id="sidebar" aria-label="Menu principal">
<div class="bo-sidebar__head">
<button type="button" class="bo-sidebar__toggle" data-sidebar-toggle
aria-label="Reduzir menu" aria-expanded="true" aria-controls="sidebar">
<i class="bi bi-list" aria-hidden="true"></i>
</button>
<a class="bo-sidebar__brand" href="<?= base_url($dash) ?>"><i class="bi bi-lightning-charge me-2" aria-hidden="true"></i><?= htmlspecialchars($app_name) ?></a>
<?php // Único sítio que abre o site público (nova aba). ?>
<a class="bo-sidebar__site" href="<?= base_url('/') ?>" target="_blank" rel="noopener"
title="Ver site público" aria-label="Ver site público"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i></a>
</div>
<div class="dropdown bo-sidebar__user">
<button class="bo-user-menu dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"
title="<?= htmlspecialchars($user['name']) ?>">
<span class="bo-user-menu__avatar" aria-hidden="true"><?= htmlspecialchars(bo_initials((string) $user['name'])) ?></span>
<span class="bo-user-menu__text">
<span class="bo-user-menu__name"><?= htmlspecialchars($user['name']) ?></span>
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>"><?= htmlspecialchars(role_label($user['role'])) ?></span>
</span>
</button>
<ul class="dropdown-menu">
<li class="px-3 pt-2 pb-1"><div class="fw-semibold small lh-sm"><?= htmlspecialchars($user['name']) ?></div></li>
<li><hr class="dropdown-divider my-1"></li>
<li><a class="dropdown-item" href="<?= base_url('views/user/profile.php') ?>"><i class="bi bi-person me-2" aria-hidden="true"></i>Perfil</a></li>
<li><a class="dropdown-item text-danger" href="<?= base_url('views/auth/logout.php') ?>"><i class="bi bi-box-arrow-right me-2" aria-hidden="true"></i>Terminar sessão</a></li>
</ul>
</div>
<nav class="bo-sidebar__nav" aria-label="Navegação">
<?= bo_nav_link($dash, 'bi-speedometer2', 'Painel', ['mod' => 'primary']) ?>
<?php if (is_admin()): ?>
<div class="sidebar-section">Gestão</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/gyms/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/gyms/index.php') ?>"><i class="bi bi-buildings-fill"></i>Ginásios</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/team/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/team/index.php') ?>"><i class="bi bi-people-fill"></i>Equipa</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/modalidades/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/modalidades/index.php') ?>"><i class="bi bi-grid-3x3-gap-fill"></i>Modalidades</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/galerias/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/galerias/index.php') ?>"><i class="bi bi-images"></i>Galerias</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/events/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/events/index.php') ?>"><i class="bi bi-megaphone-fill"></i>Eventos</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/work_hours/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/work_hours/index.php') ?>"><i class="bi bi-clock-fill"></i>Horário de Funcionamento</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/horario/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/horario/index.php') ?>"><i class="bi bi-calendar-week-fill"></i>Horário de Aulas</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/memberships/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/memberships/index.php') ?>"><i class="bi bi-credit-card-2-front-fill"></i>Mensalidades</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trials/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/trials/index.php') ?>"><i class="bi bi-person-check-fill"></i>Aulas experimentais<?php
$__tn = function_exists('db_trial_leads_count_new') ? db_trial_leads_count_new($pdo, admin_gym_id()) : 0;
if ($__tn): ?> <span class="badge bg-danger ms-1"><?= (int) $__tn ?></span><?php endif; ?></a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/reviews/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/reviews/index.php') ?>"><i class="bi bi-star-fill"></i>Reviews Google</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/info/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/info/edit.php') ?>"><i class="bi bi-telephone-fill"></i>Contactos</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/trash/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/trash/index.php') ?>"><i class="bi bi-trash3-fill"></i>Lixeira</a>
</li>
<li class="nav-item">
<a class="nav-link<?= (strpos($_SERVER['SCRIPT_NAME'] ?? '', '/views/admin/rgpd/') !== false) ? ' active' : '' ?>"
href="<?= base_url('views/admin/rgpd/index.php') ?>"><i class="bi bi-shield-lock-fill"></i>Pedidos RGPD</a>
</li>
</ul>
<?php endif; ?>
<?php bo_nav_group_start('gestao', 'Gestão', 'bi-briefcase'); ?>
<?php bo_nav_group_start('site', 'Site', 'bi-window', true); ?>
<?= bo_nav_link('views/admin/gyms/index.php', 'bi-buildings', 'Ginásios', ['mod' => 'sub2', 'match' => 'views/admin/gyms/']) ?>
<?= bo_nav_link('views/admin/modalidades/index.php', 'bi-grid-3x3-gap', 'Modalidades', ['mod' => 'sub2', 'match' => 'views/admin/modalidades/']) ?>
<?= bo_nav_link('views/admin/galerias/index.php', 'bi-images', 'Galerias', ['mod' => 'sub2', 'match' => 'views/admin/galerias/']) ?>
<?= bo_nav_link('views/admin/events/index.php', 'bi-megaphone', 'Eventos', ['mod' => 'sub2', 'match' => 'views/admin/events/']) ?>
<?= bo_nav_link('views/admin/info/edit.php', 'bi-telephone', 'Contactos', ['mod' => 'sub2', 'match' => 'views/admin/info/']) ?>
<?= bo_nav_link('views/admin/reviews/index.php', 'bi-star', 'Reviews Google', ['mod' => 'sub2', 'match' => 'views/admin/reviews/']) ?>
<?= bo_nav_link('views/admin/instagram/index.php', 'bi-instagram', 'Comunidade (Instagram)', ['mod' => 'sub2', 'match' => 'views/admin/instagram/']) ?>
<?php bo_nav_group_end(); ?>
<?php bo_nav_group_start('horarios', 'Horários', 'bi-clock-history', true); ?>
<?= bo_nav_link('views/admin/work_hours/index.php', 'bi-clock', 'Horário de Funcionamento', ['mod' => 'sub2', 'match' => 'views/admin/work_hours/']) ?>
<?= bo_nav_link('views/admin/horario/index.php', 'bi-calendar-week', 'Horário de Aulas', ['mod' => 'sub2', 'match' => 'views/admin/horario/']) ?>
<?php bo_nav_group_end(); ?>
<?php bo_nav_group_start('comercial', 'Comercial', 'bi-cash-coin', true); ?>
<?= bo_nav_link('views/admin/memberships/index.php', 'bi-credit-card-2-front', 'Mensalidades', ['mod' => 'sub2', 'match' => 'views/admin/memberships/']) ?>
<?php $__tn = function_exists('db_trial_leads_count_new') ? db_trial_leads_count_new($pdo, admin_gym_id()) : 0; ?>
<?= bo_nav_link('views/admin/trials/index.php', 'bi-person-check', 'Aulas experimentais', ['mod' => 'sub2', 'match' => 'views/admin/trials/', 'badge' => $__tn]) ?>
<?= bo_nav_link('views/admin/team/index.php', 'bi-person-workspace', 'Equipa', ['mod' => 'sub2', 'match' => 'views/admin/team/']) ?>
<?php bo_nav_group_end(); ?>
<?php bo_nav_group_start('legal', 'Legal', 'bi-shield-check', true); ?>
<?= bo_nav_link('views/admin/rgpd/index.php', 'bi-file-earmark-lock', 'Pedidos RGPD', ['mod' => 'sub2', 'match' => 'views/admin/rgpd/']) ?>
<?= bo_nav_link('views/admin/trash/index.php', 'bi-trash3', 'Lixeira', ['mod' => 'sub2', 'match' => 'views/admin/trash/']) ?>
<?php bo_nav_group_end(); ?>
<?php bo_nav_group_end(); ?>
<?php if (is_admin()): ?>
<div class="sidebar-section">Utilizadores</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/user/index.php' ? ' active' : '' ?>"
href="<?= base_url('views/user/index.php') ?>"><i class="bi bi-person-badge"></i>Utilizadores</a>
</li>
</ul>
<?php bo_nav_group_start('utilizadores', 'Utilizadores', 'bi-people'); ?>
<?= bo_nav_link('views/user/index.php', 'bi-person-vcard', 'Utilizadores', ['mod' => 'sub', 'match' => ['views/user/index.php', 'views/user/edit.php']]) ?>
<?php bo_nav_group_end(); ?>
<?php endif; ?>
<?php if (is_sysadmin()): ?>
<div class="sidebar-section">Sistema</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/system_settings.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/system_settings.php') ?>"><i class="bi bi-sliders"></i>Definições</a>
</li>
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/messages.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/messages.php') ?>"><i class="bi bi-envelope-paper"></i>Mensagens</a>
</li>
<li class="nav-item">
<a class="nav-link<?= ($_SERVER['SCRIPT_NAME'] ?? '') === '/views/sysadmin/comms.php' ? ' active' : '' ?>"
href="<?= base_url('views/sysadmin/comms.php') ?>"><i class="bi bi-send"></i>Comunicações</a>
</li>
<?php bo_nav_group_start('sistema', 'Sistema', 'bi-gear'); ?>
<?= bo_nav_link('views/sysadmin/system_settings.php', 'bi-sliders', 'Definições', ['mod' => 'sub']) ?>
<?= bo_nav_link('views/sysadmin/messages.php', 'bi-envelope-paper', 'Mensagens', ['mod' => 'sub']) ?>
<?= bo_nav_link('views/sysadmin/comms.php', 'bi-send', 'Comunicações', ['mod' => 'sub']) ?>
<?php // Backups/Auditoria/IP bans/Geo/Origem das visitas/Atualizações/Hub: um só
// link para o índice partilhado do platform-tools (como a fotografia/design/
// dance fazem), em vez de um por ferramenta — uma ferramenta nova no
// platform-tools passa a aparecer sozinha, sem precisar de link novo aqui. ?>
<li class="nav-item">
<a class="nav-link<?= (($_GET['tool'] ?? 'index') === 'index' && str_contains($_SERVER['REQUEST_URI'] ?? '', 'platform.php')) ? ' active' : '' ?>"
href="<?= base_url('platform.php?tool=index') ?>"><i class="bi bi-shield-lock"></i>Sistema</a>
</li>
</ul>
<?= bo_nav_link('platform.php?tool=index', 'bi-cpu', 'Sistema', ['mod' => 'sub',
'active' => (($_GET['tool'] ?? 'index') === 'index' && str_contains($_SERVER['REQUEST_URI'] ?? '', 'platform.php'))]) ?>
<?php if (function_exists('pt_icon_packs')): // pacotes de ícones só existem no submódulo novo ?>
<?= bo_nav_link('platform.php?tool=icons', 'bi-grid-3x3-gap', 'Pacotes de ícones', ['mod' => 'sub',
'active' => (($_GET['tool'] ?? '') === 'icons')]) ?>
<?php endif; ?>
<?php bo_nav_group_end(); ?>
<?php endif; ?>
</nav>
</aside>
<div class="sidebar-backdrop" id="sidebarBackdrop" aria-hidden="true"></div>
<main>
<div class="topbar">
<button type="button" class="menu-toggle" id="menuToggle" aria-label="Abrir menu" aria-controls="sidebar">
<i class="bi bi-list"></i>
<?php // Só em telemóvel: abre a gaveta (a barra está fora do ecrã). ?>
<button type="button" class="menu-toggle" data-sidebar-toggle aria-label="Abrir menu" aria-expanded="false" aria-controls="sidebar">
<i class="bi bi-list" aria-hidden="true"></i>
</button>
<?php
// Which gym the content pages edit (functions_gyms.php). Switching goes back
@@ -244,7 +152,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<noscript><button class="btn btn-sm btn-outline-secondary">Mudar</button></noscript>
</form>
<?php endif; ?>
<div class="user-dropdown">
<div class="bo-topbar__tools">
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary theme-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false" aria-label="Tema: automático" id="themeToggle">
@@ -256,12 +164,6 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<li><button type="button" class="dropdown-item" data-theme-set="auto"><i class="bi bi-circle-half me-2" aria-hidden="true"></i>Automático</button></li>
</ul>
</div>
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>">
<?= htmlspecialchars(role_label($user['role'])) ?>
</span>
<span class="u-name"><?= htmlspecialchars($user['name']) ?></span>
<a href="<?= base_url('views/user/profile.php') ?>" class="btn btn-sm btn-outline-secondary"><i class="bi bi-person"></i></a>
<a href="<?= base_url('views/auth/logout.php') ?>" class="btn btn-sm btn-outline-danger"><i class="bi bi-box-arrow-right"></i></a>
</div>
</div>
<?php
+96
View File
@@ -0,0 +1,96 @@
<?php
// Auxiliares da barra lateral do backoffice (incluído por includes/header.php).
// Ficam aqui, e não no HTML, para o menu ser só uma lista de itens: o ativo,
// o aria-current, o title/aria-label (o modo mini só mostra o ícone) e a
// abertura dos grupos decidem-se num sítio.
/** Painel de cada papel: o admin tem o seu, o staff o dele, o cliente a área de sócio. */
function bo_dashboard_path(): string
{
if (is_admin()) return 'views/admin/dashboard.php';
if (is_staff()) return 'views/staff/dashboard.php';
return 'views/user/dashboard.php';
}
/**
* O pedido atual está nesta página? Compara pelo FIM do caminho (e não com ===):
* em dev o site vive num subcaminho do host (/GitTea/repos/fitme3070.pt/...).
* Um caminho terminado em «/» é uma pasta inteira (inclui edit.php, create.php...).
* Aceita uma lista: basta uma coincidir.
*
* @param string|string[] $paths
*/
function bo_nav_active($paths): bool
{
$script = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? ''));
foreach ((array) $paths as $p) {
$p = '/' . ltrim($p, '/');
if (str_ends_with($p, '/') ? str_contains($script, $p) : str_ends_with($script, $p)) {
return true;
}
}
return false;
}
/**
* Uma ligação da barra lateral.
* Opções: match (caminho/s para o «ativo», por omissão o próprio href), mod
* ('sub' | 'sub2' | 'primary'), badge (inteiro; só aparece se > 0).
*/
function bo_nav_link(string $href, string $icon, string $label, array $o = []): string
{
$active = array_key_exists('active', $o) ? (bool) $o['active'] : bo_nav_active($o['match'] ?? strtok($href, '?'));
$mod = isset($o['mod']) ? ' bo-nav-item--' . $o['mod'] : '';
$l = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
$badge = (int) ($o['badge'] ?? 0);
return '<a class="nav-link bo-nav-item' . $mod . ($active ? ' active' : '') . '" href="' . htmlspecialchars(base_url($href), ENT_QUOTES, 'UTF-8') . '"'
. ' title="' . $l . '" aria-label="' . $l . '"' . ($active ? ' aria-current="page"' : '') . '>'
. '<i class="bi ' . htmlspecialchars($icon, ENT_QUOTES, 'UTF-8') . '" aria-hidden="true"></i>'
. '<span class="bo-nav-item__label">' . $l . '</span>'
. ($badge > 0 ? '<span class="badge bg-danger ms-auto bo-nav-item__badge">' . $badge . '</span>' : '')
. '</a>' . "\n";
}
/**
* Abre um grupo recolhível. O conteúdo fica em buffer até bo_nav_group_end(),
* que precisa de saber se algum item é o ativo (o grupo da página atual abre
* sempre). $sub = subcategoria dentro de outro grupo (recuada).
*/
function bo_nav_group_start(string $key, string $label, string $icon, bool $sub = false): void
{
$GLOBALS['bo_nav_group_stack'][] = [$key, $label, $icon, $sub];
ob_start();
}
/** Fecha o grupo aberto por bo_nav_group_start() e escreve o markup final. */
function bo_nav_group_end(): void
{
$items = (string) ob_get_clean();
[$key, $label, $icon, $sub] = array_pop($GLOBALS['bo_nav_group_stack']);
$hasActive = str_contains($items, 'aria-current="page"');
$id = 'bo-grp-' . $key;
$k = htmlspecialchars($key, ENT_QUOTES, 'UTF-8');
$l = htmlspecialchars($label, ENT_QUOTES, 'UTF-8');
$i = htmlspecialchars($icon, ENT_QUOTES, 'UTF-8');
$short = htmlspecialchars(mb_strtoupper(mb_substr($label, 0, 4)), ENT_QUOTES, 'UTF-8');
echo '<div class="bo-nav-group' . ($sub ? ' bo-nav-group--sub' : '') . ($hasActive ? ' has-active' : '') . '" data-group="' . $k . '">'
. '<button class="bo-nav-group__label" type="button" data-short="' . $short . '" aria-expanded="true"'
. ' aria-controls="' . $id . '" data-group-toggle="' . $k . '" title="' . $l . '" aria-label="' . $l . '">'
. '<i class="bi ' . $i . ' bo-nav-group__icon" aria-hidden="true"></i>'
. '<span class="bo-nav-group__text">' . $l . '</span>'
. '<i class="bi bi-chevron-down bo-nav-group__chevron" aria-hidden="true"></i></button>'
. '<div class="bo-nav-group__body" id="' . $id . '"><div class="bo-nav-group__inner">' . $items . '</div></div>'
. '</div>' . "\n";
}
/** Iniciais para o avatar (até duas letras: primeiro e último nome). */
function bo_initials(string $name): string
{
// Só palavras que começam por letra: «Staff (dev)» dá «SD»... não «S(».
preg_match_all('/\p{L}[\p{L}\p{M}-]*/u', $name, $m);
$parts = $m[0];
if (!$parts) return '?';
$first = mb_substr($parts[0], 0, 1);
$last = count($parts) > 1 ? mb_substr($parts[count($parts) - 1], 0, 1) : '';
return mb_strtoupper($first . $last);
}
-18
View File
@@ -1,18 +0,0 @@
<?php
// Standalone top navbar — only outputs when header.php hasn't been included yet.
// (system_settings.php includes both header.php + navbar.php, so this defers.)
if (defined('ADMIN_SHELL_OPENED') && ADMIN_SHELL_OPENED) return;
?>
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
<nav class="navbar navbar-expand navbar-dark px-3 mb-0" style="position:sticky;top:0;z-index:99;background:var(--bo-accent, #C2D500);">
<a class="navbar-brand fw-bold text-dark" href="<?= base_url('views/admin/dashboard.php') ?>">
<i class="bi bi-lightning-charge-fill me-2"></i><?= htmlspecialchars(defined('APP_NAME') ? APP_NAME : 'FitMe 3070') ?>
</a>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<span class="nav-link text-dark"><?= htmlspecialchars($user['name'] ?? '') ?></span>
</li>
<li class="nav-item"><a class="nav-link text-dark" href="<?= base_url('views/auth/logout.php') ?>"><i class="bi bi-box-arrow-right"></i></a></li>
</ul>
</nav>
+282
View File
@@ -0,0 +1,282 @@
<?php
/**
* Style lab (DEV ONLY): the public site's components on one page, with sliders
* for the visual effects added to style.css, so values can be tuned by eye and
* then copied back into the CSS. Nothing here is saved; reload resets it.
* Refuses to run when APP_ENV is prod.
*/
require __DIR__ . '/config/db.php';
require __DIR__ . '/config/functions.php';
$env = strtolower((string) (function_exists('env') ? env('APP_ENV', 'prod') : 'prod'));
if ($env === 'prod' || $env === 'production') { http_response_code(404); exit; }
$gyms = function_exists('db_gyms_published') ? db_gyms_published($pdo) : [];
$img_team = base_url('uploads/team/foto/1.jpg');
$img_gal = [base_url('site/assets/img/home.jpg'), base_url('site/assets/img/crossfit.jpg'), base_url('site/assets/img/footer-bg-gym.jpg')];
?><!doctype html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Laboratório de estilo — FitMe</title>
<?= link_tag('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>
<?= link_tag('site/assets/fonts/fontawesome/css/all.css') ?>
<?= link_tag('site/assets/css/style.css') ?>
<style id="lab-overrides"></style>
<style>
body { padding-bottom: 40px; }
.lab-panel { position: fixed; top: 12px; right: 12px; z-index: 2000; width: 300px; max-height: calc(100vh - 24px); overflow: auto;
background: rgba(11,11,13,.94); color: #fff; padding: 14px 16px; border-radius: 8px; box-shadow: 0 8px 30px rgba(0,0,0,.5); font-size: .85rem; }
.lab-panel h2 { font-size: 1rem; margin: 0 0 10px; color: var(--fitme-accent, #C2D500); filter: none; }
.lab-panel label { display: flex; justify-content: space-between; gap: 8px; margin: 10px 0 2px; }
.lab-panel output { color: var(--fitme-accent, #C2D500); font-variant-numeric: tabular-nums; }
.lab-panel input[type=range] { width: 100%; accent-color: var(--fitme-accent, #C2D500); }
.lab-panel input[type=color] { width: 100%; height: 34px; border: 0; background: none; padding: 0; }
.lab-panel button.reset { margin-top: 12px; width: 100%; border: 1px solid #555; background: none; color: #fff; padding: 6px; border-radius: 4px; }
.lab-panel .hint { color: #aaa; font-size: .75rem; margin-top: 10px; }
.lab-sec { padding: 56px 0; }
.lab-sec.dark { background: var(--fitme-dark-2); color: #fff; }
.lab-sec.dark h1, .lab-sec.dark h2 { color: #fff; }
.lab-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 18px 0; }
.lab-label { font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; opacity: .6; margin: 26px 0 6px; }
.lab-gal { display: flex; gap: 12px; }
.lab-gal .gal-tile { width: 220px; height: 150px; }
.lab-card-wrap { max-width: 270px; }
@media (max-width: 991px) { .lab-panel { position: static; width: auto; max-height: none; margin: 12px; } }
</style>
</head>
<body>
<?php include __DIR__ . '/includes/env_ribbon.php'; ?>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<filter id="pt-noise" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
<feTurbulence id="lab-turb" type="fractalNoise" baseFrequency=".9" numOctaves="2" seed="4" result="n"/>
<feColorMatrix id="lab-sat" in="n" type="saturate" values="0" result="g"/>
<feGaussianBlur id="lab-blur" in="g" stdDeviation="0" result="gb"/>
<feComponentTransfer in="gb" result="grain">
<feFuncR id="lab-fr" type="linear" slope=".15" intercept=".86"/>
<feFuncG id="lab-fg" type="linear" slope=".15" intercept=".86"/>
<feFuncB id="lab-fb" type="linear" slope=".15" intercept=".86"/>
<feFuncA type="linear" slope="0" intercept="1"/>
</feComponentTransfer>
<feBlend id="lab-blend" in="SourceGraphic" in2="grain" mode="multiply" result="mix"/>
<feComposite in="mix" in2="SourceGraphic" operator="in"/>
</filter>
</svg>
<aside class="lab-panel" aria-label="Controlos">
<h2>Laboratório de estilo</h2>
<label for="c-color">Cor da marca <output id="o-color">#C2D500</output></label>
<input type="color" id="c-color" value="#c2d500">
<label for="c-btn">Opacidade do grão — botões <output id="o-btn"></output></label>
<input type="range" id="c-btn" min="0" max="50" value="16">
<label for="c-head">Opacidade do grão — títulos h1/h2 <output id="o-head"></output></label>
<input type="range" id="c-head" min="0" max="100" value="25">
<label for="c-sat">Saturação das fotos <output id="o-sat"></output></label>
<input type="range" id="c-sat" min="40" max="100" value="90">
<label for="c-glow">Brilho dos links (hover) <output id="o-glow"></output></label>
<input type="range" id="c-glow" min="0" max="100" value="60">
<label for="c-spin">Volta da aresta rotativa <output id="o-spin"></output></label>
<input type="range" id="c-spin" min="10" max="100" value="32">
<label for="c-ring">Espessura da aresta (px) <output id="o-ring"></output></label>
<input type="range" id="c-ring" min="1" max="6" value="2">
<h2 style="margin-top:18px">Textura do grão</h2>
<label for="c-type">Tipo</label>
<select id="c-type" class="form-select form-select-sm"><option value="fractalNoise">Fino (fractalNoise)</option><option value="turbulence">Manchado (turbulence)</option></select>
<label for="c-freq">Tamanho do grão (frequência) <output id="o-freq"></output></label>
<input type="range" id="c-freq" min="5" max="200" value="90">
<label for="c-oct">Detalhe (oitavas) <output id="o-oct"></output></label>
<input type="range" id="c-oct" min="1" max="5" value="2">
<label for="c-seed">Semente <output id="o-seed"></output></label>
<input type="range" id="c-seed" min="0" max="50" value="4">
<label for="c-contrast">Contraste do grão <output id="o-contrast"></output></label>
<input type="range" id="c-contrast" min="20" max="250" value="100">
<label for="c-soft">Suavidade (desfoque) <output id="o-soft"></output></label>
<input type="range" id="c-soft" min="0" max="20" value="0">
<label for="c-tile">Mosaico dos botões (px) <output id="o-tile"></output></label>
<input type="range" id="c-tile" min="40" max="400" value="140">
<label for="c-bbm">Mistura nos botões</label>
<select id="c-bbm" class="form-select form-select-sm"><option>normal</option><option>multiply</option><option>overlay</option><option>soft-light</option><option>hard-light</option><option>screen</option><option>darken</option><option>lighten</option></select>
<label for="c-hbm">Mistura nos títulos</label>
<select id="c-hbm" class="form-select form-select-sm"><option>multiply</option><option>overlay</option><option>soft-light</option><option>screen</option><option>darken</option><option>lighten</option></select>
<label style="justify-content:flex-start;align-items:center"><input type="checkbox" id="c-mono" checked> Só cinzento (sem cor no grão)</label>
<label for="c-css">CSS gerado (copiar)</label>
<textarea id="c-css" class="form-control form-control-sm" rows="9" readonly style="font-family:monospace;font-size:.7rem"></textarea>
<button type="button" class="reset" id="c-reset">Repor valores</button>
<p class="hint">Passa o rato pelos botões, links, fotos, moldura e cartão para ver os efeitos. Os valores não são guardados.</p>
</aside>
<section class="lab-sec dark">
<div class="container">
<h1>Ginásio, Box, Aulas <span style="color:var(--fitme-accent)">&amp; Acompanhamento</span></h1>
<div class="lab-row">
<a href="#" class="site-btn">Inscreve-te já!</a>
<a href="#" class="site-btn sb-ghost">Ghost</a>
<a href="#" class="site-btn sb-dark">Dark</a>
<a href="#" class="site-btn sb-line" style="color:#fff;border-color:#fff">Line</a>
<button type="button" class="hero-text-link">Marca uma aula experimental</button>
</div>
<p>Um <a href="#">link no texto</a> sobre fundo escuro.</p>
</div>
</section>
<section class="lab-sec">
<div class="container">
<div class="section-title text-center deco">
<span class="section-eyebrow">Secção</span>
<h2>Título de secção (h2)</h2>
<p class="section-subtitle">Subtítulo de exemplo para ver o grão no h2 sobre fundo claro.</p>
</div>
<div class="lab-label">Botões e links</div>
<div class="lab-row">
<a href="#" class="site-btn">Botão principal</a>
<a href="#" class="nav-link crossfit-nav-link" style="display:inline-block;margin:0 !important">CrossFit</a>
<a href="#" class="gym-switch-btn">FitMe Mira</a>
<?php foreach (array_slice($gyms, 0, 2) as $g): ?><a href="#" class="gym-switch-btn" style="<?= htmlspecialchars(gym_brand_vars($g)) ?>"><?= htmlspecialchars($g['name']) ?></a><?php endforeach; ?>
</div>
<p>Texto corrido com um <a href="#">link normal</a> e outro <a href="#">link mais comprido para testar</a>.</p>
<div class="lab-label">Galeria (fotos)</div>
<div class="lab-gal">
<?php foreach ($img_gal as $i => $src): ?>
<div class="gal-tile"><a href="#" class="gal-link"><img src="<?= htmlspecialchars($src) ?>" alt="" draggable="false"></a></div>
<?php endforeach; ?>
</div>
<div class="lab-label">Equipa (moldura)</div>
<div class="trainer-item" style="display:inline-block">
<div class="trainer-pic"><img src="<?= htmlspecialchars($img_team) ?>" alt=""></div>
<h3 style="color:inherit">Nome do membro</h3>
</div>
<div class="lab-label">Modalidades (ícone)</div>
<div class="row" style="max-width:520px">
<div class="col-6"><div class="icon-box-item"><div class="ib-icon"><i class="fas fa-dumbbell"></i></div><h3>Musculação</h3></div></div>
<div class="col-6"><div class="icon-box-item"><div class="ib-icon"><i class="fas fa-bicycle"></i></div><h3>Cycling</h3></div></div>
</div>
<div class="lab-label">Mensalidades (cartão)</div>
<div class="lab-card-wrap">
<div class="price-card"><h3>Plano exemplo</h3><p>Passa o rato por cima.</p><a href="#" class="site-btn">Inscrever</a></div>
</div>
</div>
</section>
<script>
(function () {
var root = document.documentElement, ov = document.getElementById('lab-overrides');
var $ = function (id) { return document.getElementById(id); };
var DEF = { color: '#c2d500', btn: 16, head: 25, sat: 90, glow: 60, spin: 32, ring: 2,
type: 'fractalNoise', freq: 90, oct: 2, seed: 4, contrast: 100, soft: 0, tile: 140, bbm: 'normal', hbm: 'multiply', mono: true };
var st = Object.assign({}, DEF);
function noiseUrl() {
var f = (st.freq / 100).toFixed(2), op = (st.btn / 100).toFixed(3), c = st.contrast / 100;
var svg = "<svg xmlns='http://www.w3.org/2000/svg' width='" + st.tile + "' height='" + st.tile + "'>" +
"<filter id='n' x='0' y='0' width='100%' height='100%'>" +
"<feTurbulence type='" + st.type + "' baseFrequency='" + f + "' numOctaves='" + st.oct + "' seed='" + st.seed + "' stitchTiles='stitch'/>" +
(st.mono ? "<feColorMatrix type='saturate' values='0'/>" : "") +
(st.soft > 0 ? "<feGaussianBlur stdDeviation='" + (st.soft / 10).toFixed(1) + "'/>" : "") +
(c !== 1 ? "<feComponentTransfer><feFuncR type='linear' slope='" + c + "' intercept='" + ((1 - c) / 2).toFixed(3) + "'/><feFuncG type='linear' slope='" + c + "' intercept='" + ((1 - c) / 2).toFixed(3) + "'/><feFuncB type='linear' slope='" + c + "' intercept='" + ((1 - c) / 2).toFixed(3) + "'/></feComponentTransfer>" : "") +
"</filter><rect width='100%' height='100%' filter='url(#n)' opacity='" + op + "'/></svg>";
return 'url("data:image/svg+xml,' + svg.replace(/</g, '%3C').replace(/>/g, '%3E').replace(/#/g, '%23').replace(/%(?![0-9A-F]{2})/g, '%25') + '")';
}
function brandVars(hex) {
var r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
var sh = function (k) { return '#' + [r, g, b].map(function (v) { return ('0' + Math.round(v * k).toString(16)).slice(-2); }).join('').toUpperCase(); };
var lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return { '--fitme-accent': hex.toUpperCase(), '--fitme-accent-rgb': r + ', ' + g + ', ' + b, '--fitme-accent-dark': sh(.86),
'--fitme-accent-ink': sh(lum > .5 ? .5 : .85), '--fitme-on-accent': lum > .5 ? '#0B0B0D' : '#FFFFFF' };
}
function apply() {
var v = brandVars(st.color);
Object.keys(v).forEach(function (k) { root.style.setProperty(k, v[k]); });
var url = noiseUrl();
root.style.setProperty('--noise', url);
// headings: the SVG filter
var s = st.head / 100, c = st.contrast / 100;
var slope = (s * .6 * c).toFixed(3), icpt = (1 - s * .6 * c / 2 - s * .6 * c / 2).toFixed(3);
['lab-fr', 'lab-fg', 'lab-fb'].forEach(function (id) { $(id).setAttribute('slope', slope); $(id).setAttribute('intercept', icpt); });
$('lab-turb').setAttribute('type', st.type);
$('lab-turb').setAttribute('baseFrequency', (st.freq / 100).toFixed(2));
$('lab-turb').setAttribute('numOctaves', st.oct);
$('lab-turb').setAttribute('seed', st.seed);
$('lab-sat').setAttribute('values', st.mono ? '0' : '1');
$('lab-blur').setAttribute('stdDeviation', (st.soft / 10).toFixed(1));
$('lab-blend').setAttribute('mode', st.hbm);
var glow = st.glow / 100, spin = (st.spin / 10).toFixed(1) + 's';
ov.textContent =
'.site-btn,.gym-switch-btn,.crossfit-nav-link { background-size: ' + st.tile + 'px ' + st.tile + 'px !important; background-blend-mode: ' + st.bbm + ' !important; }' +
'.gal-link img, .trainer-item .trainer-pic img { filter: saturate(' + (st.sat / 100) + '); }' +
'.gal-link:hover img, .trainer-item .trainer-pic:hover img { filter: saturate(1) brightness(1.05); }' +
'a:where(:not(.site-btn,.gym-switch-btn,.gal-link,.nav-link)):is(:hover,:focus-visible) { text-shadow: 0 0 10px rgba(var(--fitme-accent-rgb),' + (.6 * glow * 1.67).toFixed(2) + '), 0 0 22px rgba(var(--fitme-accent-rgb),' + (.3 * glow * 1.67).toFixed(2) + ') !important; }' +
'.site-btn::before,.site-btn::after,.gym-switch-btn::before,.gym-switch-btn::after,.price-card::before,.price-card::after,.gal-link::after,.icon-box-item .ib-icon::after,.trainer-item .trainer-pic:hover { animation-duration: ' + spin + ' !important; }' +
'.site-btn,.gym-switch-btn { --ring: ' + st.ring + 'px; }';
$('o-color').textContent = st.color.toUpperCase();
$('o-btn').textContent = st.btn + '%';
$('o-head').textContent = st.head + '%';
$('o-sat').textContent = st.sat + '%';
$('o-glow').textContent = st.glow + '%';
$('o-spin').textContent = spin;
$('o-ring').textContent = st.ring;
$('o-freq').textContent = (st.freq / 100).toFixed(2);
$('o-oct').textContent = st.oct;
$('o-seed').textContent = st.seed;
$('o-contrast').textContent = st.contrast + '%';
$('o-soft').textContent = (st.soft / 10).toFixed(1);
$('o-tile').textContent = st.tile;
$('c-css').value =
':root {\n --noise: ' + url + ';\n}\n' +
'.site-btn, .gym-switch-btn, .crossfit-nav-link {\n background-image: var(--noise) !important;\n background-size: ' + st.tile + 'px ' + st.tile + 'px !important;' +
(st.bbm !== 'normal' ? '\n background-blend-mode: ' + st.bbm + ' !important;' : '') + '\n}\n' +
'/* títulos: <filter id="pt-noise"> */\n' +
'feTurbulence type="' + st.type + '" baseFrequency="' + (st.freq / 100).toFixed(2) + '" numOctaves="' + st.oct + '" seed="' + st.seed + '"\n' +
'feColorMatrix saturate ' + (st.mono ? 0 : 1) + ' · feGaussianBlur ' + (st.soft / 10).toFixed(1) + '\n' +
'feFunc* slope="' + slope + '" intercept="' + icpt + '" · feBlend mode="' + st.hbm + '"';
}
var map = { 'c-color': 'color', 'c-btn': 'btn', 'c-head': 'head', 'c-sat': 'sat', 'c-glow': 'glow', 'c-spin': 'spin', 'c-ring': 'ring',
'c-type': 'type', 'c-freq': 'freq', 'c-oct': 'oct', 'c-seed': 'seed', 'c-contrast': 'contrast', 'c-soft': 'soft', 'c-tile': 'tile',
'c-bbm': 'bbm', 'c-hbm': 'hbm', 'c-mono': 'mono' };
var textual = { 'c-color': 1, 'c-type': 1, 'c-bbm': 1, 'c-hbm': 1 };
Object.keys(map).forEach(function (id) {
$(id).addEventListener('input', function () {
st[map[id]] = id === 'c-mono' ? this.checked : (textual[id] ? this.value : +this.value);
apply();
});
});
$('c-reset').addEventListener('click', function () {
st = Object.assign({}, DEF);
Object.keys(map).forEach(function (id) { if (id === 'c-mono') $(id).checked = DEF.mono; else $(id).value = DEF[map[id]]; });
apply();
});
apply();
})();
</script>
</body>
</html>
+5 -1
View File
@@ -9,4 +9,8 @@ declare(strict_types=1);
$_dev_shared = dirname(__DIR__) . '/shared/platform-tools/functions_dev.php';
if (!is_file($_dev_shared)) { fwrite(STDERR, "REFUSED: platform-tools sem functions_dev.php\n"); exit(1); }
require_once $_dev_shared;
exit(platform_dev_accounts_run(['flags' => ['is_active' => 1]]));
exit(platform_dev_accounts_run([
'flags' => ['is_active' => 1],
'accounts' => ['sysadmin@mail.pt' => 'sysadmin', 'admin@mail.pt' => 'admin',
'client@mail.pt' => 'client', 'staff@mail.pt' => 'staff'],
]));
+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');
+877 -124
View File
File diff suppressed because it is too large. Load diff
+113 -86
View File
@@ -710,10 +710,11 @@ document.addEventListener('DOMContentLoaded', function () {
opens the <dialog> instead: prev / next buttons, ← → keys, Esc, swipe on
touch, and a click on the dark backdrop closes it. */
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.gal-masonry').forEach(function (grid) {
document.querySelectorAll('.gal-marquee').forEach(function (grid) {
var box = grid.parentElement.nextElementSibling;
if (!box || !box.classList.contains('gal-lightbox') || typeof box.showModal !== 'function') return;
var links = Array.prototype.slice.call(grid.querySelectorAll('.gal-link'));
var all = Array.prototype.slice.call(grid.querySelectorAll('.gal-link'));
var links = all.filter(function (a) { return !a.hasAttribute('data-dup'); }); // the repeated copies open the same photo
var img = box.querySelector('.gal-lb-figure img');
var count = box.querySelector('.gal-lb-count');
var current = 0;
@@ -721,17 +722,18 @@ document.addEventListener('DOMContentLoaded', function () {
function show(i) {
current = (i + links.length) % links.length;
var src = links[current].querySelector('img');
img.src = links[current].getAttribute('href');
// The tile's chosen source (JXL inside <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 = '';
count.textContent = (current + 1) + ' / ' + links.length;
}
links.forEach(function (a, i) {
all.forEach(function (a) {
a.addEventListener('click', function (e) {
e.preventDefault();
show(i);
show(+a.getAttribute('data-index') || 0);
box.showModal();
});
});
@@ -744,7 +746,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (e.key === 'ArrowLeft') show(current - 1);
else if (e.key === 'ArrowRight') show(current + 1);
});
box.addEventListener('close', function () { links[current].focus(); });
box.addEventListener('close', function () { if (links[current]) links[current].focus(); });
var x0 = null;
box.addEventListener('touchstart', function (e) { x0 = e.touches[0].clientX; }, { passive: true });
@@ -757,91 +759,116 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
/* ── Galeria: masonry layout ─────────────────────────────────────────────────
A small hand-rolled masonry (no library): the grid is split into columns
(4 / 3 / 2 by width), and each tile — at its photo's true aspect ratio, from
the width/height attributes the PHP writes — is dropped into the shortest
column; tiles marked .gal-w take two adjacent columns (the pair whose taller
one is lowest), which is what staggers the rows. Tiles are absolutely
positioned with transforms, so a relayout on resize glides into place.
The first time the gallery scrolls into view the tiles rise in one by one. */
/* ── Scroll "hover": what sits in the middle of the screen looks hovered ───────
Trainer photos, cards and icons get .is-centered while their box crosses the
middle of the viewport (±12% of its height); inside a sliding strip
(Modalidades) they must also cross the horizontal middle. Gallery photos are
left out: they only light up on a real hover. Throttled, paused
while the tab is hidden, skipped with "reduce motion". The CSS mirrors the
:hover rules (style.css, "In the middle of the screen"). */
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.gal-masonry').forEach(function (grid) {
var tiles = Array.prototype.slice.call(grid.querySelectorAll('.gal-tile'));
if (!tiles.length) return;
grid.classList.add('is-masonry');
function ratio(tile) {
var img = tile.querySelector('img');
var w = +img.getAttribute('width') || img.naturalWidth || 4;
var h = +img.getAttribute('height') || img.naturalHeight || 3;
return h / w;
}
function layout() {
var width = grid.clientWidth;
var cols = width >= 900 ? 4 : width >= 520 ? 3 : 2;
var gap = width >= 520 ? 12 : 8;
var colW = (width - gap * (cols - 1)) / cols;
var heights = [];
for (var c = 0; c < cols; c++) heights.push(0);
tiles.forEach(function (tile) {
var span = (tile.classList.contains('gal-w') && cols > 1) ? 2 : 1;
// Best column (or pair of columns) = lowest top edge.
var pick = function (span) {
var best = 0, bestTop = Infinity, waste = 0;
for (var i = 0; i <= cols - span; i++) {
var cells = heights.slice(i, i + span);
var top = Math.max.apply(null, cells);
if (top < bestTop - 0.5) { bestTop = top; best = i; waste = top - Math.min.apply(null, cells); }
}
return [best, bestTop, waste];
};
var p = pick(span);
// A wide tile whose two columns end at different heights would
// leave a hole under the shorter one — use a single column then.
if (span === 2 && p[2] > 60) { span = 1; p = pick(1); }
var best = p[0], bestTop = p[1];
var w = colW * span + gap * (span - 1);
var h = Math.round(w * ratio(tile));
tile.style.width = w + 'px';
tile.style.height = h + 'px';
tile.style.transform = 'translate(' + (best * (colW + gap)) + 'px,' + bestTop + 'px)';
for (var j = best; j < best + span; j++) heights[j] = bestTop + h + gap;
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var items = Array.prototype.slice.call(document.querySelectorAll(
'.trainer-item .trainer-pic, .icon-box-item, .price-card')).map(function (el) {
return { el: el, strip: !!el.closest('.mod-scroll'), on: false };
});
if (!items.length) return;
var last = 0;
function tick(now) {
requestAnimationFrame(tick);
if (document.hidden || now - last < 120) return;
last = now;
var vw = window.innerWidth, vh = window.innerHeight, cy = vh / 2, band = vh * 0.12;
items.forEach(function (it) {
var r = it.el.getBoundingClientRect();
var on = r.bottom > 0 && r.top < vh &&
r.top <= cy + band && r.bottom >= cy - band &&
(!it.strip || (r.left <= vw / 2 && r.right >= vw / 2));
if (on !== it.on) { it.on = on; it.el.classList.toggle('is-centered', on); }
});
grid.style.height = Math.max(0, Math.max.apply(null, heights) - gap) + 'px';
}
layout();
// Re-lay out whenever the grid's own width changes (window resize, but
// also a gallery that was laid out while hidden / zero-width).
var t, lastW = grid.clientWidth;
var relayout = function () {
if (grid.clientWidth === lastW) return;
lastW = grid.clientWidth;
clearTimeout(t); t = setTimeout(layout, 80);
};
if ('ResizeObserver' in window) new ResizeObserver(relayout).observe(grid);
window.addEventListener('resize', relayout, { passive: true }); // belt and braces; relayout() is idempotent
// Re-run once images report their size, in case an attribute was missing.
tiles.forEach(function (tile) {
var img = tile.querySelector('img');
if (!img.getAttribute('width')) img.addEventListener('load', layout, { once: true });
requestAnimationFrame(tick);
});
// Staggered entrance.
var enter = function () {
tiles.forEach(function (tile, i) {
setTimeout(function () { tile.classList.add('is-in'); }, Math.min(i, 12) * 70);
});
};
if ('IntersectionObserver' in window) {
var io = new IntersectionObserver(function (entries) {
if (entries.some(function (e) { return e.isIntersecting; })) { enter(); io.disconnect(); }
}, { rootMargin: '0px 0px -80px 0px' });
io.observe(grid);
} else {
enter();
/* ── Galeria: auto-scroll + click-and-drag ───────────────────────────────────
The strip (.gal-wrap) is four identical copies long. It advances by itself,
follows a mouse drag (touch uses the native swipe), pauses while you hover or
hold it and resumes a moment after, and whenever the position leaves the two
middle copies it jumps by exactly one copy — invisible, since every copy is the
same. A drag longer than a few px swallows the click, so it doesn't open the
lightbox. */
document.addEventListener('DOMContentLoaded', function () {
var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
document.querySelectorAll('.gal-marquee').forEach(function (strip) {
var wrap = strip.parentElement;
var set = strip.querySelector('.gal-set');
if (!wrap || !set) return;
var loop = 0, pos = null, hold = false, resumeAt = 0, dragging = false, moved = 0, startX = 0, startLeft = 0, last = 0;
var SPEED = 40; // px per second
function measure() { loop = set.offsetWidth; }
function normalise() {
if (!loop) return;
if (wrap.scrollLeft >= loop * 2) wrap.scrollLeft -= loop;
else if (wrap.scrollLeft < loop) wrap.scrollLeft += loop;
}
function start() { measure(); wrap.scrollLeft = loop; }
start();
window.addEventListener('load', start);
window.addEventListener('resize', measure, { passive: true });
wrap.addEventListener('scroll', normalise, { passive: true });
wrap.addEventListener('mouseenter', function () { hold = true; });
wrap.addEventListener('mouseleave', function () { if (!dragging) { hold = false; resumeAt = performance.now() + 600; } });
wrap.addEventListener('focusin', function () { hold = true; });
wrap.addEventListener('focusout', function () { hold = false; });
wrap.addEventListener('touchstart', function () { hold = true; }, { passive: true });
wrap.addEventListener('touchend', function () { hold = false; resumeAt = performance.now() + 1800; }, { passive: true });
wrap.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'touch' || e.button !== 0) return;
dragging = true; moved = 0;
startX = e.clientX; startLeft = wrap.scrollLeft;
});
wrap.addEventListener('pointermove', function (e) {
if (!dragging) return;
var dx = e.clientX - startX;
moved = Math.max(moved, Math.abs(dx));
if (moved <= 5) return;
// Capture only once it is a real drag: capturing on pointerdown sends the
// click to .gal-wrap instead of the photo, so the lightbox never opened.
if (!wrap.classList.contains('is-dragging')) {
wrap.classList.add('is-dragging');
try { wrap.setPointerCapture(e.pointerId); } catch (err) {}
}
wrap.scrollLeft = startLeft - dx;
});
// The tiles are links, which the browser drags natively (cancelling our drag).
wrap.addEventListener('dragstart', function (e) { e.preventDefault(); });
function endDrag(e) {
if (!dragging) return;
dragging = false;
wrap.classList.remove('is-dragging');
try { wrap.releasePointerCapture(e.pointerId); } catch (err) {}
resumeAt = performance.now() + 1200;
}
wrap.addEventListener('pointerup', endDrag);
wrap.addEventListener('pointercancel', endDrag);
// After a real drag, the click that follows must not open the lightbox.
wrap.addEventListener('click', function (e) {
if (moved > 5) { e.preventDefault(); e.stopPropagation(); moved = 0; }
}, true);
function tick(now) {
requestAnimationFrame(tick);
var dt = last ? Math.min(now - last, 100) : 16; last = now;
if (reduce || document.hidden || dragging || hold || now < resumeAt) { pos = null; return; }
// scrollLeft only keeps whole pixels, so a ~0.6px/frame step would round
// away: carry the fraction in our own float and write it back.
if (pos === null || Math.abs(wrap.scrollLeft - pos) > 2) pos = wrap.scrollLeft;
pos += SPEED * dt / 1000;
wrap.scrollLeft = pos;
}
requestAnimationFrame(tick);
});
});
+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>
+2
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>
@@ -39,5 +40,6 @@
<?= script_tag('assets/vendor/jquery/jquery-3.7.1.min.js') ?>
<?= script_tag('assets/vendor/bootstrap5/js/bootstrap.bundle.min.js') ?>
<?= script_tag('site/assets/js/main.js') ?>
<?php include dirname(__DIR__, 2) . '/includes/dev_toolbar.php'; // DEV ONLY: sem saída fora de dev ?>
</body>
</html>
+46 -23
View File
@@ -1,13 +1,15 @@
<?php
// Gallery partial — masonry (home "instalações" and the CrossFit page).
// main.js lays the tiles out itself: each photo keeps its own aspect ratio (no
// cropping) and drops into the shortest column; every few tiles one spans two
// columns, which is what staggers the heights — the photos are all landscape,
// so equal-width tiles alone would just form a grid. Without JS the list falls
// back to a plain CSS grid. Clicking a tile opens it full size in a <dialog>
// lightbox with prev / next, arrow keys, Esc and swipe (main.js).
$files = glob(rtrim($gallery_path, '/') . '/*.jpg');
if (!$files || !is_array($files)) $files = [];
// Gallery partial — an endless horizontal strip (home "instalações" and the
// CrossFit page): full-height photos alternate with 2x2 grids of four. main.js
// scrolls it by itself, lets you click-and-drag (mouse) or swipe (touch), pauses
// while you interact and loops seamlessly over the repeated copies. Clicking a
// tile (without dragging) opens it full size in a <dialog> lightbox with prev /
// next, arrow keys, Esc and swipe; the repeated copies are hidden from assistive
// tech and open the same photo.
// Every format the admin upload accepts (functions_galerias.php), without the
// .jxl.webp/.jpg fallbacks; a .jxl tile is <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';
@@ -21,23 +23,44 @@ $gal_label = $gallery_h2 ?? 'Instalações';
</div>
</div>
</div>
<?php if ($files): ?>
<div class="container">
<ul class="gal-masonry">
<?php foreach ($files as $gi => $f):
$src = base_url($f);
$alt = $gal_label . ' — FitMe 3070, foto ' . ($gi + 1); // distinct alt per photo
$dim = @getimagesize($f) ?: [0, 0];
<?php if ($files):
// Units of the strip: a full-height photo, then a 2x2 grid of four, and so on.
// (Fewer than four left over → a full-height photo.)
$units = []; $i = 0; $n = count($files); $k = 0;
while ($i < $n) {
$grid = ($k % 2 === 1) && ($n - $i >= 4);
$take = $grid ? 4 : 1;
$units[] = ['grid' => $grid, 'idx' => range($i, $i + $take - 1)];
$i += $take; $k++;
}
// The strip is rendered four times: main.js keeps the scroll position inside
// the middle copies and jumps by exactly one copy, so it loops without a seam.
$copies = 4;
?>
<li class="gal-tile gal-<?= $gal_shapes[$gi % count($gal_shapes)] ?>">
<a href="<?= htmlspecialchars($src) ?>" class="gal-link" data-index="<?= $gi ?>" aria-label="Ampliar: <?= htmlspecialchars($alt) ?>">
<img src="<?= htmlspecialchars($src) ?>" alt="<?= htmlspecialchars($alt) ?>"
<?= $dim[0] ? 'width="' . (int) $dim[0] . '" height="' . (int) $dim[1] . '"' : '' ?>
<?= $gi > 1 ? 'loading="lazy"' : '' ?> decoding="async">
<div class="gal-wrap">
<div class="gal-marquee">
<div class="gal-track">
<?php for ($c = 0; $c < $copies; $c++): $dup = $c > 0; ?>
<div class="gal-set"<?= $dup ? ' aria-hidden="true"' : '' ?>>
<?php foreach ($units as $u): ?>
<div class="gal-unit <?= $u['grid'] ? 'gal-unit--grid' : 'gal-unit--full' ?>">
<?php foreach ($u['idx'] as $gi):
$f = $files[$gi];
$src = base_url(img_compat_rel($f));
$alt = $gal_label . ' — FitMe 3070, foto ' . ($gi + 1); // distinct alt per photo
?>
<div class="gal-tile">
<a href="<?= htmlspecialchars($src) ?>" class="gal-link" data-index="<?= $gi ?>"<?= $dup ? ' data-dup="1" tabindex="-1"' : '' ?> aria-label="Ampliar: <?= htmlspecialchars($alt) ?>">
<?= img_tag($f, ['alt' => $dup ? '' : $alt, 'loading' => $gi > 2 || $dup ? 'lazy' : null, 'decoding' => 'async', 'draggable' => 'false']) ?>
</a>
</li>
</div>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
</div>
<?php endfor; ?>
</div>
</div>
</div>
<dialog class="gal-lightbox" aria-label="<?= htmlspecialchars($gal_label) ?>">
<button type="button" class="gal-lb-close" aria-label="Fechar">&times;</button>
+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>
+65 -27
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,9 +47,9 @@ $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="theme-color" content="#C2D500">
<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) ?>">
<?php if (seo_noindex()): /* staging copies (e.g. fitme.andmore.pt) must not compete with the real domain */ ?>
@@ -50,7 +60,9 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
<link rel="dns-prefetch" href="https://forms.zohopublic.eu">
<?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) -->
@@ -58,21 +70,21 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
<!-- Vendor: Bootstrap 5.3.3 -->
<?= link_tag('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>
<!-- Vendor: Font Awesome 5 -->
<?= link_tag('site/assets/fonts/fontawesome/css/all.css') ?>
<!-- Icons: Font Awesome 6 from the shared platform-tools pack (local FA 5.13.1 as fallback) -->
<?= fit_icon_assets(['fontawesome'], ['site/assets/fonts/fontawesome/css/all.css']) ?>
<!-- App styles (Bootstrap overrides) -->
<?= 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) ?>">
@@ -85,6 +97,32 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
<!-- Structured data -->
<?= view('seo') ?>
<?php if (($brand_css = gym_brand_css($gym_meta)) !== ''): ?><style><?= $brand_css ?></style><?php endif; ?>
<?php include dirname(__DIR__, 2) . '/includes/env_ribbon.php'; // moldura de aviso fora de produção ?>
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<!-- Grain for the h1/h2 lettering (style.css, "Noise texture"): fractal noise -> grey ->
squeezed to 88-100% brightness, multiplied into the glyphs and clipped to their shape. -->
<filter id="pt-noise" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="1.98" numOctaves="1" seed="7" result="n"/>
<feColorMatrix in="n" type="saturate" values="0" result="g"/>
<feComponentTransfer in="g" result="grain">
<feFuncR type="linear" slope=".123" intercept=".877"/>
<feFuncG type="linear" slope=".123" intercept=".877"/>
<feFuncB type="linear" slope=".123" intercept=".877"/>
<feFuncA type="linear" slope="0" intercept="1"/>
</feComponentTransfer>
<feBlend in="SourceGraphic" in2="grain" mode="multiply" result="mix"/>
<feComposite in="mix" in2="SourceGraphic" operator="in"/>
</filter>
<!-- Liquid glass for the navbar backdrop (style.css .header-section, Chromium):
soft low-frequency noise, blurred so it has no grain, drives a displacement
that bends the page behind the bar like uneven glass. -->
<filter id="nav-glass" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency=".008 .04" numOctaves="2" seed="3" result="t"/>
<feGaussianBlur in="t" stdDeviation="2" result="map"/>
<feDisplacementMap in="SourceGraphic" in2="map" scale="38" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</svg>
<a href="#conteudo" class="skip-link">Saltar para o conteúdo</a>
+67 -7
View File
@@ -4,16 +4,26 @@ if($current_uri_2 == 'crossfit'){
}
else
{
$logo_rel = 'site/assets/img/logos/logo_black.svg';
// Each gym shows its own logo (FitMe In on /mira/, FitMe 3070 on /3070/).
$logo_rel = gym_logo(current_gym()) ?: 'site/assets/img/logos/logo_black.svg';
}
$logo = base_url($logo_rel);
$logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
$logo_alt = 'Logo ' . (current_gym()['name'] ?? 'FitMe 3070');
$logo_html = img_fallback_rel($logo_rel) !== ''
? img_tag($logo_rel, ['loading' => 'eager', 'decoding' => 'async', 'alt' => $logo_alt])
: '<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 FitMe3070" src="<?= $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 ">
@@ -32,7 +42,7 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
?>
<li class="nav-item <?= $is_active ? 'active' : '' ?> main-menu-li" >
<?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): ?>
@@ -47,6 +57,33 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
</li>
<?php endforeach; ?>
</ul>
<?php
// Other published gyms: the main gym opens first, the rest are
// highlighted buttons at the right end of the bar.
$other_gyms = [];
if (!empty($GLOBALS['pdo'])) {
foreach (db_gyms_published($GLOBALS['pdo']) as $g) {
if ((int) $g['id'] !== (int) current_gym()['id']) $other_gyms[] = $g;
}
}
if ($other_gyms):
// The match head (style.css) is painted in the first other gym's brand.
$head_vars = gym_brand_vars($other_gyms[0]);
$head_ink = str_contains($head_vars, 'on-accent:#FFFFFF') ? ' gym-switch--light-ink' : '';
?>
<div class="gym-switch<?= $head_ink ?> d-flex flex-wrap gap-2 ms-lg-auto py-2 py-lg-0 pe-lg-3" style="<?= htmlspecialchars($head_vars) ?>">
<?php foreach ($other_gyms as $g): ?>
<?php $g_logo = gym_logo($g); ?>
<a class="gym-switch-btn<?= $g_logo !== '' ? ' gym-switch-btn--logo' : '' ?>" href="<?= htmlspecialchars(gym_url($g)) ?>" style="<?= htmlspecialchars(gym_brand_vars($g)) ?>">
<?php if ($g_logo !== ''): ?>
<?= img_tag($g_logo, ['class' => 'gym-switch-logo', 'alt' => $g['name'], 'decoding' => 'async', 'width' => null]) ?>
<?php else: ?>
<span><?= htmlspecialchars($g['name']) ?></span>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</nav>
</header>
@@ -61,6 +98,29 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
// querySelector('#equipa') returns null, `sections` stays empty, and neither
// 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'];
+55 -9
View File
@@ -2,16 +2,19 @@
// 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.
// ?agendar=ok); with more, the gym chooser.
// ?agendar=ok); with more, the main (default) gym — the others are reached
// from the navbar's «Ginásios» menu.
if (($_GET['gym'] ?? '') === '') {
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../config/functions.php';
$published = db_gyms_published($pdo);
if (count($published) > 1) { require __DIR__ . '/gym_chooser.php'; exit; }
$to = $published[0] ?? db_gyms_default($pdo) ?? gym_fallback();
$main = null;
foreach ($published as $g) { if ((int) ($g['is_default'] ?? 0) === 1) { $main = $g; break; } }
$to = $main ?? $published[0] ?? db_gyms_default($pdo) ?? gym_fallback();
$qs = $_SERVER['QUERY_STRING'] ?? '';
header('Location: ' . gym_url($to) . ($qs !== '' ? '?' . $qs : ''), true, 301);
exit;
@@ -25,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) : [];
@@ -34,6 +37,7 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
$events = site_active_events($pdo, $gid); // time-limited events, banner below the hero
$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 ────────────────────────────────────────────────────────────────
@@ -49,7 +53,7 @@ $hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img
<!-- Hero background is inline (no data-setbg) so it paints without waiting for JS;
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
@@ -58,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>
@@ -75,7 +81,6 @@ $hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img
<h1><?= htmlspecialchars($gym['hero_title'] !== '' ? $gym['hero_title'] : $gym['name']) ?><?php if ($gym['hero_subtitle'] !== ''): ?><br><span><?= htmlspecialchars($gym['hero_subtitle']) ?></span><?php endif; ?></h1>
<div class="hero-cta-row">
<a href="?assunto=insc#contactos" class="site-btn">Inscreve-te Já!</a>
<button type="button" class="site-btn sb-ghost" data-bs-toggle="modal" data-bs-target="#trialModal">Marca uma Aula Experimental</button>
</div>
</div>
</div>
@@ -115,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"
+34 -3
View File
@@ -41,9 +41,16 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
<input class="form-control" name="hero_subtitle" maxlength="150" placeholder="& Acompanhamento" value="<?= htmlspecialchars($data['hero_subtitle'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label">Imagem do header</label>
<input class="form-control font-monospace" name="hero_image" maxlength="255" placeholder="site/assets/img/home.jpg" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
<div class="form-text">Caminho de uma imagem do site. Vazio = a do ginásio principal.</div>
<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(img_compat_rel($data['hero_image']))) ?>" alt="" class="rounded border" style="max-width:100%;max-height:110px;object-fit:cover"></div>
<?php endif; ?>
<input type="hidden" name="hero_image" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
<input type="file" class="form-control" id="hero_file" name="hero_file" accept="image/jpeg,image/png,image/webp,image/jxl,.jxl">
<div class="form-text">JPG, PNG, WebP ou JPEG XL (até 6 MB). Vazio = a do ginásio principal.</div>
<?php if (($data['hero_image'] ?? '') !== ''): ?>
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="hero_remove" value="1" id="hero_remove"><label class="form-check-label small" for="hero_remove">Remover a imagem atual</label></div>
<?php endif; ?>
</div>
<div class="col-md-6">
<label class="form-label">Galeria das instalações</label>
@@ -86,6 +93,30 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
</div>
</div>
<div class="col-md-6">
<label class="form-label" for="logo_file">Logótipo (menu do site)</label>
<input type="hidden" name="logo" value="<?= htmlspecialchars($data['logo'] ?? '') ?>">
<?php $logo_now = gym_logo($data); // saved logo, or the site logo the main gym falls back to ?>
<div class="d-flex align-items-center gap-2">
<?php if ($logo_now !== ''): ?>
<img src="<?= htmlspecialchars(base_url($logo_now)) ?>" alt="Logótipo atual" title="<?= ($data['logo'] ?? '') === '' ? 'Logótipo do site (nenhum carregado)' : 'Logótipo atual' ?>" class="rounded border bg-white p-1 flex-shrink-0" style="height:38px;width:auto;max-width:120px;object-fit:contain">
<?php endif; ?>
<input type="file" class="form-control" id="logo_file" name="logo_file" accept="image/svg+xml,image/png,image/webp,image/jpeg,image/jxl,.jxl">
</div>
<div class="form-text">SVG, PNG, WebP, JPG ou JPEG XL (até 6 MB). Opcional. Aparece nas cores originais no logótipo do menu da página deste ginásio e no botão deste ginásio nas páginas dos outros.</div>
<?php if (!_gyms_has_col($pdo, 'gyms', 'logo')): ?>
<div class="form-text text-warning">Falta aplicar a migração <code>migration_20261013_gym_brand_color.sql</code> (Sistema → Atualizar plataforma) para guardar o logótipo e a cor da marca.</div>
<?php endif; ?>
<?php if (($data['logo'] ?? '') !== ''): ?>
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="logo_remove" value="1" id="logo_remove"><label class="form-check-label small" for="logo_remove">Remover o logótipo atual</label></div>
<?php endif; ?>
</div>
<div class="col-sm-6 col-lg-4">
<label class="form-label" for="brand_color">Cor da marca</label>
<input type="color" class="form-control form-control-color" id="brand_color" name="brand_color" value="<?= htmlspecialchars(($data['brand_color'] ?? '') !== '' ? $data['brand_color'] : '#C2D500') ?>">
<div class="form-text">Botões, destaques e links do site deste ginásio.</div>
</div>
<div class="col-12"><hr class="my-1"><strong>Estado</strong></div>
<div class="col-md-6">
<div class="form-check form-switch">
+1 -1
View File
@@ -42,7 +42,7 @@ include __DIR__ . '/../../../includes/header.php';
<a class="btn btn-outline-secondary btn-sm" href="index.php"><i class="bi bi-arrow-left me-1"></i>Voltar</a>
</div>
<?php foreach ($errors as $e): ?><div class="alert alert-danger"><?= htmlspecialchars($e) ?></div><?php endforeach; ?>
<form method="POST" class="card shadow-sm"><div class="card-body">
<form method="POST" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
<?= csrf_field() ?>
<?php include __DIR__ . '/_form.php'; ?>
+3 -1
View File
@@ -21,6 +21,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if (!$errors) {
$changed = db_gyms_update($pdo, $id, $data);
// Uploaded images that were replaced or removed are no longer referenced.
foreach (['hero_image', 'logo'] as $f) if (($item[$f] ?? '') !== ($data[$f] ?? '')) gyms_remove_image($item[$f] ?? '');
session()->setFlashdata($changed ? 'success' : 'warning', $changed ? 'Ginásio atualizado.' : 'Nenhuma alteração.');
header('Location: ' . base_url('views/admin/gyms/edit.php?id=' . $id));
exit;
@@ -47,7 +49,7 @@ include __DIR__ . '/../../../includes/header.php';
<button class="btn btn-sm btn-primary text-nowrap">Editar conteúdo deste</button>
</form>
<?php endif; ?>
<form method="POST" class="card shadow-sm"><div class="card-body">
<form method="POST" enctype="multipart/form-data" class="card shadow-sm"><div class="card-body">
<?= csrf_field() ?>
<?php include __DIR__ . '/_form.php'; ?>
<div class="mt-4 d-flex gap-2">
+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';
+20
View File
@@ -0,0 +1,20 @@
<?php
// Painel da equipa (role staff). O staff ainda não tem páginas de gestão:
// tudo em views/admin/ exige admin. Fica a saudação e o acesso ao perfil.
require __DIR__ . '/../../config/db.php';
require __DIR__ . '/../../config/functions.php';
$user = require_staff();
$page_title = 'Painel';
include __DIR__ . '/../../includes/header.php';
?>
<div class="container">
<h1 class="h3 mb-4">Olá, <?= htmlspecialchars($user['name']) ?></h1>
<div class="card shadow-sm"><div class="card-body">
<p class="mb-3">Bem-vindo à área da equipa FitMe 3070.</p>
<a class="btn btn-outline-primary" href="<?= base_url('views/user/profile.php') ?>"><i class="bi bi-person me-1"></i>O meu perfil</a>
<p class="text-muted small mt-3 mb-0">As ferramentas da equipa serão disponibilizadas num passo seguinte.</p>
</div></div>
</div>
<?php include __DIR__ . '/../../includes/footer.php'; ?>
-1
View File
@@ -128,7 +128,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
include __DIR__ . '/../../includes/header.php';
include __DIR__ . '/../../includes/navbar.php';
?>
<div class="container py-4" style="max-width:900px;">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">