Commit Graph
122 Commits
Author SHA1 Message Date
andreandClaude Fable 5 3f2c505c17 feat(rgpd): pedido de acesso/apagamento de dados + processo escrito (item 9.5)
- migration_20260925_rgpd_requests.sql: tabela rgpd_requests (email, tipo,
  estado, consentimento).
- config/functions_rgpd.php: rgpd_find() (acesso, art.º 15) e rgpd_erase()
  (apagamento por anonimização, art.º 17) sobre membership_leads/trial_leads/
  event_leads, + CRUD dos pedidos.
- Política de Privacidade: formulário público (POST + CSRF + anti-spam) para
  pedir acesso ou apagamento, e o processo escrito (resposta em 30 dias).
- Backoffice: página "Pedidos RGPD" (nav + index) para exportar os dados de um
  email (CSV, audit) ou anonimizá-los (POST + CSRF, audit).

O email está desligado, por isso os pedidos ficam guardados (como as leads),
não dependem de SMTP. Texto legal com placeholders — rever com jurista antes
do go-live (como já constava na Política).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 23:48:18 +01:00
andreandClaude Fable 5 116817e2de feat(backoffice): exportação CSV nas listagens, com audit
Novo helper csv_export() (config/functions.php): CSV Excel-friendly (BOM UTF-8
+ ';') e cada exportação fica no audit_log (RGPD: exportar dados pessoais tem
de ser rastreável). Botão "Exportar CSV" em todas as listagens do backoffice:
equipa, eventos, galerias, ginásios, horário, mensalidades, modalidades,
horário de funcionamento e lixeira (pedidos de inscrição e aulas experimentais
já tinham). Fecha a metade "exportação com permissões e audit" do item 6.9
(a única parte que o relatório considera ganho real à escala atual).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 23:43:41 +01:00
andreandClaude Fable 5 33158381bb chore(fonts): FontAwesome de 18 MB para 264 KB (remover build/source)
O diretório vendorizado incluía ficheiros de build/fonte que nunca são
carregados em runtime: js/, less/, scss/, metadata/, sprites/, svgs/, os
formatos de fonte não-woff2 (eot/svg/ttf/woff) e 13 folhas CSS redundantes.
Ficam só o que o site carrega de facto: css/all.css + 3 webfonts woff2
(solid/brands/regular). Sem alterar a fonte em si — nenhum ícone muda.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 23:29:53 +01:00
andreandClaude Fable 5 6d7b6456a9 fix(responsivo): elimina overflow horizontal de 3px em mobile
As regras `.container>.row` e `.contact-form .row` usavam margem negativa de
-15px (herança do Bootstrap 4), mas o container do Bootstrap 5 tem padding de
12px — sobrava 3px de overflow horizontal a 375px (scrollWidth 378 vs 375).
Passaram a -0.75rem (o gutter real do BS5), anulando exatamente o padding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 23:25:23 +01:00
andreandClaude Fable 5 c5c4a818f2 fix(contraste+fontes): verde de texto legível em fundo claro e Roboto auto-hospedado
Contraste (auditoria 4.8, WCAG AA):
- Novo token --fitme-accent-ink #667000 (≈5.1:1 sobre #F7F8FA), aplicado aos 7
  usos de texto que estavam em --fitme-accent-dark (#A6B600 ≈2.1:1 em fundo claro).
  O --fitme-accent-dark mantém-se para fundos de botões em hover/active (onde tem
  texto escuro por cima — escurecê-lo quebraria esses estados).
- .section-eyebrow passa a usar o ink green nas secções claras (oferta, galeria,
  modalidades, horário, workhours); as secções escuras mantêm o verde vivo.

Fontes (CSS "Google Fonts ainda carregado do CDN"):
- Roboto (variável, latin, 43 KB woff2) em site/assets/fonts/roboto/.
- @font-face 100-900 em style.css; removidos os <link> para fonts.googleapis.com
  em include.php e gym_chooser.php. Ficam apenas os pedidos à API do Places
  (legítimos, server-side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 23:22:06 +01:00
andreandClaude Fable 5 8a0c2272a4 chore(frontend): remove Bootstrap 4 morto e alinha breakpoints à grelha
- assets/vendor/bootstrap/ (cópia BS 4.3.1) já sem referências desde a
  migração para 5.3.3 (auditoria 5.1); removida do repositório.
- style.css: cabeçalho corrigido (Bootstrap 4.3.1 → 5.3.3).
- Breakpoints inventados alinhados à grelha (auditoria 4.1): 479px e os dois
  339px passaram a max-width: 575px (o tier xs do Bootstrap). Os cartões de
  detalhe (modalidade/equipa) passam a coluna em todos os telefones ≤575px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-25 22:47:25 +01:00
andreandClaude Sonnet 5 231b8eb699 feat(security): CSP ganha script-src (auditoria: CSP ainda sem script-src)
'self' 'unsafe-inline' https://cdn.jsdelivr.net — levantamento antes
de mexer: só um host externo carrega <script> a sério (jsdelivr, o
html2canvas de views/admin/horario/instagram.php e o bootstrap-icons
da página de erro); tudo o resto (Bootstrap, jQuery, Summernote) já é
vendorizado desde a migração de 25/09. 'unsafe-inline' fica por sítio
ter vários <script> inline (tema claro/escuro, GA-like snippets) que
não valia a pena reescrever para nonce/hash nesta passagem — mas isto
já bloqueia o caso que mais importa: um <script src="outro-domínio">
injetado por uma falha de XSS deixa de carregar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:10:26 +01:00
andreandClaude Sonnet 5 7482dc98f2 docs: atualiza CLAUDE.md e ASSETS.md pós-migração Bootstrap/Composer
Ficaram desatualizados depois da sessão de fixes: CLAUDE.md ainda
dizia "Bootstrap 4+5 em simultâneo" (já não, ver 1e2068b) e listava
"sem srcset"/"sem create/edit unificado" sem as razões reais por trás
de cada um continuar em aberto. ASSETS.md ainda tinha a tabela de
antes da migração (duas versões de Bootstrap, jQuery duplicado,
"sem Composer") — reescrito para o estado real: Bootstrap 5.3.3
único, jQuery uma cópia, Composer ligado (composer.json/lock),
PHPMailer hand-vendored por decisão documentada (não por falta de
tentar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:01:29 +01:00
andreandClaude Sonnet 5 4ddefa6dae fix(navbar): logo esticava a barra (altura 252px) — height:auto em falta
O logo tem style="width: 150px" sem height desde sempre; a auditoria
4.7 acrescentou width="756" height="252" (lidos do viewBox do SVG)
sem adicionar height:auto ao inline style. Sem height:auto, o browser
não escala a altura proporcionalmente ao width CSS — ficou fixa nos
252px do atributo, esticando a barra toda. Confirmado ao vivo:
getComputedStyle(img).height passou de "252px" para o valor
proporcional depois desta correção.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:00:45 +01:00
andreandClaude Sonnet 5 30e85b2438 fix(css): tipografia fluida com clamp() no site público (auditoria 4.5)
6 seletores tinham font-size a fazer step-function em vez de escalar:
.hero-text h1 (4 breakpoints), .section-title h2 (3), .page-top-section
h1, .icon-box-item h3/h4 e os dois .schedule_box>div (2 cada). Cada par/
trio de valores por media query passa a um único clamp() na regra base,
com a interpolação (vw) calculada para bater certo com o valor antigo
nas larguras 375/768/1400px+ testadas.

Ficam por mexer (não são apenas escala de tipo): .rounded_frame,
.hero-text .site-btn, .site-btn @479px e .schedule_frame (o font-size
muda junto com padding/width/min-width, é redimensionamento de layout,
não só tipografia); .footer-menu li a (idem, padding muda junto — alvos
de toque da auditoria 4.4); .contact-form/.newsletter-form input (o
valor sobe para 1rem no mobile, não desce — evita o zoom automático do
iOS ao focar); .md-card h3 (faz parte da troca de layout do cartão para
modal fixo, não é só escala); .schedule_box a (o valor de mobile já era
igual ao base, 11px, não havia degrau nenhum).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:51:13 +01:00
andreandClaude Sonnet 5 1e2068bea2 feat(css): site público passa de Bootstrap 4.3.1 (EOL) para 5.3.3
Auditoria: "Site público em Bootstrap 4.3.1 (EOL); backoffice em
5.3.3" — o backoffice já usa a 5.3.3 vendorizada (assets/vendor/bootstrap5/),
o site público passa a usar a mesma cópia em vez da 4.3.1.

Levantamento antes de mexer: a folha de estilo própria (style.css)
quase não usa classes utilitárias cruas do Bootstrap (grep confirma
zero ml-/mr-/pl-/pr-/float-left/float-right/custom-select/
custom-control/badge-/sr-only/font-weight-/text-left/text-right —
tudo classes próprias como .site-btn). O que precisava de mudar:

- CSS/JS: assets/vendor/bootstrap/ -> assets/vendor/bootstrap5/
  (bundle, inclui Popper).
- Os 7 atributos data-toggle/data-target/data-dismiss (BS4) ->
  data-bs-toggle/data-bs-target/data-bs-dismiss (BS5): toggle da nav
  mobile, os dois modais (aula experimental, inscrição), o botão de
  fechar do alerta. O seletor em main.js que lê data-target também
  mudou para não parar de encontrar os botões.
- .close -> .btn-close (BS5 renomeou o componente; markup sem o
  &times; interno, que o BS5 desenha via CSS).
- .form-row -> .row (BS4 tinha form-row com gutters menores; BS5 não
  tem, style.css não tinha overrides para form-row, confirmado).
- O apanhado maior: dois <script> inline chamavam
  jQuery('#trialModal').modal('show') ao estilo de plugin jQuery do
  BS4 (para reabrir o modal depois de um erro de submissão) — o BS5
  não regista plugins jQuery nenhuns, isto ficava morto em silêncio.
  Trocado por new bootstrap.Modal(...).show(), a API JS nativa do
  BS5. grep confirma zero outras chamadas .modal()/.collapse()/etc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:47:15 +01:00
andreandClaude Sonnet 5 b837def5a0 refactor(horas): partilhar _form.php entre create e edit (audit 6.8)
Mesmo padrão de horario/: extrai dia/abertura/fecho para _form.php.
create.php formatava open/close sem substr() enquanto edit.php cortava
para "HH:MM" com substr(..., 0, 5) por vir de uma coluna TIME da BD;
unificado na versão de edit.php (sem efeito em create.php).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:45:14 +01:00
andreandClaude Sonnet 5 33b7f19d15 refactor(horario): partilhar _form.php entre create e edit (audit 6.8)
Extrai os campos comuns (modalidade, dia, início, fim, sala) para
_form.php. create.php formatava hora_inicio/hora_fim sem substr()
enquanto edit.php aplicava substr(..., 0, 5) para cortar os segundos
que vêm da coluna TIME da BD — unificado na versão de edit.php (mais
defensiva; sem efeito em create.php, cujos valores já vêm em "HH:MM").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:45:14 +01:00
andreandClaude Sonnet 5 b8c0422318 refactor(modalidades): partilhar _form.php entre create e edit (audit 6.8)
Extrai os campos comuns (nome, cor, ícone/upload, ordem, descrição, e a
biblioteca de ícones disponíveis) para _form.php, seguindo o padrão de
events/ e memberships/. A diferença genuína entre criar e editar — o
bloco de ícone mostra pré-visualização e checkbox "remover imagem
atual" só quando já existe um registo — fica como um if ($item ===
null) explícito dentro do parcial, tal como o hero_image em events/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:45:14 +01:00
andreandClaude Sonnet 5 22dc14c8c8 refactor(equipa): partilhar _form.php entre create e edit (audit 6.8)
create.php e edit.php tinham os mesmos campos de formulário duplicados
lado a lado. Extrai o markup comum (nome, nascimento, área, estado,
foto, título, aptidões, formações, descrição) para _form.php, seguindo
o padrão já usado em events/ e memberships/. Cada view fica só com a
lógica que lhe é própria (criar vs. carregar registo existente,
upload/validação de foto, título da página, redirect).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:45:14 +01:00
andreandClaude Sonnet 5 595a37fd8b fix(site): width/height nas imagens da home (auditoria 4.7)
125 imagens na home, 0 com srcset e 122 sem width/height — o browser
só sabia o tamanho depois de descarregar, o que causa layout shift.

Uploads são redimensionados a um limite de lado maior (functions_images.php),
não a uma dimensão fixa (fotos de equipa vão de 389x512 a 512x512, ícones
de oferta de 128x128 a 256x256), por isso as dimensões não dão para
hardcode: site_img_dims_attr() em functions_site.php lê o ficheiro real
com getimagesize() por pedido, tal como o gallery.php já fazia, e
devolve o par width="" height="" ou "" se o ficheiro não existir. SVG
não passa por getimagesize() (sem decoder embutido) — svg_img_dims_attr()
lê os atributos width/height (ícones fitme-*.svg, 48x48 fixo) ou cai
para o viewBox (logos do navbar, só têm viewBox).

Aplicado a: ícones da oferta, fotos de equipa (card + painel de detalhe),
ícone de modalidade em modalidade_icon_html() e no horário (index.php e
includes/horario.php), logo do navbar.

srcset ficou de fora: as imagens já vêm coartadas para pouco mais do que
o maior tamanho a que aparecem (fotos de equipa a 512px cabidas numa caixa
de no máx. 200px CSS com object-fit:cover; ícones a 128-256px mostrados a
44px) — é a folga normal para ecrãs retina, não desperdício a resolver
com múltiplas resoluções. E o layout é sempre CSS-driven (width/height:
100% + object-fit), por isso um srcset não mudaria o que é descarregado
por variação de viewport, só pouparia bytes nalguns casos — gerar 2-3
tamanhos por upload via GD seria trabalho a mais para o ganho real neste
site. Fica só o fix de dimensões, que é o que resolve o CLS que a
auditoria mediu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:44:29 +01:00
andreandClaude Sonnet 5 a42b6d33c0 fix(css): .schedule-dl-btn a 43.75px por arredondamento -> min-height:44px
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:54:14 +01:00
andreandClaude Sonnet 5 6faad68059 feat(backoffice): breadcrumbs no layout partilhado (fecha 6.7)
includes/header.php passa a renderizar Painel > $page_title por
omissão em qualquer página que não seja o próprio painel — cobre as
34 páginas do backoffice de uma vez, sem editar cada uma. Uma página
que queira um trilho mais rico define $breadcrumbs = [['label'=>...,
'url'=>...], ..., ['label'=>'Atual']] antes de incluir o header (o
último item, sem 'url', é a página corrente).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:53:15 +01:00
andreandClaude Sonnet 5 90efdc4bc1 fix(css): alvos de toque >=44px nos controlos reais (auditoria 4.4)
Corrige os controlos interativos (não o texto denso em grelha/rodapé,
que fica para outra passagem): .navbar-toggler (40->44 de altura),
.md-close/.td-close (26x28 -> 44x44, box explícita em vez de
dimensionar pelo glifo), .schedule-dl-btn (padding compensado por
margin negativa — o texto sublinhado não engorda visualmente),
.sdf-btn (43->44, min-height), ícones sociais do rodapé (min 44x44).

Medido com getBoundingClientRect() no site ao vivo a 375px antes e
depois de aplicar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:52:24 +01:00
andreandClaude Sonnet 5 11b2906488 fix(assets): remove a segunda cópia do jQuery 3.7.1 (auditoria 7.2)
site/assets/js/jquery-3.7.1.min.js e assets/vendor/jquery/jquery-3.7.1.min.js
eram byte-idênticas (md5 igual). O site público passa a carregar a
mesma cópia que o backoffice já usa (assets/ já é servido em
.htaccess); a duplicada sai do git.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:45:46 +01:00
andreandClaude Sonnet 5 8300f2c421 docs: CLAUDE.md — decisão de arquitetura escrita (fecha 6.1)
Documenta o padrão atual (páginas = controller+vista, config/functions_*.php
como models, sem front controller próprio) como decisão mantida, não
herança silenciosa do CodeIgniter — mais auth partilhado, trash,
CSRF, audit, multi-ginásio, platform-tools, deploy e Composer. Já
bloqueado por HTTP (.htaccess já negava CLAUDE.md por nome).

Inclui uma secção "What's not done" honesta: sem front controller
real, sem DataTables, Bootstrap 4+5 em simultâneo — para não passar a
imagem de que isto já está resolvido.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:44:27 +01:00
andreandClaude Sonnet 5 43e0fc7be6 refactor: move o SQL restante das vistas de multi-ginásio (fecha 6.3)
Os últimos 3 pontos de SQL direto em vistas, que tinham entrado depois
da auditoria original (trabalho de multi-ginásio): gyms/create.php e
gyms/edit.php partilhavam a mesma query da lista de galerias — agora
db_galleries_options() em functions_galerias.php (e simplificada: o
_gyms_has_col() defensivo já não é preciso, galleries.deleted_at
existe desde a migração de 25/09). reviews/index.php gravava
google_place_id à mão — agora db_gym_set_place_id(), separado do
db_gyms_update() geral para não auditar/validar o resto do registo
por uma só coluna.

Zero vistas com SQL direto agora (grep confirma).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:41:56 +01:00
andreandClaude Sonnet 5 fa87aabb87 feat(auth): migra login/sessão/roles para o auth partilhado (auditoria 1.3)
config/db.php liga-se com 'auth' => 'shared' em vez de 'own': login,
sessão, papéis e guardas passam a correr sobre
shared/platform-tools/functions_auth.php (auth_attempt/auth_login/
auth_at_least/auth_is/auth_session_start/auth_remember_*) em vez da
lógica local duplicada. Os ~30 ficheiros em views/ que chamam
require_login()/require_admin()/require_staff()/require_sysadmin()/
is_admin()/etc. não mudam — só o que está por baixo dessas funções.

- roles: staff=40, client=10 registados para preservar a hierarquia
  sysadmin>admin>staff>client que os antigos in_array() garantiam.
  `users` já tinha exactamente as colunas por omissão do auth_schema(),
  não foi preciso mapear nada.
- sessão: o bloco manual de ini_set()/session_start() em config/db.php
  foi substituído pela opção 'session' do platform_connect() — mesmo
  nome de cookie (FIT_SESS), mesmo SameSite=Lax, secure/httponly
  passam a ser geridos por auth_session_start().
- «Manter sessão iniciada»: config/functions_remember.php (a
  reimplementação local do selector/validator) foi eliminado a favor
  do módulo partilhado (auth_remember_issue/resume/clear via
  'remember' => true), que já cobria o mesmo desenho.
- auth_attempts: tinha (ip, scope) do throttle próprio da app
  (config/functions_throttle.php), não o `identifier` que o throttle
  partilhado exige. Migração aditiva
  (migration_20260925_shared_auth_attempts.sql) torna a tabela
  conforme sem tocar no throttle da app, que continua a ser o único
  usado para login/registo/recuperação — o throttle partilhado não é
  chamado, para não duplicar a mesma defesa com duas contagens.
- last_login_ua (sem equivalente partilhado) passa a ser gravado por
  fit_stamp_login_ua(), chamado a seguir a auth_login().

Testado ponta-a-ponta num contentor scratch na VPS (site-fitme:8.4,
cópia da BD de produção + migração aplicada): visitante anónimo é
redirigido, password errada dá o mesmo erro genérico e conta para o
throttle (bloqueia à 5ª tentativa, 429), login correcto regenera a
sessão, grava last_login_at/ip/ua, audita "auth.login" e (com
"lembrar-me") emite o cookie am_remember; esse cookie sozinho, numa
sessão nova, repõe a sessão certa com rotação do validator; logout
limpa sessão+cookie+token e audita "auth.logout"; platform.php
(guard=require_sysadmin) e views/admin/dashboard.php respondem 302
sem sessão e 200 com sessão de sysadmin. php -l limpo em todos os
ficheiros alterados. Migração já aplicada em produção (aditiva).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:25:54 +01:00
andreandClaude Sonnet 5 ccc644ac0d feat(composer): versiona composer.lock (phpmailer/phpmailer 7.1.1)
Gerado com composer install dentro do contentor reconstruído
(binário composer:2 no Dockerfile). Fixa a versão instalada —
mesma 7.1.1 que já estava vendorizada à mão em vendor/PHPMailer,
confirmando que são a mesma versão. vendor/phpmailer/ e
vendor/composer/ ficam fora do git (gitignored), gerados por
composer install a partir daqui; vendor/PHPMailer/ continua
hand-vendored e tracked (ver .gitignore e o commit anterior).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:17:57 +01:00
andreandClaude Sonnet 5 927cc1967d feat(composer): adiciona composer.json e liga o Composer à imagem
Auditoria 6.5/7.1: sem Composer nem composer.json. Segue exatamente o
que a fotografia já faz (mesma imagem base, mesmo Dockerfile) — a
frota não converte o código próprio para classes com namespace (nem
lá isso existe), o composer.json serve para declarar PHP/extensões e
dependências, e para o botão "Atualizar dependências" do backoffice.

vendor/PHPMailer continua hand-vendored e é o que o functions_mail.php
partilhado espera por caminho fixo (vendor/PHPMailer/src/...) — mudar
isso é código partilhado, fora do alcance de uma app só. composer
install vai criar vendor/phpmailer/phpmailer/ em paralelo (igual à
fotografia, "harmless but useless" no comentário do Dockerfile deles).

.gitignore: vendor/ passa a gitignored exceto vendor/PHPMailer/
(allowlist, como o fpdf da fotografia); composer.lock fica versionado.

Dockerfile em /srv/stacks/fitme.andmore.pt/ (fora deste repo) ganhou o
binário do composer:2 e unzip — próximo passo é rebuild da imagem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:16:10 +01:00
andreandClaude Sonnet 5 df1aad8769 refactor: move raw SQL out of views into config/functions_db.php
Auditoria 6.3 apontava dashboard.php (2×), user/index.php e
user/profile.php como as únicas vistas com SQL direto, quebrando a
convenção da frota (config/functions_*.php como camada de models). As
quatro passam a chamar db_users_count_clients(), db_users_count_team(),
db_users_list() e db_user_update_profile() — mesmo SQL, mesmos
parâmetros, só movido para onde o resto do código já vai buscar dados.

Nota: views/admin/gyms/{create,edit}.php e views/admin/reviews/index.php
também têm SQL direto, mas não estavam no âmbito dos "3 vistas" da
auditoria (entraram depois, no trabalho de multi-ginásio) — por tratar
numa próxima passagem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 17:06:00 +01:00
andreandClaude Sonnet 5 7037077b48 fix(css): anima transform/opacity em vez de layout (auditoria 5.5)
As 5 transições que disparavam layout (não paint) passam a usar
propriedades compositáveis, sem mudar o efeito visual:
- .skip-link: top -100px→8px vira transform: translateY(-108px→0)
- .nav-link:after (sublinhado): width 0→100% vira transform: scaleX(0→1)
  (width fica estático a 100%, position:absolute não afeta o layout à
  volta mesmo com a largura sempre cheia)
- .crossfit-nav-link, .icon-box-item .ib-icon, .sdf-btn: transition:
  all troca por uma lista explícita das propriedades que realmente
  mudam no :hover (background-color/color/border-color/transform) —
  o "all" também escutava mudanças de layout no resize por nada.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 16:55:56 +01:00
andre ff1d0fdc07 Site polish: tighter modalidades spacing, less schedule redundancy, plan section styling
- Modalidades: reduce gap between cards
- Horário: drop the day-title above the grid in single-day mode (the active
  day-filter button already names it); restyle "Guardar horário" as a plain
  link and drop the "(JPG)" suffix
- Mensalidades: give "Escolhe o teu plano" the same dark hammered-metal
  background as the reviews/footer sections instead of the plain light one
2026-09-25 13:01:47 +01:00
andreandClaude Sonnet 5 300655b92a feat(auth): «Manter sessão iniciada» (auditoria: item Médio)
config/functions_remember.php reimplementa o desenho selector/validator
do platform-tools (functions_remember.php) localmente, porque esta app
tem autenticação própria e não passa por functions_auth.php partilhado:
selector em claro e UNIQUE indexa a linha, só o SHA-256 do validator
fica na BD, hash_equals() na comparação, rotação a cada uso, e um
validator que não bate certo revoga TODOS os tokens da conta (sinal de
cookie em circulação).

- Checkbox no login; remember_issue() só quando marcada.
- require_login() tenta remember_resume() antes de mandar para o login.
- logout.php apaga só o token deste browser (não a conta toda).
- auth_set_password() revoga todos os tokens da conta — uma password
  nova não pode deixar um cookie antigo continuar a entrar.

Migração: database/migration_20260925_remember_me.sql (remember_tokens).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 13:01:18 +01:00
andreandClaude Sonnet 5 ee30e6f116 feat(auth): throttle de login esgotado alimenta o IP ban da frota
Cada vez que o throttle local bloqueia (5/min ou 30/h por IP, 10/50
por conta) chama platform_ip_record_offence() — já carregado pelo
platform_connect() de config/db.php. A frota lembra-se entre sessões
de throttle onde o throttle sozinho esquece ao fim de uma hora: 3
vezes em 24h = 1h de ban, 5 = 1 dia, 8 = 1 semana, 12 = permanente
(IP_BAN_LADDER, já em vigor desde 25/09 para todo o site).

Also: o aviso à frota de login falhado (functions_security_events.php)
já funciona — o comentário que dizia "não faz nada até ao refresh do
vendorizado" ficou desatualizado com o submódulo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:48:17 +01:00
andreandClaude Sonnet 5 0ea8eb1cde docs(platform-tools): regista a tentativa revertida de apertar permissões
config/cache/ e assets/img/seo/ passaram a volumes próprios
(/srv/data/fitme.andmore.pt/{cache,seo}), mas o checkout continua
664/2775: o deploy-on-push corre como www-data dentro do pedido do
webhook, não como deploy, e 644/2755 partiu a primeira push a seguir
("Permission denied" a criar um ficheiro novo). Documentado para a
próxima vez que alguém for tentar isto outra vez.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:47:06 +01:00
andreandClaude Sonnet 5 32a5c24ba2 feat(db): created_at nas 7 tabelas que não tinham (auditoria 3.1.5)
fitme3070_info, galleries, horario, modalidades, system_settings,
website_offer e work_hours passam a ter created_at (DEFAULT
CURRENT_TIMESTAMP, linhas existentes ficam com a data da migração).
Sem created_by de propósito: são o CRUD de um só admin e a audit_log
(ligada 2026-09-25) já regista quem criou/mudou cada linha.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:44:07 +01:00
andreandClaude Sonnet 5 a9cda24de3 docs(platform-tools): regista o deploy-on-push ligado (webhook Gitea id 6)
Master passa a deployar-se sozinho a cada push — webhook criado no
fitme3070.pt, AUTO_UPDATE_SECRET no .env do servidor,
system_settings.auto_update_enabled = 1. Fecha "produção sem
deploy-on-push" da auditoria (1.13/resumo Médio).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:35:09 +01:00
andreandClaude Sonnet 5 0ecc38774a feat(platform-tools): volta a ser submódulo, agora com branch fixo
Estava vendorizado como cópia simples desde 24/09 (VENDORED.md) depois
de um submódulo anterior ter partido a produção: .gitmodules sem
branch fazia "Atualizar submódulo" cair para refs/remotes/origin/HEAD,
uma ref que um clone só-servidor nunca cria, e a URL era http:// (523
na origem). Corrigidos os dois: branch = main fixo e URL https (via
../platform-tools.git, relativa como a fotografia usa). A terceira
razão do VENDORED.md — "o site é deployado por upload" — não se aplica
ao deploy.sh actual (é git pull), por isso deixou de justificar ficar
vendorizado depois de corrigir as outras duas.

Confirmado antes de converter: a cópia vendorizada era byte-idêntica
ao upstream @12f26fd (única diferença, CRLF→LF em 4 ficheiros de
social/, já notada no VENDORED.md) — nada foi perdido. Submódulo fixo
nesse mesmo commit. deploy.sh (fora do repo, em /srv/stacks/) passa a
correr `git submodule update --init --recursive` a seguir ao pull.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:30:26 +01:00
andreandClaude Sonnet 5 35415429e5 fix(platform-tools): renomeia $user em config/db.php para não colidir com o router
O router (platform.php) requer config/db.php e as páginas partilhadas no
mesmo âmbito plano, sem função a separar — o $user = env('DB_USER')
(a string do utilizador da BD) ficava visível mais tarde em
includes/header.php, que só substitui $user quando está vazio
($user ?? current_user() ?? …). O utilizador de sessão nunca entrava,
e $user['role'] rebentava com TypeError "Cannot access offset of type
string on string" em todas as páginas de sistema (Backups, Auditoria,
Actualizações, IP bans, Origem das visitas) — só a Geo escapava por não
usar esse layout. Confirmado em logs/php_errors.jsonl em produção.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:25:40 +01:00
andreandClaude Sonnet 5 f066eec7c7 feat(platform-tools): liga platform_connect() e o router de páginas de sistema
O bootstrap só reimplementava pedaços do platform-tools à mão (sessão
endurecida, IP do cliente) sem nunca chamar platform_connect() — sem
router, só 4 das ~9 páginas de sistema estavam ligadas (faltavam IP
bans, Geo, Origem das visitas, Hub), e o log de erro de arranque ainda
dizia "[fotografia bootstrap]", copiado sem adaptar.

Liga-se com 'auth' => 'own': a app mantém as suas próprias passwords e
sessão, porque a auth_attempts local (ip+scope) não tem a coluna
identifier que o throttle partilhado exige — trocar para 'shared'
partia o throttle. Ganha-se à mesma o bloqueio de IP da frota e o
router (platform.php / api/platform.php), com Comunicações e Fila de
mensagens declaradas como sysadmin_tools próprias (substituem o Estado
SMTP partilhado, que já cobrem). Os stubs de Backups/Actualizações/
Auditoria passam a reencaminhar para o router; o menu de Sistema passa
a incluir IP bans, Geo e Origem das visitas.

'conformance' => 'report' para nunca recusar pedidos — só reporta no
health endpoint — enquanto o resto da migração de autenticação fica
por decidir.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:16:43 +01:00
andreandClaude Sonnet 5 5cf0f14ad2 fix: correções Baixo da auditoria (escaping, dvh, transições, assets)
Fecha os itens 3.2.2, 3.3.1, 4.6, 5.2, 5.6, 5.7 e 7.4 do relatório de
auditoria de 2026-09-24: escapa saídas de site_img_url()/canonical,
valida identificadores de coluna em db_info_save(), troca 100vh por
100dvh, remove a última cor solta, encurta a transição para ~150ms,
acrescenta decoding=async/fetchpriority=high nas imagens da home, e
documenta as versões vendorizadas em ASSETS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:16:33 +01:00
andreandClaude Opus 5.5 fcf86b60ea perf(backoffice): Summernote só nas páginas com editor
Os ~100 KB do editor e da tradução eram carregados em todas as páginas do
backoffice. Agora o footer só os pede quando existe um textarea.js-wysiwyg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:43:42 +01:00
andreandClaude Opus 5.5 b202262da0 feat(rgpd): política de privacidade e termos, mapa a pedido, fotos locais, retenção, tema claro/escuro
- Política de privacidade reescrita: tabela de dados/finalidades/fundamento
  (inclui os pedidos de aula, inscrição e eventos com IP), prazos concretos,
  subcontratantes, Google Maps e reviews, cookies. Termos e condições novos
  (/home/termos), no rodapé e no sitemap. Campos [ENTRE PARÊNTESES RETOS]
  e revisão por jurista pendentes antes de publicar no domínio real.
- Google Maps só carrega ao carregar em «Mostrar mapa» (map_click_to_load);
  as fotos dos autores das reviews são guardadas pelo servidor em
  uploads/reviews/ e servidas localmente (review_avatar_url).
- Retenção que a política declara, aplicada pelo cron (04:00): leads com
  mais de 12 meses → lixeira; blocked_submissions e outbox de segurança
  apagados aos 90 dias (trash_retention_sweep, auditado).
- Backoffice e login com tema claro/escuro/automático: data-bs-theme
  aplicado no <head> antes do CSS (sem flash), escolha em localStorage,
  segue o sistema em tempo real, color-scheme e theme-color para os dois.
  Cores próprias passaram a variáveis do Bootstrap. lang="pt-PT".

Testado numa cópia da BD de produção: 72/72 verificações.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:39:05 +01:00
andreandClaude Opus 5.5 04c8c83e66 feat: aulas experimentais guardadas, anti-spam partilhado nos formulários, audit das escritas
- Pedido de aula experimental passa a ficar em trial_leads (por ginásio)
  ANTES do email: sem SMTP o visitante já não vê «erro» e o pedido não se
  perde. Gestão → Aulas experimentais: estados, notas, CSV (auditado),
  apagar para a lixeira; aviso «sem email» quando o aviso não saiu.
- Aula experimental, inscrição e eventos usam o anti-spam do platform-tools
  (honeypot, carimbo assinado com tempo mínimo, prova de JS, limite por IP,
  pontuação de conteúdo) e o bloqueio de IP, em vez de honeypots locais.
  Recusas ficam em blocked_submissions; resposta igual à de sucesso.
  Falha aberto se o módulo rebentar.
- Checkbox de consentimento da aula experimental passa a ser guardada.
- Audit das escritas de conteúdo: criar/editar equipa, modalidades,
  eventos, mensalidades, pedidos de inscrição, galerias, horário (incl.
  arrastar no calendário), horas, contactos e ginásios, com antes/depois
  campo a campo (audit_row/audit_change). Exportação CSV de inscrições
  auditada.
- .table-responsive em todas as tabelas do backoffice.
- Migração: database/migration_20260926_trial_leads.sql (só cria tabelas).

Testado numa cópia da BD de produção: 63/63 verificações HTTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:33:24 +01:00
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00
andreandClaude Opus 5.5 5e300fc341 feat: vários ginásios (/3070/ e /<código postal>/), CRUD de ginásios, modalidades após a galeria
Estrutura multi-ginásio: as mesmas secções do site, conteúdo próprio por
ginásio, e cada um numa pasta da URL com o código postal da zona.

- migration_20260926_gyms: tabela gyms (pasta, nome, localidade, publicado,
  principal, secções visíveis, título/imagem do header, SEO, galeria, Place ID,
  página CrossFit) com o FitMe 3070 como ginásio 1 em /3070/; gym_id em team,
  modalidades (o horário segue a modalidade), work_hours, memberships,
  membership_leads e website_offer (tudo o existente → ginásio 1); events com
  gym_id NULL = todos; fitme3070_info passa a uma linha por ginásio (id = gym).
- Site: /<pasta>/, /<pasta>/crossfit, /<pasta>/agendar|inscricao. Ginásio por
  publicar → 404 para o público, pré-visualização (com faixa) para a equipa.
  / e /home → 301 para o ginásio principal (a manter a query); com mais de um
  ginásio publicado, a raiz passa a uma página para escolher. Secções
  desligadas não aparecem nem no menu; menu, canonical, JSON-LD, sitemap,
  formulários, e-mails e reviews (Place ID e cache) são do ginásio.
- Backoffice: Gestão → Ginásios (criar/editar/apagar, secções, e «começar
  com o conteúdo de» outro ginásio — cópias independentes) e o seletor
  «A editar» no topo; equipa, modalidades, horários, horário de
  funcionamento, mensalidades, pedidos, contactos e reviews trabalham sobre o
  ginásio escolhido, e um registo de outro ginásio não abre.
- Modalidades passam para logo a seguir à galeria das instalações.
- Galeria sem cantos arredondados (raio da marca).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:26:40 +01:00
andreandClaude Opus 5.5 2f8f68c61a chore: olho da password em todos os campos, JS morto removido, .gitignore
- assets/js/password-toggle.js, carregado nos dois layouts: liga os botões
  existentes e cria um para cada campo de password que não o tenha (eram 3
  em 12). aria-pressed, aria-label que muda, fora da ordem de tabulação, e
  volta a ocultar ao submeter.
- Removidas 23 bibliotecas/folhas sem nenhuma referência (193 ficheiros),
  incluindo o jQuery 3.2.1 (CVE-2020-11022/11023) que se servia por URL, o
  Splide (a galeria passou a masonry própria em d70f13a), owl, mixitup,
  masonry, magnific-popup, slicknav, jquery.lazy, fullpage, TweenMax,
  circle-progress, progressbar, o template polo e cópias antigas do
  Bootstrap/FontAwesome em site/assets.
- .table-responsive nas listagens de eventos e galerias.
- .gitignore: sem os 5 padrões herdados do fotografia; *.sql/*.dump
  ignorados (exceto migrações e o esquema); .trash/ das imagens; nota sobre
  vendor/ versionado de propósito. database/andmore_fitme3070_dev.sql passa a
  database/schema.sql (só estrutura, 0 INSERT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:19:54 +01:00
andreandClaude Opus 5.5 895b3d58d7 fix(segurança): lixeira de 30 dias, CSRF em todas as ações, IP real, sessão e uploads
Correções da auditoria de 24/09 (audits/fitme3070-auditoria-2026-09-24.html).

Lixeira
- Tudo o que é importante passa a soft delete (deleted_at + deleted_by):
  equipa, modalidades, eventos, mensalidades, galerias, imagens, horário,
  horas de funcionamento, inscrições em eventos e pedidos de inscrição.
- config/functions_trash.php: um registo por tipo; apagar, restaurar, listar
  e eliminar leem todos dele. Eliminar de vez só com 30 dias na lixeira —
  a condição está no WHERE do DELETE, um POST forjado não apaga nada.
- Gestão → Lixeira (views/admin/trash/index.php): lista única com quem e
  quando, restauro a qualquer momento.
- Imagens de galeria: o site lista a pasta, por isso o ficheiro passa para
  .trash/ (negado pelo .htaccess) e volta no restauro.
- As listagens e o site público filtram deleted_at (horário também filtra
  modalidades apagadas). Migração: database/migration_20260925_trash.sql
  (só acrescenta colunas; tem de ser aplicada antes deste código).

CSRF
- csrf_check() recusa qualquer pedido que não seja POST (405). Antes saía
  sem verificar nada em GET, e dez ações de apagar/restaurar eram GET.
- Os dez handlers passaram a POST com token; links viraram formulários.

Autenticação e sessão
- client_ip(): X-Real-IP / último X-Forwarded-For, só vindo de proxy privado
  com TRUSTED_PROXY. O throttle contava o IP do proxy (limite global).
- Throttle também por conta (hash do email), além do IP.
- Sem oráculo de tempo: login verifica contra um hash de referência quando
  a conta não existe; a recuperação deixa o email na fila.
- Cookie de sessão Secure/HttpOnly/SameSite=Lax e use_strict_mode.
- Definições de sistema só para sysadmin; autocomplete=username.

Servidor e uploads
- .htaccess: nosniff, Referrer-Policy, X-Frame-Options, Permissions-Policy,
  CSP (frame-ancestors, base-uri, object-src, form-action); nega parciais
  _*.php, PHP soltos do platform-tools e *.md; 404 própria.
- Uploads validados pelo conteúdo (finfo/getimagesize); SVG só sem scripts,
  handlers on*=, foreignObject ou hrefs externos.

Testado numa cópia da BD de produção (fitme_audit_test) com um contentor
temporário: 34/34 verificações HTTP ponta-a-ponta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:16:10 +01:00
andreandClaude Opus 5.5 d70f13aea0 feat(galeria): masonry em JS com lightbox, em vez do slider
- «Conhece as nossas instalações» (e a galeria do CrossFit) passa de slider
  Splide a masonry: layout próprio em JS (sem biblioteca), fotos com a
  proporção original, coluna mais baixa primeiro e algumas a ocupar duas
  colunas (só quando não deixam buraco). Reorganiza ao mudar de largura
  (ResizeObserver + resize) e as fotos entram em cascata ao aparecer.
  Sem JS fica uma grelha CSS.
- Clicar abre a foto num <dialog>: anterior/seguinte, setas, Esc, deslizar.
- O Splide deixa de ser carregado (CSS e JS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:05:25 +01:00
andreandClaude Opus 5.5 cb0e4540f2 feat(modalidades): 6 ícones em falta, ícones animados, ícone centrado no cartão aberto
- Ícones novos (Bootcamp, YourFit Core, Run3070, Total Workout, On Ramp, Jump)
  do mesmo conjunto; só o LPO continua sem ícone no ficheiro de design.
- Os ícones fitme-*.svg passam a ir embutidos (modalidade_icon_html): cada
  traço tem pathLength=1 e desenha-se ao aparecer, no hover e ao abrir o
  cartão; cada família tem um movimento (levantar, soco, respirar, saltar,
  chama, pedalar, correr). Outros ícones continuam <img>.
- No cartão de detalhe (desktop) o ícone e o nome deslizam até ao centro
  vertical da coluna.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:02:58 +01:00
andreandClaude Opus 5.5 42f5919915 fix: telefones em formato internacional (+351) nos links tel: e no JSON-LD
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:58:16 +01:00
andreandClaude Opus 5.5 0587be7d14 feat: borda verde a rodar nos planos, textura metálica, contactos numa só fonte
- Mensalidades: ao passar o rato, a borda do cartão ganha um brilho verde num
  conic-gradient que roda (@property --pc-a animável), centrado no cursor; o
  cartão inclina-se e desliza para o rato como os da equipa.
- «O que dizem de nós» e rodapé: textura discreta de metal preto mate (grão de
  pintura eletrostática, brilho difuso, riscas quase invisíveis), em SVG/CSS.
- Contactos: o site lê só fitme3070_info (Gestão → Contactos). Os campos
  contact_*/social_* de Sistema → Definições não eram usados e foram editados
  por engano — saem do formulário, com um aviso a apontar para Contactos.
  site_info() deixa o WhatsApp só com dígitos (wa.me não aceita + nem espaços).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:57:15 +01:00
andreandClaude Opus 5.5 21ff1b55ff style: sombra discreta nos cartões de detalhe da equipa e das modalidades
O clip-path da animação cortava a box-shadow, por isso no desktop não havia
sombra nenhuma (e no telemóvel era pesada). Aberto, o recorte passa a ir 40px
para lá do cartão e a sombra é curta e suave, um pouco mais escura na equipa
por causa do fundo cinzento.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:51:34 +01:00
andreandClaude Opus 5.5 ee3fe69afb feat(modalidades): faixa rolável, cartão de detalhe como o da equipa, ícones novos
- A faixa de modalidades passa a ser um scroller a sério: toque, trackpad,
  roda do rato, arrastar e setas; continua a deslizar sozinha (~20px/s) e
  pára enquanto alguém interage, retomando 2,5s depois. Loop infinito sobre
  uma cópia do conjunto (a posição é mantida em float — scrollLeft += 0.3
  arredondava para zero).
- Em vez do «Saber mais», o cartão com descrição abre um painel que nasce do
  próprio cartão e estica para o lado (para a esquerda junto à margem
  direita), com inclinação 3D a seguir o rato; no telemóvel voa para o
  centro com o fundo escurecido. O painel vive fora do scroller para o
  overflow não o cortar.
- 14 ícones novos (SVG, traço 3) em uploads/modalidades/icons/fitme-*.svg.
- Títulos dos cartões em <h3>, h2#modalidades-title + aria-labelledby; sem
  ícone não se gera <img> vazio.
- #modalidades entra só com opacidade: o transform deixado pela animação de
  entrada prendia o fundo escurecido (position:fixed) dentro da secção.
- prefers-reduced-motion: os cartões de detalhe (equipa e modalidades)
  ficam fora da regra global que reduzia as transições a 0,01ms — com ela
  (ativa por defeito em Remote Desktop / Windows sem animações) o cartão
  aparecia de repente em vez de esticar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:47:17 +01:00