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>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-25 11:39:05 +01:00
1 parent 04c8c83e66
commit b202262da0
16 files changed
+297 -34

No files matched your search

+4
View File
@@ -16,6 +16,10 @@ uploads/events/*
# apagados à espera dos 30 dias. Nunca vão para o git. # apagados à espera dos 30 dias. Nunca vão para o git.
uploads/**/.trash/ uploads/**/.trash/
# Fotos dos autores das reviews do Google, guardadas pelo servidor
# (config/functions_privacy.php) para o browser não as pedir à Google.
uploads/reviews/
# Nota: as fotos da equipa, galerias e ícones em uploads/ ainda estão # Nota: as fotos da equipa, galerias e ícones em uploads/ ainda estão
# versionados (vieram assim do site antigo). Em produção uploads/ é um volume # versionados (vieram assim do site antigo). Em produção uploads/ é um volume
# (/srv/data/fitme.andmore.pt/uploads) e é esse a fonte; o que está no git é # (/srv/data/fitme.andmore.pt/uploads) e é esse a fonte; o que está no git é
+1
View File
@@ -60,6 +60,7 @@ RewriteRule ^home/agendar/?$ site/agendar.php [L,QSA]
RewriteRule ^home/inscricao/?$ site/inscricao.php [L,QSA] RewriteRule ^home/inscricao/?$ site/inscricao.php [L,QSA]
RewriteRule ^home/crossfit/?$ site/crossfit.php [L,QSA] RewriteRule ^home/crossfit/?$ site/crossfit.php [L,QSA]
RewriteRule ^home/privacidade/?$ site/privacidade.php [L,QSA] RewriteRule ^home/privacidade/?$ site/privacidade.php [L,QSA]
RewriteRule ^home/termos/?$ site/termos.php [L,QSA]
RewriteRule ^evento/([a-zA-Z0-9-]+)/?$ site/evento.php?slug=$1 [L,QSA] RewriteRule ^evento/([a-zA-Z0-9-]+)/?$ site/evento.php?slug=$1 [L,QSA]
# ── Ginásios: /<pasta>/ (código postal da zona, ex.: /3070/) ───────────────── # ── Ginásios: /<pasta>/ (código postal da zona, ex.: /3070/) ─────────────────
# Uma pasta por ginásio (Gestão → Ginásios); pastas desconhecidas ou por publicar # Uma pasta por ginásio (Gestão → Ginásios); pastas desconhecidas ou por publicar
+1
View File
@@ -38,6 +38,7 @@ require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_i
require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição
require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache
require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental
require_once __DIR__ . '/functions_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente
require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
// ─── DB-backed settings → legacy constants ─────────────────────────────────── // ─── DB-backed settings → legacy constants ───────────────────────────────────
+61
View File
@@ -0,0 +1,61 @@
<?php
// ─── Terceiros só depois de o visitante pedir ───────────────────────────────
// RGPD: nada da Google carrega sem o visitante o pedir. O iframe do Google Maps
// punha cookies e enviava o IP de cada visita; as fotos dos autores das
// reviews vinham de lh3.googleusercontent.com. Assim:
// - map_click_to_load(): o mapa fica num <template> e só entra na página
// quando se carrega no botão; o link «Abrir no Google Maps» funciona sem ele;
// - review_avatar_url(): o servidor guarda a foto uma vez em uploads/reviews/
// e o browser pede-a ao próprio site.
function map_click_to_load(string $iframeHtml, string $mapsLink = ''): string {
if (trim($iframeHtml) === '') return '';
static $n = 0;
$id = 'map-consent-' . (++$n);
$esc = fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$link = $mapsLink !== ''
? '<a href="' . $esc($mapsLink) . '" target="_blank" rel="noopener" class="map-consent-link">Abrir no Google Maps</a>'
: '';
return '<div class="map-consent" id="' . $id . '" style="position:absolute;inset:0;display:flex;flex-direction:column;'
. 'align-items:center;justify-content:center;gap:.6rem;padding:1rem;text-align:center;color:#f5f5f5;">'
. '<i class="fas fa-map-marker-alt" aria-hidden="true" style="font-size:1.8rem;color:#C2D500"></i>'
. '<button type="button" class="site-btn" style="min-height:44px" '
. 'onclick="var w=document.getElementById(\'' . $id . '\');var t=w.nextElementSibling;'
. 'w.insertAdjacentHTML(\'afterend\',t.innerHTML);w.remove();">Mostrar mapa</button>'
. '<small style="max-width:32em;opacity:.8">O mapa é do Google Maps: ao abri-lo, a Google recebe o seu endereço IP e pode guardar cookies.</small>'
. $link
. '</div><template>' . $iframeHtml . '</template>';
}
function review_avatar_url(array $r): string {
$src = (string) ($r['author_photo'] ?? '');
if ($src === '' || !preg_match('#^https://[a-z0-9.-]*googleusercontent\.com/#i', $src)) return '';
$base = substr(sha1($src), 0, 24);
$dir = dirname(__DIR__) . '/uploads/reviews/';
foreach (['jpg', 'png', 'webp'] as $ext) {
if (is_file($dir . $base . '.' . $ext)) return base_url('uploads/reviews/' . $base . '.' . $ext);
}
// Uma falha recente não se repete a cada visita (cada tentativa custa até 4 s).
$fail = $dir . '.' . $base . '.fail';
if (is_file($fail) && filemtime($fail) > time() - 86400) return '';
if (!function_exists('curl_init')) return '';
$ch = curl_init($src);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 2,
CURLOPT_TIMEOUT => 4, CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
]);
$bin = curl_exec($ch);
curl_close($ch);
$info = (is_string($bin) && strlen($bin) > 0 && strlen($bin) < 512 * 1024) ? @getimagesizefromstring($bin) : false;
$ext = $info ? (['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$info['mime']] ?? null) : null;
if (!is_dir($dir)) @mkdir($dir, 0775, true);
if ($ext === null || !@file_put_contents($dir . $base . '.' . $ext, $bin)) {
@touch($fail);
return '';
}
return base_url('uploads/reviews/' . $base . '.' . $ext);
}
+1
View File
@@ -140,6 +140,7 @@ function site_sitemap_urls(PDO $pdo): array {
if (!empty($g['has_crossfit'])) $urls[] = ['loc' => gym_url($g, 'crossfit'), 'priority' => '0.8']; if (!empty($g['has_crossfit'])) $urls[] = ['loc' => gym_url($g, 'crossfit'), 'priority' => '0.8'];
} }
$urls[] = ['loc' => base_url('home/privacidade'), 'priority' => '0.2']; $urls[] = ['loc' => base_url('home/privacidade'), 'priority' => '0.2'];
$urls[] = ['loc' => base_url('home/termos'), 'priority' => '0.1'];
foreach (function_exists('db_events_active') ? db_events_active($pdo) : [] as $ev) { foreach (function_exists('db_events_active') ? db_events_active($pdo) : [] as $ev) {
$urls[] = ['loc' => base_url('evento/' . rawurlencode($ev['slug'])), 'priority' => '0.6']; $urls[] = ['loc' => base_url('evento/' . rawurlencode($ev['slug'])), 'priority' => '0.6'];
} }
+29
View File
@@ -216,6 +216,35 @@ function _trash_audit(PDO $pdo, string $action, string $type, int $id, string $s
]); ]);
} }
// ─── Retenção (política de privacidade, secção 3) ────────────────────────────
// Leads com mais de 12 meses vão para a lixeira (deleted_by NULL = sistema); a
// eliminação definitiva continua a exigir os 30 dias e um clique. Submissões
// recusadas pelo anti-spam e o outbox de segurança: apagados aos 90 dias.
// Chamado uma vez por dia pelo cron.php.
function trash_retention_sweep(PDO $pdo): array {
$done = [];
foreach (['trial_lead' => 'trial_leads', 'membership_lead' => 'membership_leads', 'event_lead' => 'event_leads'] as $type => $table) {
try {
$n = $pdo->exec("UPDATE `{$table}` SET deleted_at = NOW(), deleted_by = NULL
WHERE deleted_at IS NULL AND created_at < DATE_SUB(NOW(), INTERVAL 12 MONTH)");
if ($n) {
$done[$type] = (int) $n;
if (function_exists('platform_audit')) {
platform_audit($pdo, 'trash.retention', ['target_type' => $type, 'actor' => false,
'summary' => $n . ' registo(s) com mais de 12 meses enviados para a lixeira']);
}
}
} catch (Throwable $e) { error_log('[retention] ' . $table . ': ' . $e->getMessage()); }
}
foreach (['blocked_submissions' => 'created_at', 'hub_security_outbox' => 'occurred_at'] as $table => $col) {
try {
$n = $pdo->exec("DELETE FROM `{$table}` WHERE `{$col}` < DATE_SUB(NOW(), INTERVAL 90 DAY)");
if ($n) $done[$table] = (int) $n;
} catch (Throwable $e) { /* tabela ainda não existe */ }
}
return $done;
}
// ─── Efeitos em ficheiros ──────────────────────────────────────────────────── // ─── Efeitos em ficheiros ────────────────────────────────────────────────────
// O site público lista as fotos da galeria com glob() na pasta, não pela BD. // O site público lista as fotos da galeria com glob() na pasta, não pela BD.
// Por isso uma imagem apagada tem de sair da pasta: vai para .trash/ (o // Por isso uma imagem apagada tem de sair da pasta: vai para .trash/ (o
+2
View File
@@ -94,6 +94,8 @@ if ((int) date('G') === 4 && (int) date('i') < 5) {
$soft = db_soft_delete_sent_older_than($pdo, 90); $soft = db_soft_delete_sent_older_than($pdo, 90);
$hard = db_purge_deleted_messages($pdo, 90); $hard = db_purge_deleted_messages($pdo, 90);
if ($soft || $hard) cron_log("retenção: {$soft} arquivada(s), {$hard} eliminada(s)"); if ($soft || $hard) cron_log("retenção: {$soft} arquivada(s), {$hard} eliminada(s)");
// Leads > 12 meses → lixeira; registos de segurança > 90 dias.
foreach (trash_retention_sweep($pdo) as $what => $n) cron_log("retenção: {$n} em {$what}");
} catch (Throwable $e) { } catch (Throwable $e) {
cron_log('ERRO na retenção: ' . $e->getMessage()); cron_log('ERRO na retenção: ' . $e->getMessage());
} }
+19 -4
View File
@@ -8,12 +8,26 @@
$page_title = $page_title ?? 'Entrar'; $page_title = $page_title ?? 'Entrar';
$app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
?><!doctype html> ?><!doctype html>
<html lang="pt"> <html lang="pt-PT">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title><?= htmlspecialchars($page_title) ?> — <?= htmlspecialchars($app_name) ?></title> <title><?= htmlspecialchars($page_title) ?> — <?= htmlspecialchars($app_name) ?></title>
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#eef0f4" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)">
<script>
// Tema antes do CSS, para não piscar o tema errado. Escolha guardada no
// navegador (claro / escuro / auto); auto segue o sistema operativo.
(function () {
var pref = 'auto';
try { pref = localStorage.getItem('bo-theme') || 'auto'; } catch (e) {}
var dark = pref === 'dark' || (pref === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme-pref', pref);
})();
</script>
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet"> <link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet"> <link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
<style> <style>
@@ -32,7 +46,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
} }
.auth-card { .auth-card {
width: 100%; max-width: 420px; width: 100%; max-width: 420px;
background: #fff; color: #0a0a0a; background: var(--bs-body-bg); color: var(--bs-body-color);
border-radius: 14px; padding: 2rem; border-radius: 14px; padding: 2rem;
box-shadow: 0 18px 50px rgba(0,0,0,.45); box-shadow: 0 18px 50px rgba(0,0,0,.45);
} }
@@ -41,12 +55,13 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
letter-spacing: .5px; margin-bottom: .25rem; letter-spacing: .5px; margin-bottom: .25rem;
} }
.auth-brand i { color: var(--bo-accent); } .auth-brand i { color: var(--bo-accent); }
.auth-sub { text-align: center; color: #6c757d; font-size: .875rem; margin-bottom: 1.5rem; } .auth-sub { text-align: center; color: var(--bs-secondary-color); font-size: .875rem; margin-bottom: 1.5rem; }
.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); font-weight: 600; } .btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); font-weight: 600; }
.btn-primary:hover, .btn-primary:focus { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); } .btn-primary:hover, .btn-primary:focus { background: #a8ba00; border-color: #a8ba00; color: var(--bo-dark); }
.form-control:focus { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); } .form-control:focus { border-color: var(--bo-accent); box-shadow: 0 0 0 .2rem rgba(var(--bo-accent-rgb), .25); }
.auth-foot { color: #6c757d; } .auth-foot { color: var(--bs-secondary-color); }
.auth-foot a, .auth-card a { color: #6a7a00; } .auth-foot a, .auth-card a { color: #6a7a00; }
[data-bs-theme="dark"] .auth-foot a, [data-bs-theme="dark"] .auth-card a { color: var(--bo-accent); }
/* 16px inputs stop iOS from auto-zooming on focus */ /* 16px inputs stop iOS from auto-zooming on focus */
@media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } } @media (max-width: 575.98px) { .form-control { font-size: 16px; } .auth-card { padding: 1.5rem; } }
</style> </style>
+33
View File
@@ -34,6 +34,39 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
}); });
// Tema: claro / escuro / automático (o <head> já aplicou o guardado).
(function () {
var root = document.documentElement, btn = document.getElementById('themeToggle');
var mq = window.matchMedia('(prefers-color-scheme: dark)');
var icons = { light: 'bi-sun', dark: 'bi-moon-stars', auto: 'bi-circle-half' };
var names = { light: 'claro', dark: 'escuro', auto: 'automático' };
function apply(pref) {
var dark = pref === 'dark' || (pref === 'auto' && mq.matches);
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
root.setAttribute('data-theme-pref', pref);
if (btn) {
btn.querySelector('i').className = 'bi ' + icons[pref];
btn.setAttribute('aria-label', 'Tema: ' + names[pref]);
}
document.querySelectorAll('[data-theme-set]').forEach(function (b) {
var on = b.getAttribute('data-theme-set') === pref;
b.classList.toggle('active', on);
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
var pref = root.getAttribute('data-theme-pref') || 'auto';
apply(pref);
document.querySelectorAll('[data-theme-set]').forEach(function (b) {
b.addEventListener('click', function () {
pref = b.getAttribute('data-theme-set');
try { localStorage.setItem('bo-theme', pref); } catch (e) {}
apply(pref);
});
});
// O sistema mudou de tema: só conta em «automático».
mq.addEventListener('change', function () { if (pref === 'auto') apply('auto'); });
})();
// Mobile off-canvas sidebar: hamburger toggles .sidebar.open + the backdrop; // Mobile off-canvas sidebar: hamburger toggles .sidebar.open + the backdrop;
// tapping the backdrop, a nav link, Escape, or resizing to desktop closes it. // tapping the backdrop, a nav link, Escape, or resizing to desktop closes it.
(function () { (function () {
+34 -6
View File
@@ -6,11 +6,25 @@ $page_title = $page_title ?? 'Painel';
$user = $user ?? current_user() ?? ['name' => 'Visitante', 'role' => 'client']; $user = $user ?? current_user() ?? ['name' => 'Visitante', 'role' => 'client'];
$app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
?><!doctype html> ?><!doctype html>
<html lang="pt"> <html lang="pt-PT">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($page_title) ?> — <?= htmlspecialchars($app_name) ?></title> <title><?= htmlspecialchars($page_title) ?> — <?= htmlspecialchars($app_name) ?></title>
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#eef0f4" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)">
<script>
// Tema antes do CSS, para não piscar o tema errado. Escolha guardada no
// navegador (claro / escuro / auto); auto segue o sistema operativo.
(function () {
var pref = 'auto';
try { pref = localStorage.getItem('bo-theme') || 'auto'; } catch (e) {}
var dark = pref === 'dark' || (pref === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme-pref', pref);
})();
</script>
<link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet"> <link href="<?= base_url('assets/vendor/bootstrap5/css/bootstrap.min.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet"> <link href="<?= base_url('assets/vendor/bootstrap-icons/bootstrap-icons.css') ?>" rel="stylesheet">
<link href="<?= base_url('assets/vendor/summernote/summernote-bs5.min.css') ?>" rel="stylesheet"> <link href="<?= base_url('assets/vendor/summernote/summernote-bs5.min.css') ?>" rel="stylesheet">
@@ -22,6 +36,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
--bo-dark-2: #151515; --bo-dark-2: #151515;
--bo-surface: #eef0f4; --bo-surface: #eef0f4;
} }
[data-bs-theme="dark"] { --bo-surface: #121416; }
body { background: var(--bo-surface); font-size: .925rem; } body { background: var(--bo-surface); font-size: .925rem; }
.sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 100; .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; } padding: 68px 0 0; width: 240px; background: var(--bo-dark); overflow-y: auto; }
@@ -38,7 +53,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
letter-spacing: 1.5px; padding: 1.25rem 1.25rem .35rem; } letter-spacing: 1.5px; padding: 1.25rem 1.25rem .35rem; }
main { margin-left: 240px; padding-top: 68px; } main { margin-left: 240px; padding-top: 68px; }
.topbar { position: fixed; top: 0; left: 240px; right: 0; z-index: 99; height: 56px; .topbar { position: fixed; top: 0; left: 240px; right: 0; z-index: 99; height: 56px;
background: #fff; border-bottom: 1px solid #e2e6ec; padding: 0 1.5rem; 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; } display: flex; align-items: center; justify-content: space-between; }
.topbar .user-dropdown { display: flex; align-items: center; gap: .5rem; margin-left: auto; } .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-sysadmin { background: var(--bo-accent); color: var(--bo-dark); }
@@ -50,17 +65,19 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
.btn-primary { 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); } .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); } .card { border: none; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
a { color: #0a0a0a; } [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 */ /* Summernote (WYSIWYG) — align with the backoffice look */
.note-editor.note-frame { border-color: #ced4da; border-radius: .375rem; } .note-editor.note-frame { border-color: #ced4da; border-radius: .375rem; }
.note-editor .note-editing-area .note-editable { font-size: .95rem; } .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-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: #f8f9fa; } .note-toolbar { background: var(--bs-tertiary-bg); }
.note-btn.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); } .note-btn.btn-primary { background: var(--bo-accent); border-color: var(--bo-accent); color: var(--bo-dark); }
/* ── Mobile: off-canvas sidebar + hamburger ──────────────────────────── */ /* ── Mobile: off-canvas sidebar + hamburger ──────────────────────────── */
.sidebar { transition: transform .25s ease; } .sidebar { transition: transform .25s ease; }
.menu-toggle { display: none; width: 40px; height: 40px; padding: 0; border: 1px solid #e2e6ec; .menu-toggle { display: none; width: 44px; height: 44px; padding: 0; border: 1px solid var(--bs-border-color);
background: #fff; color: var(--bo-dark); border-radius: 8px; font-size: 1.35rem; 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; } 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 { display: none; position: fixed; inset: 0; z-index: 1090; background: rgba(0,0,0,.45); }
.sidebar-backdrop.show { display: block; } .sidebar-backdrop.show { display: block; }
@@ -228,6 +245,17 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
</form> </form>
<?php endif; ?> <?php endif; ?>
<div class="user-dropdown"> <div class="user-dropdown">
<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">
<i class="bi bi-circle-half" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="themeToggle">
<li><button type="button" class="dropdown-item" data-theme-set="light"><i class="bi bi-sun me-2" aria-hidden="true"></i>Claro</button></li>
<li><button type="button" class="dropdown-item" data-theme-set="dark"><i class="bi bi-moon-stars me-2" aria-hidden="true"></i>Escuro</button></li>
<li><button type="button" class="dropdown-item" data-theme-set="auto"><i class="bi bi-circle-half me-2" aria-hidden="true"></i>Automático</button></li>
</ul>
</div>
<span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>"> <span class="badge rounded-pill badge-role-<?= htmlspecialchars($user['role']) ?>">
<?= htmlspecialchars(role_label($user['role'])) ?> <?= htmlspecialchars(role_label($user['role'])) ?>
</span> </span>
+2
View File
@@ -26,6 +26,8 @@
</ul> </ul>
<p class="small footer-legal">Este estabelecimento possui livro de reclamações online em <a href="https://www.livroreclamacoes.pt/Pedido/Reclamacao" target="_blank" rel="noopener">www.livroreclamacoes.pt</a></p> <p class="small footer-legal">Este estabelecimento possui livro de reclamações online em <a href="https://www.livroreclamacoes.pt/Pedido/Reclamacao" target="_blank" rel="noopener">www.livroreclamacoes.pt</a></p>
<p class="small footer-legal"><a href="<?= base_url('home/privacidade') ?>">Política de Privacidade</a> <p class="small footer-legal"><a href="<?= base_url('home/privacidade') ?>">Política de Privacidade</a>
<span class="footer-legal-sep" aria-hidden="true">·</span>
<a href="<?= base_url('home/termos') ?>">Termos e Condições</a>
<?php /* Discreet backoffice entry point — bounces through SSO (id.andmore.pt). */ ?> <?php /* Discreet backoffice entry point — bounces through SSO (id.andmore.pt). */ ?>
<span class="footer-legal-sep" aria-hidden="true">·</span> <span class="footer-legal-sep" aria-hidden="true">·</span>
<a href="<?= base_url('views/auth/login.php') ?>" class="footer-admin-link" rel="nofollow" aria-label="Acesso reservado" title="Backoffice"><i class="fas fa-lock"></i></a> <a href="<?= base_url('views/auth/login.php') ?>" class="footer-admin-link" rel="nofollow" aria-label="Acesso reservado" title="Backoffice"><i class="fas fa-lock"></i></a>
+2
View File
@@ -9,6 +9,8 @@ $page_meta = [
'Box de CrossFit em Mira (Lagoa). Aulas para todos os níveis com treinadores certificados. Marca a tua aula experimental no CrossFit 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', 'privacidade' => ['Política de Privacidade | FitMe 3070',
'Política de privacidade do FitMe 3070 — como recolhemos, usamos e protegemos os teus dados pessoais.'], '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 // 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 // (Gestão → Ginásios); a new gym without them gets a generic one from its name
+1 -1
View File
@@ -6,5 +6,5 @@
</div> </div>
</div> </div>
<div class="map" id="map"> <div class="map" id="map">
<?= clean_map_embed($info['google_maps'] ?? '') ?> <?= map_click_to_load(clean_map_embed($info['google_maps'] ?? ''), (string) ($info['google_maps_link'] ?? '')) ?>
</div> </div>
+3 -2
View File
@@ -540,7 +540,8 @@ if ($reviews):
<figure class="review-card"> <figure class="review-card">
<?php // Reviewer first: big photo (or initial), name, date, stars — then the text, smaller. ?> <?php // Reviewer first: big photo (or initial), name, date, stars — then the text, smaller. ?>
<div class="rv-avatar"> <div class="rv-avatar">
<?php if ($r['author_photo'] !== ''): ?><img src="<?= htmlspecialchars($r['author_photo']) ?>" alt="" loading="lazy" referrerpolicy="no-referrer" width="84" height="84"> <?php $rv_img = review_avatar_url($r); /* guardada no próprio site (config/functions_privacy.php) */ ?>
<?php if ($rv_img !== ''): ?><img src="<?= htmlspecialchars($rv_img) ?>" alt="" loading="lazy" decoding="async" width="84" height="84">
<?php else: ?><span aria-hidden="true"><?= htmlspecialchars(mb_strtoupper(mb_substr($r['author'], 0, 1))) ?></span><?php endif; ?> <?php else: ?><span aria-hidden="true"><?= htmlspecialchars(mb_strtoupper(mb_substr($r['author'], 0, 1))) ?></span><?php endif; ?>
</div> </div>
<figcaption class="rv-author"> <figcaption class="rv-author">
@@ -641,7 +642,7 @@ $session = session();
if (strpos($maps_embed, 'loading=') === false) { if (strpos($maps_embed, 'loading=') === false) {
$maps_embed = str_replace('<iframe ', '<iframe loading="lazy" ', $maps_embed); $maps_embed = str_replace('<iframe ', '<iframe loading="lazy" ', $maps_embed);
} }
echo clean_map_embed($maps_embed); echo map_click_to_load(clean_map_embed($maps_embed), (string) ($info['google_maps_link'] ?? ''));
?> ?>
</div> </div>
</div> </div>
+53 -21
View File
@@ -1,41 +1,73 @@
<?php <?php
// Política de Privacidade (RGPD). Conteúdo base — deve ser revisto pelo // Política de Privacidade (RGPD).
// responsável antes de publicar. //
// ATENÇÃO: texto-base gerado na auditoria de 25/09/2026. Os campos entre
// [PARÊNTESES RETOS] têm de ser preenchidos, e o texto revisto por um jurista,
// antes de fitme3070.pt apontar para esta instalação. Os prazos de conservação
// aqui declarados são os que o código aplica (cron.php → trash_retention_sweep,
// retenção da fila de mensagens, do audit_log e dos backups).
$current_uri_2 = 'privacidade'; $current_uri_2 = 'privacidade';
require __DIR__ . '/_open.php'; require __DIR__ . '/_open.php';
$pp_name = htmlspecialchars($info['name'] ?? 'FitMe 3070');
$pp_email = htmlspecialchars($info['email'] ?? '');
$pp_addr = htmlspecialchars($info['adress'] ?? '');
?> ?>
<section class="privacy-section" style="padding: 140px 0 60px;"> <section class="privacy-section" style="padding: 140px 0 60px;">
<div class="container"> <div class="container">
<div class="row justify-content-md-center"> <div class="row justify-content-md-center">
<div class="col-lg-8"> <div class="col-lg-8">
<h1 style="font-size: 2.2rem;">Política de Privacidade</h1> <h1 style="font-size: 2.2rem;">Política de Privacidade</h1>
<p class="small">Última atualização: 10 de junho de 2026</p> <p class="small">Última atualização: 25 de setembro de 2026</p>
<h4 class="mt-4">1. Responsável pelo tratamento</h4> <h2 class="h4 mt-4">1. Responsável pelo tratamento</h2>
<p>O tratamento dos dados pessoais recolhidos através deste website é da responsabilidade de <b><?= htmlspecialchars($info['name'] ?? 'FitMe 3070') ?></b>, com instalações em <?= htmlspecialchars($info['adress'] ?? '') ?>. Para qualquer questão relacionada com os teus dados pessoais podes contactar-nos através do e-mail <a href="mailto:<?= htmlspecialchars($info['email'] ?? '') ?>"><?= htmlspecialchars($info['email'] ?? '') ?></a>.</p> <p>O responsável pelo tratamento dos dados pessoais recolhidos neste website é <b>[DENOMINAÇÃO SOCIAL]</b>, que explora o ginásio <b><?= $pp_name ?></b>, com o NIF <b>[NIF]</b> e sede em <?= $pp_addr !== '' ? $pp_addr : '[MORADA]' ?>.
Para qualquer questão sobre os teus dados escreve para <a href="mailto:<?= $pp_email ?>"><?= $pp_email !== '' ? $pp_email : '[EMAIL]' ?></a>.</p>
<h4 class="mt-4">2. Dados que recolhemos</h4> <h2 class="h4 mt-4">2. Que dados recolhemos e para quê</h2>
<p>Recolhemos apenas os dados que nos forneces voluntariamente:</p> <div class="table-responsive">
<table class="table table-sm small">
<thead><tr><th>Onde</th><th>Dados</th><th>Finalidade</th><th>Fundamento (RGPD, art.º 6.º)</th></tr></thead>
<tbody>
<tr><td>Pedido de aula experimental</td><td>nome, telemóvel, e-mail, dia e período preferidos, mensagem</td><td>marcar a aula e contactar-te sobre ela</td><td>diligências pré-contratuais a teu pedido (n.º 1, al. b)</td></tr>
<tr><td>Pedido de inscrição (mensalidades)</td><td>nome, telemóvel, e-mail, mensalidade escolhida, mensagem</td><td>tratar da inscrição</td><td>diligências pré-contratuais (n.º 1, al. b)</td></tr>
<tr><td>Inscrição em eventos</td><td>nome, contacto, mensagem</td><td>gerir a participação no evento</td><td>diligências pré-contratuais (n.º 1, al. b)</td></tr>
<tr><td>Formulário de contacto</td><td>nome, telefone, motivo, mensagem</td><td>responder-te</td><td>interesse legítimo em responder a quem nos contacta (n.º 1, al. f)</td></tr>
<tr><td>Newsletter</td><td>e-mail</td><td>enviar novidades do ginásio</td><td>consentimento (n.º 1, al. a), que podes retirar a qualquer momento</td></tr>
<tr><td>Todos os formulários</td><td>endereço IP, navegador, data e hora</td><td>segurança: impedir spam e abusos, e provar o consentimento dado</td><td>interesse legítimo (n.º 1, al. f)</td></tr>
</tbody>
</table>
</div>
<p>Não tomamos decisões automatizadas nem fazemos perfis. Os formulários têm uma proteção automática contra spam que pode recusar submissões suspeitas; se achares que o teu pedido não chegou, contacta-nos por telefone.</p>
<h2 class="h4 mt-4">3. Durante quanto tempo</h2>
<ul style="padding-left: 1.25rem;"> <ul style="padding-left: 1.25rem;">
<li><b>Formulário de contacto</b> — nome, contacto telefónico, razão do contacto e mensagem. O formulário é processado pela Zoho Forms (Zoho Corporation), com alojamento na União Europeia.</li> <li>Pedidos de aula experimental, de inscrição e de eventos: <b>12 meses</b> após o pedido; depois passam para a lixeira do sistema e são eliminados definitivamente ao fim de mais 30 dias.</li>
<li><b>Newsletter</b> — endereço de e-mail.</li> <li>Mensagens enviadas pelo sistema (e-mails): 90 dias.</li>
<li>Registos de segurança (tentativas de acesso, submissões recusadas): até 90 dias; registo de auditoria do backoffice: 2 anos.</li>
<li>Cópias de segurança: 30 dias, cifradas.</li>
<li>Newsletter: até retirares o consentimento.</li>
<li>Se te tornares cliente, os dados do contrato seguem os prazos legais aplicáveis (por exemplo, fiscais).</li>
</ul> </ul>
<h4 class="mt-4">3. Finalidades e fundamento</h4> <h2 class="h4 mt-4">4. Com quem partilhamos</h2>
<p>Os dados são utilizados exclusivamente para responder aos teus pedidos de contacto e, no caso da newsletter, para o envio de novidades e campanhas do ginásio. O fundamento do tratamento é o teu consentimento (art.º 6.º, n.º 1, al. a) do RGPD), que podes retirar a qualquer momento.</p> <p>Não vendemos nem cedemos os teus dados para marketing. Recorremos a estes subcontratantes, com contrato e apenas para as finalidades acima:</p>
<ul style="padding-left: 1.25rem;">
<li><b>André Moreira</b> (<a href="https://andmore.pt" target="_blank" rel="noopener">andmore.pt</a>) — desenvolvimento, alojamento e manutenção da plataforma e envio dos e-mails;</li>
<li><b>[FORNECEDOR DO SERVIDOR]</b> — centro de dados onde a plataforma está alojada, na União Europeia;</li>
<li><b>Zoho Corporation</b> — processa o formulário de contacto, com alojamento na União Europeia.</li>
</ul>
<p>O mapa da secção de contactos é do <b>Google Maps</b> e só é carregado se carregares em «Mostrar mapa»; nesse momento a Google recebe o teu endereço IP e pode guardar cookies, segundo a <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">política da Google</a>. As avaliações apresentadas vêm do perfil público do ginásio no Google; as fotos dos autores são guardadas neste site para o teu navegador não ter de as pedir à Google.</p>
<h4 class="mt-4">4. Prazo de conservação</h4> <h2 class="h4 mt-4">5. Os teus direitos</h2>
<p>Conservamos os dados apenas durante o tempo necessário às finalidades indicadas, salvo quando exista obrigação legal de conservação por período superior. A subscrição da newsletter mantém-se até que retires o teu consentimento.</p> <p>Tens direito de acesso, retificação, apagamento, limitação, portabilidade e oposição, e de retirar o consentimento a qualquer momento (sem afetar o tratamento feito até aí). Basta escreveres para <a href="mailto:<?= $pp_email ?>"><?= $pp_email !== '' ? $pp_email : '[EMAIL]' ?></a>; respondemos no prazo de um mês.
Se tiveres conta na área de cliente, podes corrigir os teus dados e preferências no teu perfil.
Tens ainda o direito de apresentar reclamação à Comissão Nacional de Proteção de Dados (<a href="https://www.cnpd.pt" target="_blank" rel="noopener">www.cnpd.pt</a>).</p>
<h4 class="mt-4">5. Partilha de dados</h4> <h2 class="h4 mt-4">6. Cookies</h2>
<p>Não vendemos nem cedemos os teus dados a terceiros para fins de marketing. Os dados do formulário de contacto são processados pela Zoho Forms, na qualidade de subcontratante, em servidores localizados na União Europeia.</p> <p>Este site usa apenas um cookie técnico de sessão (<code>FIT_SESS</code>), necessário para os formulários e para a área reservada, que expira ao fechar o navegador. Não usamos cookies de publicidade nem de estatística. O Google Maps só coloca cookies se escolheres ver o mapa.</p>
<p>Esta plataforma é desenvolvida e mantida por André Moreira (<a href="https://andremoreira.pt" target="_blank" rel="noopener">andremoreira.pt</a> / <a href="https://andmore.pt" target="_blank" rel="noopener">andmore.pt</a>), que atua como subcontratante e intermediário técnico no envio das comunicações por e-mail. Ao submeteres os teus dados, autorizas o tratamento e a utilização dos mesmos por este intermediário, exclusivamente para esse efeito e nos termos legais aplicáveis (RGPD).</p>
<h4 class="mt-4">6. Os teus direitos</h4> <h2 class="h4 mt-4">7. Segurança</h2>
<p>Nos termos do RGPD, tens direito de acesso, retificação, apagamento, limitação do tratamento, portabilidade e oposição, bem como o direito de retirar o consentimento a qualquer momento. Para exercer estes direitos contacta-nos pelo e-mail indicado acima. Tens ainda o direito de apresentar reclamação à Comissão Nacional de Proteção de Dados (<a href="https://www.cnpd.pt" target="_blank" rel="noopener">www.cnpd.pt</a>).</p> <p>As comunicações são cifradas (HTTPS), o acesso ao backoffice é restrito e registado, e as cópias de segurança são cifradas.</p>
<h4 class="mt-4">7. Cookies</h4>
<p>Este website utiliza apenas cookies técnicos estritamente necessários ao seu funcionamento (por exemplo, cookies de sessão). Não utilizamos cookies de publicidade nem de análise de terceiros.</p>
</div> </div>
</div> </div>
</div> </div>
+51
View File
@@ -0,0 +1,51 @@
<?php
// Termos e Condições de utilização do website.
//
// ATENÇÃO: texto-base gerado na auditoria de 25/09/2026. Os campos entre
// [PARÊNTESES RETOS] têm de ser preenchidos, e o texto revisto por um jurista,
// antes de fitme3070.pt apontar para esta instalação. Não substitui o contrato
// de adesão/regulamento interno do ginásio, que rege a relação com os sócios.
$current_uri_2 = 'termos';
require __DIR__ . '/_open.php';
$tc_name = htmlspecialchars($info['name'] ?? 'FitMe 3070');
$tc_email = htmlspecialchars($info['email'] ?? '');
?>
<section class="privacy-section" style="padding: 140px 0 60px;">
<div class="container">
<div class="row justify-content-md-center">
<div class="col-lg-8">
<h1 style="font-size: 2.2rem;">Termos e Condições</h1>
<p class="small">Última atualização: 25 de setembro de 2026</p>
<h2 class="h4 mt-4">1. Quem somos</h2>
<p>Este website pertence a <b>[DENOMINAÇÃO SOCIAL]</b>, NIF <b>[NIF]</b>, com sede em <b>[MORADA]</b>, que explora o ginásio <b><?= $tc_name ?></b>. Contacto: <a href="mailto:<?= $tc_email ?>"><?= $tc_email !== '' ? $tc_email : '[EMAIL]' ?></a>.</p>
<h2 class="h4 mt-4">2. Âmbito</h2>
<p>Estes termos regulam a utilização do website. A frequência do ginásio, as mensalidades, as regras de utilização das instalações e o cancelamento regem-se pelo <b>[REGULAMENTO INTERNO / CONTRATO DE ADESÃO]</b>, entregue no momento da inscrição.</p>
<h2 class="h4 mt-4">3. Informação no site</h2>
<p>Os horários, modalidades, preços e eventos publicados são informativos e podem mudar sem aviso. Um pedido de aula experimental ou de inscrição feito no site não é uma reserva nem um contrato: confirmamos sempre contigo por telefone ou e-mail. Os preços incluem IVA à taxa legal em vigor, salvo indicação em contrário.</p>
<h2 class="h4 mt-4">4. Utilização aceitável</h2>
<p>Não é permitido usar o site para enviar conteúdo ilícito, publicidade não solicitada ou pedidos automatizados, nem tentar aceder a áreas reservadas sem autorização. Submissões automatizadas podem ser recusadas e o endereço de origem bloqueado.</p>
<h2 class="h4 mt-4">5. Conta de cliente</h2>
<p>Se criares conta, és responsável por manter a palavra-passe em segredo. Podes pedir o encerramento da conta a qualquer momento pelo e-mail acima.</p>
<h2 class="h4 mt-4">6. Propriedade intelectual</h2>
<p>Textos, fotografias, logótipos e restantes conteúdos pertencem a <?= $tc_name ?> ou são usados com autorização. Não podem ser reproduzidos sem consentimento escrito.</p>
<h2 class="h4 mt-4">7. Responsabilidade</h2>
<p>Fazemos o possível para manter o site disponível e correto, mas não garantimos que esteja sempre livre de erros ou interrupções. Nada nestes termos limita os direitos que a lei te reconhece como consumidor.</p>
<h2 class="h4 mt-4">8. Dados pessoais</h2>
<p>O tratamento dos teus dados está descrito na <a href="<?= base_url('home/privacidade') ?>">Política de Privacidade</a>.</p>
<h2 class="h4 mt-4">9. Resolução de litígios</h2>
<p>Em caso de litígio de consumo, podes recorrer ao Livro de Reclamações Eletrónico (<a href="https://www.livroreclamacoes.pt" target="_blank" rel="noopener">www.livroreclamacoes.pt</a>) ou a uma entidade de resolução alternativa de litígios: <b>[ENTIDADE RAL COMPETENTE, ex.: Centro de Arbitragem de Conflitos de Consumo da área]</b>. Mais informação em <a href="https://www.consumidor.gov.pt" target="_blank" rel="noopener">www.consumidor.gov.pt</a>. Aplica-se a lei portuguesa.</p>
</div>
</div>
</div>
</section>
<?php
echo view('footer');